diff --git a/.eslintrc.js b/.eslintrc.js index a0160fdd..3ae11f3f 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -18,7 +18,8 @@ module.exports = { '@typescript-eslint/no-extra-semi': 'off', '@typescript-eslint/ban-ts-comment': 'off', '@typescript-eslint/no-namespace': 'off', - 'no-case-declarations': 'off' + 'no-case-declarations': 'off', + 'no-extra-semi': 'off' }, ignorePatterns: ['example/*', 'tests/**/*'] } diff --git a/CHANGELOG.md b/CHANGELOG.md index dc2b6629..e764efac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,49 @@ +# 0.7.0 - 20 Sep 2023 +Feature: +- rewrite type +- rewrite Web Socket +- add mapper method +- add affix, prefix, suffix +- trace +- typeBox.Transfom +- rewrite Type.ElysiaMeta to use TypeBox.Transform +- new type: + - t.Cookie + - t.ObjectString + - t.MaybeEmpty + - t.Nullable +- add `Context` to `onError` +- lifecycle hook now accept array function +- true encapsulation scope + +Improvement: +- static Code Analysis now support rest parameter +- breakdown dynamic router into single pipeline instead of inlining to static router to reduce memory usage +- set `t.File` and `t.Files` to `File` instead of `Blob` +- skip class instance merging +- handle `UnknownContextPassToFunction` +- [#157](https://github.com/elysiajs/elysia/pull/179) WebSocket - added unit tests and fixed example & api by @bogeychan +- [#179](https://github.com/elysiajs/elysia/pull/179) add github action to run bun test by @arthurfiorette + +Breaking Change: +- remove `ws` plugin, migrate to core +- rename `addError` to `error` + +Change: +- using single findDynamicRoute instead of inlining to static map +- remove `mergician` +- remove array routes due to problem with TypeScript + +Bug fix: +- strictly validate response by default +- `t.Numeric` not working on headers / query / params +- `t.Optional(t.Object({ [name]: t.Numeric }))` causing error +- add null check before converting `Numeric` +- inherits store to instance plugin +- handle class overlapping +- [#187](https://github.com/elysiajs/elysia/pull/187) InternalServerError message fixed to "INTERNAL_SERVER_ERROR" instead of "NOT_FOUND" by @bogeychan +- [#167](https://github.com/elysiajs/elysia/pull/167) mapEarlyResponse with aot on after handle + # 0.6.24 - 18 Sep 2023 Feature: - [#149](https://github.com/elysiajs/elysia/pulls/149) support additional status codes in redirects diff --git a/README.md b/README.md index edfc6d7c..fcf30b9a 100644 --- a/README.md +++ b/README.md @@ -3,16 +3,12 @@ Elysia label

-

Fast and friendly Bun web framework.

+

Ergonomic Framework for Human

Documentation | Discord

-
- Formerly known as KingWorld -
- ## Philosophies Building on top of 3 philosophies: diff --git a/bun.lockb b/bun.lockb index 4076cf66..67a4fc16 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/example/a.ts b/example/a.ts index 48f04fcf..d98fe5d6 100644 --- a/example/a.ts +++ b/example/a.ts @@ -1,33 +1,12 @@ import { Elysia, t } from '../src' -import { prisma } from '../../../demo/24/src/auth' +const one = new Elysia({ name: 'one' }).onRequest(() => console.log('Hello, one!')) +const two = new Elysia().use(one) -const setup = new Elysia() - .state({ - prisma - }) - .decorate({ - prisma - }) +const three = new Elysia() + .use(one) + .use(two) + .get('/hello', () => 'Hello, world!') + .listen(3000) -const app = new Elysia() - .decorate({ - a: 'a' - }) - .state({ - a: 'a' - }) - .use(setup) - .decorate({ - b: 'b' - }) - .state({ - b: 'b' - }) - .use(setup) - .get('/', async ({ prisma }) => { - const a = (await prisma.authKey.findFirst()) ?? 'Empty' - - return a - }) - .listen(8080) +// three.handle(new Request('http://localhost:3000/hello')) diff --git a/example/b.ts b/example/b.ts index 66728a14..8c5fd44e 100644 --- a/example/b.ts +++ b/example/b.ts @@ -1,11 +1,31 @@ -import { Elysia } from '../src' +import Elysia from '../src' -const app = new Elysia({ - serve: { - // can be string, BunFile, TypedArray, Buffer, or array thereof - key: Bun.file('./key.pem'), - cert: Bun.file('./cert.pem'), - // passphrase, only required if key is encrypted - passphrase: 'super-secret' - } -}).listen(3000) +const app = new Elysia() + .get('/', ({ set }) => { + set.status = "I'm a teapot" + + return Bun.file('example/teapot.webp') + }) + .trace(async ({ beforeHandle }) => { + try { + console.log('Start BeforeHandle') + const { end } = await beforeHandle + + const a = await end + } catch { + console.log("A") + } + }) + .get('/trace', () => 'a', { + beforeHandle: [ + function setup() {}, + function error() { + throw new Error('A') + }, + function resume() {} + ], + afterHandle() { + console.log('After') + } + }) + .listen(3000) diff --git a/example/b/index.ts b/example/b/index.ts new file mode 100644 index 00000000..c789d4a0 --- /dev/null +++ b/example/b/index.ts @@ -0,0 +1,55 @@ +// setup.ts + +import { Elysia } from '../../src' + +// database.ts + +export const databasePlugin = new Elysia({ + name: 'database', + seed: 'database' +}).decorate('database', 'a') + +// authentication-plugin.ts + +export const authenticationPlugin = new Elysia({ + name: 'authentication', + seed: 'authentication' +}) + .use(databasePlugin) + .derive(async ({ headers, database }) => { + // logic + }) + +// setup +export const setup = new Elysia({ name: 'setup', seed: 'setup' }).use( + authenticationPlugin +) + +// register.ts + +export const register = new Elysia({ prefix: '/register' }) + .use(setup) + .get('/', async ({ body, set, database }) => { + // logic + }) +// login.ts + +export const login = new Elysia({ prefix: '/login' }) + .use(setup) + .get('/', async ({ body, set, database }) => { + // logic + }) +// authentication.ts +export const authenticationRoute = new Elysia({ prefix: '/authentication' }) + .use(login) + .use(register) + +export const routes = new Elysia().use(authenticationRoute) + +export const v2 = new Elysia({ prefix: '/v2' }).use(routes) + +const app = new Elysia() + // .use(cors()) + // .use(bearer()) + .use(v2) + .listen(8080) diff --git a/example/c.ts b/example/c.ts index 14637ae4..3c259a03 100644 --- a/example/c.ts +++ b/example/c.ts @@ -1,5 +1,14 @@ -import type { Elysia } from "../src"; +import { Elysia, t } from '../src' -export default function plugin(app: Elysia) { - return app.get('/from-plugin', () => 'hi') -} \ No newline at end of file +const app = new Elysia() + .post('/0.6', ({ body }) => body, { + body: t.Union([ + t.Undefined(), + t.Object({ + name: t.String(), + job: t.String(), + trait: t.Optional(t.String()) + }) + ]) + }) + .listen(3000) diff --git a/example/cookie.ts b/example/cookie.ts new file mode 100644 index 00000000..e560519c --- /dev/null +++ b/example/cookie.ts @@ -0,0 +1,43 @@ +import { Elysia, getSchemaValidator, t } from '../src' + +const app = new Elysia() + // .get( + // '/council', + // ({ cookie: { council } }) => + // (council.value = [ + // { + // name: 'Rin', + // affilation: 'Administration' + // } + // ]) + // ) + // .get('/create', ({ cookie: { name } }) => (name.value = 'Himari')) + .get( + '/update', + ({ cookie: { name } }) => { + name.value = 'seminar: Rio' + + name.value = 'seminar: Himari' + + name.maxAge = 86400 + + return name.value + }, + { + cookie: t.Cookie( + { + name: t.Optional(t.String()) + }, + { + secrets: 'Fischl von Luftschloss Narfidort', + sign: ['name'] + } + ) + } + ) + // .get('/remove', ({ cookie }) => { + // for (const self of Object.values(cookie)) self.remove() + + // return 'Deleted' + // }) + .listen(3000) diff --git a/example/lazy-module.ts b/example/lazy-module.ts index b71855c8..c609f062 100644 --- a/example/lazy-module.ts +++ b/example/lazy-module.ts @@ -1,15 +1,14 @@ -import { Elysia, SCHEMA } from '../src' +import { Elysia } from '../src' const plugin = (app: Elysia) => app.get('/plugin', () => 'Plugin') const asyncPlugin = async (app: Elysia) => app.get('/async', () => 'A') const app = new Elysia() - .decorate('a', () => 'hello') + // .decorate('a', () => 'hello') .use(plugin) - .use(asyncPlugin) .use(import('./lazy')) - .use((app) => app.get('/inline', () => 'inline')) - .get('/', ({ a }) => a()) + .use((app) => app.get('/inline', ({ store: { a } }) => 'inline')) + // .get('/', ({ a }) => a()) .listen(3000) await app.modules diff --git a/example/lazy/index.ts b/example/lazy/index.ts index ab17b3b7..8f37daa6 100644 --- a/example/lazy/index.ts +++ b/example/lazy/index.ts @@ -1,5 +1,5 @@ import Elysia from "../../src"; -export const lazy = (app: Elysia) => app.get('/lazy', () => 'Hi') +export const lazy = (app: Elysia) => app.state('a', 'b').get('/lazy', () => 'Hi') export default lazy diff --git a/example/rename.ts b/example/rename.ts new file mode 100644 index 00000000..8d565ea3 --- /dev/null +++ b/example/rename.ts @@ -0,0 +1,32 @@ +import { Elysia, t } from '../src' + +// ? Elysia#83 | Proposal: Standardized way of renaming third party plugin-scoped stuff +// this would be a plugin provided by a third party +const myPlugin = new Elysia() + .decorate('myProperty', 42) + .model('salt', t.String()) + +new Elysia() + .use( + myPlugin + // map decorator, rename "myProperty" to "renamedProperty" + .decorate(({ myProperty, ...decorators }) => ({ + renamedProperty: myProperty, + ...decorators + })) + // map model, rename "salt" to "pepper" + .model(({ salt, ...models }) => ({ + ...models, + pepper: salt + })) + // Add prefix + .prefix('decorator', 'unstable') + ) + .get( + '/mapped', + ({ unstableRenamedProperty }) => unstableRenamedProperty + ) + .post('/pepper', ({ body }) => body, { + body: 'pepper', + response: t.String() + }) diff --git a/example/schema.ts b/example/schema.ts index 578e1f4d..8d4e0dde 100644 --- a/example/schema.ts +++ b/example/schema.ts @@ -1,4 +1,4 @@ -import { Elysia, t, SCHEMA, DEFS } from '../src' +import { Elysia, t } from '../src' const app = new Elysia() .model({ diff --git a/example/stress/instance.ts b/example/stress/instance.ts index b84996cb..de8b9da1 100644 --- a/example/stress/instance.ts +++ b/example/stress/instance.ts @@ -1,12 +1,18 @@ -import { Elysia } from '../../src' +import { Elysia, t } from '../../src' const total = 10_000 -const t = performance.now() +const setup = new Elysia({ name: 'setup' }) + .decorate('decorate', 'decorate') + .state('state', 'state') + .model('model', t.String()) + .error('error', Error) -for (let i = 0; i < total; i++) new Elysia() +const t1 = performance.now() -const took = performance.now() - t +for (let i = 0; i < total; i++) new Elysia().use(setup) + +const took = performance.now() - t1 console.log( Intl.NumberFormat().format(total), diff --git a/example/teapot.webp b/example/teapot.webp new file mode 100644 index 00000000..cc1b33f6 Binary files /dev/null and b/example/teapot.webp differ diff --git a/example/trace.ts b/example/trace.ts new file mode 100644 index 00000000..5dc59052 --- /dev/null +++ b/example/trace.ts @@ -0,0 +1,23 @@ +import { Elysia } from '../src' + +const logs = [] + +const app = new Elysia() + .trace(async ({ beforeHandle, request, response }) => { + const { children, time: start, end } = await beforeHandle + for (const child of children) { + const { time: start, end, name } = await child + + console.log(name, 'took', (await end) - start, 'ms') + } + console.log('beforeHandle took', (await end) - start) + }) + .get('/', () => 'Hi', { + beforeHandle: [ + function setup() {}, + async function delay() { + await new Promise((resolve) => setTimeout(resolve, 1000)) + } + ] + }) + .listen(3000) diff --git a/example/websocket.ts b/example/websocket.ts index b4504500..0e5841fd 100644 --- a/example/websocket.ts +++ b/example/websocket.ts @@ -1,7 +1,6 @@ -import { Elysia, t, ws } from '../src' +import { Elysia } from '../src' const app = new Elysia() - .use(ws()) .state('start', 'here') .ws('/ws', { open(ws) { @@ -11,7 +10,7 @@ const app = new Elysia() ws.publish('asdf', message) } }) - .get('/publish/:publish', ({ publish, params: { publish: text } }) => { + .get('/publish/:publish', ({ params: { publish: text } }) => { app.server!.publish('asdf', text) return text diff --git a/package.json b/package.json index 6f0dbccf..c9ffa012 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "elysia", - "description": "Fast, and friendly Bun web framework", - "version": "0.6.24", + "description": "Ergonomic Framework for Human", + "version": "0.7.0", "author": { "name": "saltyAom", "url": "https://github.com/SaltyAom", @@ -23,6 +23,13 @@ "import": "./dist/ws/index.js", "default": "./dist/ws/index.js" }, + "./compose": { + "bun": "./dist/compose.js", + "node": "./dist/cjs/compose.js", + "require": "./dist/cjs/compose.js", + "import": "./dist/compose.js", + "default": "./dist/compose.js" + }, "./context": { "bun": "./dist/context.js", "node": "./dist/cjs/context.js", @@ -30,12 +37,12 @@ "import": "./dist/context.js", "default": "./dist/context.js" }, - "./compose": { - "bun": "./dist/compose.js", - "node": "./dist/cjs/compose.js", - "require": "./dist/cjs/compose.js", - "import": "./dist/compose.js", - "default": "./dist/compose.js" + "./cookie": { + "bun": "./dist/cookie.js", + "node": "./dist/cjs/cookie.js", + "require": "./dist/cjs/cookie.js", + "import": "./dist/cookie.js", + "default": "./dist/cookie.js" }, "./custom-types": { "bun": "./dist/custom-types.js", @@ -65,6 +72,13 @@ "import": "./dist/schema.js", "default": "./dist/schema.js" }, + "./trace": { + "bun": "./dist/trace.js", + "node": "./dist/cjs/trace.js", + "require": "./dist/cjs/trace.js", + "import": "./dist/trace.js", + "default": "./dist/trace.js" + }, "./types": { "bun": "./dist/types.js", "node": "./dist/cjs/types.js", @@ -99,7 +113,7 @@ "test:functionality": "bun test && npm run test:node", "test:types": "tsc --project tsconfig.test.json", "test:node": "npm install --prefix ./test/node/cjs/ && npm install --prefix ./test/node/esm/ && node ./test/node/cjs/index.js && node ./test/node/esm/index.js", - "dev": "bun run --hot example/http.ts", + "dev": "bun run --watch example/http.ts", "build": "rimraf dist && npm run build:esm && npm run build:bun && npm run build:cjs", "build:bun": "bun build.ts", "build:esm": "swc src -d dist && tsc --project tsconfig.esm.json", @@ -110,28 +124,31 @@ "release:local": "npm run build && npm run test && npm publish" }, "dependencies": { - "@sinclair/typebox": "^0.30.4", + "@sinclair/typebox": "^0.31.15", + "cookie": "^0.5.0", + "cookie-signature": "^1.2.1", + "eventemitter3": "^5.0.1", "fast-querystring": "^1.1.2", "memoirist": "0.1.4", - "mergician": "^1.1.0", "openapi-types": "^12.1.3" }, "devDependencies": { "@elysiajs/cookie": "^0.6.1", - "@elysiajs/html": "^0.6.0", - "@elysiajs/jwt": "^0.6.2", - "@elysiajs/swagger": "^0.6.0", + "@elysiajs/html": "^0.6.6", + "@elysiajs/jwt": "^0.6.4", + "@elysiajs/swagger": "^0.6.2", "@swc/cli": "^0.1.62", - "@swc/core": "^1.3.71", - "@types/node": "^20.4.5", - "@typescript-eslint/eslint-plugin": "^6.2.0", - "@typescript-eslint/parser": "^6.2.0", - "bumpp": "^9.1.1", - "bun-types": "^0.7.0", - "eslint": "^8.45.0", + "@types/cookie": "^0.5.2", + "@types/cookie-signature": "^1.1.0", + "@swc/core": "^1.3.86", + "@typescript-eslint/eslint-plugin": "^6.7.2", + "@typescript-eslint/parser": "^6.7.2", + "bumpp": "^9.2.0", + "bun-types": "^1.0.2", + "eslint": "^8.49.0", "expect-type": "^0.16.0", "rimraf": "4.4.1", - "typescript": "^5.1.6" + "typescript": "^5.2.2" }, "peerDependencies": { "@sinclair/typebox": ">= 0.28.10", @@ -139,9 +156,6 @@ "typescript": ">= 5.0.0" }, "peerDependenciesMeta": { - "@elysiajs/fn": { - "optional": true - }, "@sinclair/typebox": { "optional": true }, diff --git a/src/compose.ts b/src/compose.ts index b1668392..775f777e 100644 --- a/src/compose.ts +++ b/src/compose.ts @@ -1,7 +1,12 @@ -import type { Elysia } from '.' +import { type Elysia } from '.' + +import { TypeCheck } from '@sinclair/typebox/compiler' +import type { TAnySchema } from '@sinclair/typebox' import { parse as parseQuery } from 'fast-querystring' +import { sign as signCookie } from 'cookie-signature' + import { mapEarlyResponse, mapResponse, mapCompactResponse } from './handler' import { NotFoundError, @@ -10,22 +15,114 @@ import { ERROR_CODE } from './error' +import { parseCookie } from './cookie' + import type { - ElysiaConfig, ComposedHandler, - HTTPMethod, - LocalHandler, - RegisteredHook, + ElysiaConfig, + Handler, + LifeCycleStore, + PreHandler, SchemaValidator, - BeforeRequestHandler + TraceEvent, + TraceReporter } from './types' -import type { TAnySchema } from '@sinclair/typebox' -import { TypeCheck } from '@sinclair/typebox/compiler' - -const _demoHeaders = new Headers() +const headersHasToJSON = new Headers().toJSON const findAliases = new RegExp(` (\\w+) = context`, 'g') +const requestId = { value: 0 } + +const createReport = ({ + hasTrace, + hasTraceSet = false, + hasTraceChildren = false, + addFn, + condition = {} +}: { + hasTrace: boolean | number + hasTraceChildren: boolean + hasTraceSet?: boolean + addFn(string: string): void + condition: Partial> +}) => { + if (hasTrace) { + const microtask = hasTraceChildren + ? '\nawait new Promise(r => {queueMicrotask(() => queueMicrotask(r))})\n' + : '\nawait new Promise(r => {queueMicrotask(r)})\n' + + return ( + event: TraceEvent, + { + name, + attribute = '', + unit = 0 + }: { + name?: string + attribute?: string + unit?: number + } = {} + ) => { + const dotIndex = event.indexOf('.') + const isGroup = dotIndex === -1 + + if ( + event !== 'request' && + event !== 'response' && + !condition[ + (isGroup + ? event + : event.slice(0, dotIndex)) as keyof typeof condition + ] + ) + return () => { + if (hasTraceSet && event === 'afterHandle') addFn(microtask) + } + + if (isGroup) name ||= event + else name ||= 'anonymous' + + addFn( + '\n' + + `reporter.emit('event', { + id, + event: '${event}', + type: 'begin', + name: '${name}', + time: performance.now(), + ${isGroup ? `unit: ${unit},` : ''} + ${attribute} + })`.replace(/(\t| |\n)/g, '') + + '\n' + ) + + let handled = false + + return () => { + if (handled) return + + handled = true + addFn( + '\n' + + `reporter.emit('event', { + id, + event: '${event}', + type: 'end', + time: performance.now() + })`.replace(/(\t| |\n)/g, '') + + '\n' + ) + + if (hasTraceSet && event === 'afterHandle') { + addFn(microtask) + } + } + } + } else { + return () => () => {} + } +} + export const hasReturn = (fnLiteral: string) => { const parenthesisEnd = fnLiteral.indexOf(')') @@ -40,7 +137,14 @@ export const hasReturn = (fnLiteral: string) => { return fnLiteral.includes('return') } -const composeValidationFactory = (hasErrorHandler: boolean) => ({ +const composeValidationFactory = ( + hasErrorHandler: boolean, + { + injectResponse = '' + }: { + injectResponse?: string + } = {} +) => ({ composeValidation: (type: string, value = `c.${type}`) => hasErrorHandler ? `c.set.status = 400; throw new ValidationError( @@ -53,24 +157,34 @@ ${value} ${type}, ${value} ).toResponse(c.set.headers)`, - composeResponseValidation: (value = 'r') => - hasErrorHandler + composeResponseValidation: (name = 'r') => { + const returnError = hasErrorHandler ? `throw new ValidationError( 'response', response[c.set.status], -${value} +${name} )` : `return new ValidationError( 'response', response[c.set.status], -${value} +${name} ).toResponse(c.set.headers)` + + return `\n${injectResponse} + if(response[c.set.status]?.Check(${name}) === false) { + if(!(response instanceof Error)) + ${returnError} +}\n` + } }) export const isFnUse = (keyword: string, fnLiteral: string) => { fnLiteral = fnLiteral.trimStart() fnLiteral = fnLiteral.replaceAll(/^async /g, '') + if (/^(\w+)\(/g.test(fnLiteral)) + fnLiteral = fnLiteral.slice(fnLiteral.indexOf('(')) + const argument = // CharCode 40 is '(' fnLiteral.charCodeAt(0) === 40 || fnLiteral.startsWith('function') @@ -84,12 +198,15 @@ export const isFnUse = (keyword: string, fnLiteral: string) => { if (argument === '') return false + const restIndex = + argument.charCodeAt(0) === 123 ? argument.indexOf('...') : -1 + // Using object destructuring if (argument.charCodeAt(0) === 123) { // Since Function already format the code, styling is enforced if (argument.includes(keyword)) return true - return false + if (restIndex === -1) return false } // Match dot notation and named access @@ -101,56 +218,149 @@ export const isFnUse = (keyword: string, fnLiteral: string) => { return true } + const restAlias = + restIndex !== -1 + ? argument.slice( + restIndex + 3, + argument.indexOf(' ', restIndex + 3) + ) + : undefined + + if ( + fnLiteral.match( + new RegExp(`${restAlias}(.${keyword}|\\["${keyword}"\\])`) + ) + ) + return true + const aliases = [argument] + if (restAlias) aliases.push(restAlias) + for (const found of fnLiteral.matchAll(findAliases)) aliases.push(found[1]) const destructuringRegex = new RegExp(`{.*?} = (${aliases.join('|')})`, 'g') - for (const [params] of fnLiteral.matchAll(destructuringRegex)) { + for (const [params] of fnLiteral.matchAll(destructuringRegex)) if (params.includes(`{ ${keyword}`) || params.includes(`, ${keyword}`)) return true - } return false } -export const findElysiaMeta = ( - type: string, - schema: TAnySchema, - found: string[] = [], - parent = '' -) => { +const isContextPassToFunction = (fnLiteral: string) => { + fnLiteral = fnLiteral.trimStart() + fnLiteral = fnLiteral.replaceAll(/^async /g, '') + + if (/^(\w+)\(/g.test(fnLiteral)) + fnLiteral = fnLiteral.slice(fnLiteral.indexOf('(')) + + const argument = + // CharCode 40 is '(' + fnLiteral.charCodeAt(0) === 40 || fnLiteral.startsWith('function') + ? // Bun: (context) => {} + fnLiteral.slice( + fnLiteral.indexOf('(') + 1, + fnLiteral.indexOf(')') + ) + : // Node: context => {} + fnLiteral.slice(0, fnLiteral.indexOf('=') - 1) + + if (argument === '') return false + + const restIndex = + argument.charCodeAt(0) === 123 ? argument.indexOf('...') : -1 + + const restAlias = + restIndex !== -1 + ? argument.slice( + restIndex + 3, + argument.indexOf(' ', restIndex + 3) + ) + : undefined + + const aliases = [argument] + if (restAlias) aliases.push(restAlias) + + for (const found of fnLiteral.matchAll(findAliases)) aliases.push(found[1]) + + for (const alias of aliases) + if (new RegExp(`\\b\\w+\\([^)]*\\b${alias}\\b[^)]*\\)`).test(fnLiteral)) + return true + + const destructuringRegex = new RegExp(`{.*?} = (${aliases.join('|')})`, 'g') + + for (const [renamed] of fnLiteral.matchAll(destructuringRegex)) + if ( + new RegExp(`\\b\\w+\\([^)]*\\b${renamed}\\b[^)]*\\)`).test( + fnLiteral + ) + ) + return true + + return false +} + +const KindSymbol = Symbol.for('TypeBox.Kind') + +export const hasType = (type: string, schema: TAnySchema) => { + if (!schema) return + + if (KindSymbol in schema && schema[KindSymbol] === type) return true + if (schema.type === 'object') { const properties = schema.properties as Record - for (const key in properties) { + for (const key of Object.keys(properties)) { const property = properties[key] - const accessor = !parent ? key : parent + '.' + key - if (property.type === 'object') { - findElysiaMeta(type, property, found, accessor) - continue + if (hasType(type, property)) return true } else if (property.anyOf) { - for (const prop of property.anyOf) { - findElysiaMeta(type, prop, found, accessor) - } - - continue + for (let i = 0; i < property.anyOf.length; i++) + if (hasType(type, property.anyOf[i])) return true } - if (property.elysiaMeta === type) found.push(accessor) + if (KindSymbol in property && property[KindSymbol] === type) + return true } - if (found.length === 0) return null + return false + } + + return ( + schema.properties && + KindSymbol in schema.properties && + schema.properties[KindSymbol] === type + ) +} + +const TransformSymbol = Symbol.for('TypeBox.Transform') + +export const hasTransform = (schema: TAnySchema) => { + if (!schema) return + + if (schema.type === 'object') { + const properties = schema.properties as Record + for (const key of Object.keys(properties)) { + const property = properties[key] + + if (property.type === 'object') { + if (hasTransform(property)) return true + } else if (property.anyOf) { + for (let i = 0; i < property.anyOf.length; i++) + if (hasTransform(property.anyOf[i])) return true + } - return found - } else if (schema?.elysiaMeta === type) { - if (parent) found.push(parent) + const hasTransformSymbol = TransformSymbol in property + if (hasTransformSymbol) return true + } - return 'root' + return false } - return null + return ( + TransformSymbol in schema || + (schema.properties && TransformSymbol in schema.properties) + ) } /** @@ -187,6 +397,9 @@ const getUnionedType = (validator: TypeCheck | undefined) => { if (!foundDifference) return type } + + // @ts-ignore + return validator.schema?.type } const matchFnReturn = /(?:return|=>) \S*\(/g @@ -194,42 +407,40 @@ const matchFnReturn = /(?:return|=>) \S*\(/g export const isAsync = (fn: Function) => { if (fn.constructor.name === 'AsyncFunction') return true - const literal = fn.toString() - - if (literal.match(matchFnReturn)) return true - - return false + return fn.toString().match(matchFnReturn) } export const composeHandler = ({ - // path, + path, method, hooks, validator, handler, handleError, - meta, + definitions, + schema, onRequest, - config + config, + reporter }: { path: string - method: HTTPMethod - hooks: RegisteredHook + method: string + hooks: LifeCycleStore validator: SchemaValidator - handler: LocalHandler + handler: Handler handleError: Elysia['handleError'] - meta?: Elysia['meta'] - onRequest: BeforeRequestHandler[] + definitions?: Elysia['definitions']['type'] + schema?: Elysia['schema'] + onRequest: PreHandler[] config: ElysiaConfig + reporter: TraceReporter }): ComposedHandler => { const hasErrorHandler = config.forceErrorEncapsulation || hooks.error.length > 0 || typeof Bun === 'undefined' || - hooks.onResponse.length > 0 - - const { composeValidation, composeResponseValidation } = - composeValidationFactory(hasErrorHandler) + hooks.onResponse.length > 0 || + !!hooks.trace.length const handleResponse = hooks.onResponse.length ? `\n;(async () => {${hooks.onResponse @@ -237,10 +448,60 @@ export const composeHandler = ({ .join(';')}})();\n` : '' - let fnLiteral = hasErrorHandler ? 'try {\n' : '' + const traceLiteral = hooks.trace.map((x) => x.toString()) + + let hasUnknownContext = false + + if (isContextPassToFunction(handler.toString())) hasUnknownContext = true + + if (!hasUnknownContext) + for (const [key, value] of Object.entries(hooks)) { + if ( + !Array.isArray(value) || + !value.length || + ![ + 'parse', + 'transform', + 'beforeHandle', + 'afterHandle', + 'onResponse' + ].includes(key) + ) + continue + + for (const handle of value) { + if (typeof handle !== 'function') continue + + if (isContextPassToFunction(handle.toString())) { + hasUnknownContext = true + break + } + } + + if (hasUnknownContext) break + } + + const traceConditions: Record< + Exclude, + boolean + > = { + parse: traceLiteral.some((x) => isFnUse('parse', x)), + transform: traceLiteral.some((x) => isFnUse('transform', x)), + handle: traceLiteral.some((x) => isFnUse('handle', x)), + beforeHandle: traceLiteral.some((x) => isFnUse('beforeHandle', x)), + afterHandle: traceLiteral.some((x) => isFnUse('afterHandle', x)), + error: hasErrorHandler || traceLiteral.some((x) => isFnUse('error', x)) + } + + const hasTrace = hooks.trace.length > 0 + let fnLiteral = '' + + if (hasTrace) fnLiteral += '\nconst id = c.$$requestId\n' + + fnLiteral += hasErrorHandler ? 'try {\n' : '' const lifeCycleLiteral = - validator || method !== 'GET' + validator || (method !== 'GET' && method !== 'HEAD') ? [ handler, ...hooks.transform, @@ -250,20 +511,70 @@ export const composeHandler = ({ : [] const hasBody = - method !== 'GET' && - hooks.type !== 'none' && - (!!validator.body || - !!hooks.type || - lifeCycleLiteral.some((fn) => isFnUse('body', fn))) + hasUnknownContext || + (method !== 'GET' && + method !== 'HEAD' && + hooks.type !== 'none' && + (!!validator.body || + !!hooks.type || + lifeCycleLiteral.some((fn) => isFnUse('body', fn)))) const hasHeaders = + hasUnknownContext || validator.headers || lifeCycleLiteral.some((fn) => isFnUse('headers', fn)) + const hasCookie = + hasUnknownContext || + validator.cookie || + lifeCycleLiteral.some((fn) => isFnUse('cookie', fn)) + + // @ts-ignore + const cookieMeta = validator?.cookie?.schema as { + secrets?: string | string[] + sign: string[] | true + properties: { [x: string]: Object } + } + + let encodeCookie = '' + + if (cookieMeta?.sign) { + if (!cookieMeta.secrets) + throw new Error( + `t.Cookie required secret which is not set in (${method}) ${path}.` + ) + + const secret = !cookieMeta.secrets + ? undefined + : typeof cookieMeta.secrets === 'string' + ? cookieMeta.secrets + : cookieMeta.secrets[0] + + encodeCookie += `const _setCookie = c.set.cookie + if(_setCookie) {` + + if (cookieMeta.sign === true) { + // encodeCookie += `if(_setCookie['${name}']?.value) { c.set.cookie['${name}'].value = signCookie(_setCookie['${name}'].value, '${secret}') }\n` + encodeCookie += `for(const [key, cookie] of Object.entries(_setCookie)) { + c.set.cookie[key].value = signCookie(cookie.value, '${secret}') + }` + } else + for (const name of cookieMeta.sign) { + // if (!(name in cookieMeta.properties)) continue + + encodeCookie += `if(_setCookie['${name}']?.value) { c.set.cookie['${name}'].value = signCookie(_setCookie['${name}'].value, '${secret}') }\n` + } + + encodeCookie += '}\n' + } + + const { composeValidation, composeResponseValidation } = + composeValidationFactory(hasErrorHandler) + if (hasHeaders) { // This function is Bun specific // @ts-ignore - fnLiteral += _demoHeaders.toJSON + fnLiteral += headersHasToJSON ? `c.headers = c.request.headers.toJSON()\n` : `c.headers = {} for (const [key, value] of c.request.headers.entries()) @@ -271,8 +582,43 @@ export const composeHandler = ({ ` } + if (hasCookie) { + const options = cookieMeta + ? `{ + secret: ${ + cookieMeta.secrets !== undefined + ? typeof cookieMeta.secrets === 'string' + ? `'${cookieMeta.secrets}'` + : '[' + + cookieMeta.secrets.reduce( + (a, b) => a + `'${b}',`, + '' + ) + + ']' + : 'undefined' + }, + sign: ${ + cookieMeta.sign === true + ? true + : cookieMeta.sign !== undefined + ? '[' + + cookieMeta.sign.reduce((a, b) => a + `'${b}',`, '') + + ']' + : 'undefined' + } + }` + : 'undefined' + + if (hasHeaders) + fnLiteral += `\nc.cookie = parseCookie(c.set, c.headers.cookie, ${options})\n` + else + fnLiteral += `\nc.cookie = parseCookie(c.set, c.request.headers.get('cookie'), ${options})\n` + } + const hasQuery = - validator.query || lifeCycleLiteral.some((fn) => isFnUse('query', fn)) + hasUnknownContext || + validator.query || + lifeCycleLiteral.some((fn) => isFnUse('query', fn)) if (hasQuery) { fnLiteral += `const url = c.request.url @@ -285,310 +631,347 @@ export const composeHandler = ({ ` } + const traceLiterals = hooks.trace.map((x) => x.toString()) + const hasTraceSet = traceLiterals.some( + (fn) => isFnUse('set', fn) || isContextPassToFunction(fn) + ) + const hasTraceChildren = + hasTraceSet && + traceLiterals.some( + (fn) => fn.includes('children') || isContextPassToFunction(fn) + ) + + hasUnknownContext || hooks.trace.some((fn) => isFnUse('set', fn.toString())) + const hasSet = + hasTraceSet || + hasCookie || lifeCycleLiteral.some((fn) => isFnUse('set', fn)) || onRequest.some((fn) => isFnUse('set', fn.toString())) + const report = createReport({ + hasTrace, + hasTraceSet, + hasTraceChildren, + condition: traceConditions, + addFn: (word) => { + fnLiteral += word + } + }) + const maybeAsync = hasBody || + hasTraceSet || isAsync(handler) || hooks.parse.length > 0 || hooks.afterHandle.some(isAsync) || hooks.beforeHandle.some(isAsync) || hooks.transform.some(isAsync) + const endParse = report('parse', { + unit: hooks.parse.length + }) + if (hasBody) { const type = getUnionedType(validator?.body) - if (hooks.type || type) { + if (hooks.type && !Array.isArray(hooks.type)) { if (hooks.type) { switch (hooks.type) { + case 'json': case 'application/json': - fnLiteral += `c.body = await c.request.json();` + fnLiteral += `c.body = await c.request.json()\n` break + case 'text': case 'text/plain': - fnLiteral += `c.body = await c.request.text();` + fnLiteral += `c.body = await c.request.text()\n` break + case 'urlencoded': case 'application/x-www-form-urlencoded': - fnLiteral += `c.body = parseQuery(await c.request.text());` + fnLiteral += `c.body = parseQuery(await c.request.text())\n` break + case 'arrayBuffer': case 'application/octet-stream': - fnLiteral += `c.body = await c.request.arrayBuffer();` + fnLiteral += `c.body = await c.request.arrayBuffer()\n` break + case 'formdata': case 'multipart/form-data': fnLiteral += `c.body = {} - const form = await c.request.formData() - for (const key of form.keys()) { - if (c.body[key]) - continue + const form = await c.request.formData() + for (const key of form.keys()) { + if (c.body[key]) + continue - const value = form.getAll(key) - if (value.length === 1) - c.body[key] = value[0] - else c.body[key] = value - }` - break - } - } else if (type) { - // @ts-ignore - const schema = validator?.body?.schema - - switch (type) { - case 'object': - if (schema.elysiaMeta === 'URLEncoded') { - fnLiteral += `c.body = parseQuery(await c.request.text())` - } // Accept file which means it's formdata - else if ( - validator.body!.Code().includes("custom('File") - ) - fnLiteral += `c.body = {} - - const form = await c.request.formData() - for (const key of form.keys()) { - if (c.body[key]) - continue - - const value = form.getAll(key) - if (value.length === 1) - c.body[key] = value[0] - else c.body[key] = value - }` - else { - // Since it's an object an not accepting file - // we can infer that it's JSON - fnLiteral += `c.body = JSON.parse(await c.request.text())` - } - break - - default: - fnLiteral += 'c.body = await c.request.text()' + const value = form.getAll(key) + if (value.length === 1) + c.body[key] = value[0] + else c.body[key] = value + }\n` break } } - if (hooks.parse.length) fnLiteral += '}}' } else { - fnLiteral += '\n' - fnLiteral += hasHeaders - ? `let contentType = c.headers['content-type']` - : `let contentType = c.request.headers.get('content-type')` - - fnLiteral += ` - if (contentType) { - const index = contentType.indexOf(';') - if (index !== -1) contentType = contentType.substring(0, index)\n` + const getAotParser = () => { + if (hooks.parse.length && type && !Array.isArray(hooks.type)) { + // @ts-ignore + const schema = validator?.body?.schema + + switch (type) { + case 'object': + if ( + hasType('File', schema) || + hasType('Files', schema) + ) + return `c.body = {} + + const form = await c.request.formData() + for (const key of form.keys()) { + if (c.body[key]) + continue + + const value = form.getAll(key) + if (value.length === 1) + c.body[key] = value[0] + else c.body[key] = value + }` + // else { + // // Since it's an object an not accepting file + // // we can infer that it's JSON + // fnLiteral += `c.body = await c.request.json()\n` + // } + break + + default: + // fnLiteral += defaultParser + break + } + } + } - if (hooks.parse.length) { - fnLiteral += `let used = false\n` + const aotParse = getAotParser() - for (let i = 0; i < hooks.parse.length; i++) { - const name = `bo${i}` + if (aotParse) fnLiteral += aotParse + else { + fnLiteral += '\n' + fnLiteral += hasHeaders + ? `let contentType = c.headers['content-type']` + : `let contentType = c.request.headers.get('content-type')` - if (i !== 0) fnLiteral += `if(!used) {\n` + fnLiteral += ` + if (contentType) { + const index = contentType.indexOf(';') + if (index !== -1) contentType = contentType.substring(0, index)\n` - fnLiteral += `let ${name} = parse[${i}](c, contentType);` - fnLiteral += `if(${name} instanceof Promise) ${name} = await ${name};` + if (hooks.parse.length) { + fnLiteral += `let used = false\n` - fnLiteral += ` - if(${name} !== undefined) { c.body = ${name}; used = true }\n` + const endReport = report('parse', { + unit: hooks.parse.length + }) - if (i !== 0) fnLiteral += `}` - } + for (let i = 0; i < hooks.parse.length; i++) { + const endUnit = report('parse.unit', { + name: hooks.parse[i].name + }) - fnLiteral += `if (!used)` - } + const name = `bo${i}` - fnLiteral += `switch (contentType) { - case 'application/json': - c.body = await c.request.json() - break + if (i !== 0) fnLiteral += `if(!used) {\n` - case 'text/plain': - c.body = await c.request.text() - break + fnLiteral += `let ${name} = parse[${i}](c, contentType)\n` + fnLiteral += `if(${name} instanceof Promise) ${name} = await ${name}\n` + fnLiteral += `if(${name} !== undefined) { c.body = ${name}; used = true }\n` - case 'application/x-www-form-urlencoded': - c.body = parseQuery(await c.request.text()) - break + endUnit() - case 'application/octet-stream': - c.body = await c.request.arrayBuffer(); - break + if (i !== 0) fnLiteral += `}` + } - case 'multipart/form-data': - c.body = {} + endReport() + } - const form = await c.request.formData() - for (const key of form.keys()) { - if (c.body[key]) - continue + if (hooks.parse.length) fnLiteral += `if (!used)` - const value = form.getAll(key) - if (value.length === 1) - c.body[key] = value[0] - else c.body[key] = value - } + fnLiteral += ` + switch (contentType) { + case 'application/json': + c.body = await c.request.json() + break + + case 'text/plain': + c.body = await c.request.text() + break + + case 'application/x-www-form-urlencoded': + c.body = parseQuery(await c.request.text()) + break + + case 'application/octet-stream': + c.body = await c.request.arrayBuffer(); + break + + case 'multipart/form-data': + c.body = {} + + const form = await c.request.formData() + for (const key of form.keys()) { + if (c.body[key]) + continue + + const value = form.getAll(key) + if (value.length === 1) + c.body[key] = value[0] + else c.body[key] = value + } + + break + }\n` - break + fnLiteral += '}\n' } - }\n` } fnLiteral += '\n' } - if (validator.params) { - // @ts-ignore - const properties = findElysiaMeta('Numeric', validator.params.schema) + endParse() - if (properties) { - switch (typeof properties) { - case 'object': - for (const property of properties) - fnLiteral += `if(c.params.${property}) c.params.${property} = +c.params.${property};` - break - } + if (hooks?.transform) { + const endTransform = report('transform', { + unit: hooks.transform.length + }) - fnLiteral += '\n' - } - } + for (let i = 0; i < hooks.transform.length; i++) { + const transform = hooks.transform[i] - if (validator.query) { - // @ts-ignore - const properties = findElysiaMeta('Numeric', validator.query.schema) + const endUnit = report('transform.unit', { + name: transform.name + }) - if (properties) { - switch (typeof properties) { - case 'object': - for (const property of properties) - fnLiteral += `if(c.query.${property}) c.query.${property} = +c.query.${property};` - break - } + // @ts-ignore + if (transform.$elysia === 'derive') + fnLiteral += isAsync(hooks.transform[i]) + ? `Object.assign(c, await transform[${i}](c));` + : `Object.assign(c, transform[${i}](c));` + else + fnLiteral += isAsync(hooks.transform[i]) + ? `await transform[${i}](c);` + : `transform[${i}](c);` - fnLiteral += '\n' + endUnit() } + + endTransform() } - if (validator.headers) { - // @ts-ignore - const properties = findElysiaMeta('Numeric', validator.headers.schema) + if (validator) { + fnLiteral += '\n' - if (properties) { - switch (typeof properties) { - case 'object': - for (const property of properties) - fnLiteral += `c.headers.${property} = +c.headers.${property};` - break - } + if (validator.headers) { + fnLiteral += `if(headers.Check(c.headers) === false) { + ${composeValidation('headers')} + }` - fnLiteral += '\n' + // @ts-ignore + if (hasTransform(validator.headers.schema)) + fnLiteral += `\nc.headers = headers.Decode(c.headers)\n` } - } - if (validator.body) { - // @ts-ignore - const numericProperties = findElysiaMeta( - 'Numeric', - // @ts-ignore - validator.body.schema - ) + if (validator.params) { + fnLiteral += `if(params.Check(c.params) === false) { + ${composeValidation('params')} + }` - if (numericProperties) { - switch (typeof numericProperties) { - case 'string': - fnLiteral += `c.body = +c.body;` - break + // @ts-ignore + if (hasTransform(validator.params.schema)) + fnLiteral += `\nc.params = params.Decode(c.params)\n` + } - case 'object': - for (const property of numericProperties) - fnLiteral += `c.body.${property} = +c.body.${property};` - break - } + if (validator.query) { + fnLiteral += `if(query.Check(c.query) === false) { + ${composeValidation('query')} + }` - fnLiteral += '\n' + // @ts-ignore + if (hasTransform(validator.query.schema)) + // Decode doesn't work with Object.create(null) + fnLiteral += `\nc.query = query.Decode(Object.assign({}, c.query))\n` } - // @ts-ignore - const filesProperties = findElysiaMeta('Files', validator.body.schema) - if (filesProperties) { - switch (typeof filesProperties) { - case 'object': - for (const property of filesProperties) - fnLiteral += `if(!Array.isArray(c.body.${property})) c.body.${property} = [c.body.${property}];` - break - } + if (validator.body) { + fnLiteral += `if(body.Check(c.body) === false) { + ${composeValidation('body')} + }` - fnLiteral += '\n' + // @ts-ignore + if (hasTransform(validator.body.schema)) + fnLiteral += `\nc.body = body.Decode(c.body)\n` } - } - if (hooks?.transform) - for (let i = 0; i < hooks.transform.length; i++) { - const transform = hooks.transform[i] + if (validator.cookie) { + fnLiteral += `const cookieValue = {} + for(const [key, value] of Object.entries(c.cookie)) + cookieValue[key] = value.value + + if(cookie.Check(cookieValue) === false) { + ${composeValidation('cookie', 'cookieValue')} + }` // @ts-ignore - if (transform.$elysia === 'derive') - fnLiteral += isAsync(hooks.transform[i]) - ? `Object.assign(c, await transform[${i}](c));` - : `Object.assign(c, transform[${i}](c));` - else - fnLiteral += isAsync(hooks.transform[i]) - ? `await transform[${i}](c);` - : `transform[${i}](c);` + if (hasTransform(validator.cookie.schema)) + fnLiteral += `\nc.cookie = params.Decode(c.cookie)\n` } - - if (validator) { - if (validator.headers) - fnLiteral += ` - if (headers.Check(c.headers) === false) { - ${composeValidation('headers')} - } - ` - - if (validator.params) - fnLiteral += `if(params.Check(c.params) === false) { ${composeValidation( - 'params' - )} }` - - if (validator.query) - fnLiteral += `if(query.Check(c.query) === false) { ${composeValidation( - 'query' - )} }` - - if (validator.body) - fnLiteral += `if(body.Check(c.body) === false) { ${composeValidation( - 'body' - )} }` } - if (hooks?.beforeHandle) + if (hooks?.beforeHandle) { + const endBeforeHandle = report('beforeHandle', { + unit: hooks.beforeHandle.length + }) + for (let i = 0; i < hooks.beforeHandle.length; i++) { - const name = `be${i}` + const endUnit = report('beforeHandle.unit', { + name: hooks.beforeHandle[i].name + }) + const name = `be${i}` const returning = hasReturn(hooks.beforeHandle[i].toString()) if (!returning) { fnLiteral += isAsync(hooks.beforeHandle[i]) ? `await beforeHandle[${i}](c);\n` : `beforeHandle[${i}](c);\n` + + endUnit() } else { fnLiteral += isAsync(hooks.beforeHandle[i]) ? `let ${name} = await beforeHandle[${i}](c);\n` : `let ${name} = beforeHandle[${i}](c);\n` + endUnit() + fnLiteral += `if(${name} !== undefined) {\n` - if (hooks?.afterHandle) { + const endAfterHandle = report('afterHandle', { + unit: hooks.transform.length + }) + if (hooks.afterHandle) { const beName = name for (let i = 0; i < hooks.afterHandle.length; i++) { const returning = hasReturn( hooks.afterHandle[i].toString() ) + const endUnit = report('afterHandle.unit', { + name: hooks.afterHandle[i].name + }) + + fnLiteral += `c.response = ${beName}\n` + if (!returning) { fnLiteral += isAsync(hooks.afterHandle[i]) ? `await afterHandle[${i}](c, ${beName});\n` @@ -597,130 +980,210 @@ export const composeHandler = ({ const name = `af${i}` fnLiteral += isAsync(hooks.afterHandle[i]) - ? `const ${name} = await afterHandle[${i}](c, ${beName});\n` - : `const ${name} = afterHandle[${i}](c, ${beName});\n` + ? `const ${name} = await afterHandle[${i}](c);\n` + : `const ${name} = afterHandle[${i}](c);\n` - fnLiteral += `if(${name} !== undefined) { ${beName} = ${name} }\n` + fnLiteral += `if(${name} !== undefined) { c.response = ${beName} = ${name} }\n` } + + endUnit() } } + endAfterHandle() if (validator.response) - fnLiteral += `if(response[c.set.status]?.Check(${name}) === false) { - if(!(response instanceof Error)) - ${composeResponseValidation(name)} - }\n` + fnLiteral += composeResponseValidation(name) + fnLiteral += encodeCookie fnLiteral += `return mapEarlyResponse(${name}, c.set)}\n` } } + endBeforeHandle() + } + if (hooks?.afterHandle.length) { - fnLiteral += isAsync(handler) - ? `let r = await handler(c);\n` - : `let r = handler(c);\n` + const endHandle = report('handle', { + name: handler.name + }) + + if (hooks.afterHandle.length) + fnLiteral += isAsync(handler) + ? `let r = c.response = await handler(c);\n` + : `let r = c.response = handler(c);\n` + else + fnLiteral += isAsync(handler) + ? `let r = await handler(c);\n` + : `let r = handler(c);\n` + + endHandle() + + const endAfterHandle = report('afterHandle', { + unit: hooks.afterHandle.length + }) for (let i = 0; i < hooks.afterHandle.length; i++) { const name = `af${i}` - const returning = hasReturn(hooks.afterHandle[i].toString()) + const endUnit = report('afterHandle.unit', { + name: hooks.afterHandle[i].name + }) + if (!returning) { fnLiteral += isAsync(hooks.afterHandle[i]) - ? `await afterHandle[${i}](c, r)\n` - : `afterHandle[${i}](c, r)\n` + ? `await afterHandle[${i}](c)\n` + : `afterHandle[${i}](c)\n` + + endUnit() } else { - fnLiteral += isAsync(hooks.afterHandle[i]) - ? `let ${name} = await afterHandle[${i}](c, r)\n` - : `let ${name} = afterHandle[${i}](c, r)\n` + if (validator.response) + fnLiteral += isAsync(hooks.afterHandle[i]) + ? `let ${name} = await afterHandle[${i}](c)\n` + : `let ${name} = afterHandle[${i}](c)\n` + else + fnLiteral += isAsync(hooks.afterHandle[i]) + ? `let ${name} = mapEarlyResponse(await afterHandle[${i}](c), c.set)\n` + : `let ${name} = mapEarlyResponse(afterHandle[${i}](c), c.set)\n` + + endUnit() if (validator.response) { fnLiteral += `if(${name} !== undefined) {` - fnLiteral += `if(response[c.set.status]?.Check(${name}) === false) { - if(!(response instanceof Error)) - ${composeResponseValidation(name)} - }\n` - } + fnLiteral += composeResponseValidation(name) - fnLiteral += `${name} = mapEarlyResponse(${name}, c.set);\n` - fnLiteral += `if(${name}) return ${name};\n` + fnLiteral += `${name} = mapEarlyResponse(${name}, c.set)\n` - if (validator.response) { - fnLiteral += '}' + fnLiteral += `if(${name}) {` + endAfterHandle() + fnLiteral += `return ${name} } }` + } else { + fnLiteral += `if(${name}) {` + endAfterHandle() + fnLiteral += `return ${name}}\n` } } } - if (validator.response) - fnLiteral += `if(response[c.set.status]?.Check(r) === false) { - if(!(response instanceof Error)) - ${composeResponseValidation()} - }\n` + endAfterHandle() + + fnLiteral += `r = c.response\n` + if (validator.response) fnLiteral += composeResponseValidation() + + fnLiteral += encodeCookie if (hasSet) fnLiteral += `return mapResponse(r, c.set)\n` else fnLiteral += `return mapCompactResponse(r)\n` } else { + const endHandle = report('handle', { + name: handler.name + }) + if (validator.response) { fnLiteral += isAsync(handler) ? `const r = await handler(c);\n` : `const r = handler(c);\n` - fnLiteral += `if(response[c.set.status]?.Check(r) === false) { - if(!(response instanceof Error)) - ${composeResponseValidation()} - }\n` + endHandle() + + fnLiteral += composeResponseValidation() + + report('afterHandle')() + + fnLiteral += encodeCookie if (hasSet) fnLiteral += `return mapResponse(r, c.set)\n` else fnLiteral += `return mapCompactResponse(r)\n` } else { - const handled = isAsync(handler) - ? 'await handler(c) ' - : 'handler(c)' + if (traceConditions.handle || hasCookie) { + fnLiteral += isAsync(handler) + ? `let r = await handler(c);\n` + : `let r = handler(c);\n` - if (hasSet) fnLiteral += `return mapResponse(${handled}, c.set)\n` - else fnLiteral += `return mapCompactResponse(${handled})\n` + endHandle() + + report('afterHandle')() + + fnLiteral += encodeCookie + + if (hasSet) fnLiteral += `return mapResponse(r, c.set)\n` + else fnLiteral += `return mapCompactResponse(r)\n` + } else { + endHandle() + + const handled = isAsync(handler) + ? 'await handler(c) ' + : 'handler(c)' + + report('afterHandle')() + + if (hasSet) + fnLiteral += `return mapResponse(${handled}, c.set)\n` + else fnLiteral += `return mapCompactResponse(${handled})\n` + } } } - if (hasErrorHandler) { + if (hasErrorHandler || handleResponse) { fnLiteral += ` -} catch(error) { - ${ - '' - // hasStrictContentType || - // // @ts-ignore - // validator?.body?.schema - // ? `if(!c.body) error = parseError` - // : '' - } +} catch(error) {` + + if (!maybeAsync) fnLiteral += `return (async () => {` - ${maybeAsync ? '' : 'return (async () => {'} - const set = c.set + fnLiteral += `const set = c.set if (!set.status || set.status < 300) set.status = 500 + ` + + const endError = report('error', { + unit: hooks.error.length + }) + if (hooks.error.length) { + for (let i = 0; i < hooks.error.length; i++) { + const name = `er${i}` + const endUnit = report('error.unit', { + name: hooks.error[i].name + }) - ${ - hooks.error.length - ? `for (let i = 0; i < handleErrors.length; i++) { - let handled = handleErrors[i]({ + fnLiteral += `\nlet ${name} = handleErrors[${i}]({ request: c.request, error: error, set, code: error.code ?? error[ERROR_CODE] ?? "UNKNOWN" - }) - if (handled instanceof Promise) handled = await handled + })\n` - const response = mapEarlyResponse(handled, set) - if (response) return response - }` - : '' + if (isAsync(hooks.error[i])) + fnLiteral += `if (${name} instanceof Promise) ${name} = await ${name}\n` + + endUnit() + + fnLiteral += `${name} = mapEarlyResponse(${name}, set)\n` + fnLiteral += `if (${name}) {` + fnLiteral += `return ${name} }\n` + } } - return handleError(c.request, error, set) - ${maybeAsync ? '' : '})()'} -} finally { - ${handleResponse} -}` + endError() + + fnLiteral += `return handleError(c, error)\n\n` + + if (!maybeAsync) fnLiteral += '})()' + + fnLiteral += '}' + + if (handleResponse || hasTrace) { + fnLiteral += ` finally { ` + + const endResponse = report('response', { + unit: hooks.onResponse.length + }) + + fnLiteral += handleResponse + + endResponse() + + fnLiteral += `}` + } } // console.log(fnLiteral) @@ -741,7 +1204,8 @@ export const composeHandler = ({ headers, params, query, - response + response, + cookie }, utils: { mapResponse, @@ -754,8 +1218,13 @@ export const composeHandler = ({ ValidationError, InternalServerError }, - meta, - ERROR_CODE + schema, + definitions, + ERROR_CODE, + reporter, + requestId, + parseCookie, + signCookie } = hooks ${ @@ -767,12 +1236,10 @@ export const composeHandler = ({ } return ${maybeAsync ? 'async' : ''} function(c) { - ${meta ? 'c["schema"] = meta["schema"]; c["defs"] = meta["defs"];' : ''} + ${schema && definitions ? 'c.schema = schema; c.defs = definitions;' : ''} ${fnLiteral} }` - // console.log(fnLiteral) - const createHandler = Function('hooks', fnLiteral) return createHandler({ @@ -791,13 +1258,19 @@ export const composeHandler = ({ ValidationError, InternalServerError }, - meta, - ERROR_CODE + schema, + definitions, + ERROR_CODE, + reporter, + requestId, + parseCookie, + signCookie }) } -export const composeGeneralHandler = (app: Elysia) => { +export const composeGeneralHandler = (app: Elysia) => { let decoratorsLiteral = '' + let fnLiteral = '' // @ts-ignore for (const key of Object.keys(app.decorators)) @@ -806,6 +1279,8 @@ export const composeGeneralHandler = (app: Elysia) => { // @ts-ignore const { router, staticRouter } = app + const hasTrace = app.event.trace.length > 0 + const findDynamicRoute = ` const route = find(request.method, path) ${ router.root.ALL ? '?? find("ALL", path)' : '' @@ -813,11 +1288,7 @@ export const composeGeneralHandler = (app: Elysia) => { if (route === null) return ${ app.event.error.length - ? `handleError( - request, - notFound, - ctx.set - )` + ? `app.handleError(ctx, notFound)` : `new Response(error404, { status: 404 })` @@ -830,14 +1301,18 @@ export const composeGeneralHandler = (app: Elysia) => { let switchMap = `` for (const [path, { code, all }] of Object.entries(staticRouter.map)) switchMap += `case '${path}':\nswitch(request.method) {\n${code}\n${ - all ?? `default: ${findDynamicRoute}` + all ?? `default: break map` }}\n\n` - let fnLiteral = `const { + const maybeAsync = app.event.request.some(isAsync) + + fnLiteral += `const { app, app: { store, router, staticRouter }, mapEarlyResponse, - NotFoundError + NotFoundError, + requestId, + reporter } = data const notFound = new NotFoundError() @@ -851,39 +1326,86 @@ export const composeGeneralHandler = (app: Elysia) => { ${app.event.error.length ? '' : `const error404 = notFound.message.toString()`} - return function(request) { + return ${maybeAsync ? 'async' : ''} function map(request) { ` + const traceLiteral = app.event.trace.map((x) => x.toString()) + const report = createReport({ + hasTrace, + hasTraceChildren: + hasTrace && + traceLiteral.some( + (x) => x.includes('children') || isContextPassToFunction(x) + ), + condition: { + request: traceLiteral.some( + (x) => isFnUse('request', x) || isContextPassToFunction(x) + ) + }, + addFn: (word) => { + fnLiteral += word + } + }) + if (app.event.request.length) { fnLiteral += ` + ${hasTrace ? 'const id = +requestId.value++' : ''} + const ctx = { request, store, set: { + cookie: {}, headers: {}, status: 200 } + ${hasTrace ? ',$$requestId: +id' : ''} ${decoratorsLiteral} } + ` + + const endReport = report('request', { + attribute: 'ctx', + unit: app.event.request.length + }) - try {\n` + fnLiteral += `try {\n` for (let i = 0; i < app.event.request.length; i++) { - const withReturn = hasReturn(app.event.request[i].toString()) + const fn = app.event.request[i] + const withReturn = hasReturn(fn.toString()) + const maybeAsync = isAsync(fn) + + const endUnit = report('request.unit', { + name: app.event.request[i].name + }) - fnLiteral += !withReturn - ? `mapEarlyResponse(onRequest[${i}](ctx), ctx.set);` - : `const response = mapEarlyResponse( - onRequest[${i}](ctx), + const name = `re${i}` + + if (withReturn) { + fnLiteral += `const ${name} = mapEarlyResponse( + ${maybeAsync ? 'await' : ''} onRequest[${i}](ctx), ctx.set - ) - if (response) return response\n` + )\n` + + endUnit() + + fnLiteral += `if(${name}) return ${name}\n` + } else { + fnLiteral += `${ + maybeAsync ? 'await' : '' + } onRequest[${i}](ctx)\n` + endUnit() + } } fnLiteral += `} catch (error) { - return handleError(request, error, ctx.set) - } - + return app.handleError(ctx, error) + }` + + endReport() + + fnLiteral += ` const url = request.url, s = url.indexOf('/', 11), i = ctx.qi = url.indexOf('?', s + 1), @@ -897,6 +1419,8 @@ export const composeGeneralHandler = (app: Elysia) => { ? url.substring(s) : url.substring(s, qi) + ${hasTrace ? 'const id = +requestId.value++' : ''} + const ctx = { request, store, @@ -906,17 +1430,30 @@ export const composeGeneralHandler = (app: Elysia) => { headers: {}, status: 200 } + ${hasTrace ? ',$$requestId: id' : ''} ${decoratorsLiteral} }` + + report('request', { + unit: app.event.request.length, + attribute: + traceLiteral.some((x) => isFnUse('context', x)) || + traceLiteral.some((x) => isFnUse('store', x)) || + traceLiteral.some((x) => isFnUse('set', x)) + ? 'ctx' + : '' + })() } fnLiteral += ` - switch(path) { + map: switch(path) { ${switchMap} default: - ${findDynamicRoute} + break } + + ${findDynamicRoute} }` // @ts-ignore @@ -928,11 +1465,14 @@ export const composeGeneralHandler = (app: Elysia) => { )({ app, mapEarlyResponse, - NotFoundError + NotFoundError, + // @ts-ignore + reporter: app.reporter, + requestId }) } -export const composeErrorHandler = (app: Elysia) => { +export const composeErrorHandler = (app: Elysia) => { let fnLiteral = `const { app: { event: { error: onError, onResponse: res } }, mapResponse, @@ -941,7 +1481,9 @@ export const composeErrorHandler = (app: Elysia) => { return ${ app.event.error.find(isAsync) ? 'async' : '' - } function(request, error, set) {` + } function(context, error) { + const { request, set } = context + ` for (let i = 0; i < app.event.error.length; i++) { const handler = app.event.error[i] diff --git a/src/context.ts b/src/context.ts index c24911a3..262a8ca1 100644 --- a/src/context.ts +++ b/src/context.ts @@ -1,32 +1,93 @@ -import type { Elysia, TypedRoute } from '.' +import { HTTPStatusName } from './utils' + +import { Cookie, type CookieOptions } from './cookie' + +type WithoutNullableKeys = { + [Key in keyof Type]-?: NonNullable +} + +import type { + DecoratorBase, + RouteSchema, + Prettify, + GetPathParameter +} from './types' export type Context< - Route extends TypedRoute = TypedRoute, - Store extends Elysia['store'] = Elysia['store'] -> = { - request: Request - headers: undefined extends Route['headers'] - ? Record - : Route['headers'] - query: undefined extends Route['query'] - ? Record - : Route['query'] - params: Route['params'] - body: Route['body'] - store: Store - path: string - - set: { - headers: { [header: string]: string } & { - ['Set-Cookie']?: string | string[] + Route extends RouteSchema = RouteSchema, + Decorators extends DecoratorBase = { + request: {} + store: {} + }, + Path extends string = '' +> = Prettify< + { + body: Route['body'] + query: undefined extends Route['query'] + ? Record + : Route['query'] + params: undefined extends Route['params'] + ? Path extends `${string}/${':' | '*'}${string}` + ? Record, string> + : never + : Route['params'] + headers: undefined extends Route['headers'] + ? Record + : Route['headers'] + cookie: undefined extends Route['cookie'] + ? Record> + : Record> & + WithoutNullableKeys<{ + [key in keyof Route['cookie']]: Cookie< + Route['cookie'][key] + > + }> + + set: { + headers: Record & { + 'Set-Cookie'?: string | string[] + } + status?: number | HTTPStatusName + redirect?: string + cookie?: Record< + string, + Prettify< + { + value: string + } & CookieOptions + > + > } - status?: number - redirect?: string - } -} + + path: string + request: Request + store: Decorators['store'] + } & Decorators['request'] +> // Use to mimic request before mapping route export type PreContext< - Route extends TypedRoute = TypedRoute, - Store extends Elysia['store'] = Elysia['store'] -> = Omit, 'query' | 'params' | 'body'> + Route extends RouteSchema = RouteSchema, + Decorators extends DecoratorBase = { + request: {} + store: {} + } +> = Prettify< + { + headers: undefined extends Route['headers'] + ? Record + : Route['headers'] + + set: { + headers: { [header: string]: string } & { + ['Set-Cookie']?: string | string[] + } + status?: number + redirect?: string + } + + path: string + request: Request + store: Decorators['store'] + } & Decorators['request'] +> diff --git a/src/cookie.ts b/src/cookie.ts new file mode 100644 index 00000000..fd9f8815 --- /dev/null +++ b/src/cookie.ts @@ -0,0 +1,409 @@ +// @ts-ignore +import { parse } from 'cookie' +import type { Context } from './context' + +import { unsign as unsignCookie } from 'cookie-signature' +import { InvalidCookieSignature } from './error' + +export interface CookieOptions { + /** + * Specifies the value for the {@link https://tools.ietf.org/html/rfc6265#section-5.2.3|Domain Set-Cookie attribute}. By default, no + * domain is set, and most clients will consider the cookie to apply to only + * the current domain. + */ + domain?: string | undefined + + /** + * Specifies the `Date` object to be the value for the {@link https://tools.ietf.org/html/rfc6265#section-5.2.1|`Expires` `Set-Cookie` attribute}. By default, + * no expiration is set, and most clients will consider this a "non-persistent cookie" and will delete + * it on a condition like exiting a web browser application. + * + * *Note* the {@link https://tools.ietf.org/html/rfc6265#section-5.3|cookie storage model specification} + * states that if both `expires` and `maxAge` are set, then `maxAge` takes precedence, but it is + * possible not all clients by obey this, so if both are set, they should + * point to the same date and time. + */ + expires?: Date | undefined + /** + * Specifies the boolean value for the {@link https://tools.ietf.org/html/rfc6265#section-5.2.6|`HttpOnly` `Set-Cookie` attribute}. + * When truthy, the `HttpOnly` attribute is set, otherwise it is not. By + * default, the `HttpOnly` attribute is not set. + * + * *Note* be careful when setting this to true, as compliant clients will + * not allow client-side JavaScript to see the cookie in `document.cookie`. + */ + httpOnly?: boolean | undefined + /** + * Specifies the number (in seconds) to be the value for the `Max-Age` + * `Set-Cookie` attribute. The given number will be converted to an integer + * by rounding down. By default, no maximum age is set. + * + * *Note* the {@link https://tools.ietf.org/html/rfc6265#section-5.3|cookie storage model specification} + * states that if both `expires` and `maxAge` are set, then `maxAge` takes precedence, but it is + * possible not all clients by obey this, so if both are set, they should + * point to the same date and time. + */ + maxAge?: number | undefined + /** + * Specifies the value for the {@link https://tools.ietf.org/html/rfc6265#section-5.2.4|`Path` `Set-Cookie` attribute}. + * By default, the path is considered the "default path". + */ + path?: string | undefined + /** + * Specifies the `string` to be the value for the [`Priority` `Set-Cookie` attribute][rfc-west-cookie-priority-00-4.1]. + * + * - `'low'` will set the `Priority` attribute to `Low`. + * - `'medium'` will set the `Priority` attribute to `Medium`, the default priority when not set. + * - `'high'` will set the `Priority` attribute to `High`. + * + * More information about the different priority levels can be found in + * [the specification][rfc-west-cookie-priority-00-4.1]. + * + * **note** This is an attribute that has not yet been fully standardized, and may change in the future. + * This also means many clients may ignore this attribute until they understand it. + */ + priority?: 'low' | 'medium' | 'high' | undefined + /** + * Specifies the boolean or string to be the value for the {@link https://tools.ietf.org/html/draft-ietf-httpbis-rfc6265bis-03#section-4.1.2.7|`SameSite` `Set-Cookie` attribute}. + * + * - `true` will set the `SameSite` attribute to `Strict` for strict same + * site enforcement. + * - `false` will not set the `SameSite` attribute. + * - `'lax'` will set the `SameSite` attribute to Lax for lax same site + * enforcement. + * - `'strict'` will set the `SameSite` attribute to Strict for strict same + * site enforcement. + * - `'none'` will set the SameSite attribute to None for an explicit + * cross-site cookie. + * + * More information about the different enforcement levels can be found in {@link https://tools.ietf.org/html/draft-ietf-httpbis-rfc6265bis-03#section-4.1.2.7|the specification}. + * + * *note* This is an attribute that has not yet been fully standardized, and may change in the future. This also means many clients may ignore this attribute until they understand it. + */ + sameSite?: true | false | 'lax' | 'strict' | 'none' | undefined + /** + * Specifies the boolean value for the {@link https://tools.ietf.org/html/rfc6265#section-5.2.5|`Secure` `Set-Cookie` attribute}. When truthy, the + * `Secure` attribute is set, otherwise it is not. By default, the `Secure` attribute is not set. + * + * *Note* be careful when setting this to `true`, as compliant clients will + * not send the cookie back to the server in the future if the browser does + * not have an HTTPS connection. + */ + secure?: boolean | undefined + + /** + * Secret key for signing cookie + * + * If array is passed, will use Key Rotation. + * + * Key rotation is when an encryption key is retired + * and replaced by generating a new cryptographic key. + */ + secrets?: string | string[] +} + +type MutateCookie = CookieOptions & { + value?: T +} extends infer A + ? A | ((previous: A) => A) + : never + +type CookieJar = Record + +export class Cookie implements CookieOptions { + public name: string | undefined + private setter: Context['set'] | undefined + + constructor( + private _value: T, + public property: Readonly = {} + ) {} + + get() { + return this._value + } + + get value(): T { + return this._value as any + } + + set value(value: T) { + if (typeof value === 'object') { + if (JSON.stringify(this.value) === JSON.stringify(value)) return + } else if (this.value === value) return + + this._value = value as any + + this.sync() + } + + add(config: MutateCookie): Cookie { + const updated = Object.assign( + this.property, + typeof config === 'function' + ? config(Object.assign(this.property, this.value) as any) + : config + ) + + if ('value' in updated) { + this._value = updated.value as any + + delete updated.value + } + + this.property = updated + return this.sync() as any + } + + set(config: MutateCookie): Cookie { + const updated = + typeof config === 'function' + ? config(Object.assign(this.property, this.value) as any) + : config + + if ('value' in updated) { + this._value = updated.value as any + + delete updated.value + } + + this.property = updated + return this.sync() as any + } + + remove() { + if (this.value === undefined) return + + this.set({ + value: '' as any, + expires: new Date() + }) + } + + get domain() { + return this.property.domain + } + + set domain(value) { + // @ts-ignore + this.property.domain = value + + this.sync() + } + + get expires() { + return this.property.expires + } + + set expires(value) { + // @ts-ignore + this.property.expires = value + + this.sync() + } + + get httpOnly() { + return this.property.httpOnly + } + + set httpOnly(value) { + // @ts-ignore + this.property.httpOnly = value + + this.sync() + } + + get maxAge() { + return this.property.maxAge + } + + set maxAge(value) { + // @ts-ignore + this.property.maxAge = value + + this.sync() + } + + get path() { + return this.property.path + } + + set path(value) { + // @ts-ignore + this.property.path = value + + this.sync() + } + + get priority() { + return this.property.priority + } + + set priority(value) { + // @ts-ignore + this.property.priority = value + + this.sync() + } + + get sameSite() { + return this.property.sameSite + } + + set sameSite(value) { + // @ts-ignore + this.property.sameSite = value + + this.sync() + } + + get secure() { + return this.property.secure + } + + set secure(value) { + // @ts-ignore + this.property.secure = value + + this.sync() + } + + toString() { + return typeof this.value === 'object' + ? JSON.stringify(this.value) + : this.value?.toString() ?? '' + } + + private sync() { + if (!this.name || !this.setter) return this + + if (!this.setter.cookie) + this.setter.cookie = { + [this.name]: Object.assign(this.property, { + value: this.toString() + }) + } + else + this.setter.cookie[this.name] = Object.assign(this.property, { + value: this.toString() + }) + + return this + } +} + +export const createCookieJar = (initial: CookieJar, set: Context['set']) => + new Proxy(initial as CookieJar, { + get(target, key: string) { + if (key in target) return target[key] + + // @ts-ignore + const cookie = new Cookie(undefined) + // @ts-ignore + cookie.setter = set + cookie.name = key + + // @ts-ignore + return cookie + }, + set(target, key: string, value) { + if (!(value instanceof Cookie)) return false + + if (!set.cookie) set.cookie = {} + + // @ts-ignore + value.setter = set + value.name = key + + // @ts-ignore + value.sync() + + target[key] = value + + return true + } + }) + +export const parseCookie = ( + set: Context['set'], + cookieString?: string | null, + { + secret, + sign + }: { + secret?: string | string[] + sign?: true | string | string[] + } = {} +) => { + if (!cookieString) return createCookieJar({}, set) + + const jar: CookieJar = {} + const isStringKey = typeof secret === 'string' + + if (sign && sign !== true && !Array.isArray(sign)) sign = [sign] + + const cookieKeys = Object.keys(parse(cookieString)) + for (let i = 0; i < cookieKeys.length; i++) { + const key = cookieKeys[i] + let value = parse(cookieString)[key] + + if (sign === true || sign?.includes(key)) { + if (!secret) + throw new Error('No secret is provided to cookie plugin') + + if (isStringKey) { + // @ts-ignore + value = unsignCookie(value as string, secret) + + // @ts-ignore + if (value === false) throw new InvalidCookieSignature(key) + } else { + let fail = true + for (let i = 0; i < secret.length; i++) { + const temp = unsignCookie(value as string, secret[i]) + + if (temp !== false) { + value = temp + fail = false + break + } + } + + if (fail) throw new InvalidCookieSignature(key) + } + } + + const start = (value as string).charCodeAt(0) + if (start === 123 || start === 91) + try { + const cookie = new Cookie(JSON.parse(value as string)) + + // @ts-ignore + cookie.setter = set + cookie.name = key + + jar[key] = cookie + + continue + } catch { + // Not empty + } + + // @ts-ignore + if (!Number.isNaN(+value)) value = +value + // @ts-ignore + else if (value === 'true') value = true + // @ts-ignore + else if (value === 'false') value = false + + const cookie = new Cookie(value) + + // @ts-ignore + cookie.setter = set + cookie.name = key + + jar[key] = cookie + } + + return createCookieJar(jar, set) +} diff --git a/src/custom-types.ts b/src/custom-types.ts index 818cd9ae..df5649eb 100644 --- a/src/custom-types.ts +++ b/src/custom-types.ts @@ -1,10 +1,18 @@ +import { TypeSystem } from '@sinclair/typebox/system' import { Type, type SchemaOptions, - type NumericOptions + type NumericOptions, + type TNull, + type TUnion, + type TSchema, + type TUndefined, + TProperties, + ObjectOptions, + TObject, + TNumber } from '@sinclair/typebox' -import type { TypeCheck } from '@sinclair/typebox/compiler' -import { TypeSystem } from '@sinclair/typebox/system' +import { type TypeCheck } from '@sinclair/typebox/compiler' try { TypeSystem.Format('email', (value) => @@ -129,39 +137,96 @@ const validateFile = (options: ElysiaTypeOptions.File, value: any) => { return true } +const Files = TypeSystem.Type( + 'Files', + (options, value) => { + if (!Array.isArray(value)) return validateFile(options, value) + + if (options.minItems && value.length < options.minItems) return false + + if (options.maxItems && value.length > options.maxItems) return false + + for (let i = 0; i < value.length; i++) + if (!validateFile(options, value[i])) return false + + return true + } +) + export const ElysiaType = { - // Numeric type is for type reference only since it's aliased to t.Number - Numeric: TypeSystem.Type>( - 'Numeric', - {} as any - ), - File: TypeSystem.Type('File', validateFile), - Files: TypeSystem.Type( - 'Files', - (options, value) => { - if (!Array.isArray(value)) return validateFile(options, value) - - if (options.minItems && value.length < options.minItems) - return false - - if (options.maxItems && value.length > options.maxItems) - return false - - for (let i = 0; i < value.length; i++) - if (!validateFile(options, value[i])) return false - - return true + Numeric: (property?: NumericOptions) => + Type.Transform(Type.Union([Type.String(), Type.Number(property)])) + .Decode((value) => { + const number = +value + if (isNaN(number)) return value + + return number + }) + .Encode((value) => value) as any as TNumber, + ObjectString: ( + properties: T, + options?: ObjectOptions + ) => + Type.Transform( + Type.Union([Type.String(), Type.Object(properties, options)]) + ) + .Decode((value) => { + if (typeof value === 'string') + try { + return JSON.parse(value as string) + } catch { + return value + } + + return value + }) + .Encode((value) => JSON.stringify(value)) as any as TObject, + File: TypeSystem.Type('File', validateFile), + Files: (options: ElysiaTypeOptions.Files) => + Type.Transform(Type.Union([Files(options)])) + .Decode((value) => { + if (Array.isArray(value)) return value + return [value] + }) + .Encode((value) => value), + Nullable: (schema: T): TUnion<[T, TNull]> => + ({ ...schema, nullable: true } as any), + MaybeEmpty: (schema: T): TUnion<[T, TUndefined]> => + Type.Union([Type.Undefined(), schema]) as any, + Cookie: ( + properties: T, + options?: ObjectOptions & { + /** + * Secret key for signing cookie + * + * If array is passed, will use Key Rotation. + * + * Key rotation is when an encryption key is retired + * and replaced by generating a new cryptographic key. + */ + secrets?: string | string[] + /** + * Specified cookie name to be signed globally + */ + sign?: Readonly<(keyof T | (string & {}))[]> } - ) + ): TObject => Type.Object(properties, options) } as const +export type TCookie = (typeof ElysiaType)['Cookie'] + declare module '@sinclair/typebox' { interface TypeBuilder { + ObjectString: typeof ElysiaType.ObjectString // @ts-ignore Numeric: typeof ElysiaType.Numeric + // @ts-ignore File: typeof ElysiaType.File + // @ts-ignore Files: typeof ElysiaType.Files - URLEncoded: (typeof Type)['Object'] + Nullable: typeof ElysiaType.Nullable + MaybeEmpty: typeof ElysiaType.MaybeEmpty + Cookie: typeof ElysiaType.Cookie } interface SchemaOptions { @@ -175,27 +240,17 @@ declare module '@sinclair/typebox' { } } +Type.ObjectString = ElysiaType.ObjectString + /** * A Numeric string * * Will be parse to Number */ -Type.Numeric = (properties) => { - return Type.Number({ - ...properties, - elysiaMeta: 'Numeric' - }) as any -} +Type.Numeric = ElysiaType.Numeric -Type.URLEncoded = (property, options) => - Type.Object(property, { - ...options, - elysiaMeta: 'URLEncoded' - }) - -Type.File = (arg?: ElysiaTypeOptions.File) => +Type.File = (arg = {}) => ElysiaType.File({ - elysiaMeta: 'File', default: 'File', ...arg, extension: arg?.type, @@ -203,7 +258,7 @@ Type.File = (arg?: ElysiaTypeOptions.File) => format: 'binary' }) -Type.Files = (arg?: ElysiaTypeOptions.Files) => +Type.Files = (arg = {}) => ElysiaType.Files({ ...arg, elysiaMeta: 'Files', @@ -218,4 +273,87 @@ Type.Files = (arg?: ElysiaTypeOptions.Files) => } }) +Type.Nullable = (schema) => ElysiaType.Nullable(schema) +Type.MaybeEmpty = ElysiaType.MaybeEmpty + +Type.Cookie = ElysiaType.Cookie + export { Type as t } + +// type Template = +// | string +// | number +// | bigint +// | boolean +// | StringConstructor +// | NumberConstructor +// | undefined + +// type Join = A extends Readonly<[infer First, ...infer Rest]> +// ? ( +// First extends Readonly +// ? First[number] +// : First extends StringConstructor +// ? string +// : First extends NumberConstructor +// ? `${number}` +// : First +// ) extends infer A +// ? Rest extends [] +// ? A extends undefined +// ? NonNullable | '' +// : A +// : // @ts-ignore +// A extends undefined +// ? `${NonNullable}${Join}` | '' +// : // @ts-ignore +// `${A}${Join}` +// : '' +// : '' + +// const template = < +// const T extends Readonly<(Template | Readonly)[]> +// >( +// ...p: T +// ): Join => { +// return a as any +// } + +// const create = +// (t: T): ((t: T) => void) => +// (t) => +// t + +// const optional = < +// const T extends Readonly<(Template | Readonly)[]> +// >( +// ...p: T +// ): T | undefined => { +// return undefined +// } + +// template.optional = optional + +// const hi = create( +// template( +// ['seminar', 'millennium'], +// ':', +// ['Rio', 'Yuuka', 'Noa', 'Koyuki'], +// template.optional(template(',', ['Rio', 'Yuuka', 'Noa', 'Koyuki'])), +// template.optional(template(',', ['Rio', 'Yuuka', 'Noa', 'Koyuki'])), +// template.optional(template(',', ['Rio', 'Yuuka', 'Noa', 'Koyuki'])) +// ) +// ) + +// hi(`seminar:Noa,Koyuki,Yuuka`) + +// const a = TypeCompiler.Compile(Type.String()) + +// console.log(v.Decode.toString()) + +// const T = Type.Transform(v.schema) +// .Decode((value) => new Date(value)) // required: number to Date +// .Encode((value) => value.getTime()) // required: Date to number + +// const decoded = Value.Decode(T, 0) // const decoded = Date(1970-01-01T00:00:00.000Z) +// const encoded = Value.Encode(T, decoded) diff --git a/src/dynamic-handle.ts b/src/dynamic-handle.ts index d1eeddce..8741c3dc 100644 --- a/src/dynamic-handle.ts +++ b/src/dynamic-handle.ts @@ -1,29 +1,30 @@ import type { Elysia } from '.' -import { - ElysiaErrors, - NotFoundError, - ValidationError, - ERROR_CODE -} from './error' import { mapEarlyResponse, mapResponse } from './handler' +import { ElysiaErrors, NotFoundError, ValidationError } from './error' import type { Context } from './context' -import type { Handler, RegisteredHook, SchemaValidator } from './types' import { parse as parseQuery } from 'fast-querystring' +import { sign as signCookie } from 'cookie-signature' +import { parseCookie } from './cookie' + +import type { Handler, LifeCycleStore, SchemaValidator } from './types' + +// JIT Handler export type DynamicHandler = { handle: Handler content?: string - hooks: RegisteredHook + hooks: LifeCycleStore validator?: SchemaValidator } export const createDynamicHandler = - (app: Elysia) => + (app: Elysia) => async (request: Request): Promise => { const set: Context['set'] = { + cookie: {}, status: 200, headers: {} } @@ -39,12 +40,11 @@ export const createDynamicHandler = context.set = set context.store = app.store } else { - // @ts-ignore context = { set, store: app.store, request - } + } as any as Context } const url = request.url, @@ -53,12 +53,10 @@ export const createDynamicHandler = path = q === -1 ? url.substring(s) : url.substring(s, q) try { - // @ts-ignore - for (let i = 0; i < app.event.request.length; i++) { // @ts-ignore const onRequest = app.event.request[i] - let response = onRequest(context) + let response = onRequest(context as any) if (response instanceof Promise) response = await response response = mapEarlyResponse(response, set) @@ -76,7 +74,7 @@ export const createDynamicHandler = const { handle, hooks, validator, content } = handler.store let body: string | Record | undefined - if (request.method !== 'GET') { + if (request.method !== 'GET' && request.method !== 'HEAD') { if (content) { switch (content) { case 'application/json': @@ -117,7 +115,6 @@ export const createDynamicHandler = if (index !== -1) contentType = contentType.slice(0, index) - // @ts-ignore for (let i = 0; i < app.event.parse.length; i++) { // @ts-ignore let temp = app.event.parse[i](context, contentType) @@ -169,9 +166,44 @@ export const createDynamicHandler = } context.body = body - context.params = handler?.params || {} + // @ts-ignore + context.params = handler?.params || undefined context.query = q === -1 ? {} : parseQuery(url.substring(q + 1)) + context.headers = {} + for (const [key, value] of request.headers.entries()) + context.headers[key] = value + + // @ts-ignore + const cookieMeta = validator?.cookie?.schema as { + secrets?: string | string[] + sign: string[] | true + properties: { [x: string]: Object } + } + + context.cookie = parseCookie( + context.set, + context.headers.cookie, + cookieMeta + ? { + secret: + cookieMeta.secrets !== undefined + ? typeof cookieMeta.secrets === 'string' + ? cookieMeta.secrets + : cookieMeta.secrets.join(',') + : undefined, + sign: + cookieMeta.sign === true + ? true + : cookieMeta.sign !== undefined + ? typeof cookieMeta.sign === 'string' + ? cookieMeta.sign + : cookieMeta.sign.join(',') + : undefined + } + : undefined + ) + for (let i = 0; i < hooks.transform.length; i++) { const operation = hooks.transform[i](context) @@ -211,6 +243,19 @@ export const createDynamicHandler = context.query ) + if (validator.cookie) { + const cookieValue: Record = {} + for (const [key, value] of Object.entries(context.cookie)) + cookieValue[key] = value.value + + if (validator.cookie?.Check(cookieValue) === false) + throw new ValidationError( + 'cookie', + validator.cookie, + cookieValue + ) + } + if (validator.body?.Check(body) === false) throw new ValidationError('body', validator.body, body) } @@ -221,10 +266,17 @@ export const createDynamicHandler = // `false` is a falsey value, check for undefined instead if (response !== undefined) { + ;( + context as Context & { + response: unknown + } + ).response = response + for (let i = 0; i < hooks.afterHandle.length; i++) { let newResponse = hooks.afterHandle[i]( - context, - response + context as Context & { + response: unknown + } ) if (newResponse instanceof Promise) newResponse = await newResponse @@ -249,9 +301,19 @@ export const createDynamicHandler = responseValidator, response ) - } else + } else { + ;( + context as Context & { + response: unknown + } + ).response = response + for (let i = 0; i < hooks.afterHandle.length; i++) { - let newResponse = hooks.afterHandle[i](context, response) + let newResponse = hooks.afterHandle[i]( + context as Context & { + response: unknown + } + ) if (newResponse instanceof Promise) newResponse = await newResponse @@ -270,6 +332,35 @@ export const createDynamicHandler = return result } } + } + + if (context.set.cookie && cookieMeta?.sign) { + const secret = !cookieMeta.secrets + ? undefined + : typeof cookieMeta.secrets === 'string' + ? cookieMeta.secrets + : cookieMeta.secrets[0] + + if (cookieMeta.sign === true) + for (const [key, cookie] of Object.entries( + context.set.cookie + )) + context.set.cookie[key].value = signCookie( + cookie.value, + '${secret}' + ) + else + for (const name of cookieMeta.sign) { + if (!(name in cookieMeta.properties)) continue + + if (context.set.cookie[name]?.value) { + context.set.cookie[name].value = signCookie( + context.set.cookie[name].value, + secret as any + ) + } + } + } return mapResponse(response, context.set) } catch (error) { @@ -277,7 +368,7 @@ export const createDynamicHandler = set.status = (error as ElysiaErrors).status // @ts-ignore - return app.handleError(request, error as Error, set) + return app.handleError(context, error) } finally { // @ts-ignore for (const onResponse of app.event.onResponse) @@ -286,33 +377,23 @@ export const createDynamicHandler = } export const createDynamicErrorHandler = - (app: Elysia) => - async ( - request: Request, - error: ElysiaErrors, - set: Context['set'] = { - headers: {} - } - ) => { + (app: Elysia) => + async (context: Context, error: ElysiaErrors) => { + const errorContext = Object.assign(context, error) + errorContext.set = context.set + // @ts-ignore for (let i = 0; i < app.event.error.length; i++) { - // @ts-ignore - let response = app.event.error[i]({ - request, - // @ts-ignore - code: error.code ?? error[ERROR_CODE] ?? 'UNKNOWN', - error, - set - }) + let response = app.event.error[i](errorContext as any) if (response instanceof Promise) response = await response if (response !== undefined && response !== null) - return mapResponse(response, set) + return mapResponse(response, context.set) } return new Response( typeof error.cause === 'string' ? error.cause : error.message, { - headers: set.headers, + headers: context.set.headers, status: error.status ?? 500 } ) diff --git a/src/error.ts b/src/error.ts index e4e9c14e..895c98f4 100644 --- a/src/error.ts +++ b/src/error.ts @@ -18,12 +18,12 @@ export type ElysiaErrors = | NotFoundError | ParseError | ValidationError + | InvalidCookieSignature export class InternalServerError extends Error { code = 'INTERNAL_SERVER_ERROR' status = 500 - constructor(message: string) constructor(message?: string) { super(message ?? 'INTERNAL_SERVER_ERROR') } @@ -33,7 +33,6 @@ export class NotFoundError extends Error { code = 'NOT_FOUND' status = 404 - constructor(message: string) constructor(message?: string) { super(message ?? 'NOT_FOUND') } @@ -43,12 +42,20 @@ export class ParseError extends Error { code = 'PARSE' status = 400 - constructor(message: string) constructor(message?: string) { super(message ?? 'PARSE') } } +export class InvalidCookieSignature extends Error { + code = 'INVALID_COOKIE_SIGNATURE' + status = 400 + + constructor(public key: string, message?: string) { + super(message ?? `"${key}" has invalid cookie signature`) + } +} + export class ValidationError extends Error { code = 'VALIDATION' status = 400 diff --git a/src/handler.ts b/src/handler.ts index 9c6307c9..4d7a897c 100644 --- a/src/handler.ts +++ b/src/handler.ts @@ -1,15 +1,25 @@ -/* eslint-disable no-case-declarations */ +// @ts-ignore +import { serialize } from 'cookie' +import { StatusMap } from './utils' + import type { Context } from './context' +import { Cookie } from './cookie' const hasHeaderShorthand = 'toJSON' in new Headers() +type SetResponse = Omit & { + status: number +} + export const isNotEmpty = (obj: Object) => { for (const x in obj) return true return false } -const parseSetCookies = (headers: Headers, setCookie: string[]) => { +export const parseSetCookies = (headers: Headers, setCookie: string[]) => { + if (!headers || !Array.isArray(setCookie)) return headers + headers.delete('Set-Cookie') for (let i = 0; i < setCookie.length; i++) { @@ -24,95 +34,139 @@ const parseSetCookies = (headers: Headers, setCookie: string[]) => { return headers } -export const mapEarlyResponse = ( +export const cookieToHeader = (cookies: Context['set']['cookie']) => { + if (!cookies || typeof cookies !== 'object' || !isNotEmpty(cookies)) + return undefined + + const set: string[] = [] + + for (const [key, property] of Object.entries(cookies)) { + if (!key || !property) continue + + if (Array.isArray(property.value)) { + for (let i = 0; i < property.value.length; i++) { + let value = property.value[i] + if (value === undefined || value === null) continue + + if (typeof value === 'object') value = JSON.stringify(value) + + set.push(serialize(key, value, property)) + } + } else { + let value = property.value + if (value === undefined || value === null) continue + + if (typeof value === 'object') value = JSON.stringify(value) + + set.push(serialize(key, property.value, property)) + } + } + + if (set.length === 0) return undefined + if (set.length === 1) return set[0] + + return set +} + +export const mapResponse = ( response: unknown, set: Context['set'] -): Response | undefined => { - if (isNotEmpty(set.headers) || set.status !== 200 || set.redirect) { +): Response => { + if ( + isNotEmpty(set.headers) || + set.status !== 200 || + set.redirect || + set.cookie + ) { + if (typeof set.status === 'string') set.status = StatusMap[set.status] + if (set.redirect) { set.headers.Location = set.redirect - if (set.status === 200) set.status = 302 + if (!set.status || set.status < 300 || set.status >= 400) + set.status = 302 } + if (set.cookie && isNotEmpty(set.cookie)) + set.headers['Set-Cookie'] = cookieToHeader(set.cookie) + if ( set.headers['Set-Cookie'] && Array.isArray(set.headers['Set-Cookie']) ) - // @ts-ignore set.headers = parseSetCookies( new Headers(set.headers), set.headers['Set-Cookie'] - ) + ) as any switch (response?.constructor?.name) { case 'String': case 'Blob': - return new Response(response as string | Blob, set) + return new Response(response as string | Blob, { + status: set.status, + headers: set.headers + }) case 'Object': case 'Array': - return Response.json(response, set) + return Response.json(response, set as SetResponse) case undefined: - if (!response) return + if (!response) return new Response('', set as SetResponse) - return Response.json(response, set) + return Response.json(response, set as SetResponse) case 'Response': - const inherits = Object.assign({}, set.headers) + const inherits = { ...set.headers } if (hasHeaderShorthand) - // @ts-ignore set.headers = (response as Response).headers.toJSON() else for (const [key, value] of ( response as Response ).headers.entries()) - if (!(key in set.headers)) set.headers[key] = value + if (key in set.headers) set.headers[key] = value for (const key in inherits) (response as Response).headers.append(key, inherits[key]) - if ((response as Response).status !== set.status) - set.status = (response as Response).status - return response as Response + case 'Error': + return errorToResponse(response as Error, set) + case 'Promise': // @ts-ignore - return (response as Promise).then((x) => { - const r = mapEarlyResponse(x, set) - - if (r !== undefined) return r - - return - }) - - case 'Error': - return errorToResponse(response as Error, set.headers) + return response.then((x) => mapResponse(x, set)) case 'Function': - return (response as Function)() + return mapResponse((response as Function)(), set) case 'Number': case 'Boolean': return new Response( (response as number | boolean).toString(), - set + set as SetResponse ) - default: - if (response instanceof Response) return response + case 'Cookie': + if (response instanceof Cookie) + return new Response(response.value, set as SetResponse) + + return new Response(response?.toString(), set as SetResponse) + default: const r = JSON.stringify(response) if (r.charCodeAt(0) === 123) { if (!set.headers['Content-Type']) set.headers['Content-Type'] = 'application/json' - return new Response(JSON.stringify(response), set) as any + return new Response( + JSON.stringify(response), + set as SetResponse + ) as any } - return new Response(r, set) + return new Response(r, set as SetResponse) } } else switch (response?.constructor?.name) { @@ -140,29 +194,34 @@ export const mapEarlyResponse = ( case 'Response': return response as Response + case 'Error': + return errorToResponse(response as Error, set) + case 'Promise': // @ts-ignore - return (response as Promise).then((x) => { - const r = mapEarlyResponse(x, set) + return (response as any as Promise).then((x) => { + const r = mapCompactResponse(x) if (r !== undefined) return r - return + return new Response('') }) - case 'Error': - return errorToResponse(response as Error, set.headers) - + // ? Maybe response or Blob case 'Function': - return (response as Function)() + return mapCompactResponse((response as Function)()) case 'Number': case 'Boolean': return new Response((response as number | boolean).toString()) - default: - if (response instanceof Response) return response + case 'Cookie': + if (response instanceof Cookie) + return new Response(response.value, set as SetResponse) + + return new Response(response?.toString(), set as SetResponse) + default: const r = JSON.stringify(response) if (r.charCodeAt(0) === 123) return new Response(JSON.stringify(response), { @@ -175,42 +234,55 @@ export const mapEarlyResponse = ( } } -export const mapResponse = ( +export const mapEarlyResponse = ( response: unknown, set: Context['set'] -): Response => { - if (isNotEmpty(set.headers) || set.status !== 200 || set.redirect) { +): Response | undefined => { + if (response === undefined || response === null) return + + if ( + isNotEmpty(set.headers) || + set.status !== 200 || + set.redirect || + set.cookie + ) { + if (typeof set.status === 'string') set.status = StatusMap[set.status] + if (set.redirect) { set.headers.Location = set.redirect - if (set.status === 200) set.status = 302 + + if (!set.status || set.status < 300 || set.status >= 400) + set.status = 302 } + if (set.cookie && isNotEmpty(set.cookie)) + set.headers['Set-Cookie'] = cookieToHeader(set.cookie) + if ( set.headers['Set-Cookie'] && Array.isArray(set.headers['Set-Cookie']) ) - // @ts-ignore set.headers = parseSetCookies( new Headers(set.headers), set.headers['Set-Cookie'] - ) + ) as any switch (response?.constructor?.name) { case 'String': case 'Blob': - return new Response(response as string | Blob, { - status: set.status, - headers: set.headers - }) + return new Response( + response as string | Blob, + set as SetResponse + ) case 'Object': case 'Array': - return Response.json(response, set) + return Response.json(response, set as SetResponse) case undefined: - if (!response) return new Response('', set) + if (!response) return - return Response.json(response, set) + return Response.json(response, set as SetResponse) case 'Response': const inherits = Object.assign({}, set.headers) @@ -227,37 +299,53 @@ export const mapResponse = ( for (const key in inherits) (response as Response).headers.append(key, inherits[key]) - return response as Response + if ((response as Response).status !== set.status) + set.status = (response as Response).status - case 'Error': - return errorToResponse(response as Error, set.headers) + return response as Response case 'Promise': // @ts-ignore - return response.then((x) => mapResponse(x, set)) + return (response as Promise).then((x) => { + const r = mapEarlyResponse(x, set) + + if (r !== undefined) return r + + return + }) + + case 'Error': + return errorToResponse(response as Error, set) case 'Function': - return (response as Function)() + return mapEarlyResponse((response as Function)(), set) case 'Number': case 'Boolean': return new Response( (response as number | boolean).toString(), - set + set as SetResponse ) - default: - if (response instanceof Response) return response + case 'Cookie': + if (response instanceof Cookie) + return new Response(response.value, set as SetResponse) + return new Response(response?.toString(), set as SetResponse) + + default: const r = JSON.stringify(response) if (r.charCodeAt(0) === 123) { if (!set.headers['Content-Type']) set.headers['Content-Type'] = 'application/json' - return new Response(JSON.stringify(response), set) as any + return new Response( + JSON.stringify(response), + set as SetResponse + ) as any } - return new Response(r, set) + return new Response(r, set as SetResponse) } } else switch (response?.constructor?.name) { @@ -285,30 +373,33 @@ export const mapResponse = ( case 'Response': return response as Response - case 'Error': - return errorToResponse(response as Error) - case 'Promise': // @ts-ignore - return (response as any as Promise).then((x) => { - const r = mapResponse(x, set) + return (response as Promise).then((x) => { + const r = mapEarlyResponse(x, set) if (r !== undefined) return r - return new Response('') + return }) - // ? Maybe response or Blob + case 'Error': + return errorToResponse(response as Error, set) + case 'Function': - return (response as Function)() + return mapCompactResponse((response as Function)()) case 'Number': case 'Boolean': return new Response((response as number | boolean).toString()) - default: - if (response instanceof Response) return response + case 'Cookie': + if (response instanceof Cookie) + return new Response(response.value, set as SetResponse) + return new Response(response?.toString(), set as SetResponse) + + default: const r = JSON.stringify(response) if (r.charCodeAt(0) === 123) return new Response(JSON.stringify(response), { @@ -362,15 +453,13 @@ export const mapCompactResponse = (response: unknown): Response => { // ? Maybe response or Blob case 'Function': - return (response as Function)() + return mapCompactResponse((response as Function)()) case 'Number': case 'Boolean': return new Response((response as number | boolean).toString()) default: - if (response instanceof Response) return response - const r = JSON.stringify(response) if (r.charCodeAt(0) === 123) return new Response(JSON.stringify(response), { @@ -383,7 +472,7 @@ export const mapCompactResponse = (response: unknown): Response => { } } -export const errorToResponse = (error: Error, headers?: HeadersInit) => +export const errorToResponse = (error: Error, set?: Context['set']) => new Response( JSON.stringify({ name: error?.name, @@ -391,7 +480,7 @@ export const errorToResponse = (error: Error, headers?: HeadersInit) => cause: error?.cause }), { - status: 500, - headers + status: set?.status !== 200 ? (set?.status as number) ?? 500 : 500, + headers: set?.headers } ) diff --git a/src/index.ts b/src/index.ts index 7ac582d9..4d321680 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,80 +1,81 @@ +import type { Serve, Server, ServerWebSocket } from 'bun' + import { Memoirist } from 'memoirist' -import type { Serve, Server } from 'bun' +import EventEmitter from 'eventemitter3' +import type { Static, TSchema } from '@sinclair/typebox' + +import { createTraceListener } from './trace' +import type { Context } from './context' +import { ElysiaWS, websocket } from './ws' +import type { WS } from './ws/types' + +import { isNotEmpty } from './handler' +import { + composeHandler, + composeGeneralHandler, + composeErrorHandler +} from './compose' import { mergeHook, getSchemaValidator, getResponseSchemaValidator, mergeDeep, + mergeCookie, checksum, mergeLifeCycle, filterGlobalHook, asGlobal } from './utils' -import type { Context } from './context' import { - composeErrorHandler, - composeGeneralHandler, - composeHandler -} from './compose' + createDynamicErrorHandler, + createDynamicHandler, + type DynamicHandler +} from './dynamic-handle' -import { ws } from './ws' -import type { ElysiaWSContext, ElysiaWSOptions, WSTypedSchema } from './ws' +import { + isProduction, + ERROR_CODE, + ValidationError, + type ParseError, + type NotFoundError, + type InternalServerError +} from './error' import type { - Handler, - RegisteredHook, - VoidRequestHandler, - TypedRoute, - ElysiaInstance, ElysiaConfig, - HTTPMethod, + DecoratorBase, + DefinitionBase, + RouteBase, + Handler, ComposedHandler, + InputSchema, + LocalHook, + MergeSchema, + RouteSchema, + UnwrapRoute, InternalRoute, - BodyParser, + HTTPMethod, + SchemaValidator, + VoidHandler, + PreHandler, + BodyHandler, + OptionalHandler, + AfterHandler, ErrorHandler, - TypedSchema, - LocalHook, - LocalHandler, - LifeCycle, - LifeCycleEvent, LifeCycleStore, - VoidLifeCycle, - AfterRequestHandler, - SchemaValidator, - IsAny, - OverwritableTypeRoute, - MergeSchema, - ListenCallback, - NoReturnHandler, MaybePromise, Prettify, - TypedWSRouteToEden, - UnwrapSchema, - ExtractPath, - TypedSchemaToRoute, - DeepWritable, - Reconciliation, - BeforeRequestHandler, - ElysiaDefaultMeta + ListenCallback, + AddPrefix, + AddSuffix, + AddPrefixCapitalize, + AddSuffixCapitalize, + TraceReporter, + TraceHandler, + MaybeArray } from './types' -import type { Static, TSchema } from '@sinclair/typebox' - -import { - type ValidationError, - type ParseError, - type NotFoundError, - type InternalServerError, - isProduction, - ERROR_CODE -} from './error' - -import { - createDynamicErrorHandler, - createDynamicHandler, - type DynamicHandler -} from './dynamic-handle' /** * ### Elysia Server @@ -92,38 +93,34 @@ import { */ export default class Elysia< BasePath extends string = '', - Instance extends ElysiaInstance<{ - store?: Record - request?: Record - error?: Record - schema?: TypedSchema - meta?: ElysiaDefaultMeta - }> = { - store: {} + Decorators extends DecoratorBase = { request: {} - schema: {} + store: {} + }, + Definitions extends DefinitionBase = { + type: {} error: {} - meta: { - schema: {} - defs: {} - exposed: {} - } - } + }, + ParentSchema extends RouteSchema = {}, + Routes extends RouteBase = {}, + Scoped extends boolean = false > { config: ElysiaConfig private dependencies: Record = {} - store: Instance['store'] = {} - meta: Instance['meta'] = { - schema: Object.create(null), - defs: Object.create(null), - exposed: Object.create(null) + store: Decorators['store'] = {} + private decorators = {} as Decorators['request'] + private definitions = { + type: {}, + error: {} + } as { + type: Definitions['type'] + error: Definitions['error'] } - // Will be applied to Context - private decorators: Instance['request'] = {} + schema = {} as Routes - event: LifeCycleStore = { + event: LifeCycleStore = { start: [], request: [], parse: [], @@ -131,17 +128,18 @@ export default class Elysia< beforeHandle: [], afterHandle: [], onResponse: [], + trace: [], error: [], stop: [] } - server: Server | null = null + reporter: TraceReporter = new EventEmitter() - private $schema: SchemaValidator | null = null - private error: Instance['error'] = {} + server: Server | null = null + private validator: SchemaValidator | null = null private router = new Memoirist() - routes: InternalRoute[] = [] + routes: InternalRoute[] = [] private staticRouter = { handlers: [] as ComposedHandler[], @@ -155,19 +153,19 @@ export default class Elysia< >, all: '' } - private wsRouter: Memoirist | undefined private dynamicRouter = new Memoirist() private lazyLoadModules: Promise>[] = [] path: BasePath = '' as any - constructor(config?: Partial>) { + constructor(config?: Partial>) { this.config = { forceErrorEncapsulation: false, prefix: '', aot: true, strictPath: false, scoped: false, + cookie: {}, ...config, seed: config?.seed === undefined ? '' : config?.seed } as any @@ -175,184 +173,213 @@ export default class Elysia< private add( method: HTTPMethod, - path: string, - handler: LocalHandler, - hook?: LocalHook, + paths: string | Readonly, + handler: Handler, + hook?: LocalHook, { allowMeta = false, skipPrefix = false } = { allowMeta: false as boolean | undefined, skipPrefix: false as boolean | undefined } ) { - path = - path === '' ? path : path.charCodeAt(0) === 47 ? path : `/${path}` - - if (this.config.prefix && !skipPrefix) path = this.config.prefix + path - - const defs = this.meta.defs - - if (hook?.type) - switch (hook.type) { - case 'text': - hook.type = 'text/plain' - break - - case 'json': - hook.type = 'application/json' - break - - case 'formdata': - hook.type = 'multipart/form-data' - break - - case 'urlencoded': - hook.type = 'application/x-www-form-urlencoded' - break - - case 'arrayBuffer': - hook.type = 'application/octet-stream' - break + if (typeof paths === 'string') paths = [paths] + + for (let path of paths) { + path = + path === '' + ? path + : path.charCodeAt(0) === 47 + ? path + : `/${path}` + + if (this.config.prefix && !skipPrefix) + path = this.config.prefix + path + + if (hook?.type) + switch (hook.type) { + case 'text': + hook.type = 'text/plain' + break + + case 'json': + hook.type = 'application/json' + break + + case 'formdata': + hook.type = 'multipart/form-data' + break + + case 'urlencoded': + hook.type = 'application/x-www-form-urlencoded' + break + + case 'arrayBuffer': + hook.type = 'application/octet-stream' + break + + default: + break + } - default: - break - } + const models = this.definitions.type as Record - const validator = { - body: getSchemaValidator( - hook?.body ?? (this.$schema?.body as any), - { - dynamic: !this.config.aot, - models: defs - } - ), - headers: getSchemaValidator( - hook?.headers ?? (this.$schema?.headers as any), + const cookieValidator = getSchemaValidator( + hook?.cookie ?? (this.validator?.cookie as any), { dynamic: !this.config.aot, - models: defs, + models, additionalProperties: true } - ), - params: getSchemaValidator( - hook?.params ?? (this.$schema?.params as any), - { - dynamic: !this.config.aot, - models: defs - } - ), - query: getSchemaValidator( - hook?.query ?? (this.$schema?.query as any), - { - dynamic: !this.config.aot, - models: defs - } - ), - response: getResponseSchemaValidator( - hook?.response ?? (this.$schema?.response as any), - { - dynamic: !this.config.aot, - models: defs - } ) - } as any - const hooks = mergeHook(this.event, hook as RegisteredHook) - const loosePath = path.endsWith('/') - ? path.slice(0, path.length - 1) - : path + '/' + if (cookieValidator && isNotEmpty(this.config.cookie ?? [])) + // @ts-ignore + cookieValidator.schema = mergeCookie( + // @ts-ignore + cookieValidator.schema, + this.config.cookie ?? {} + ) - if (this.config.aot === false) { - this.dynamicRouter.add(method, path, { - validator, - hooks, - content: hook?.type as string, - handle: handler - }) + const validator = { + body: getSchemaValidator( + hook?.body ?? (this.validator?.body as any), + { + dynamic: !this.config.aot, + models + } + ), + headers: getSchemaValidator( + hook?.headers ?? (this.validator?.headers as any), + { + dynamic: !this.config.aot, + models, + additionalProperties: true + } + ), + params: getSchemaValidator( + hook?.params ?? (this.validator?.params as any), + { + dynamic: !this.config.aot, + models + } + ), + query: getSchemaValidator( + hook?.query ?? (this.validator?.query as any), + { + dynamic: !this.config.aot, + models + } + ), + cookie: cookieValidator, + response: getResponseSchemaValidator( + hook?.response ?? (this.validator?.response as any), + { + dynamic: !this.config.aot, + models + } + ) + } as any + + const hooks = mergeHook(this.event, hook) + const loosePath = path.endsWith('/') + ? path.slice(0, path.length - 1) + : path + '/' - if (this.config.strictPath === false) { - this.dynamicRouter.add(method, loosePath, { + if (this.config.aot === false) { + this.dynamicRouter.add(method, path, { validator, hooks, content: hook?.type as string, handle: handler }) + + if (this.config.strictPath === false) { + this.dynamicRouter.add(method, loosePath, { + validator, + hooks, + content: hook?.type as string, + handle: handler + }) + } + + this.routes.push({ + method, + path, + composed: null, + handler, + hooks: hooks as any + }) + + return } + const mainHandler = composeHandler({ + path, + method, + hooks, + validator, + handler, + handleError: this.handleError, + onRequest: this.event.request, + config: this.config, + definitions: allowMeta ? this.definitions.type : undefined, + schema: allowMeta ? this.schema : undefined, + reporter: this.reporter + }) + this.routes.push({ method, path, - composed: null, + composed: mainHandler, handler, - hooks + hooks: hooks as any }) - return - } - - const mainHandler = composeHandler({ - path, - method, - hooks, - validator, - handler, - handleError: this.handleError, - meta: allowMeta ? this.meta : undefined, - onRequest: this.event.request, - config: this.config - }) - - this.routes.push({ - method, - path, - composed: mainHandler, - handler, - hooks - }) - - if (path.indexOf(':') === -1 && path.indexOf('*') === -1) { - const index = this.staticRouter.handlers.length - this.staticRouter.handlers.push(mainHandler) + if (path.indexOf(':') === -1 && path.indexOf('*') === -1) { + const index = this.staticRouter.handlers.length + this.staticRouter.handlers.push(mainHandler) - this.staticRouter.variables += `const st${index} = staticRouter.handlers[${index}]\n` - - if (!this.staticRouter.map[path]) - this.staticRouter.map[path] = { - code: '' - } + this.staticRouter.variables += `const st${index} = staticRouter.handlers[${index}]\n` - if (method === 'ALL') - this.staticRouter.map[ - path - ].all = `default: return st${index}(ctx)\n` - else - this.staticRouter.map[ - path - ].code += `case '${method}': return st${index}(ctx)\n` - - if (!this.config.strictPath) { - if (!this.staticRouter.map[loosePath]) - this.staticRouter.map[loosePath] = { + if (!this.staticRouter.map[path]) + this.staticRouter.map[path] = { code: '' } if (method === 'ALL') this.staticRouter.map[ - loosePath + path ].all = `default: return st${index}(ctx)\n` else this.staticRouter.map[ - loosePath + path ].code += `case '${method}': return st${index}(ctx)\n` + + if (!this.config.strictPath) { + if (!this.staticRouter.map[loosePath]) + this.staticRouter.map[loosePath] = { + code: '' + } + + if (method === 'ALL') + this.staticRouter.map[ + loosePath + ].all = `default: return st${index}(ctx)\n` + else + this.staticRouter.map[ + loosePath + ].code += `case '${method}': return st${index}(ctx)\n` + } + } else { + this.router.add(method, path, mainHandler) + if (!this.config.strictPath) + this.router.add( + method, + path.endsWith('/') + ? path.slice(0, path.length - 1) + : path + '/', + mainHandler + ) } - } else { - this.router.add(method, path, mainHandler) - if (!this.config.strictPath) - this.router.add( - method, - path.endsWith('/') - ? path.slice(0, path.length - 1) - : path + '/', - mainHandler - ) } } @@ -370,7 +397,7 @@ export default class Elysia< * .listen(8080) * ``` */ - onStart(handler: VoidLifeCycle) { + onStart(handler: MaybeArray>) { this.on('start', handler) return this @@ -389,8 +416,10 @@ export default class Elysia< * }) * ``` */ - onRequest( - handler: BeforeRequestHandler + onRequest( + handler: MaybeArray< + PreHandler, Decorators> + > ) { this.on('request', handler) @@ -416,7 +445,7 @@ export default class Elysia< * }) * ``` */ - onParse(parser: BodyParser) { + onParse(parser: MaybeArray>) { this.on('parse', parser) return this @@ -436,8 +465,10 @@ export default class Elysia< * }) * ``` */ - onTransform( - handler: NoReturnHandler + onTransform( + handler: MaybeArray< + VoidHandler, Decorators> + > ) { this.on('transform', handler) @@ -463,10 +494,12 @@ export default class Elysia< * }) * ``` */ - onBeforeHandle( - handler: Handler + onBeforeHandle( + handler: MaybeArray< + OptionalHandler, Decorators> + > ) { - this.on('beforeHandle', handler) + this.on('beforeHandle', handler as any) return this } @@ -487,10 +520,12 @@ export default class Elysia< * }) * ``` */ - onAfterHandle( - handler: AfterRequestHandler + onAfterHandle( + handler: MaybeArray< + AfterHandler, Decorators> + > ) { - this.on('afterHandle', handler) + this.on('afterHandle', handler as AfterHandler) return this } @@ -511,14 +546,58 @@ export default class Elysia< * ``` */ - onResponse( - handler: VoidRequestHandler + onResponse( + handler: MaybeArray< + VoidHandler, Decorators> + > ) { this.on('response', handler) return this } + /** + * ### After Handle | Life cycle event + * Intercept request **after** main handler is called. + * + * If truthy value is returned, will be assigned as `Response` + * + * --- + * @example + * ```typescript + * new Elysia() + * .onAfterHandle((context, response) => { + * if(typeof response === "object") + * return JSON.stringify(response) + * }) + * ``` + */ + trace( + handler: TraceHandler + ) { + this.reporter.on('event', createTraceListener(this.reporter, handler)) + + this.on('trace', handler) + + return this + } + + /** + * @deprecated this method will be renamed to `error` on 0.8, consider using `.error` instead + * + * --- + * @example + * ```typescript + * class CustomError extends Error { + * constructor() { + * super() + * } + * } + * + * new Elysia() + * .error('CUSTOM_ERROR', CustomError) + * ``` + */ addError< const Errors extends Record< string, @@ -527,25 +606,43 @@ export default class Elysia< } > >( - // eslint-disable-next-line @typescript-eslint/no-unused-vars errors: Errors ): Elysia< BasePath, + Decorators, { - store: Instance['store'] - error: Instance['error'] & { - [K in NonNullable]: Errors[K] extends { + type: Definitions['type'] + error: Definitions['error'] & { + [K in keyof Errors]: Errors[K] extends { prototype: infer LiteralError extends Error } ? LiteralError : Errors[K] } - request: Instance['request'] - schema: Instance['schema'] - meta: Instance['meta'] - } + }, + ParentSchema, + Routes, + Scoped > + /** + * @deprecated this method will be renamed to `error` on 0.8, consider using `.error` instead + * + * --- + * @example + * ```typescript + * class CustomError extends Error { + * constructor() { + * super() + * } + * } + * + * new Elysia() + * .error({ + * CUSTOM_ERROR: CustomError + * }) + * ``` + */ addError< Name extends string, const CustomError extends { @@ -556,34 +653,22 @@ export default class Elysia< errors: CustomError ): Elysia< BasePath, + Decorators, { - store: Instance['store'] - error: Instance['error'] & { + type: Definitions['type'] + error: Definitions['error'] & { [name in Name]: CustomError extends { prototype: infer LiteralError extends Error } ? LiteralError : CustomError } - request: Instance['request'] - schema: Instance['schema'] - meta: Instance['meta'] - } + }, + ParentSchema, + Routes, + Scoped > - /** - * Register errors - * - * --- - * @example - * ```typescript - * new Elysia() - * .onError(({ code }) => { - * if(code === "NOT_FOUND") - * return "Path not found :(" - * }) - * ``` - */ addError( // eslint-disable-next-line @typescript-eslint/no-unused-vars name: @@ -593,235 +678,378 @@ export default class Elysia< { prototype: Error } - >, + > + | Function, // eslint-disable-next-line @typescript-eslint/no-unused-vars error?: { prototype: Error } - ): Elysia { - if (typeof name === 'string' && error) { - // @ts-ignore - error.prototype[ERROR_CODE] = name - - return this - } - - // @ts-ignore - for (const [code, error] of Object.entries(name)) - error.prototype[ERROR_CODE] = code - - return this + ): Elysia { + return this.error(name as any, error as any) } /** - * ### Error | Life cycle event - * Called when error is thrown during processing request + * Register errors * * --- * @example * ```typescript + * class CustomError extends Error { + * constructor() { + * super() + * } + * } + * * new Elysia() - * .onError(({ code }) => { - * if(code === "NOT_FOUND") - * return "Path not found :(" - * }) + * .error('CUSTOM_ERROR', CustomError) * ``` */ - onError(handler: ErrorHandler): Elysia< + error< + const Errors extends Record< + string, + { + prototype: Error + } + > + >( + errors: Errors + ): Elysia< BasePath, + Decorators, { - store: Instance['store'] - error: Instance['error'] - request: Instance['request'] - schema: Instance['schema'] - meta: Instance['meta'] - } - > { - this.on('error', handler) - - return this as any - } + type: Definitions['type'] + error: Definitions['error'] & { + [K in keyof Errors]: Errors[K] extends { + prototype: infer LiteralError extends Error + } + ? LiteralError + : Errors[K] + } + }, + ParentSchema, + Routes, + Scoped + > /** - * ### stop | Life cycle event - * Called after server stop serving request + * Register errors * * --- * @example * ```typescript + * class CustomError extends Error { + * constructor() { + * super() + * } + * } + * * new Elysia() - * .onStop((app) => { - * cleanup() + * .error({ + * CUSTOM_ERROR: CustomError * }) * ``` */ - onStop(handler: VoidLifeCycle) { - this.on('stop', handler) - - return this - } - - /** - * ### on - * Syntax sugar for attaching life cycle event by name - * - * Does the exact same thing as `.on[Event]()` - * - * --- - * @example - * ```typescript - * new Elysia() - * .on('error', ({ code }) => { + error< + Name extends string, + const CustomError extends { + prototype: Error + } + >( + name: Name, + errors: CustomError + ): Elysia< + BasePath, + Decorators, + { + type: Definitions['type'] + error: Definitions['error'] & { + [name in Name]: CustomError extends { + prototype: infer LiteralError extends Error + } + ? LiteralError + : CustomError + } + }, + ParentSchema, + Routes, + Scoped + > + + /** + * Register errors + * + * --- + * @example + * ```typescript + * class CustomError extends Error { + * constructor() { + * super() + * } + * } + * + * new Elysia() + * .error('CUSTOM_ERROR', CustomError) + * ``` + */ + error>( + mapper: (decorators: Definitions['error']) => NewErrors + ): Elysia< + BasePath, + Decorators, + { + type: Definitions['type'] + error: { + [K in keyof NewErrors]: NewErrors[K] extends { + prototype: infer LiteralError extends Error + } + ? LiteralError + : never + } + }, + ParentSchema, + Routes, + Scoped + > + + error( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + name: + | string + | Record< + string, + { + prototype: Error + } + > + | Function, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + error?: { + prototype: Error + } + ): Elysia { + switch (typeof name) { + case 'string': + // @ts-ignore + error.prototype[ERROR_CODE] = name + + // @ts-ignore + this.definitions.error[name] = error + + return this + + case 'function': + this.definitions.error = name(this.definitions.error) + + return this as any + } + + for (const [code, error] of Object.entries(name)) { + // @ts-ignore + error.prototype[ERROR_CODE] = code + + // @ts-ignore + this.definitions.error[code] = error + } + + return this + } + + /** + * ### Error | Life cycle event + * Called when error is thrown during processing request + * + * --- + * @example + * ```typescript + * new Elysia() + * .onError(({ code }) => { * if(code === "NOT_FOUND") * return "Path not found :(" * }) * ``` */ - on( - type: Event, - handler: LifeCycle[Event] + onError( + handler: ErrorHandler< + Definitions['error'], + MergeSchema, + Decorators + > ) { - handler = asGlobal(handler) - - switch (type) { - case 'start': - this.event.start.push(handler as LifeCycle['start']) - break - - case 'request': - this.event.request.push(handler as LifeCycle['request']) - break - - case 'response': - this.event.onResponse.push(handler as LifeCycle['response']) - break - - case 'parse': - this.event.parse.splice( - this.event.parse.length - 1, - 0, - handler as BodyParser - ) - break + this.on('error', handler as ErrorHandler) - case 'transform': - this.event.transform.push(handler as LifeCycle['transform']) - break + return this + } - case 'beforeHandle': - this.event.beforeHandle.push( - handler as LifeCycle['beforeHandle'] - ) - break + /** + * ### stop | Life cycle event + * Called after server stop serving request + * + * --- + * @example + * ```typescript + * new Elysia() + * .onStop((app) => { + * cleanup() + * }) + * ``` + */ + onStop(handler: VoidHandler) { + this.on('stop', handler) + + return this + } + + /** + * ### on + * Syntax sugar for attaching life cycle event by name + * + * Does the exact same thing as `.on[Event]()` + * + * --- + * @example + * ```typescript + * new Elysia() + * .on('error', ({ code }) => { + * if(code === "NOT_FOUND") + * return "Path not found :(" + * }) + * ``` + */ + on( + type: Exclude | 'response', + handlers: MaybeArray[0]> + ) { + for (let handler of Array.isArray(handlers) ? handlers : [handlers]) { + handler = asGlobal(handler) + + switch (type) { + case 'start': + this.event.start.push(handler as any) + break + + case 'request': + this.event.request.push(handler as any) + break + + case 'response': + this.event.onResponse.push(handler as any) + break + + case 'parse': + this.event.parse.splice( + this.event.parse.length - 1, + 0, + handler as any + ) + break + + case 'transform': + this.event.transform.push(handler as any) + break + + case 'beforeHandle': + this.event.beforeHandle.push(handler as any) + break - case 'afterHandle': - this.event.afterHandle.push(handler as LifeCycle['afterHandle']) - break + case 'afterHandle': + this.event.afterHandle.push(handler as any) + break + + case 'trace': + this.event.trace.push(handler as any) + break - case 'error': - this.event.error.push(handler as LifeCycle['error']) - break + case 'error': + this.event.error.push(handler as any) + break - case 'stop': - this.event.stop.push(handler as LifeCycle['stop']) - break + case 'stop': + this.event.stop.push(handler as any) + break + } } return this } group< - NewElysia extends Elysia = Elysia, - Prefix extends string = string + const NewElysia extends Elysia, + const Prefix extends string >( prefix: Prefix, run: ( group: Elysia< `${BasePath}${Prefix}`, - { - error: Instance['error'] - request: Instance['request'] - store: Instance['store'] & ElysiaInstance['store'] - schema: Instance['schema'] - meta: { - schema: Instance['meta']['schema'] - defs: Instance['meta']['defs'] - exposed: {} - } - } + Decorators, + Definitions, + ParentSchema, + {} > ) => NewElysia - ): NewElysia extends Elysia<`${BasePath}${Prefix}`, infer NewInstance> + ): NewElysia extends Elysia< + any, + infer PluginDecorators, + infer PluginDefinitions, + infer PluginSchema, + any + > ? Elysia< BasePath, - { - error: Instance['error'] - request: Instance['request'] - schema: Instance['schema'] - store: Instance['store'] - meta: Instance['meta'] & NewInstance['meta'] - } + PluginDecorators, + PluginDefinitions, + PluginSchema, + Prettify > : this group< - Schema extends TypedSchema< - Exclude + const LocalSchema extends InputSchema< + Extract >, - NewElysia extends Elysia = Elysia, - Prefix extends string = string + const NewElysia extends Elysia, + const Prefix extends string, + const Schema extends MergeSchema< + UnwrapRoute, + ParentSchema + > >( prefix: Prefix, - schema: LocalHook, + schema: LocalHook< + LocalSchema, + Schema, + Decorators, + Definitions['error'], + `${BasePath}${Prefix}` + >, run: ( group: Elysia< `${BasePath}${Prefix}`, - { - error: Instance['error'] - request: Instance['request'] - store: Instance['store'] & ElysiaInstance['store'] - schema: { - body: undefined extends Schema['body'] - ? Instance['schema']['body'] - : Schema['body'] - headers: undefined extends Schema['headers'] - ? Instance['schema']['headers'] - : Schema['headers'] - query: undefined extends Schema['query'] - ? Instance['schema']['query'] - : Schema['query'] - params: undefined extends Schema['params'] - ? Instance['schema']['params'] - : Schema['params'] - response: undefined extends Schema['response'] - ? Instance['schema']['response'] - : Schema['response'] - } - meta: { - schema: Instance['meta']['schema'] - defs: Instance['meta']['defs'] - exposed: {} - } - } + Decorators, + Definitions, + Schema, + {} > ) => NewElysia - ): NewElysia extends Elysia<`${BasePath}${Prefix}`, infer NewInstance> + ): NewElysia extends Elysia< + any, + infer PluginDecorators, + infer PluginDefinitions, + infer PluginSchema, + any + > ? Elysia< BasePath, + Prettify, { - error: Instance['error'] - request: Instance['request'] - schema: Instance['schema'] - store: Instance['store'] - meta: Instance['meta'] & - (Omit & - Record< - 'schema', - { - [Path in keyof NewInstance['meta']['schema']]: NewInstance['meta']['schema'][Path] - } - >) - } + type: Prettify< + Definitions['type'] & PluginDefinitions['type'] + > + error: Prettify< + Definitions['error'] & PluginDefinitions['error'] + > + }, + Prettify, + Prettify > : this @@ -839,24 +1067,23 @@ export default class Elysia< * }) * ``` */ - group< - Executor extends (group: Elysia) => Elysia, - Schema extends TypedSchema< - Exclude - > - >( + group( prefix: string, - schemaOrRun: LocalHook | Executor, - run?: Executor + schemaOrRun: + | LocalHook + | (( + group: Elysia + ) => Elysia), + run?: ( + group: Elysia + ) => Elysia ): this { - const instance = new Elysia({ + const instance = new Elysia({ ...this.config, prefix: '' }) instance.store = this.store - if (this.wsRouter) instance.use(ws()) - const isSchema = typeof schemaOrRun === 'object' const sandbox = (isSchema ? run! : schemaOrRun)(instance) @@ -874,7 +1101,7 @@ export default class Elysia< ...(sandbox.event.onResponse as any) ] - this.model(sandbox.meta.defs) + this.model(sandbox.definitions.type) Object.values(instance.routes).forEach( ({ method, path, handler, hooks }) => { @@ -882,28 +1109,13 @@ export default class Elysia< if (isSchema) { const hook = schemaOrRun - const localHook = hooks - - // Same as guard - const hasWsRoute = instance.wsRouter?.find( - 'subscribe', - path - ) - if (hasWsRoute) { - const wsRoute = instance.wsRouter!.history.find( - // eslint-disable-next-line @typescript-eslint/no-unused-vars - ([_, wsPath]) => path === wsPath - ) - if (!wsRoute) return - - return this.ws(path as any, wsRoute[2] as any) - } + const localHook = hooks as LocalHook this.add( method, path, handler, - mergeHook(hook as LocalHook, { + mergeHook(hook, { ...localHook, error: !localHook.error ? sandbox.event.error @@ -913,25 +1125,11 @@ export default class Elysia< }) ) } else { - const hasWsRoute = instance.wsRouter?.find( - 'subscribe', - path - ) - if (hasWsRoute) { - const wsRoute = instance.wsRouter!.history.find( - // eslint-disable-next-line @typescript-eslint/no-unused-vars - ([_, wsPath]) => path === wsPath - ) - if (!wsRoute) return - - return this.ws(path as any, wsRoute[2] as any) - } - this.add( method, path, handler, - mergeHook(hooks, { + mergeHook(hooks as LocalHook, { error: sandbox.event.error }), { @@ -942,91 +1140,57 @@ export default class Elysia< } ) - if (instance.wsRouter && this.wsRouter) - instance.wsRouter.history.forEach(([method, path, handler]) => { - path = this.config.prefix + prefix + path - - if (path === '/') this.wsRouter?.add(method, prefix, handler) - else this.wsRouter?.add(method, `${prefix}${path}`, handler) - }) - return this as any } guard< - Schema extends TypedSchema< - Exclude + const LocalSchema extends InputSchema, + const Route extends MergeSchema< + UnwrapRoute, + ParentSchema > >( - hook: LocalHook - ): Elysia< - any, - { - error: Instance['error'] - request: Instance['request'] - store: Instance['store'] - schema: Instance['schema'] - meta: Instance['meta'] & - Record< - 'schema', - { - [key in keyof Schema]: Schema[key] - } - > - } - > + hook: LocalHook< + LocalSchema, + Route, + Decorators, + Definitions['error'], + BasePath + > + ): Elysia guard< - Schema extends TypedSchema< - Exclude + const LocalSchema extends InputSchema< + Extract >, - NewElysia extends Elysia = Elysia + const NewElysia extends Elysia, + const Schema extends MergeSchema< + UnwrapRoute, + ParentSchema + > >( - hook: LocalHook, + schema: LocalHook< + LocalSchema, + Schema, + Decorators, + Definitions['error'] + >, run: ( - group: Elysia< - BasePath, - { - error: Instance['error'] - request: Instance['request'] - store: Instance['store'] - schema: { - body: undefined extends Schema['body'] - ? Instance['schema']['body'] - : Schema['body'] - headers: undefined extends Schema['headers'] - ? Instance['schema']['headers'] - : Schema['headers'] - query: undefined extends Schema['query'] - ? Instance['schema']['query'] - : Schema['query'] - params: undefined extends Schema['params'] - ? Instance['schema']['params'] - : Schema['params'] - response: undefined extends Schema['response'] - ? Instance['schema']['response'] - : Schema['response'] - } - meta: Instance['meta'] - } - > + group: Elysia ) => NewElysia - ): NewElysia extends Elysia + ): NewElysia extends Elysia< + any, + infer PluginDecorators, + infer PluginDefinitions, + infer PluginSchema, + any + > ? Elysia< BasePath, - { - error: Instance['error'] - request: Instance['request'] - store: Instance['store'] - schema: Instance['schema'] - meta: Instance['meta'] & - Record< - 'schema', - { - [key in keyof NewInstance['meta']['schema']]: NewInstance['meta']['schema'][key] - } - > - } + PluginDecorators, + PluginDefinitions, + PluginSchema, + Prettify > : this @@ -1054,12 +1218,14 @@ export default class Elysia< * ``` */ guard( - hook: LocalHook, - run?: (group: Elysia) => Elysia - ): Elysia { + hook: LocalHook, + run?: ( + group: Elysia + ) => Elysia + ): Elysia { if (!run) { this.event = mergeLifeCycle(this.event, hook) - this.$schema = { + this.validator = { body: hook.body, headers: hook.headers, params: hook.params, @@ -1072,7 +1238,6 @@ export default class Elysia< const instance = new Elysia() instance.store = this.store - if (this.wsRouter) instance.use(ws()) const sandbox = run(instance) this.decorators = mergeDeep(this.decorators, instance.decorators) @@ -1089,27 +1254,16 @@ export default class Elysia< ...sandbox.event.onResponse ] - this.model(sandbox.meta.defs) + this.model(sandbox.definitions.type) Object.values(instance.routes).forEach( ({ method, path, handler, hooks: localHook }) => { - const hasWsRoute = instance.wsRouter?.find('subscribe', path) - if (hasWsRoute) { - const wsRoute = instance.wsRouter!.history.find( - // eslint-disable-next-line @typescript-eslint/no-unused-vars - ([_, wsPath]) => path === wsPath - ) - if (!wsRoute) return - - return this.ws(path as any, wsRoute[2] as any) - } - this.add( method, path, handler, - mergeHook(hook as LocalHook, { - ...localHook, + mergeHook(hook as LocalHook, { + ...(localHook as LocalHook), error: !localHook.error ? sandbox.event.error : Array.isArray(localHook.error) @@ -1120,139 +1274,143 @@ export default class Elysia< } ) - if (instance.wsRouter && this.wsRouter) - instance.wsRouter.history.forEach(([method, path, handler]) => { - this.wsRouter?.add(method, path, handler) - }) - return this as any } // Inline Fn - use< - NewInstance extends ElysiaInstance, - Params extends Elysia = Elysia - >( - plugin: MaybePromise< - ( - app: Params extends Elysia - ? IsAny extends true - ? this - : any - : Params - ) => MaybePromise> - > - ): Elysia< - BasePath, - { - error: Instance['error'] & NewInstance['error'] - request: Reconciliation - store: Reconciliation - schema: Instance['schema'] & NewInstance['schema'] - meta: { - schema: Instance['meta']['schema'] & { - [Path in keyof NewInstance['meta']['schema'] as Path extends string - ? `${BasePath}${Path}` - : Path]: NewInstance['meta']['schema'][Path] - } - defs: Reconciliation< - Instance['meta']['defs'], - NewInstance['meta']['defs'] - > - exposed: Instance['meta']['exposed'] & - NewInstance['meta']['exposed'] - } - } + use = this>( + plugin: MaybePromise<(app: NewElysia) => MaybePromise> + ): NewElysia extends Elysia< + any, + infer PluginDecorators, + infer PluginDefinitions, + infer PluginSchema, + any > + ? Elysia< + BasePath, + { + request: Prettify< + Decorators['request'] & PluginDecorators['request'] + > + store: Prettify< + Decorators['store'] & PluginDecorators['store'] + > + }, + { + type: Prettify< + Definitions['type'] & PluginDefinitions['type'] + > + error: Prettify< + Definitions['error'] & PluginDefinitions['error'] + > + }, + Prettify>, + Routes & NewElysia['schema'], + Scoped + > + : this - use( - instance: Elysia - ): Elysia< - BasePath, - { - error: Instance['error'] & NewInstance['error'] - request: Reconciliation - store: Reconciliation - schema: Instance['schema'] & NewInstance['schema'] - meta: { - schema: Instance['meta']['schema'] & { - [Path in keyof NewInstance['meta']['schema'] as Path extends string - ? `${BasePath}${Path}` - : Path]: NewInstance['meta']['schema'][Path] - } - defs: Reconciliation< - Instance['meta']['defs'], - NewInstance['meta']['defs'] - > - exposed: Instance['meta']['exposed'] & - NewInstance['meta']['exposed'] - } - } + // Entire Instance + use>( + instance: NewElysia + ): NewElysia extends Elysia< + any, + infer PluginDecorators, + infer PluginDefinitions, + infer PluginSchema, + any, + infer IsScoped > + ? IsScoped extends true + ? this + : Elysia< + BasePath, + { + request: Prettify< + Decorators['request'] & PluginDecorators['request'] + > + store: Prettify< + Decorators['store'] & PluginDecorators['store'] + > + }, + { + type: Prettify< + Definitions['type'] & PluginDefinitions['type'] + > + error: Prettify< + Definitions['error'] & PluginDefinitions['error'] + > + }, + Prettify>, + BasePath extends `` + ? Routes & NewElysia['schema'] + : Routes & AddPrefix, + Scoped + > + : this // Import Fn - use( + use>( plugin: Promise<{ default: ( - elysia: Elysia - ) => MaybePromise> + elysia: Elysia + ) => MaybePromise }> - ): Elysia< - BasePath, - { - error: Instance['error'] & LazyLoadElysia['error'] - request: Reconciliation< - Instance['request'], - LazyLoadElysia['request'] - > - store: Reconciliation - schema: Instance['schema'] & LazyLoadElysia['schema'] - meta: { - schema: Instance['meta']['schema'] & { - [Path in keyof LazyLoadElysia['meta']['schema'] as Path extends string - ? `${BasePath}${Path}` - : Path]: LazyLoadElysia['meta']['schema'][Path] - } - defs: Reconciliation< - Instance['meta']['defs'], - LazyLoadElysia['meta']['defs'] - > - exposed: Instance['meta']['exposed'] & - LazyLoadElysia['meta']['exposed'] - } - } + ): NewElysia extends Elysia< + any, + infer PluginDecorators, + infer PluginDefinitions, + infer PluginSchema, + any > + ? Elysia< + BasePath, + { + request: Decorators['request'] & PluginDecorators['request'] + store: Decorators['store'] & PluginDecorators['store'] + }, + { + type: Definitions['type'] & PluginDefinitions['type'] + error: Definitions['error'] & PluginDefinitions['error'] + }, + MergeSchema, + BasePath extends `` + ? Routes & NewElysia['schema'] + : Routes & AddPrefix, + Scoped + > + : this - // Import inline - use( + // Import entire instance + use>( plugin: Promise<{ - default: (elysia: Elysia) => Elysia + default: LazyLoadElysia }> - ): Elysia< - BasePath, - { - error: Instance['error'] & LazyLoadElysia['error'] - request: Reconciliation< - Instance['request'], - LazyLoadElysia['request'] - > - store: Reconciliation - schema: Instance['schema'] & LazyLoadElysia['schema'] - meta: { - schema: Instance['meta']['schema'] & { - [Path in keyof LazyLoadElysia['meta']['schema'] as Path extends string - ? `${BasePath}${Path}` - : Path]: LazyLoadElysia['meta']['schema'][Path] - } - defs: Reconciliation< - Instance['meta']['defs'], - LazyLoadElysia['meta']['defs'] - > - exposed: Instance['meta']['exposed'] & - LazyLoadElysia['meta']['exposed'] - } - } + ): LazyLoadElysia extends Elysia< + any, + infer PluginDecorators, + infer PluginDefinitions, + infer PluginSchema, + any > + ? Elysia< + BasePath, + { + request: PluginDecorators['request'] & Decorators['request'] + store: PluginDecorators['store'] & Decorators['store'] + }, + { + type: PluginDefinitions['type'] & Definitions['type'] + + error: PluginDefinitions['error'] & Definitions['error'] + }, + MergeSchema, + BasePath extends `` + ? Routes & LazyLoadElysia['schema'] + : Routes & AddPrefix + > + : this /** * ### use @@ -1270,106 +1428,21 @@ export default class Elysia< */ use( plugin: - | Elysia + | Elysia | MaybePromise< - (app: Elysia) => MaybePromise> + ( + app: Elysia + ) => MaybePromise> > | Promise<{ - default: Elysia + default: Elysia }> | Promise<{ default: ( - elysia: Elysia - ) => MaybePromise> + elysia: Elysia + ) => MaybePromise> }> - ): Elysia { - const register = ( - plugin: - | Elysia - | ((app: Elysia) => MaybePromise>) - ) => { - if (typeof plugin === 'function') { - const instance = plugin( - this as unknown as any - ) as unknown as any - if (instance instanceof Promise) { - this.lazyLoadModules.push(instance.then((x) => x.compile())) - - return this as unknown as any - } - - return instance - } - - const isScoped = plugin.config.scoped - - if (!isScoped) { - this.decorators = mergeDeep(this.decorators, plugin.decorators) - this.state(plugin.store) - this.model(plugin.meta.defs) - this.addError(plugin.error) - } - - const { - config: { name, seed } - } = plugin - - Object.values(plugin.routes).forEach( - ({ method, path, handler, hooks }) => { - const hasWsRoute = plugin.wsRouter?.find('subscribe', path) - if (hasWsRoute) { - const wsRoute = plugin.wsRouter!.history.find( - // eslint-disable-next-line @typescript-eslint/no-unused-vars - ([_, wsPath]) => path === wsPath - ) - if (!wsRoute) return - - return this.ws(path as any, wsRoute[2] as any) - } - - this.add( - method, - path, - handler, - mergeHook(hooks, { - error: plugin.event.error - }) - ) - } - ) - - if (!isScoped) - if (name) { - if (!(name in this.dependencies)) - this.dependencies[name] = [] - - const current = - seed !== undefined - ? checksum(name + JSON.stringify(seed)) - : 0 - - if ( - this.dependencies[name].some( - (checksum) => current === checksum - ) - ) - return this - - this.dependencies[name].push(current) - this.event = mergeLifeCycle( - this.event, - filterGlobalHook(plugin.event), - current - ) - } else - this.event = mergeLifeCycle( - this.event, - filterGlobalHook(plugin.event) - ) - - return this - } - + ): Elysia { if (plugin instanceof Promise) { this.lazyLoadModules.push( plugin @@ -1384,13 +1457,114 @@ export default class Elysia< this as unknown as any ) as unknown as Elysia - return register(plugin.default) + return this._use(plugin.default) }) .then((x) => x.compile()) ) return this as unknown as any - } else return register(plugin) + } else return this._use(plugin) + + return this + } + + private _use( + plugin: + | Elysia + | (( + app: Elysia + ) => MaybePromise>) + ) { + if (typeof plugin === 'function') { + const instance = plugin(this as unknown as any) as unknown as any + if (instance instanceof Promise) { + this.lazyLoadModules.push(instance.then((x) => x.compile())) + + return this as unknown as any + } + + return instance + } + + const { name, seed } = plugin.config + + const isScoped = plugin.config.scoped + if (isScoped) { + if (name) { + if (!(name in this.dependencies)) this.dependencies[name] = [] + + const current = + seed !== undefined + ? checksum(name + JSON.stringify(seed)) + : 0 + + if ( + this.dependencies[name].some( + (checksum) => current === checksum + ) + ) + return this + + this.dependencies[name].push(current) + } + + plugin.model(this.definitions.type as any) + plugin.error(this.definitions.error as any) + plugin.onRequest((context) => { + Object.assign(context, this.decorators) + Object.assign(context.store, this.store) + }) + + if (plugin.config.aot) plugin.compile() + + return this.mount(plugin.fetch) + } + + this.decorate(plugin.decorators) + this.state(plugin.store) + this.model(plugin.definitions.type) + this.error(plugin.definitions.error) + + for (const { method, path, handler, hooks } of Object.values( + plugin.routes + )) { + this.add( + method, + path, + handler, + mergeHook(hooks as LocalHook, { + error: plugin.event.error + }) + ) + } + + if (!isScoped) + if (name) { + if (!(name in this.dependencies)) this.dependencies[name] = [] + + const current = + seed !== undefined + ? checksum(name + JSON.stringify(seed)) + : 0 + + if ( + this.dependencies[name].some( + (checksum) => current === checksum + ) + ) + return this + + this.dependencies[name].push(current) + this.event = mergeLifeCycle( + this.event, + filterGlobalHook(plugin.event), + current + ) + } else + this.event = mergeLifeCycle( + this.event, + filterGlobalHook(plugin.event) + ) return this } @@ -1411,10 +1585,10 @@ export default class Elysia< const handler: Handler = async ({ request, path }) => run(new Request('http://a.cc' + path || '/', request)) - this.all('/', handler, { + this.all('/', handler as any, { type: 'none' }) - this.all('/*', handler, { + this.all('/*', handler as any, { type: 'none' }) @@ -1427,10 +1601,10 @@ export default class Elysia< new Request('http://a.cc' + path.slice(length) || '/', request) ) - this.all(path, handler, { + this.all(path, handler as any, { type: 'none' }) - this.all(path + (path.endsWith('/') ? '*' : '/*'), handler, { + this.all(path + (path.endsWith('/') ? '*' : '/*'), handler as any, { type: 'none' }) @@ -1456,128 +1630,50 @@ export default class Elysia< * ``` */ get< - Paths extends string | string[], - Handler extends LocalHandler< - Schema, - Instance, - `${BasePath}${Extract}` + const Path extends string, + const LocalSchema extends InputSchema< + keyof Definitions['type'] & string >, - Schema extends TypedSchema< - Extract - > + const Route extends MergeSchema< + UnwrapRoute, + ParentSchema + >, + const Function extends Handler >( - paths: Paths, - handler: Handler, + path: Path, + handler: Function, hook?: LocalHook< - Schema, - Instance, - `${BasePath}${Extract}` + LocalSchema, + Route, + Decorators, + Definitions['error'], + `${BasePath}${Path}` > ): Elysia< BasePath, - { - request: Instance['request'] - store: Instance['store'] - schema: Instance['schema'] - error: Instance['error'] - meta: { - defs: Instance['meta']['defs'] - exposed: Instance['meta']['exposed'] - schema: Prettify< - Instance['meta']['schema'] & - (MergeSchema< - Schema, - Instance['schema'] - > extends infer Typed extends TypedSchema + Decorators, + Definitions, + ParentSchema, + Prettify< + Routes & { + [path in `${BasePath}${Path}`]: { + get: { + body: Route['body'] + params: Route['params'] + query: Route['query'] + headers: Route['headers'] + response: unknown extends Route['response'] ? { - [path in `${BasePath}${Extract< - Paths, - string - >}`]: { - get: { - body: UnwrapSchema< - Typed['body'], - Instance['meta']['defs'] - > - headers: UnwrapSchema< - Typed['headers'], - Instance['meta']['defs'] - > extends infer Result - ? Result extends Record< - string, - any - > - ? Result - : undefined - : undefined - query: UnwrapSchema< - Typed['query'], - Instance['meta']['defs'] - > extends infer Result - ? Result extends Record< - string, - any - > - ? Result - : undefined - : undefined - params: UnwrapSchema< - Typed['params'], - Instance['meta']['defs'] - > extends infer Result - ? Result extends Record< - string, - any - > - ? Result - : undefined - : Record< - ExtractPath< - Extract< - Paths, - string - > - >, - string - > - response: Typed['response'] extends - | TSchema - | string - ? { - '200': UnwrapSchema< - Typed['response'], - Instance['meta']['defs'], - ReturnType - > - } - : Typed['response'] extends Record< - string, - TSchema | string - > - ? { - [key in keyof Typed['response']]: UnwrapSchema< - Typed['response'][key], - Instance['meta']['defs'], - ReturnType - > - } - : { - '200': ReturnType - } - } - } + 200: ReturnType } - : {}) - > + : Route['response'] + } + } } - } + >, + Scoped > { - if (typeof paths === 'string') { - paths = [paths] as Paths - } - for (const path of paths) { - this.add('GET', path, handler as any, hook as LocalHook) - } + this.add('GET', path, handler as any, hook) return this as any } @@ -1601,129 +1697,50 @@ export default class Elysia< * ``` */ post< - Paths extends string | string[], - Handler extends LocalHandler< - Schema, - Instance, - `${BasePath}${Extract}` + const Path extends string, + const LocalSchema extends InputSchema< + keyof Definitions['type'] & string >, - Schema extends TypedSchema< - Extract - > + const Route extends MergeSchema< + UnwrapRoute, + ParentSchema + >, + const Function extends Handler >( - paths: Paths, - handler: Handler, + path: Path, + handler: Function, hook?: LocalHook< - Schema, - Instance, - `${BasePath}${Extract}` + LocalSchema, + Route, + Decorators, + Definitions['error'], + `${BasePath}${Path}` > ): Elysia< BasePath, - { - request: Instance['request'] - store: Instance['store'] - schema: Instance['schema'] - error: Instance['error'] - meta: Record<'defs', Instance['meta']['defs']> & - Record<'exposed', Instance['meta']['exposed']> & - Record< - 'schema', - Prettify< - Instance['meta']['schema'] & - (MergeSchema< - Schema, - Instance['schema'] - > extends infer Typed extends TypedSchema - ? { - [path in `${BasePath}${Extract< - Paths, - string - >}`]: { - post: { - body: UnwrapSchema< - Typed['body'], - Instance['meta']['defs'] - > - headers: UnwrapSchema< - Typed['headers'], - Instance['meta']['defs'] - > extends infer Result - ? Result extends Record< - string, - any - > - ? Result - : undefined - : undefined - query: UnwrapSchema< - Typed['query'], - Instance['meta']['defs'] - > extends infer Result - ? Result extends Record< - string, - any - > - ? Result - : undefined - : undefined - params: UnwrapSchema< - Typed['params'], - Instance['meta']['defs'] - > extends infer Result - ? Result extends Record< - string, - any - > - ? Result - : undefined - : Record< - ExtractPath< - Extract< - Paths, - string - > - >, - string - > - response: Typed['response'] extends - | TSchema - | string - ? { - '200': UnwrapSchema< - Typed['response'], - Instance['meta']['defs'], - ReturnType - > - } - : Typed['response'] extends Record< - string, - TSchema | string - > - ? { - [key in keyof Typed['response']]: UnwrapSchema< - Typed['response'][key], - Instance['meta']['defs'], - ReturnType - > - } - : { - '200': ReturnType - } - } - } - } - : {}) - > - > - } + Decorators, + Definitions, + ParentSchema, + Prettify< + Routes & { + [path in `${BasePath}${Path}`]: { + post: { + body: Route['body'] + params: Route['params'] + query: Route['query'] + headers: Route['headers'] + response: unknown extends Route['response'] + ? { + 200: ReturnType + } + : Route['response'] + } + } + } + >, + Scoped > { - if (typeof paths === 'string') { - paths = [paths] as Paths - } - for (const path of paths) { - this.add('POST', path, handler as any, hook as LocalHook) - } + this.add('POST', path, handler as any, hook) return this as any } @@ -1747,129 +1764,50 @@ export default class Elysia< * ``` */ put< - Paths extends string | string[], - Handler extends LocalHandler< - Schema, - Instance, - `${BasePath}${Extract}` + const Path extends string, + const LocalSchema extends InputSchema< + keyof Definitions['type'] & string >, - Schema extends TypedSchema< - Extract - > + const Route extends MergeSchema< + UnwrapRoute, + ParentSchema + >, + const Function extends Handler >( - paths: Paths, - handler: Handler, + path: Path, + handler: Function, hook?: LocalHook< - Schema, - Instance, - `${BasePath}${Extract}` + LocalSchema, + Route, + Decorators, + Definitions['error'], + `${BasePath}${Path}` > ): Elysia< BasePath, - { - request: Instance['request'] - store: Instance['store'] - schema: Instance['schema'] - error: Instance['error'] - meta: Record<'defs', Instance['meta']['defs']> & - Record<'exposed', Instance['meta']['exposed']> & - Record< - 'schema', - Prettify< - Instance['meta']['schema'] & - (MergeSchema< - Schema, - Instance['schema'] - > extends infer Typed extends TypedSchema - ? { - [path in `${BasePath}${Extract< - Paths, - string - >}`]: { - put: { - body: UnwrapSchema< - Typed['body'], - Instance['meta']['defs'] - > - headers: UnwrapSchema< - Typed['headers'], - Instance['meta']['defs'] - > extends infer Result - ? Result extends Record< - string, - any - > - ? Result - : undefined - : undefined - query: UnwrapSchema< - Typed['query'], - Instance['meta']['defs'] - > extends infer Result - ? Result extends Record< - string, - any - > - ? Result - : undefined - : undefined - params: UnwrapSchema< - Typed['params'], - Instance['meta']['defs'] - > extends infer Result - ? Result extends Record< - string, - any - > - ? Result - : undefined - : Record< - ExtractPath< - Extract< - Paths, - string - > - >, - string - > - response: Typed['response'] extends - | TSchema - | string - ? { - '200': UnwrapSchema< - Typed['response'], - Instance['meta']['defs'], - ReturnType - > - } - : Typed['response'] extends Record< - string, - TSchema | string - > - ? { - [key in keyof Typed['response']]: UnwrapSchema< - Typed['response'][key], - Instance['meta']['defs'], - ReturnType - > - } - : { - '200': ReturnType - } - } - } - } - : {}) - > - > - } + Decorators, + Definitions, + ParentSchema, + Prettify< + Routes & { + [path in `${BasePath}${Path}`]: { + put: { + body: Route['body'] + params: Route['params'] + query: Route['query'] + headers: Route['headers'] + response: unknown extends Route['response'] + ? { + 200: ReturnType + } + : Route['response'] + } + } + } + >, + Scoped > { - if (typeof paths === 'string') { - paths = [paths] as Paths - } - for (const path of paths) { - this.add('PUT', path, handler as any, hook as LocalHook) - } + this.add('PUT', path, handler as any, hook) return this as any } @@ -1893,129 +1831,50 @@ export default class Elysia< * ``` */ patch< - Paths extends string | string[], - Handler extends LocalHandler< - Schema, - Instance, - `${BasePath}${Extract}` + const Path extends string, + const LocalSchema extends InputSchema< + keyof Definitions['type'] & string >, - Schema extends TypedSchema< - Extract - > + const Route extends MergeSchema< + UnwrapRoute, + ParentSchema + >, + const Function extends Handler >( - paths: Paths, - handler: Handler, + path: Path, + handler: Function, hook?: LocalHook< - Schema, - Instance, - `${BasePath}${Extract}` + LocalSchema, + Route, + Decorators, + Definitions['error'], + `${BasePath}${Path}` > ): Elysia< BasePath, - { - request: Instance['request'] - store: Instance['store'] - schema: Instance['schema'] - error: Instance['error'] - meta: Record<'defs', Instance['meta']['defs']> & - Record<'exposed', Instance['meta']['exposed']> & - Record< - 'schema', - Prettify< - Instance['meta']['schema'] & - (MergeSchema< - Schema, - Instance['schema'] - > extends infer Typed extends TypedSchema - ? { - [path in `${BasePath}${Extract< - Paths, - string - >}`]: { - patch: { - body: UnwrapSchema< - Typed['body'], - Instance['meta']['defs'] - > - headers: UnwrapSchema< - Typed['headers'], - Instance['meta']['defs'] - > extends infer Result - ? Result extends Record< - string, - any - > - ? Result - : undefined - : undefined - query: UnwrapSchema< - Typed['query'], - Instance['meta']['defs'] - > extends infer Result - ? Result extends Record< - string, - any - > - ? Result - : undefined - : undefined - params: UnwrapSchema< - Typed['params'], - Instance['meta']['defs'] - > extends infer Result - ? Result extends Record< - string, - any - > - ? Result - : undefined - : Record< - ExtractPath< - Extract< - Paths, - string - > - >, - string - > - response: Typed['response'] extends - | TSchema - | string - ? { - '200': UnwrapSchema< - Typed['response'], - Instance['meta']['defs'], - ReturnType - > - } - : Typed['response'] extends Record< - string, - TSchema | string - > - ? { - [key in keyof Typed['response']]: UnwrapSchema< - Typed['response'][key], - Instance['meta']['defs'], - ReturnType - > - } - : { - '200': ReturnType - } - } - } - } - : {}) - > - > - } + Decorators, + Definitions, + ParentSchema, + Prettify< + Routes & { + [path in `${BasePath}${Path}`]: { + patch: { + body: Route['body'] + params: Route['params'] + query: Route['query'] + headers: Route['headers'] + response: unknown extends Route['response'] + ? { + 200: ReturnType + } + : Route['response'] + } + } + } + >, + Scoped > { - if (typeof paths === 'string') { - paths = [paths] as Paths - } - for (const path of paths) { - this.add('PATCH', path, handler as any, hook as LocalHook) - } + this.add('PATCH', path, handler as any, hook) return this as any } @@ -2039,134 +1898,50 @@ export default class Elysia< * ``` */ delete< - Paths extends string | string[], - Handler extends LocalHandler< - Schema, - Instance, - `${BasePath}${Extract}` + const Path extends string, + const LocalSchema extends InputSchema< + keyof Definitions['type'] & string >, - Schema extends TypedSchema< - Extract - > + const Route extends MergeSchema< + UnwrapRoute, + ParentSchema + >, + const Function extends Handler >( - paths: Paths, - handler: Handler, + path: Path, + handler: Function, hook?: LocalHook< - Schema, - Instance, - `${BasePath}${Extract}` + LocalSchema, + Route, + Decorators, + Definitions['error'], + `${BasePath}${Path}` > ): Elysia< BasePath, - { - request: Instance['request'] - store: Instance['store'] - schema: Instance['schema'] - error: Instance['error'] - meta: Record<'defs', Instance['meta']['defs']> & - Record<'exposed', Instance['meta']['exposed']> & - Record< - 'schema', - Prettify< - Instance['meta']['schema'] & - (MergeSchema< - Schema, - Instance['schema'] - > extends infer Typed extends TypedSchema - ? { - [path in `${BasePath}${Extract< - Paths, - string - >}`]: { - delete: { - body: UnwrapSchema< - Typed['body'], - Instance['meta']['defs'] - > - headers: UnwrapSchema< - Typed['headers'], - Instance['meta']['defs'] - > extends infer Result - ? Result extends Record< - string, - any - > - ? Result - : undefined - : undefined - query: UnwrapSchema< - Typed['query'], - Instance['meta']['defs'] - > extends infer Result - ? Result extends Record< - string, - any - > - ? Result - : undefined - : undefined - params: UnwrapSchema< - Typed['params'], - Instance['meta']['defs'] - > extends infer Result - ? Result extends Record< - string, - any - > - ? Result - : undefined - : Record< - ExtractPath< - Extract< - Paths, - string - > - >, - string - > - response: Typed['response'] extends - | TSchema - | string - ? { - '200': UnwrapSchema< - Typed['response'], - Instance['meta']['defs'], - ReturnType - > - } - : Typed['response'] extends Record< - string, - TSchema | string - > - ? { - [key in keyof Typed['response']]: UnwrapSchema< - Typed['response'][key], - Instance['meta']['defs'], - ReturnType - > - } - : { - '200': ReturnType - } - } - } - } - : {}) - > - > - } + Decorators, + Definitions, + ParentSchema, + Prettify< + Routes & { + [path in `${BasePath}${Path}`]: { + delete: { + body: Route['body'] + params: Route['params'] + query: Route['query'] + headers: Route['headers'] + response: unknown extends Route['response'] + ? { + 200: ReturnType + } + : Route['response'] + } + } + } + >, + Scoped > { - if (typeof paths === 'string') { - paths = [paths] as Paths - } - for (const path of paths) { - this.add( - 'DELETE', - path, - handler as any, - hook as LocalHook - ) - } + this.add('DELETE', path, handler as any, hook) return this as any } @@ -2190,134 +1965,50 @@ export default class Elysia< * ``` */ options< - Paths extends string | string[], - Handler extends LocalHandler< - Schema, - Instance, - `${BasePath}${Extract}` + const Path extends string, + const LocalSchema extends InputSchema< + keyof Definitions['type'] & string >, - Schema extends TypedSchema< - Extract - > + const Route extends MergeSchema< + UnwrapRoute, + ParentSchema + >, + const Function extends Handler >( - paths: Paths, - handler: Handler, + path: Path, + handler: Function, hook?: LocalHook< - Schema, - Instance, - `${BasePath}${Extract}` + LocalSchema, + Route, + Decorators, + Definitions['error'], + `${BasePath}${Path}` > ): Elysia< BasePath, - { - request: Instance['request'] - store: Instance['store'] - schema: Instance['schema'] - error: Instance['error'] - meta: Record<'defs', Instance['meta']['defs']> & - Record<'exposed', Instance['meta']['exposed']> & - Record< - 'schema', - Prettify< - Instance['meta']['schema'] & - (MergeSchema< - Schema, - Instance['schema'] - > extends infer Typed extends TypedSchema - ? { - [path in `${BasePath}${Extract< - Paths, - string - >}`]: { - options: { - body: UnwrapSchema< - Typed['body'], - Instance['meta']['defs'] - > - headers: UnwrapSchema< - Typed['headers'], - Instance['meta']['defs'] - > extends infer Result - ? Result extends Record< - string, - any - > - ? Result - : undefined - : undefined - query: UnwrapSchema< - Typed['query'], - Instance['meta']['defs'] - > extends infer Result - ? Result extends Record< - string, - any - > - ? Result - : undefined - : undefined - params: UnwrapSchema< - Typed['params'], - Instance['meta']['defs'] - > extends infer Result - ? Result extends Record< - string, - any - > - ? Result - : undefined - : Record< - ExtractPath< - Extract< - Paths, - string - > - >, - string - > - response: Typed['response'] extends - | TSchema - | string - ? { - '200': UnwrapSchema< - Typed['response'], - Instance['meta']['defs'], - ReturnType - > - } - : Typed['response'] extends Record< - string, - TSchema | string - > - ? { - [key in keyof Typed['response']]: UnwrapSchema< - Typed['response'][key], - Instance['meta']['defs'], - ReturnType - > - } - : { - '200': ReturnType - } - } - } - } - : {}) - > - > - } + Decorators, + Definitions, + ParentSchema, + Prettify< + Routes & { + [path in `${BasePath}${Path}`]: { + options: { + body: Route['body'] + params: Route['params'] + query: Route['query'] + headers: Route['headers'] + response: unknown extends Route['response'] + ? { + 200: ReturnType + } + : Route['response'] + } + } + } + >, + Scoped > { - if (typeof paths === 'string') { - paths = [paths] as Paths - } - for (const path of paths) { - this.add( - 'OPTIONS', - path, - handler as any, - hook as LocalHook - ) - } + this.add('OPTIONS', path, handler as any, hook) return this as any } @@ -2336,282 +2027,57 @@ export default class Elysia< * ``` */ all< - Paths extends string | string[], - Handler extends LocalHandler< - Schema, - Instance, - `${BasePath}${Extract}` + const Path extends string, + const LocalSchema extends InputSchema< + keyof Definitions['type'] & string >, - Schema extends TypedSchema< - Extract - > - >( - paths: Paths, - handler: Handler, - hook?: LocalHook< - Schema, - Instance, - `${BasePath}${Extract}` - > - ): Elysia< - BasePath, - { - request: Instance['request'] - store: Instance['store'] - schema: Instance['schema'] - error: Instance['error'] - meta: Record<'defs', Instance['meta']['defs']> & - Record<'exposed', Instance['meta']['exposed']> & - Record< - 'schema', - Prettify< - Instance['meta']['schema'] & - (MergeSchema< - Schema, - Instance['schema'] - > extends infer Typed extends TypedSchema - ? { - [path in `${BasePath}${Extract< - Paths, - string - >}`]: { - all: { - body: UnwrapSchema< - Typed['body'], - Instance['meta']['defs'] - > - headers: UnwrapSchema< - Typed['headers'], - Instance['meta']['defs'] - > extends infer Result - ? Result extends Record< - string, - any - > - ? Result - : undefined - : undefined - query: UnwrapSchema< - Typed['query'], - Instance['meta']['defs'] - > extends infer Result - ? Result extends Record< - string, - any - > - ? Result - : undefined - : undefined - params: UnwrapSchema< - Typed['params'], - Instance['meta']['defs'] - > extends infer Result - ? Result extends Record< - string, - any - > - ? Result - : undefined - : Record< - ExtractPath< - Extract< - Paths, - string - > - >, - string - > - response: Typed['response'] extends - | TSchema - | string - ? { - '200': UnwrapSchema< - Typed['response'], - Instance['meta']['defs'], - ReturnType - > - } - : Typed['response'] extends Record< - string, - TSchema | string - > - ? { - [key in keyof Typed['response']]: UnwrapSchema< - Typed['response'][key], - Instance['meta']['defs'], - ReturnType - > - } - : { - '200': ReturnType - } - } - } - } - : {}) - > - > - } - > { - if (typeof paths === 'string') { - paths = [paths] as Paths - } - for (const path of paths) { - this.add('ALL', path, handler, hook as LocalHook) - } - - return this as any - } - - /** - * ### head - * Register handler for path with method [HEAD] - * - * --- - * @example - * ```typescript - * import { Elysia, t } from 'elysia' - * - * new Elysia() - * .head('/', () => 'hi') - * .head('/with-hook', () => 'hi', { - * schema: { - * response: t.String() - * } - * }) - * ``` - */ - head< - Paths extends string | string[], - Handler extends LocalHandler< - Schema, - Instance, - `${BasePath}${Extract}` + const Route extends MergeSchema< + UnwrapRoute, + ParentSchema >, - Schema extends TypedSchema< - Extract - > + const Function extends Handler >( - paths: Paths, - handler: Handler, + path: Path, + handler: Function, hook?: LocalHook< - Schema, - Instance, - `${BasePath}${Extract}` + LocalSchema, + Route, + Decorators, + Definitions['error'], + `${BasePath}${Path}` > ): Elysia< BasePath, - { - request: Instance['request'] - store: Instance['store'] - schema: Instance['schema'] - error: Instance['error'] - meta: Record<'defs', Instance['meta']['defs']> & - Record<'exposed', Instance['meta']['exposed']> & - Record< - 'schema', - Prettify< - Instance['meta']['schema'] & - (MergeSchema< - Schema, - Instance['schema'] - > extends infer Typed extends TypedSchema - ? { - [path in `${BasePath}${Extract< - Paths, - string - >}`]: { - head: { - body: UnwrapSchema< - Typed['body'], - Instance['meta']['defs'] - > - headers: UnwrapSchema< - Typed['headers'], - Instance['meta']['defs'] - > extends infer Result - ? Result extends Record< - string, - any - > - ? Result - : undefined - : undefined - query: UnwrapSchema< - Typed['query'], - Instance['meta']['defs'] - > extends infer Result - ? Result extends Record< - string, - any - > - ? Result - : undefined - : undefined - params: UnwrapSchema< - Typed['params'], - Instance['meta']['defs'] - > extends infer Result - ? Result extends Record< - string, - any - > - ? Result - : undefined - : Record< - ExtractPath< - Extract< - Paths, - string - > - >, - string - > - response: Typed['response'] extends - | TSchema - | string - ? { - '200': UnwrapSchema< - Typed['response'], - Instance['meta']['defs'], - ReturnType - > - } - : Typed['response'] extends Record< - string, - TSchema | string - > - ? { - [key in keyof Typed['response']]: UnwrapSchema< - Typed['response'][key], - Instance['meta']['defs'], - ReturnType - > - } - : { - '200': ReturnType - } - } - } - } - : {}) - > - > - } + Decorators, + Definitions, + ParentSchema, + Prettify< + Routes & { + [path in `${BasePath}${Path}`]: { + [method in string]: { + body: Route['body'] + params: Route['params'] + query: Route['query'] + headers: Route['headers'] + response: unknown extends Route['response'] + ? { + 200: ReturnType + } + : Route['response'] + } + } + } + >, + Scoped > { - if (typeof paths === 'string') { - paths = [paths] as Paths - } - for (const path of paths) { - this.add('HEAD', path, handler, hook as LocalHook) - } + this.add('ALL', path, handler as any, hook) return this as any } /** - * ### trace - * Register handler for path with method [TRACE] + * ### head + * Register handler for path with method [HEAD] * * --- * @example @@ -2619,138 +2085,59 @@ export default class Elysia< * import { Elysia, t } from 'elysia' * * new Elysia() - * .trace('/', () => 'hi') - * .trace('/with-hook', () => 'hi', { + * .head('/', () => 'hi') + * .head('/with-hook', () => 'hi', { * schema: { * response: t.String() * } * }) * ``` */ - trace< - Paths extends string | string[], - Handler extends LocalHandler< - Schema, - Instance, - `${BasePath}${Extract}` + head< + const Path extends string, + const LocalSchema extends InputSchema< + keyof Definitions['type'] & string >, - Schema extends TypedSchema< - Extract - > + const Route extends MergeSchema< + UnwrapRoute, + ParentSchema + >, + const Function extends Handler >( - paths: Paths, - handler: Handler, + path: Path, + handler: Function, hook?: LocalHook< - Schema, - Instance, - `${BasePath}${Extract}` + LocalSchema, + Route, + Decorators, + Definitions['error'], + `${BasePath}${Path}` > ): Elysia< BasePath, - { - request: Instance['request'] - store: Instance['store'] - schema: Instance['schema'] - error: Instance['error'] - meta: Record<'defs', Instance['meta']['defs']> & - Record<'exposed', Instance['meta']['exposed']> & - Record< - 'schema', - Prettify< - Instance['meta']['schema'] & - (MergeSchema< - Schema, - Instance['schema'] - > extends infer Typed extends TypedSchema - ? { - [path in `${BasePath}${Extract< - Paths, - string - >}`]: { - trace: { - body: UnwrapSchema< - Typed['body'], - Instance['meta']['defs'] - > - headers: UnwrapSchema< - Typed['headers'], - Instance['meta']['defs'] - > extends infer Result - ? Result extends Record< - string, - any - > - ? Result - : undefined - : undefined - query: UnwrapSchema< - Typed['query'], - Instance['meta']['defs'] - > extends infer Result - ? Result extends Record< - string, - any - > - ? Result - : undefined - : undefined - params: UnwrapSchema< - Typed['params'], - Instance['meta']['defs'] - > extends infer Result - ? Result extends Record< - string, - any - > - ? Result - : undefined - : Record< - ExtractPath< - Extract< - Paths, - string - > - >, - string - > - response: Typed['response'] extends - | TSchema - | string - ? { - '200': UnwrapSchema< - Typed['response'], - Instance['meta']['defs'], - ReturnType - > - } - : Typed['response'] extends Record< - string, - TSchema | string - > - ? { - [key in keyof Typed['response']]: UnwrapSchema< - Typed['response'][key], - Instance['meta']['defs'], - ReturnType - > - } - : { - '200': ReturnType - } - } - } - } - : {}) - > - > - } + Decorators, + Definitions, + ParentSchema, + Prettify< + Routes & { + [path in `${BasePath}${Path}`]: { + head: { + body: Route['body'] + params: Route['params'] + query: Route['query'] + headers: Route['headers'] + response: unknown extends Route['response'] + ? { + 200: ReturnType + } + : Route['response'] + } + } + } + >, + Scoped > { - if (typeof paths === 'string') { - paths = [paths] as Paths - } - for (const path of paths) { - this.add('TRACE', path, handler, hook as LocalHook) - } + this.add('HEAD', path, handler as any, hook) return this as any } @@ -2774,136 +2161,57 @@ export default class Elysia< * ``` */ connect< - Paths extends string | string[], - Handler extends LocalHandler< - Schema, - Instance, - `${BasePath}${Extract}` + const Path extends string, + const LocalSchema extends InputSchema< + keyof Definitions['type'] & string >, - Schema extends TypedSchema< - Extract - > + const Route extends MergeSchema< + UnwrapRoute, + ParentSchema + >, + const Function extends Handler >( - paths: Paths, - handler: Handler, + path: Path, + handler: Function, hook?: LocalHook< - Schema, - Instance, - `${BasePath}${Extract}` + LocalSchema, + Route, + Decorators, + Definitions['error'], + `${BasePath}${Path}` > ): Elysia< BasePath, - { - request: Instance['request'] - store: Instance['store'] - schema: Instance['schema'] - error: Instance['error'] - meta: Record<'defs', Instance['meta']['defs']> & - Record<'exposed', Instance['meta']['exposed']> & - Record< - 'schema', - Prettify< - Instance['meta']['schema'] & - (MergeSchema< - Schema, - Instance['schema'] - > extends infer Typed extends TypedSchema - ? { - [path in `${BasePath}${Extract< - Paths, - string - >}`]: { - connect: { - body: UnwrapSchema< - Typed['body'], - Instance['meta']['defs'] - > - headers: UnwrapSchema< - Typed['headers'], - Instance['meta']['defs'] - > extends infer Result - ? Result extends Record< - string, - any - > - ? Result - : undefined - : undefined - query: UnwrapSchema< - Typed['query'], - Instance['meta']['defs'] - > extends infer Result - ? Result extends Record< - string, - any - > - ? Result - : undefined - : undefined - params: UnwrapSchema< - Typed['params'], - Instance['meta']['defs'] - > extends infer Result - ? Result extends Record< - string, - any - > - ? Result - : undefined - : Record< - ExtractPath< - Extract< - Paths, - string - > - >, - string - > - response: Typed['response'] extends - | TSchema - | string - ? { - '200': UnwrapSchema< - Typed['response'], - Instance['meta']['defs'], - ReturnType - > - } - : Typed['response'] extends Record< - string, - TSchema | string - > - ? { - [key in keyof Typed['response']]: UnwrapSchema< - Typed['response'][key], - Instance['meta']['defs'], - ReturnType - > - } - : { - '200': ReturnType - } - } - } - } - : {}) - > - > - } + Decorators, + Definitions, + ParentSchema, + Prettify< + Routes & { + [path in `${BasePath}${Path}`]: { + connect: { + body: Route['body'] + params: Route['params'] + query: Route['query'] + headers: Route['headers'] + response: unknown extends Route['response'] + ? { + 200: ReturnType + } + : Route['response'] + } + } + } + >, + Scoped > { - if (typeof paths === 'string') { - paths = [paths] as Paths - } - for (const path of paths) { - this.add('CONNECT', path, handler, hook as LocalHook) - } + this.add('CONNECT', path, handler as any, hook) return this as any } /** * ### ws - * Register handler for websocket. + * Register handler for path with method [ws] * * --- * @example @@ -2911,8 +2219,7 @@ export default class Elysia< * import { Elysia, t } from 'elysia' * * new Elysia() - * .use(ws()) - * .ws('/ws', { + * .ws('/', { * message(ws, message) { * ws.send(message) * } @@ -2920,103 +2227,156 @@ export default class Elysia< * ``` */ ws< - Paths extends string | string[], - Schema extends WSTypedSchema< - Extract + const Path extends string, + const LocalSchema extends InputSchema< + keyof Definitions['type'] & string + >, + const Route extends MergeSchema< + UnwrapRoute, + ParentSchema > >( - /** - * Path to register websocket to - */ - paths: Paths, - options: this extends Elysia - ? ElysiaWSOptions< - `${BasePath}${Extract}`, - Schema, - Instance - > - : never + path: Path, + options: WS.LocalHook< + LocalSchema, + Route, + Decorators, + Definitions['error'], + `${BasePath}${Path}` + > ): Elysia< BasePath, - { - request: Instance['request'] - store: Instance['store'] - schema: Instance['schema'] - error: Instance['error'] - meta: Instance['meta'] & - Record< - 'schema', - Record< - `${BasePath}${Extract}`, - MergeSchema< - Schema, - Instance['schema'] - > extends infer Typed extends TypedSchema - ? { - subscribe: TypedWSRouteToEden< - Typed, - Instance['meta']['defs'], - `${BasePath}${Extract}` - > - } - : {} - > - > - } + Decorators, + Definitions, + ParentSchema, + Prettify< + Routes & { + [path in `${BasePath}${Path}`]: { + subscribe: { + body: Route['body'] + params: Route['params'] + query: Route['query'] + headers: Route['headers'] + response: Route['response'] + } + } + } + >, + Scoped > { - if (!this.wsRouter) - throw new Error( - "Can't find WebSocket. Please register WebSocket plugin first by importing 'elysia/ws'" - ) + const transform = options.transformMessage + ? Array.isArray(options.transformMessage) + ? options.transformMessage + : [options.transformMessage] + : undefined + + this.get( + path as any, + // @ts-ignore + (context) => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { set, path, qi, ...wsContext } = context - if (typeof paths === 'string') { - paths = [paths] as Paths - } - for (const path of paths) { - this.wsRouter.add('subscribe', path, options as any) + // For Aot evaluation + context.headers + context.query + context.params - this.get( - path, - // @ts-ignore - (context) => { - if ( - // @ts-ignore - this.server?.upgrade(context.request, { - headers: - typeof options.upgrade === 'function' - ? options.upgrade(context as any) - : options.upgrade, - // @ts-ignore - data: { - ...context, - id: Date.now(), - headers: context.request.headers.toJSON(), - message: getSchemaValidator(options?.body, { - models: this.meta.defs - }), - transformMessage: !options.transform - ? [] - : Array.isArray(options.transformMessage) - ? options.transformMessage - : [options.transformMessage] - } as ElysiaWSContext['data'] - }) - ) - return + const validateMessage = getSchemaValidator(options?.body, { + models: this.definitions.type as Record + }) - context.set.status = 400 + const validateResponse = getSchemaValidator( + options?.response as any, + { + models: this.definitions.type as Record + } + ) - return 'Expected a websocket connection' - }, - { - beforeHandle: options.beforeHandle, - transform: options.transform, - headers: options?.headers, - params: options?.params, - query: options?.query - } as any - ) - } + const parseMessage = (message: any) => { + const start = message.charCodeAt(0) + + if (start === 47 || start === 123) + try { + message = JSON.parse(message) + } catch { + // Not empty + } + else if (!Number.isNaN(+message)) message = +message + + if (transform?.length) + for (let i = 0; i < transform.length; i++) { + const temp = transform[i](message) + + if (temp !== undefined) message = temp + } + + return message + } + + if ( + this.server?.upgrade(context.request, { + headers: + typeof options.upgrade === 'function' + ? options.upgrade(context as any as Context) + : options.upgrade, + data: { + validator: validateResponse, + open(ws: ServerWebSocket) { + options.open?.( + new ElysiaWS(ws, wsContext as any) + ) + }, + message: (ws: ServerWebSocket, msg: any) => { + const message = parseMessage(msg) + + if (validateMessage?.Check(message) === false) + return void ws.send( + new ValidationError( + 'message', + validateMessage, + message + ).message as string + ) + + options.message?.( + new ElysiaWS(ws, wsContext as any), + message + ) + }, + drain(ws: ServerWebSocket) { + options.drain?.( + new ElysiaWS(ws, wsContext as any) + ) + }, + close( + ws: ServerWebSocket, + code: number, + reason: string + ) { + options.close?.( + new ElysiaWS(ws, wsContext as any), + code, + reason + ) + } + } + }) + ) + return + + set.status = 400 + + return 'Expected a websocket connection' + }, + { + beforeHandle: options.beforeHandle, + transform: options.transform, + headers: options.headers, + params: options.params, + query: options.query + } as any + ) return this as any } @@ -3039,29 +2399,35 @@ export default class Elysia< * }) * ``` */ + route< - Schema extends TypedSchema< - Exclude + const Method extends HTTPMethod, + const Paths extends Readonly, + const LocalSchema extends InputSchema< + Extract >, - Method extends HTTPMethod, - Path extends string | string[], - Handler extends LocalHandler< - Schema, - Instance, - `${BasePath}${Extract}` + const Function extends Handler< + Route, + Decorators, + `${BasePath}${Paths[number]}` + >, + const Route extends MergeSchema< + UnwrapRoute, + ParentSchema > >( method: Method, - path: Path, - handler: Handler, - // @ts-ignore + path: Paths, + handler: Function, { config, ...hook }: LocalHook< - Schema, - Instance, - `${BasePath}${Extract}` + LocalSchema, + Route, + Decorators, + Definitions['error'], + `${BasePath}${Paths[number]}` > & { config: { allowMeta?: boolean @@ -3070,113 +2436,40 @@ export default class Elysia< config: { allowMeta: false } - } + } as any ): Elysia< BasePath, - { - request: Instance['request'] - store: Instance['store'] - schema: Instance['schema'] - error: Instance['error'] - meta: Record<'defs', Instance['meta']['defs']> & - Record<'exposed', Instance['meta']['exposed']> & - Record< - 'schema', - Prettify< - Instance['meta']['schema'] & - MergeSchema< - Schema, - Instance['schema'] - > extends infer Typed extends TypedSchema - ? { - [path in `${BasePath}${Extract< - Path, - string - >}`]: { - [method in Method]: { - body: UnwrapSchema< - Typed['body'], - Instance['meta']['defs'] - > - headers: UnwrapSchema< - Typed['headers'], - Instance['meta']['defs'] - > extends infer Result - ? Result extends Record< - string, - any - > - ? Result - : undefined - : undefined - query: UnwrapSchema< - Typed['query'], - Instance['meta']['defs'] - > extends infer Result - ? Result extends Record< - string, - any - > - ? Result - : undefined - : undefined - params: UnwrapSchema< - Typed['params'], - Instance['meta']['defs'] - > extends infer Result - ? Result extends Record< - string, - any - > - ? Result - : undefined - : Record< - ExtractPath< - Extract< - Path, - string - > - >, - string - > - response: Typed['response'] extends - | TSchema - | string - ? { - '200': UnwrapSchema< - Typed['response'], - Instance['meta']['defs'], - ReturnType - > - } - : Typed['response'] extends Record< - string, - TSchema | string - > - ? { - [key in keyof Typed['response']]: UnwrapSchema< - Typed['response'][key], - Instance['meta']['defs'], - ReturnType - > - } - : { - '200': ReturnType - } - } - } - } - : never - > - > - } + Decorators, + Definitions, + ParentSchema, + Prettify< + Routes & { + [path in `${BasePath}${Paths[number]}`]: { + [method in HTTPMethod]: Route extends { + body: infer Body + params: infer Params + query: infer Query + headers: infer Headers + response: infer Response + } + ? { + body: Body + params: Params + query: Query + headers: Headers + response: unknown extends Response + ? { + 200: ReturnType + } + : Response + } + : never + } + } + >, + Scoped > { - if (typeof path === 'string') { - path = [path] as Path - } - for (const p of path) { - this.add(method, p, handler, hook as LocalHook, config) - } + this.add(method, path, handler as any, hook, config) return this as any } @@ -3193,18 +2486,23 @@ export default class Elysia< * .get('/', (({ counter }) => ++counter) * ``` */ - state( - name: Key, + state( + name: Name, value: Value ): Elysia< BasePath, { - store: Reconciliation> - error: Instance['error'] - request: Instance['request'] - schema: Instance['schema'] - meta: Instance['meta'] - } + request: Decorators['request'] + store: Prettify< + Decorators['store'] & { + [name in Name]: Value + } + > + }, + Definitions, + ParentSchema, + Routes, + Scoped > /** @@ -3219,17 +2517,32 @@ export default class Elysia< * .get('/', (({ counter }) => ++counter) * ``` */ - state>( - store: NewStore + state>( + store: Store ): Elysia< BasePath, { - store: Reconciliation> - error: Instance['error'] - request: Instance['request'] - schema: Instance['schema'] - meta: Instance['meta'] - } + request: Decorators['request'] + store: Prettify + }, + Definitions, + ParentSchema, + Routes, + Scoped + > + + state>( + mapper: (decorators: Decorators['store']) => NewStore + ): Elysia< + BasePath, + { + request: Decorators['request'] + store: NewStore + }, + Definitions, + ParentSchema, + Routes, + Scoped > /** @@ -3245,13 +2558,19 @@ export default class Elysia< * ``` */ state( - name: string | number | symbol | Record, + name: string | number | symbol | Record | Function, value?: unknown ) { - if (typeof name === 'object') { - this.store = mergeDeep(this.store, name) + switch (typeof name) { + case 'object': + this.store = mergeDeep(this.store, name) + + return this as any - return this as any + case 'function': + this.store = name(this.store) + + return this as any } if (!(name in this.store)) { @@ -3281,12 +2600,17 @@ export default class Elysia< ): Elysia< BasePath, { - store: Instance['store'] - error: Instance['error'] - request: Reconciliation> - schema: Instance['schema'] - meta: Instance['meta'] - } + request: Prettify< + Decorators['request'] & { + [name in Name]: Value + } + > + store: Decorators['store'] + }, + Definitions, + ParentSchema, + Routes, + Scoped > /** @@ -3301,20 +2625,32 @@ export default class Elysia< * .get('/', (({ getDate }) => getDate()) * ``` */ - decorate>( - name: Decorators + decorate>( + decorators: NewDecorators ): Elysia< BasePath, { - store: Instance['store'] - error: Instance['error'] - request: Reconciliation< - Instance['request'], - Decorators - > - schema: Instance['schema'] - meta: Instance['meta'] - } + request: Prettify + store: Decorators['store'] + }, + Definitions, + ParentSchema, + Routes, + Scoped + > + + decorate>( + mapper: (decorators: Decorators['request']) => NewDecorators + ): Elysia< + BasePath, + { + request: NewDecorators + store: Decorators['store'] + }, + Definitions, + ParentSchema, + Routes, + Scoped > /** @@ -3329,11 +2665,20 @@ export default class Elysia< * .get('/', (({ getDate }) => getDate()) * ``` */ - decorate(name: string | Record, value?: unknown) { - if (typeof name === 'object') { - this.decorators = mergeDeep(this.decorators, name) + decorate( + name: string | Record | Function, + value?: unknown + ) { + switch (typeof name) { + case 'object': + this.decorators = mergeDeep(this.decorators, name) + + return this as any - return this as any + case 'function': + this.decorators = name(this.decorators) + + return this as any } // @ts-ignore @@ -3357,26 +2702,22 @@ export default class Elysia< * } * })) */ - derive( + derive( transform: ( - context: Context< - TypedSchemaToRoute< - Instance['schema'], - Instance['meta']['defs'] - >, - Instance['store'] - > & - Instance['request'] - ) => MaybePromise extends { store: any } ? never : Returned + context: Prettify> + ) => MaybePromise extends { store: any } + ? never + : Derivative ): Elysia< BasePath, { - store: Instance['store'] - error: Instance['error'] - request: Instance['request'] & Awaited - schema: Instance['schema'] - meta: Instance['meta'] - } + request: Prettify> + store: Decorators['store'] + }, + Definitions, + ParentSchema, + Routes, + Scoped > { // @ts-ignore transform.$elysia = 'derive' @@ -3384,64 +2725,216 @@ export default class Elysia< return this.onTransform(transform as any) as any } - /** - * ### schema - * Define type strict validation for request - * - * --- - * @example - * ```typescript - * import { Elysia, t } from 'elysia' - * - * new Elysia() - * .schema({ - * response: t.String() - * }) - * .get('/', () => 'hi') - * ``` - */ - schema< - Schema extends TypedSchema< - Exclude - > = TypedSchema< - Exclude - >, - NewInstance = Elysia< - BasePath, - { - request: Instance['request'] - store: Instance['store'] - error: Instance['error'] - schema: MergeSchema - meta: Instance['meta'] + model( + name: Name, + model: Model + ): Elysia< + BasePath, + Decorators, + { + type: Prettify< + Definitions['type'] & { [name in Name]: Static } + > + error: Definitions['error'] + }, + ParentSchema, + Routes, + Scoped + > + + model>( + record: Recorder + ): Elysia< + BasePath, + Decorators, + { + type: Prettify< + Definitions['type'] & { + [key in keyof Recorder]: Static + } + > + error: Definitions['error'] + }, + ParentSchema, + Routes, + Scoped + > + + model>( + mapper: (decorators: Definitions['type']) => NewType + ): Elysia< + BasePath, + Decorators, + { + type: NewType + error: Definitions['error'] + }, + ParentSchema, + Routes, + Scoped + > + + model(name: string | Record | Function, model?: TSchema) { + switch (typeof name) { + case 'object': + Object.entries(name).forEach(([key, value]) => { + if (!(key in this.definitions.type)) + // @ts-ignore + this.definitions.type[key] = value as TSchema + }) + + return this + + case 'function': + this.definitions.type = name(this.definitions.type) + + return this as any + } + + ;(this.definitions.type as Record)[name] = model! + + return this as any + } + + mapDerive>( + mapper: (decorators: Decorators['request']) => MaybePromise + ): Elysia< + BasePath, + { + request: Decorators['request'] + store: NewStore + }, + Definitions, + ParentSchema, + Routes, + Scoped + > { + // @ts-ignore + mapper.$elysia = 'derive' + + return this.onTransform(mapper as any) as any + } + + affix< + const Base extends 'prefix' | 'suffix', + const Type extends 'all' | 'decorator' | 'state' | 'model' | 'error', + const Word extends string + >( + base: Base, + type: Type, + word: Word + ): Elysia< + BasePath, + { + request: Type extends 'decorator' | 'all' + ? 'prefix' extends Base + ? Word extends `${string}${'_' | '-' | ' '}` + ? AddPrefix + : AddPrefixCapitalize + : AddSuffixCapitalize + : Decorators['request'] + store: Type extends 'state' | 'all' + ? 'prefix' extends Base + ? Word extends `${string}${'_' | '-' | ' '}` + ? AddPrefix + : AddPrefixCapitalize + : AddSuffix + : Decorators['store'] + }, + { + type: Type extends 'model' | 'all' + ? 'prefix' extends Base + ? Word extends `${string}${'_' | '-' | ' '}` + ? AddPrefix + : AddPrefixCapitalize + : AddSuffixCapitalize + : Definitions['type'] + error: Type extends 'error' | 'all' + ? 'prefix' extends Base + ? Word extends `${string}${'_' | '-' | ' '}` + ? AddPrefix + : AddPrefixCapitalize + : AddSuffixCapitalize + : Definitions['error'] + }, + ParentSchema, + Routes, + Scoped + > { + if (word === '') return this as any + + const delimieter = ['_', '-', ' '] + const capitalize = (word: string) => + word[0].toUpperCase() + word.slice(1) + + const joinKey = + base === 'prefix' + ? (prefix: string, word: string) => + delimieter.includes(prefix.at(-1) ?? '') + ? prefix + word + : prefix + capitalize(word) + : delimieter.includes(word.at(-1) ?? '') + ? (suffix: string, word: string) => word + suffix + : (suffix: string, word: string) => word + capitalize(suffix) + + const remap = (type: 'decorator' | 'state' | 'model' | 'error') => { + const store: Record = {} + + switch (type) { + case 'decorator': + for (const key in this.decorators) + store[joinKey(word, key)] = this.decorators[key] + + this.decorators = store + break + + case 'state': + for (const key in this.store) + store[joinKey(word, key)] = this.store[key] + + this.store = store + break + + case 'model': + for (const key in this.definitions.type) + store[joinKey(word, key)] = this.definitions.type[key] + + this.definitions.type = store + break + + case 'error': + for (const key in this.definitions.error) + store[joinKey(word, key)] = this.definitions.error[key] + + this.definitions.error = store + break } - > - >(schema: Schema): NewInstance { - const models = this.meta.defs - - this.$schema = { - body: getSchemaValidator(schema.body, { - models - }), - headers: getSchemaValidator(schema?.headers, { - models, - additionalProperties: true - }), - params: getSchemaValidator(schema?.params, { - models - }), - query: getSchemaValidator(schema?.query, { - models - }), - // @ts-ignore - response: getSchemaValidator(schema?.response, { - models - }) } + const types = Array.isArray(type) ? type : [type] + + for (const type of types.some((x) => x === 'all') + ? ['decorator', 'state', 'model', 'error'] + : types) + remap(type as 'decorator') + return this as any } + prefix< + const Type extends 'all' | 'decorator' | 'state' | 'model' | 'error', + const Word extends string + >(type: Type, word: Word) { + return this.affix('prefix', type, word) + } + + suffix< + const Type extends 'all' | 'decorator' | 'state' | 'model' | 'error', + const Word extends string + >(type: Type, word: Word) { + return this.affix('suffix', type, word) + } + compile() { this.fetch = this.config.aot ? composeGeneralHandler(this) @@ -3469,21 +2962,20 @@ export default class Elysia< : createDynamicHandler(this))(request) private handleError = async ( - request: Request, + context: Context, error: | Error | ValidationError | ParseError | NotFoundError - | InternalServerError, - set: Context['set'] + | InternalServerError ) => (this.handleError = this.config.aot ? composeErrorHandler(this) - : createDynamicErrorHandler(this))(request, error, set) + : createDynamicErrorHandler(this))(context, error) private outerErrorHandler = (error: Error) => - new Response(error.message, { + new Response(error.message || error.name || 'Error', { // @ts-ignore status: error?.status ?? 500 }) @@ -3523,12 +3015,20 @@ export default class Elysia< development: !isProduction, ...this.config.serve, ...options, + websocket: { + ...this.config.websocket, + ...websocket + }, fetch, error: this.outerErrorHandler } as Serve) : ({ development: !isProduction, ...this.config.serve, + websocket: { + ...this.config.websocket, + ...websocket + }, port: options, fetch, error: this.outerErrorHandler @@ -3547,7 +3047,7 @@ export default class Elysia< if (callback) callback(this.server!) Promise.all(this.lazyLoadModules).then(() => { - Bun?.gc(true) + Bun?.gc(false) }) return this @@ -3586,66 +3086,13 @@ export default class Elysia< get modules() { return Promise.all(this.lazyLoadModules) } - - model( - name: Name, - model: Model - ): Elysia< - BasePath, - { - store: Instance['store'] - request: Instance['request'] - schema: Instance['schema'] - error: Instance['error'] - meta: { - schema: Instance['meta']['schema'] - defs: Reconciliation< - Instance['meta']['defs'], - Record> - > - exposed: Instance['meta']['exposed'] - } - } - > - - model>( - record: Recorder - ): Elysia< - BasePath, - { - store: Instance['store'] - request: Instance['request'] - schema: Instance['schema'] - error: Instance['error'] - meta: { - schema: Instance['meta']['schema'] - defs: Reconciliation< - Instance['meta']['defs'], - { - [key in keyof Recorder]: Static - } - > - exposed: Instance['meta']['exposed'] - } - } - > - - model(name: string, model?: TSchema) { - if (typeof name === 'object') - Object.entries(name).forEach(([key, value]) => { - // @ts-ignore - if (!(key in this.meta.defs)) this.meta.defs[key] = value - }) - else (this.meta.defs as Record)[name] = model! - - return this as any - } } -export { mapResponse, mapCompactResponse, mapEarlyResponse } from './handler' export { Elysia } + +export { mapResponse, mapCompactResponse, mapEarlyResponse } from './handler' export { t } from './custom-types' -export { ws } from './ws' +export { Cookie } from './cookie' export { getSchemaValidator, @@ -3659,47 +3106,39 @@ export { ParseError, NotFoundError, ValidationError, - InternalServerError + InternalServerError, + InvalidCookieSignature } from './error' export type { Context, PreContext } from './context' + export type { - Handler, - RegisteredHook, - BeforeRequestHandler, - VoidRequestHandler, - TypedRoute, - OverwritableTypeRoute, - ElysiaInstance, ElysiaConfig, - HTTPMethod, + DecoratorBase, + DefinitionBase, + RouteBase, + Handler, ComposedHandler, + InputSchema, + LocalHook, + MergeSchema, + RouteSchema, + UnwrapRoute, InternalRoute, - BodyParser, + HTTPMethod, + SchemaValidator, + VoidHandler, + PreHandler, + BodyHandler, + OptionalHandler, ErrorHandler, - ErrorCode, - TypedSchema, - LocalHook, - LocalHandler, - LifeCycle, + AfterHandler, + TraceHandler, + TraceStream, LifeCycleEvent, - AfterRequestHandler, - HookHandler, - TypedSchemaToRoute, - UnwrapSchema, + TraceEvent, LifeCycleStore, - VoidLifeCycle, - SchemaValidator, - ExtractPath, - IsPathParameter, - IsAny, - IsNever, - UnknownFallback, - WithArray, - ObjectValues, MaybePromise, - MergeIfNotNull, - ElysiaDefaultMeta, - AnyTypedSchema, - DeepMergeTwoTypes + ListenCallback, + UnwrapSchema } from './types' diff --git a/src/trace.ts b/src/trace.ts new file mode 100644 index 00000000..5480ec04 --- /dev/null +++ b/src/trace.ts @@ -0,0 +1,267 @@ +import type { + TraceHandler, + TraceProcess, + TraceReporter, + TraceStream +} from './types' + +export const createTraceListener = ( + reporter: TraceReporter, + handler: TraceHandler +) => { + return (event: TraceStream) => { + const id = event.id + + if (event.event === 'request' && event.type === 'begin') { + const createSignal = () => { + let resolveHandle: (value: TraceProcess<'begin'>) => void + let resolveHandleEnd: (value: TraceProcess<'end'>) => void + + let childIteration = -1 + const children: ((value: TraceProcess<'begin'>) => void)[] = [] + const endChildren: ((value: TraceProcess<'end'>) => void)[] = [] + + let resolved = false + const handle = new Promise>((resolve) => { + resolveHandle = (a) => { + if (resolved) return + else resolved = true + + resolve(a) + } + }) + + let resolvedEnd = false + const handleEnd = new Promise>( + (resolve) => { + resolveHandleEnd = (a) => { + if (resolvedEnd) return + else resolvedEnd = true + + if (childIteration === -1) childIteration = 0 + for ( + ; + childIteration < endChildren.length; + childIteration++ + ) { + // eslint-disable-next-line prefer-const + let end: TraceProcess<'end'> + const start: TraceProcess<'begin'> = { + name: 'anonymous', + time: performance.now(), + skip: true, + end: new Promise>( + (resolve) => { + resolve(end) + } + ), + children: [] + } + + end = performance.now() + + children[childIteration](start) + } + + resolve(a) + } + } + ) + + return { + signal: handle, + consumeChild(event: TraceStream) { + switch (event.type) { + case 'begin': + children[++childIteration]({ + name: event.name, + time: event.time, + skip: false, + end: new Promise>( + (resolve) => { + endChildren.push(resolve) + } + ) + } as TraceProcess<'begin'>) + break + + case 'end': + endChildren[childIteration](event.time) + break + } + }, + consume(event: TraceStream) { + switch (event.type) { + case 'begin': + const unitsProcess: Promise< + TraceProcess<'begin'> + >[] = [] + + const units = event.unit ?? 0 + for (let i = 0; i < units; i++) { + let resolveChild: + | (( + stream: TraceProcess<'begin'> + ) => void) + | undefined + + unitsProcess.push( + new Promise>( + (resolve) => { + resolveChild = resolve as any + } + ) + ) + + children.push(resolveChild!) + } + + resolveHandle({ + // Begin always have name + name: event.name!, + time: event.time, + skip: false, + end: handleEnd as any, + children: unitsProcess + } satisfies TraceProcess<'begin'>) + break + + case 'end': + resolveHandleEnd( + event.time as TraceProcess<'end'> + ) + break + } + }, + resolve() { + if (resolved && resolvedEnd) return + + // eslint-disable-next-line prefer-const + let end: TraceProcess<'end'> + const start: TraceProcess<'begin'> = { + name: 'anonymous', + time: performance.now(), + skip: true, + end: new Promise>((resolve) => { + resolve(end) + }), + children: [] + } + + end = performance.now() + + resolveHandle(start) + resolveHandleEnd(end) + } + } + } + + const request = createSignal() + const parse = createSignal() + const transform = createSignal() + const beforeHandle = createSignal() + const handle = createSignal() + const afterHandle = createSignal() + const error = createSignal() + const response = createSignal() + + request.consume(event) + + const reducer = (event: TraceStream) => { + if (event.id === id) + switch (event.event) { + case 'request': + request.consume(event) + break + + case 'request.unit': + request.consumeChild(event) + break + + case 'parse': + parse.consume(event) + break + + case 'parse.unit': + parse.consumeChild(event) + break + + case 'transform': + transform.consume(event) + break + + case 'transform.unit': + transform.consumeChild(event) + break + + case 'beforeHandle': + beforeHandle.consume(event) + break + + case 'beforeHandle.unit': + beforeHandle.consumeChild(event) + break + + case 'handle': + handle.consume(event) + break + + case 'afterHandle': + afterHandle.consume(event) + break + + case 'afterHandle.unit': + afterHandle.consumeChild(event) + break + + case 'error': + error.consume(event) + break + + case 'error.unit': + error.consumeChild(event) + break + + case 'response': + if (event.type === 'begin') { + request.resolve() + parse.resolve() + transform.resolve() + beforeHandle.resolve() + handle.resolve() + afterHandle.resolve() + error.resolve() + } else reporter.off('event', reducer) + + response.consume(event) + break + + case 'response.unit': + response.consumeChild(event) + break + } + } + + reporter.on('event', reducer) + + handler({ + id: event.id, + // @ts-ignore + context: event.ctx, + // @ts-ignore + set: event.ctx?.set, + // @ts-ignore + store: event.ctx?.store, + time: event.time, + request: request.signal as any, + parse: parse.signal as any, + transform: transform.signal as any, + beforeHandle: beforeHandle.signal as any, + handle: handle.signal as any, + afterHandle: afterHandle.signal as any, + error: error.signal, + response: response.signal as any + }) + } + } +} diff --git a/src/types.ts b/src/types.ts index 2ffe45c2..2ae90544 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,211 +1,180 @@ -import type { Serve, Server } from 'bun' +import type { Serve, Server, WebSocketHandler } from 'bun' -import type { Elysia } from '.' -import { - ParseError, - NotFoundError, - ValidationError, - InternalServerError -} from './error' - -import type { Static, TObject, TSchema } from '@sinclair/typebox' +import type { TSchema, TObject, Static } from '@sinclair/typebox' import type { TypeCheck } from '@sinclair/typebox/compiler' + import type { OpenAPIV3 } from 'openapi-types' +import type { EventEmitter } from 'eventemitter3' +import type { CookieOptions } from './cookie' import type { Context, PreContext } from './context' +import type { + InternalServerError, + InvalidCookieSignature, + NotFoundError, + ParseError, + ValidationError +} from './error' -export type WithArray = T | T[] -export type ObjectValues = T[keyof T] - -export type ElysiaDefaultMeta = { - schema: Record< - string, - Record< - string, - { - body: unknown - headers: unknown - query: unknown - params: unknown - response: unknown - } - > +export type ElysiaConfig = { + name?: string + seed?: unknown + serve?: Partial + prefix?: T + /** + * Disable `new Error` thrown marked as Error on Bun 0.6 + */ + forceErrorEncapsulation?: boolean + /** + * Disable Ahead of Time compliation + * + * Reduced performance but faster startup time + */ + aot?: boolean + /** + * Whether should Elysia tolerate suffix '/' or vice-versa + * + * @default false + */ + strictPath?: boolean + /** + * If set to true, other Elysia handler will not inherits global life-cycle, store, decorators from the current instance + * + * @default false + */ + scoped?: Scoped + websocket?: Omit< + WebSocketHandler, + 'open' | 'close' | 'message' | 'drain' > - defs: Record - exposed: Record> + cookie?: CookieOptions & { + /** + * Specified cookie name to be signed globally + */ + sign?: true | string | string[] + } } -export type ElysiaInstance< - Instance extends { - store?: Record - request?: Record - error?: Record - schema?: TypedSchema - meta?: ElysiaDefaultMeta - } = {} -> = { - error: undefined extends Instance['error'] ? {} : Instance['error'] - request: undefined extends Instance['request'] ? {} : Instance['request'] - store: undefined extends Instance['store'] ? {} : Instance['store'] - schema: undefined extends Instance['schema'] - ? TypedSchema - : Instance['schema'] - meta: undefined extends Instance['meta'] - ? { - schema: {} - defs: {} - exposed: {} - } - : Instance['meta'] -} +export type MaybeArray = T | T[] +export type MaybePromise = T | Promise -export type Handler< - Route extends TypedRoute, - Instance extends ElysiaInstance -> = ( - context: Context & Instance['request'] -) => IsUnknown extends false - ? Route['response'] extends { 200: unknown } - ? Response | MaybePromise - : Response | MaybePromise - : Response | MaybePromise - -export type NoReturnHandler< - Route extends TypedRoute = TypedRoute, - Instance extends ElysiaInstance = ElysiaInstance -> = ( - context: Context & Instance['request'] -) => void | Promise +export type ObjectValues = T[keyof T] -export type LifeCycleEvent = - | 'start' - | 'request' - | 'parse' - | 'transform' - | 'beforeHandle' - | 'afterHandle' - | 'response' - | 'error' - | 'stop' +/** + * @link https://stackoverflow.com/a/49928360/1490091 + */ +// export type IsAny = 0 extends 1 & T ? true : false +// export type IsNever = [T] extends [never] ? true : false +// export type IsUnknown = IsAny extends true +// ? false +// : unknown extends T +// ? true +// : false + +type IsPathParameter = Part extends `:${infer Parameter}` + ? Parameter + : Part extends `*` + ? '*' + : never -export type ListenCallback = - | ((server: Server) => void) - | ((server: Server) => Promise) +export type GetPathParameter = + Path extends `${infer A}/${infer B}` + ? IsPathParameter | GetPathParameter + : IsPathParameter -export type VoidLifeCycle = - | ((app: Elysia) => void) - | ((app: Elysia) => Promise) +// https://twitter.com/mattpocockuk/status/1622730173446557697?s=20 +export type Prettify = { + [K in keyof T]: T[K] +} & {} -export type BodyParser< - Route extends TypedRoute = TypedRoute, - Instance extends ElysiaInstance = ElysiaInstance -> = ( - context: PreContext & Instance['request'], - contentType: string -) => any | Promise +export type Reconcile = { + [key in keyof A as key extends keyof B ? never : key]: A[key] +} extends infer Collision + ? {} extends Collision + ? { + [key in keyof B]: B[key] + } + : Prettify< + Collision & { + [key in keyof B]: B[key] + } + > + : never -export interface LifeCycle { - start: VoidLifeCycle - request: BeforeRequestHandler - parse: BodyParser - transform: NoReturnHandler - beforeHandle: Handler - afterHandle: AfterRequestHandler - response: VoidRequestHandler - error: ErrorHandler - stop: VoidLifeCycle +export type DecoratorBase = { + request: { + [x: string]: unknown + } + store: { + [x: string]: unknown + } } -export type AfterRequestHandler< - Route extends TypedRoute, - Instance extends ElysiaInstance -> = ( - context: Context & Instance['request'], - response: Route['response'] -) => void | MaybePromise | Response - -export interface LifeCycleStore { - type?: ContentType - start: VoidLifeCycle[] - request: BeforeRequestHandler[] - parse: BodyParser[] - transform: NoReturnHandler[] - beforeHandle: Handler[] - afterHandle: AfterRequestHandler[] - onResponse: VoidRequestHandler[] - error: ErrorHandler[] - stop: VoidLifeCycle[] +export type DefinitionBase = { + type: { + [x: string]: unknown + } + error: { + [x: string]: Error + } } -export type BeforeRequestHandler< - Route extends TypedRoute = TypedRoute, - Instance extends ElysiaInstance = ElysiaInstance -> = (context: PreContext & Instance['request']) => any - -export type VoidRequestHandler< - Route extends TypedRoute = TypedRoute, - Instance extends ElysiaInstance = ElysiaInstance -> = (context: Context & Instance['request']) => any - -export interface RegisteredHook< - Instance extends ElysiaInstance = ElysiaInstance -> { - type?: ContentType - schema?: TypedSchema - transform: NoReturnHandler[] - beforeHandle: Handler[] - afterHandle: AfterRequestHandler[] - onResponse: VoidRequestHandler[] - parse: BodyParser[] - error: ErrorHandler[] +export type RouteBase = { + [path: string]: { + [method: string]: RouteSchema + } } -export interface TypedSchema { - body?: TSchema | ModelName - headers?: TObject | ModelName - query?: TObject | ModelName - params?: TObject | ModelName - response?: - | TSchema - | Record - | ModelName - | Record +export interface RouteSchema { + body?: unknown + headers?: unknown + query?: unknown + params?: unknown + cookie?: unknown + response?: unknown } export type UnwrapSchema< - Schema extends TSchema | undefined | string, - Definitions extends ElysiaInstance['meta']['defs'] = {}, - Fallback = unknown -> = Schema extends string + Schema extends TSchema | string | undefined, + Definitions extends DefinitionBase['type'] = {} +> = Schema extends undefined + ? unknown + : Schema extends TSchema + ? Static> + : Schema extends string ? Definitions extends Record ? NamedSchema : Definitions - : Schema extends TSchema - ? Static> - : Fallback + : unknown -export type TypedSchemaToRoute< - Schema extends TypedSchema, - Definitions extends ElysiaInstance['meta']['defs'] +export type UnwrapRoute< + Schema extends InputSchema, + Definitions extends DefinitionBase['type'] = {} > = { body: UnwrapSchema headers: UnwrapSchema< Schema['headers'], Definitions - > extends infer Result extends Record - ? Result + > extends infer A extends Record + ? A : undefined query: UnwrapSchema< Schema['query'], Definitions - > extends infer Result extends Record - ? Result + > extends infer A extends Record + ? A : undefined params: UnwrapSchema< Schema['params'], Definitions - > extends infer Result extends Record - ? Result + > extends infer A extends Record + ? A + : undefined + cookie: UnwrapSchema< + Schema['cookie'], + Definitions + > extends infer A extends Record + ? A : undefined response: Schema['response'] extends TSchema | string ? UnwrapSchema @@ -213,343 +182,91 @@ export type TypedSchemaToRoute< [k in string]: TSchema | string } ? UnwrapSchema, Definitions> - : unknown -} - -export type AnyTypedSchema = { - body: unknown - headers: Record | undefined - query: Record | undefined - params: Record | undefined - response: any + : unknown | void } -export type SchemaValidator = { - body?: TypeCheck - headers?: TypeCheck - query?: TypeCheck - params?: TypeCheck - response?: Record> -} - -export type HookHandler< - Schema extends TypedSchema = TypedSchema, - Instance extends ElysiaInstance = ElysiaInstance, - Path extends string = string, - Typed extends AnyTypedSchema = TypedSchemaToRoute< - Schema, - Instance['meta']['defs'] - > -> = Handler< - Typed extends { - body: infer Body - headers: infer Headers - query: infer Query - params: infer Params - response: infer Response - } - ? { - body: Body - headers: Headers - query: Query - params: Params extends undefined - ? Record, string> - : Params - response: Response | void - } - : Typed, - Instance -> - -type NotUndefined = undefined extends T ? false : true - -export type MergeIfNotNull = B extends null ? A : A & B -export type UnknownFallback = unknown extends A ? B : A -export type MergeSchema = { - body: NotUndefined extends true - ? A['body'] - : NotUndefined extends true - ? B['body'] - : undefined - headers: NotUndefined extends true - ? A['headers'] - : NotUndefined extends true - ? B['headers'] - : undefined - query: NotUndefined extends true - ? A['query'] - : NotUndefined extends true - ? B['query'] - : undefined - params: NotUndefined extends true - ? A['params'] - : NotUndefined extends true - ? B['params'] - : undefined - response: NotUndefined extends true - ? A['response'] - : NotUndefined extends true - ? B['response'] - : undefined -} - -type MaybeArray = T | T[] - -type ContentType = MaybeArray< - | (string & {}) - // Do not parse body - | 'none' - // Shorthand for 'text/plain' - | 'text' - // Shorthand for 'application/json' - | 'json' - // Shorthand for 'multipart/form-data' - | 'formdata' - // Shorthand for 'application/x-www-form-urlencoded' - | 'urlencoded' - // Shorthand for 'application/octet-stream' - | 'arrayBuffer' - | 'text/plain' - | 'application/json' - | 'multipart/form-data' - | 'application/x-www-form-urlencoded' -> - -export type LocalHook< - Schema extends TypedSchema, - Instance extends ElysiaInstance, - Path extends string = string -> = Partial & - (MergeSchema< - Schema, - Instance['schema'] - > extends infer Route extends TypedSchema - ? { - /** - * Short for 'Content-Type' - * - * Available: - * - 'none': do not parse body - * - 'text' / 'text/plain': parse body as string - * - 'json' / 'application/json': parse body as json - * - 'formdata' / 'multipart/form-data': parse body as form-data - * - 'urlencoded' / 'application/x-www-form-urlencoded: parse body as urlencoded - * - 'arraybuffer': parse body as readable stream - */ - type?: ContentType - /** - * Short for 'Content-Type' - */ - detail?: Partial - /** - * Transform context's value - * - * --- - * Lifecycle: - * - * __transform__ -> beforeHandle -> handler -> afterHandle - */ - transform?: WithArray> - /** - * Execute before main handler - * - * --- - * Lifecycle: - * - * transform -> __beforeHandle__ -> handler -> afterHandle - */ - beforeHandle?: WithArray> - /** - * Execute after main handler - * - * --- - * Lifecycle: - * - * transform -> beforeHandle -> handler -> __afterHandle__ - */ - afterHandle?: WithArray< - AfterRequestHandler< - TypedSchemaToRoute, - Instance - > - > - /** - * Catch error - */ - error?: WithArray> - /** - * Custom body parser - */ - parse?: WithArray - /** - * Custom body parser - */ - onResponse?: WithArray> - } - : never) - -export type TypedWSRouteToEden< - Schema extends TypedSchema = TypedSchema, - Definitions extends TypedSchema = ElysiaInstance['meta']['defs'], - Path extends string = string, - Catch = unknown -> = TypedSchemaToEden< - Schema, - Definitions -> extends infer Typed extends AnyTypedSchema - ? { - body: Typed['body'] - headers: Typed['headers'] - query: Typed['query'] - params: undefined extends Typed['params'] - ? Record, string> - : Typed['params'] - response: undefined extends Typed['response'] - ? Catch - : Typed['response']['200'] - } - : never - -export type TypedSchemaToEden< - Schema extends TypedSchema, - Definitions extends ElysiaInstance['meta']['defs'] +export type UnwrapGroupGuardRoute< + Schema extends InputSchema, + Definitions extends DefinitionBase['type'] = {}, + Path extends string = '' > = { body: UnwrapSchema headers: UnwrapSchema< Schema['headers'], Definitions - > extends infer Result extends Record - ? Result + > extends infer A extends Record + ? A : undefined query: UnwrapSchema< Schema['query'], Definitions - > extends infer Result extends Record - ? Result + > extends infer A extends Record + ? A : undefined params: UnwrapSchema< Schema['params'], Definitions - > extends infer Result extends Record - ? Result + > extends infer A extends Record + ? A + : Path extends `${string}/${':' | '*'}${string}` + ? Record, string> + : never + cookie: UnwrapSchema< + Schema['cookie'], + Definitions + > extends infer A extends Record + ? A : undefined response: Schema['response'] extends TSchema | string - ? { - '200': UnwrapSchema - } + ? UnwrapSchema : Schema['response'] extends { - [x in string]: TSchema | string - } - ? { - [key in keyof Schema['response']]: UnwrapSchema< - Schema['response'][key], - Definitions - > - } - : unknown -} - -export type LocalHandler< - Schema extends TypedSchema, - Instance extends ElysiaInstance, - Path extends string = string -> = Handler< - MergeSchema< - Schema, - Instance['schema'] - > extends infer Typed extends TypedSchema - ? TypedSchemaToRoute extends { - body: infer Body - params: infer Params - query: infer Query - headers: infer Headers - response: infer Response + [k in string]: TSchema | string } - ? { - body: Body - params: Params extends undefined - ? Record, string> - : Params - query: Query - headers: Headers - response: Response - } - : // It's impossible to land here - never - : never, - Instance -> - -export interface TypedRoute { - body?: unknown - headers?: Record - query?: Record - params?: Record - response?: unknown -} - -export type OverwritableTypeRoute = { - body?: unknown - headers?: Record - query?: Record - params?: Record - response?: unknown + ? UnwrapSchema, Definitions> + : unknown | void } -export type ComposedHandler = (context: Context) => MaybePromise - -export type ElysiaConfig = { - name?: string - seed?: unknown - serve?: Partial - prefix?: T - /** - * Disable `new Error` thrown marked as Error on Bun 0.6 - */ - forceErrorEncapsulation?: boolean - /** - * Disable Ahead of Time compliation - * - * Reduced performance but faster startup time - * - * @default !isCloudflareWorker (false if not Cloudflare worker) - */ - aot?: boolean - /** - * Whether should Elysia tolerate suffix '/' or vice-versa - * - * @default false - */ - strictPath?: boolean - /** - * If set to true, other Elysia handler will not inherits global life-cycle, store, decorators from the current instance - * - * @default false - */ - scoped: boolean +export interface LifeCycleStore { + type?: ContentType + start: PreHandler[] + request: PreHandler[] + parse: BodyHandler[] + transform: VoidHandler[] + beforeHandle: OptionalHandler[] + afterHandle: AfterHandler[] + onResponse: VoidHandler[] + trace: TraceHandler[] + error: ErrorHandler[] + stop: VoidHandler[] } -export type IsPathParameter = - Part extends `:${infer Parameter}` - ? Parameter - : Part extends `*` - ? '*' - : never - -export type ExtractPath = - Path extends `${infer A}/${infer B}` - ? IsPathParameter | ExtractPath - : IsPathParameter +export type LifeCycleEvent = + | 'start' + | 'request' + | 'parse' + | 'transform' + | 'beforeHandle' + | 'afterHandle' + | 'response' + | 'error' + | 'stop' -export interface InternalRoute { - method: HTTPMethod - path: string - composed: ComposedHandler | null - handler: Handler - hooks: LocalHook -} +export type ContentType = MaybeArray< + | (string & {}) + | 'none' + | 'text' + | 'json' + | 'formdata' + | 'urlencoded' + | 'arrayBuffer' + | 'text/plain' + | 'application/json' + | 'multipart/form-data' + | 'application/x-www-form-urlencoded' +> export type HTTPMethod = + | (string & {}) | 'ACL' | 'BIND' | 'CHECKOUT' @@ -586,151 +303,320 @@ export type HTTPMethod = | 'UNSUBSCRIBE' | 'ALL' -export type ErrorCode = - | (string & {}) - // ? Default 404 - | 'NOT_FOUND' - // ? Default 502 - | 'INTERNAL_SERVER_ERROR' - // ? Validation error - | 'VALIDATION' - // ? Body parsing error - | 'PARSE' - // ? Error that's not in defined list - | 'UNKNOWN' - -export type ErrorHandler = {}> = ( - params: - | { - request: Request - code: 'UNKNOWN' - error: Readonly - set: Context['set'] - } - | { - request: Request - code: 'VALIDATION' - error: Readonly - set: Context['set'] - } - | { - request: Request - code: 'NOT_FOUND' - error: Readonly - set: Context['set'] - } - | { - request: Request - code: 'PARSE' - error: Readonly - set: Context['set'] - } - | { - request: Request - code: 'INTERNAL_SERVER_ERROR' - error: Readonly - set: Context['set'] - } - | { - [K in keyof T]: { +export interface InputSchema { + body?: TSchema | Name + headers?: TObject | Name + query?: TObject | Name + params?: TObject | Name + cookie?: TObject | Name + response?: + | TSchema + | Record + | Name + | Record +} + +export type MergeSchema = { + body: undefined extends A['body'] ? B['body'] : A['body'] + headers: undefined extends A['headers'] ? B['headers'] : A['headers'] + query: undefined extends A['query'] ? B['query'] : A['query'] + params: undefined extends A['params'] ? B['params'] : A['params'] + cookie: undefined extends A['cookie'] ? B['cookie'] : A['cookie'] + response: undefined extends A['response'] ? B['response'] : A['response'] +} + +export type Handler< + Route extends RouteSchema = {}, + Decorators extends DecoratorBase = { + request: {} + store: {} + }, + Path extends string = '' +> = ( + context: Prettify> +) => Route['response'] extends { 200: unknown } + ? Response | MaybePromise + : Response | MaybePromise + +export type OptionalHandler< + Route extends RouteSchema = {}, + Decorators extends DecoratorBase = { + request: {} + store: {} + } +> = Handler extends ( + context: infer Context +) => infer Returned + ? (context: Context) => Returned | MaybePromise + : never + +export type AfterHandler< + Route extends RouteSchema = {}, + Decorators extends DecoratorBase = { + request: {} + store: {} + } +> = Handler extends ( + context: infer Context +) => infer Returned + ? ( + context: Prettify< + { + response: Route['response'] + } & Context + > + ) => Returned | MaybePromise + : never + +export type VoidHandler< + Route extends RouteSchema = {}, + Decorators extends DecoratorBase = { + request: {} + store: {} + } +> = (context: Prettify>) => MaybePromise + +export type TraceEvent = + | 'request' + | 'parse' + | 'transform' + | 'beforeHandle' + | 'afterHandle' + | 'error' + | 'response' extends infer Events extends string + ? Events | `${Events}.unit` | 'handle' + : never + +export type TraceStream = { + id: number + event: TraceEvent + type: 'begin' | 'end' + time: number + name?: string + unit?: number +} + +export type TraceReporter = EventEmitter<{ + event(stream: TraceStream): MaybePromise +}> + +export type TraceProcess = + Type extends 'begin' + ? Prettify<{ + name: string + time: number + skip: boolean + end: Promise> + children: Promise>[] + }> + : number + +export type TraceHandler< + Route extends RouteSchema = {}, + Decorators extends DecoratorBase = { + request: {} + store: {} + } +> = ( + lifecycle: Prettify< + { + context: Context + set: Context['set'] + id: number + time: number + } & { + [x in + | 'request' + | 'parse' + | 'transform' + | 'beforeHandle' + | 'handle' + | 'afterHandle' + | 'error' + | 'response']: Promise> + } & { + store: Decorators['store'] + } + > +) => MaybePromise + +export type TraceListener = EventEmitter<{ + [event in TraceEvent | 'all']: (trace: TraceProcess) => MaybePromise +}> + +export type BodyHandler< + Route extends RouteSchema = {}, + Decorators extends DecoratorBase = { + request: {} + store: {} + } +> = ( + context: Prettify>, + contentType: string +) => MaybePromise + +export type PreHandler< + Route extends RouteSchema = {}, + Decorators extends DecoratorBase = { + request: {} + store: {} + } +> = ( + context: Prettify> +) => MaybePromise + +export type ErrorHandler< + T extends Record = {}, + Route extends RouteSchema = {}, + Decorators extends DecoratorBase = { + request: {} + store: {} + } +> = ( + context: Prettify< + Context & + ( + | { + request: Request + code: 'UNKNOWN' + error: Readonly + set: Context['set'] + } + | { + request: Request + code: 'VALIDATION' + error: Readonly + set: Context['set'] + } + | { + request: Request + code: 'NOT_FOUND' + error: Readonly + set: Context['set'] + } + | { + request: Request + code: 'PARSE' + error: Readonly + set: Context['set'] + } + | { + request: Request + code: 'INTERNAL_SERVER_ERROR' + error: Readonly + set: Context['set'] + } + | { request: Request - code: K - error: Readonly + code: 'INVALID_COOKIE_SIGNATURE' + error: Readonly set: Context['set'] - } - }[keyof T] + } + | { + [K in keyof T]: { + request: Request + code: K + error: Readonly + set: Context['set'] + } + }[keyof T] + ) + > ) => any | Promise -export type DeepWritable = { -readonly [P in keyof T]: DeepWritable } +export type Isolate = { + [P in keyof T]: T[P] +} -// ? From https://dev.to/svehla/typescript-how-to-deep-merge-170c -// eslint-disable-next-line @typescript-eslint/no-unused-vars -type Head = T extends [infer I, ...infer _Rest] ? I : never -// eslint-disable-next-line @typescript-eslint/no-unused-vars -type Tail = T extends [infer _I, ...infer Rest] ? Rest : never +export type LocalHook< + LocalSchema extends InputSchema = {}, + Route extends RouteSchema = RouteSchema, + Decorators extends DecoratorBase = { + request: {} + store: {} + }, + Errors extends Record = {}, + Path extends string = '', + TypedRoute extends RouteSchema = Route extends { + params: Record + } + ? Route + : Route & { + params: Record, string> + } +> = (LocalSchema extends {} ? LocalSchema : Isolate) & { + /** + * Short for 'Content-Type' + * + * Available: + * - 'none': do not parse body + * - 'text' / 'text/plain': parse body as string + * - 'json' / 'application/json': parse body as json + * - 'formdata' / 'multipart/form-data': parse body as form-data + * - 'urlencoded' / 'application/x-www-form-urlencoded: parse body as urlencoded + * - 'arraybuffer': parse body as readable stream + */ + type?: ContentType + detail?: Partial + /** + * Transform context's value + */ + transform?: MaybeArray> + /** + * Execute before main handler + */ + beforeHandle?: MaybeArray> + /** + * Execute after main handler + */ + afterHandle?: MaybeArray> + /** + * Catch error + */ + error?: MaybeArray> + /** + * Custom body parser + */ + parse?: MaybeArray> + /** + * Custom body parser + */ + onResponse?: MaybeArray> +} -type Zip_DeepMergeTwoTypes = T extends [] - ? U - : U extends [] - ? T - : [ - DeepMergeTwoTypes, Head>, - ...Zip_DeepMergeTwoTypes, Tail> - ] +export type ComposedHandler = (context: Context) => MaybePromise -/** - * Take two objects T and U and create the new one with uniq keys for T a U objectI - * helper generic for `DeepMergeTwoTypes` - */ -type GetObjDifferentKeys< - T, - U, - T0 = Omit & Omit, - T1 = { [K in keyof T0]: T0[K] } -> = T1 -/** - * Take two objects T and U and create the new one with the same objects keys - * helper generic for `DeepMergeTwoTypes` - */ -type GetObjSameKeys = Omit> - -type MergeTwoObjects< - T, - U, - // non shared keys are optional - T0 = Partial> & { - // shared keys are recursively resolved by `DeepMergeTwoTypes<...>` - [K in keyof GetObjSameKeys]: DeepMergeTwoTypes - }, - T1 = { [K in keyof T0]: T0[K] } -> = T1 - -// it merge 2 static types and try to avoid of unnecessary options (`'`) -export type DeepMergeTwoTypes = - // ----- 2 added lines ------ - [T, U] extends [any[], any[]] - ? Zip_DeepMergeTwoTypes - : // check if generic types are objects - [T, U] extends [{ [key: string]: unknown }, { [key: string]: unknown }] - ? MergeTwoObjects - : T | U +export interface InternalRoute { + method: HTTPMethod + path: string + composed: ComposedHandler | null + handler: Handler + hooks: LocalHook +} -/** - * @link https://stackoverflow.com/a/49928360/1490091 - */ -export type IsAny = 0 extends 1 & T ? true : false +export type SchemaValidator = { + body?: TypeCheck + headers?: TypeCheck + query?: TypeCheck + params?: TypeCheck + cookie?: TypeCheck + response?: Record> +} -/** - * Returns a boolean for whether the the type is `never`. - */ -export type IsNever = [T] extends [never] ? true : false +export type ListenCallback = (server: Server) => MaybePromise -/** - * Returns a boolean for whether the the type is `unknown`. - */ -export type IsUnknown = IsAny extends true - ? false - : unknown extends T - ? true - : false +export type AddPrefix = { + [K in keyof T as `${Prefix}${K & string}`]: T[K] +} -export type MaybePromise = T | Promise +export type AddPrefixCapitalize = { + [K in keyof T as `${Prefix}${Capitalize}`]: T[K] +} -// https://twitter.com/mattpocockuk/status/1622730173446557697?s=20 -export type Prettify = { - [K in keyof T]: T[K] -} & {} +export type AddSuffix = { + [K in keyof T as `${K & string}${Suffix}`]: T[K] +} -export type Reconciliation = { - [key in keyof A as key extends keyof B ? never : key]: A[key] -} extends infer Collision - ? {} extends Collision - ? { - [key in keyof B]: B[key] - } - : Prettify< - Collision & { - [key in keyof B]: B[key] - } - > - : never +export type AddSuffixCapitalize = { + [K in keyof T as `${K & string}${Capitalize}`]: T[K] +} diff --git a/src/utils.ts b/src/utils.ts index 365d8c67..2fe80342 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -2,23 +2,65 @@ import { Kind, TSchema } from '@sinclair/typebox' import { Value } from '@sinclair/typebox/value' import { TypeCheck, TypeCompiler } from '@sinclair/typebox/compiler' -// @ts-ignore -import Mergician from 'mergician' - import type { - ElysiaInstance, LifeCycleStore, LocalHook, - TypedSchema, - RegisteredHook, - WithArray + MaybeArray, + InputSchema } from './types' -export const mergeDeep = Mergician({ - appendArrays: true -}) +const isObject = (item: any): item is Object => + item && typeof item === 'object' && !Array.isArray(item) + +const isClass = (v: Object) => + typeof v === 'function' && /^\s*class\s+/.test(v.toString()) + +export const mergeDeep = ( + target: A, + source: B, + { + skipKeys + }: { + skipKeys?: string[] + } = {} +): A & B => { + const output: Record = Object.assign({}, target) + + if (isObject(target) && isObject(source)) + for (const [key, value] of Object.entries(source)) { + if (skipKeys?.includes(key)) continue + + if (!isObject(value)) { + output[key] = value + continue + } + + if (!(key in target)) { + output[key] = value + continue + } + + if (key in target && isClass(value)) { + output[key] = value + continue + } + + output[key] = mergeDeep((target as any)[key] as any, value) + } + + return output as A & B +} + +export const mergeCookie = ( + target: A, + source: B +): A & B => + mergeDeep(target, source, { + skipKeys: ['properties'] + }) export const mergeObjectArray = (a: T | T[], b: T | T[]): T[] => { + // ! Must copy to remove side-effect const array = [...(Array.isArray(a) ? a : [a])] const checksums = [] @@ -38,9 +80,9 @@ export const mergeObjectArray = (a: T | T[], b: T | T[]): T[] => { } export const mergeHook = ( - a: LocalHook | LifeCycleStore, - b: LocalHook -): RegisteredHook => { + a?: LocalHook | LifeCycleStore, + b?: LocalHook +): LifeCycleStore => { return { // Merge local hook first // @ts-ignore @@ -60,56 +102,28 @@ export const mergeHook = ( // @ts-ignore a?.detail ?? {} ), - parse: mergeObjectArray((a.parse as any) ?? [], b?.parse ?? []), + parse: mergeObjectArray((a?.parse as any) ?? [], b?.parse ?? []), transform: mergeObjectArray( - a.transform ?? [], + a?.transform ?? [], b?.transform ?? [] ) as any, beforeHandle: mergeObjectArray( - a.beforeHandle ?? [], + a?.beforeHandle ?? [], b?.beforeHandle ?? [] ), afterHandle: mergeObjectArray( - a.afterHandle ?? [], + a?.afterHandle ?? [], b?.afterHandle ?? [] ), onResponse: mergeObjectArray( - a.onResponse ?? [], + a?.onResponse ?? [], b?.onResponse ?? [] ) as any, - - error: mergeObjectArray(a.error ?? [], b?.error ?? []) + trace: mergeObjectArray(a?.trace ?? [], b?.trace ?? []) as any, + error: mergeObjectArray(a?.error ?? [], b?.error ?? []) } } -// const isObject = (item: any): item is Object => -// item && typeof item === 'object' && !Array.isArray(item) - -// https://stackoverflow.com/a/37164538 -// export const mergeDeep = ( -// target: A, -// source: B -// ): DeepMergeTwoTypes => { -// const output: Partial> = Object.assign({}, target) -// if (isObject(target) && isObject(source)) { -// Object.keys(source).forEach((key) => { -// // @ts-ignore -// if (isObject(source[key])) { -// if (!(key in target)) -// // @ts-ignore -// Object.assign(output, { [key]: source[key] }) -// // @ts-ignore -// else output[key] = mergeDeep(target[key], source[key]) -// } else { -// // @ts-ignore -// Object.assign(output, { [key]: source[key] }) -// } -// }) -// } - -// return output as DeepMergeTwoTypes -// } - export const getSchemaValidator = ( s: TSchema | string | undefined, { @@ -146,7 +160,7 @@ export const getSchemaValidator = ( } export const getResponseSchemaValidator = ( - s: TypedSchema['response'] | undefined, + s: InputSchema['response'] | undefined, { models = {}, additionalProperties = false, @@ -177,15 +191,19 @@ export const getResponseSchemaValidator = ( return TypeCompiler.Compile(schema) } - if (Kind in maybeSchemaOrRecord) + if (Kind in maybeSchemaOrRecord) { + if ('additionalProperties' in maybeSchemaOrRecord === false) + maybeSchemaOrRecord.additionalProperties = additionalProperties + return { 200: compile(maybeSchemaOrRecord) } + } const record: Record> = {} Object.keys(maybeSchemaOrRecord).forEach((status): TSchema | undefined => { - const maybeNameOrSchema = maybeSchemaOrRecord[status] + const maybeNameOrSchema = maybeSchemaOrRecord[+status] if (typeof maybeNameOrSchema === 'string') { if (maybeNameOrSchema in models) { @@ -225,14 +243,11 @@ export const checksum = (s: string) => { return (h = h ^ (h >>> 9)) } -export const mergeLifeCycle = < - A extends ElysiaInstance, - B extends ElysiaInstance ->( - a: LifeCycleStore, - b: LifeCycleStore | LocalHook<{}, B>, +export const mergeLifeCycle = ( + a: LifeCycleStore, + b: LifeCycleStore | LocalHook, checksum?: number -): LifeCycleStore => { +): LifeCycleStore => { const injectChecksum = (x: T): T => { if (checksum) // @ts-ignore @@ -244,15 +259,16 @@ export const mergeLifeCycle = < return { start: mergeObjectArray( a.start as any, - ('start' in b ? b.start : []).map(injectChecksum) as any + ('start' in b ? b.start ?? [] : []).map(injectChecksum) as any ), request: mergeObjectArray( a.request as any, - ('request' in b ? b.request : []).map(injectChecksum) as any - ), - parse: mergeObjectArray(a.parse as any, b?.parse ?? ([] as any)).map( - injectChecksum + ('request' in b ? b.request ?? [] : []).map(injectChecksum) as any ), + parse: mergeObjectArray( + a.parse as any, + 'parse' in b ? b?.parse ?? [] : undefined ?? ([] as any) + ).map(injectChecksum), transform: mergeObjectArray( a.transform as any, (b?.transform ?? ([] as any)).map(injectChecksum) @@ -269,21 +285,25 @@ export const mergeLifeCycle = < a.onResponse as any, (b?.onResponse ?? ([] as any)).map(injectChecksum) ), + trace: mergeObjectArray( + a.trace as any, + ('trace' in b ? b.trace ?? [] : ([] as any)).map(injectChecksum) + ), error: mergeObjectArray( a.error as any, (b?.error ?? ([] as any)).map(injectChecksum) ), stop: mergeObjectArray( a.stop as any, - ('stop' in b ? b.stop : ([] as any)).map(injectChecksum) + ('stop' in b ? b.stop ?? [] : ([] as any)).map(injectChecksum) ) } } -export const asGlobalHook = >( - hook: T, +export const asGlobalHook = ( + hook: LocalHook, inject = true -): T => { +): LocalHook => { return { // rest is validator ...hook, @@ -295,10 +315,10 @@ export const asGlobalHook = >( afterHandle: asGlobal(hook?.afterHandle, inject), onResponse: asGlobal(hook?.onResponse, inject), error: asGlobal(hook?.error, inject) - } as T + } as LocalHook } -export const asGlobal = | undefined>( +export const asGlobal = | undefined>( fn: T, inject = true ): T => { @@ -325,7 +345,7 @@ export const asGlobal = | undefined>( }) as T } -const filterGlobal = | undefined>(fn: T): T => { +const filterGlobal = | undefined>(fn: T): T => { if (!fn) return fn if (typeof fn === 'function') { @@ -337,7 +357,9 @@ const filterGlobal = | undefined>(fn: T): T => { return fn.filter((x) => x.$elysiaHookType === 'global') as T } -export const filterGlobalHook = >(hook: T): T => { +export const filterGlobalHook = ( + hook: LocalHook +): LocalHook => { return { // rest is validator ...hook, @@ -349,5 +371,70 @@ export const filterGlobalHook = >(hook: T): T => { afterHandle: filterGlobal(hook?.afterHandle), onResponse: filterGlobal(hook?.onResponse), error: filterGlobal(hook?.error) - } as T + } as LocalHook } + +export const StatusMap = { + Continue: 100, + 'Switching Protocols': 101, + Processing: 102, + 'Early Hints': 103, + OK: 200, + Created: 201, + Accepted: 202, + 'Non-Authoritative Information': 203, + 'No Content': 204, + 'Reset Content': 205, + 'Partial Content': 206, + 'Multi-Status': 207, + 'Already Reported': 208, + 'Multiple Choices': 300, + 'Moved Permanently': 301, + Found: 302, + 'See Other': 303, + 'Not Modified': 304, + 'Temporary Redirect': 307, + 'Permanent Redirect': 308, + 'Bad Request': 400, + Unauthorized: 401, + 'Payment Required': 402, + Forbidden: 403, + 'Not Found': 404, + 'Method Not Allowed': 405, + 'Not Acceptable': 406, + 'Proxy Authentication Required': 407, + 'Request Timeout': 408, + Conflict: 409, + Gone: 410, + 'Length Required': 411, + 'Precondition Failed': 412, + 'Payload Too Large': 413, + 'URI Too Long': 414, + 'Unsupported Media Type': 415, + 'Range Not Satisfiable': 416, + 'Expectation Failed': 417, + "I'm a teapot": 418, + 'Misdirected Request': 421, + 'Unprocessable Content': 422, + Locked: 423, + 'Failed Dependency': 424, + 'Too Early': 425, + 'Upgrade Required': 426, + 'Precondition Required': 428, + 'Too Many Requests': 429, + 'Request Header Fields Too Large': 431, + 'Unavailable For Legal Reasons': 451, + 'Internal Server Error': 500, + 'Not Implemented': 501, + 'Bad Gateway': 502, + 'Service Unavailable': 503, + 'Gateway Timeout': 504, + 'HTTP Version Not Supported': 505, + 'Variant Also Negotiates': 506, + 'Insufficient Storage': 507, + 'Loop Detected': 508, + 'Not Extended': 510, + 'Network Authentication Required': 511 +} as const + +export type HTTPStatusName = keyof typeof StatusMap diff --git a/src/ws/index.ts b/src/ws/index.ts index dfbbdd46..b1c1918c 100644 --- a/src/ws/index.ts +++ b/src/ws/index.ts @@ -1,54 +1,48 @@ -import { Memoirist } from 'memoirist' -import type { - ServerWebSocket, - ServerWebSocketSendStatus, - WebSocketHandler -} from 'bun' +import type { ServerWebSocket, WebSocketHandler } from 'bun' -import type { Elysia, Context } from '..' +import type { TSchema } from '@sinclair/typebox' +import type { TypeCheck } from '@sinclair/typebox/compiler' -import type { ElysiaWSContext } from './types' import { ValidationError } from '../error' - -const getPath = (url: string) => { - const start = url.indexOf('/', 10) - const end = url.indexOf('?', start) - - if (end === -1) return url.slice(start) - - return url.slice(start, end) +import type { Context } from '../context' + +import type { RouteSchema } from '../types' + +export const websocket: WebSocketHandler = { + open(ws) { + ws.data.open?.(ws) + }, + message(ws, message) { + ws.data.message?.(ws, message) + }, + drain(ws) { + ws.data.drain?.(ws) + }, + close(ws, code, reason) { + ws.data.close?.(ws, code, reason) + } } -export class ElysiaWS { - raw: WS - data: WS['data'] - isSubscribed: WS['isSubscribed'] +export class ElysiaWS< + WS extends ServerWebSocket<{ + validator?: TypeCheck + }>, + Route extends RouteSchema = RouteSchema +> { + validator?: TypeCheck - constructor(ws: WS) { - this.raw = ws - this.data = ws.data - this.isSubscribed = ws.isSubscribed + constructor(public raw: WS, public data: Context) { + this.validator = raw.data.validator } publish( topic: string, - data: WS['data']['schema']['response'] = undefined as any, + data: Route['response'] = undefined, compress?: boolean ) { - // @ts-ignore - if (typeof data === 'object') data = JSON.stringify(data) - - this.raw.publish(topic, data as unknown as string, compress) - - return this - } + if (this.validator?.Check(data) === false) + throw new ValidationError('message', this.validator, data) - publishToSelf( - topic: string, - data: WS['data']['schema']['response'] = undefined as any, - compress?: boolean - ) { - // @ts-ignore if (typeof data === 'object') data = JSON.stringify(data) this.raw.publish(topic, data as unknown as string, compress) @@ -56,8 +50,10 @@ export class ElysiaWS { return this } - send(data: WS['data']['schema']['response']) { - // @ts-ignore + send(data: Route['response']) { + if (this.validator?.Check(data) === false) + throw new ValidationError('message', this.validator, data) + if (typeof data === 'object') data = JSON.stringify(data) this.raw.send(data as unknown as string) @@ -77,8 +73,8 @@ export class ElysiaWS { return this } - cork(callback: (ws: ServerWebSocket) => any) { - this.raw.cork(callback) + cork(callback: (ws: WS) => this) { + this.raw.cork(callback as any) return this } @@ -88,155 +84,17 @@ export class ElysiaWS { return this } -} -/** - * Register websocket config for Elysia - * - * --- - * @example - * ```typescript - * import { Elysia } from 'elysia' - * import { websocket } from '@elysiajs/websocket' - * - * const app = new Elysia() - * .use(websocket()) - * .ws('/ws', { - * message: () => "Hi" - * }) - * .listen(8080) - * ``` - */ -export const ws = - (config?: Omit) => - (app: Elysia) => { - // @ts-ignore - if (!app.wsRouter) app.wsRouter = new Memoirist() - - // @ts-ignore - const router = app.wsRouter! - - if (!app.config.serve) - app.config.serve = { - websocket: { - ...config, - open(ws) { - if (!ws.data) return - - const url = getPath( - (ws?.data as unknown as Context).request.url - ) - - if (!url) return - - const route = router.find('subscribe', url)?.store - - if (route && route.open) - route.open(new ElysiaWS(ws as any)) - }, - message(ws, message: any): void { - if (!ws.data) return - - const url = getPath( - (ws?.data as unknown as Context).request.url - ) - - if (!url) return - - const route = router.find('subscribe', url)?.store - if (!route?.message) return - - message = message.toString() - const start = message.charCodeAt(0) - - if (start === 47 || start === 123) - try { - message = JSON.parse(message) - } catch (error) { - // Not empty - } - else if (!Number.isNaN(+message)) message = +message - - for ( - let i = 0; - i < - (ws.data as ElysiaWSContext['data']) - .transformMessage.length; - i++ - ) { - const temp: any = ( - ws.data as ElysiaWSContext['data'] - ).transformMessage[i](message) - - if (temp !== undefined) message = temp - } - - if ( - (ws.data as ElysiaWSContext['data']).message?.Check( - message - ) === false - ) - return void ws.send( - new ValidationError( - 'message', - (ws.data as ElysiaWSContext['data']) - .message as any, - message - ).cause as string - ) - - route.message(new ElysiaWS(ws as any), message) - }, - close(ws, code, reason) { - if (!ws.data) return - - const url = getPath( - (ws?.data as unknown as Context).request.url - ) - - if (!url) return - - const route = router.find('subscribe', url)?.store - - if (route && route.close) - route.close(new ElysiaWS(ws as any), code, reason) - }, - drain(ws) { - if (!ws.data) return - - const url = getPath( - (ws?.data as unknown as Context).request.url - ) - - if (!url) return - - const route = router.find('subscribe', url)?.store - - if (route && route.drain) - route.drain(new ElysiaWS(ws as any)) - } - } - } - - return app - .decorate('publish', app.server?.publish as WSPublish) - .onStart((app) => { - // @ts-ignore - app.decorators.publish = app.server?.publish - }) + terminate() { + this.raw.terminate() } -type WSPublish = ( - topic: string, - data: string | ArrayBufferView | ArrayBuffer | SharedArrayBuffer, - compress?: boolean -) => ServerWebSocketSendStatus - -export type { - WSTypedSchema, - WebSocketHeaderHandler, - WebSocketSchemaToRoute, - ElysiaWSContext, - ElysiaWSOptions, - TransformMessageHandler -} from './types' + isSubscribed(room: string) { + // get isSubscribed() { return this.raw.isSubscribed } -> Expected 'this' to be instanceof ServerWebSocket + return this.raw.isSubscribed(room) + } + + get remoteAddress() { + return this.raw.remoteAddress + } +} diff --git a/src/ws/types.ts b/src/ws/types.ts index 44976910..bdf8086a 100644 --- a/src/ws/types.ts +++ b/src/ws/types.ts @@ -1,172 +1,107 @@ import type { ServerWebSocket, WebSocketHandler } from 'bun' -import type { TObject, TSchema } from '@sinclair/typebox' + +import type { TSchema } from '@sinclair/typebox' import type { TypeCheck } from '@sinclair/typebox/compiler' +import type { ElysiaWS } from '.' import type { Context } from '../context' + import type { - ElysiaInstance, - UnwrapSchema, - ExtractPath, - WithArray, - NoReturnHandler, - HookHandler, - TypedRoute + DecoratorBase, + Handler, + VoidHandler, + ErrorHandler, + InputSchema, + RouteSchema, + Isolate, + GetPathParameter, + MaybeArray } from '../types' -import type { ElysiaWS } from '.' -export interface WSTypedSchema { - body?: TSchema | ModelName - headers?: TObject | ModelName - query?: TObject | ModelName - params?: TObject | ModelName - response?: TSchema | ModelName -} +export namespace WS { + export type Config = Omit< + WebSocketHandler, + 'open' | 'message' | 'close' | 'drain' + > -export type TypedWSSchemaToRoute< - Schema extends WSTypedSchema = WSTypedSchema, - Definitions extends ElysiaInstance['meta']['defs'] = {} -> = { - body: UnwrapSchema - headers: UnwrapSchema< - Schema['headers'], - Definitions - > extends infer Result extends Record - ? Result - : undefined - query: UnwrapSchema< - Schema['query'], - Definitions - > extends infer Result extends Record - ? Result - : undefined - params: UnwrapSchema< - Schema['params'], - Definitions - > extends infer Result extends Record - ? Result - : undefined - response: UnwrapSchema< - Schema['params'], - Definitions - > extends infer Result extends Record - ? Result - : undefined -} + export type LocalHook< + LocalSchema extends InputSchema = {}, + Route extends RouteSchema = RouteSchema, + Decorators extends DecoratorBase = { + request: {} + store: {} + }, + Errors extends Record = {}, + Path extends string = '', + TypedRoute extends RouteSchema = Route extends { + params: Record + } + ? Route + : Route & { + params: Record, string> + } + > = (LocalSchema extends {} ? LocalSchema : Isolate) & + Omit< + Partial>, + 'open' | 'message' | 'close' | 'drain' | 'publish' | 'publishToSelf' + > & + (ElysiaWS< + ServerWebSocket<{ + validator?: TypeCheck + }>, + Route + > extends infer WS + ? { + transform?: MaybeArray> + transformMessage?: MaybeArray< + VoidHandler + > + beforeHandle?: MaybeArray> + /** + * Catch error + */ + error?: MaybeArray> -export type WebSocketSchemaToRoute< - Schema extends WSTypedSchema, - Definitions extends ElysiaInstance['meta']['defs'] = {} -> = { - body: UnwrapSchema - headers: UnwrapSchema - query: UnwrapSchema - params: UnwrapSchema - response: UnwrapSchema -} + /** + * Headers to register to websocket before `upgrade` + */ + upgrade?: HeadersInit | ((context: Context) => HeadersInit) -export type TransformMessageHandler = ( - message: UnwrapSchema -) => void | UnwrapSchema + /** + * The {@link ServerWebSocket} has been opened + * + * @param ws The {@link ServerWebSocket} that was opened + */ + open?: (ws: WS) => void | Promise -export type ElysiaWSContext< - Schema extends WSTypedSchema = WSTypedSchema, - Instance extends ElysiaInstance = ElysiaInstance, - Path extends string = never -> = ServerWebSocket< - Context<{ - body: UnwrapSchema - headers: UnwrapSchema> - query: UnwrapSchema> - params: ExtractPath extends infer Params extends string - ? Record - : UnwrapSchema> - response: UnwrapSchema - }> & { - id: number - message: TypeCheck - transformMessage: TransformMessageHandler[] - schema: TypedRoute - } & Instance["request"] -> + /** + * Handle an incoming message to a {@link ServerWebSocket} + * + * @param ws The {@link ServerWebSocket} that received the message + * @param message The message received + * + * To change `message` to be an `ArrayBuffer` instead of a `Uint8Array`, set `ws.binaryType = "arraybuffer"` + */ + message?: (ws: WS, message: Route['body']) => any -export type WebSocketHeaderHandler< - Schema extends WSTypedSchema = WSTypedSchema, - Path extends string = string -> = ( - context: TypedWSSchemaToRoute['params'] extends {} - ? Omit, 'response'> & { - response: void | TypedWSSchemaToRoute['response'] - } - : Omit< - Omit, 'response'> & { - response: void | TypedWSSchemaToRoute['response'] - }, - 'params' - > & { - params: Record, string> - } -) => HeadersInit + /** + * The {@link ServerWebSocket} is being closed + * @param ws The {@link ServerWebSocket} that was closed + * @param code The close code + * @param message The close message + */ + close?: ( + ws: WS, + code: number, + message: string + ) => void | Promise -export type ElysiaWSOptions< - Path extends string, - Schema extends WSTypedSchema, - Instance extends ElysiaInstance -> = Omit< - Partial>, - 'open' | 'message' | 'close' | 'drain' | 'publish' | 'publishToSelf' -> & - (ElysiaWS> extends infer WS - ? Partial & { - beforeHandle?: WithArray> - transform?: WithArray< - NoReturnHandler> - > - transformMessage?: WithArray< - TransformMessageHandler - > - - /** - * Headers to register to websocket before `upgrade` - */ - upgrade?: HeadersInit | WebSocketHeaderHandler - - /** - * The {@link ServerWebSocket} has been opened - * - * @param ws The {@link ServerWebSocket} that was opened - */ - open?: (ws: WS) => void | Promise - - /** - * Handle an incoming message to a {@link ServerWebSocket} - * - * @param ws The {@link ServerWebSocket} that received the message - * @param message The message received - * - * To change `message` to be an `ArrayBuffer` instead of a `Uint8Array`, set `ws.binaryType = "arraybuffer"` - */ - message?: ( - ws: WS, - message: UnwrapSchema - ) => any - - /** - * The {@link ServerWebSocket} is being closed - * @param ws The {@link ServerWebSocket} that was closed - * @param code The close code - * @param message The close message - */ - close?: ( - ws: WS, - code: number, - message: string - ) => void | Promise - - /** - * The {@link ServerWebSocket} is ready for more data - * - * @param ws The {@link ServerWebSocket} that is ready - */ - drain?: (ws: WS) => void | Promise - } - : never) + /** + * The {@link ServerWebSocket} is ready for more data + * + * @param ws The {@link ServerWebSocket} that is ready + */ + drain?: (ws: WS) => void | Promise + } + : {}) +} diff --git a/test/after-handle.test.ts b/test/after-handle.test.ts index 2459bce8..a4acf1c7 100644 --- a/test/after-handle.test.ts +++ b/test/after-handle.test.ts @@ -4,14 +4,11 @@ import { describe, expect, it } from 'bun:test' import { req } from './utils' describe('After Handle', () => { - it('Ensure mapEarlyResponse is called', async () => { - const app = new Elysia() - .onAfterHandle(() => Bun.file('./package.json')) - .get('/', () => 'NOOP') + it('ensure mapEarlyResponse is called', async () => { + const app = new Elysia().onAfterHandle(() => 'A').get('/', () => 'NOOP') - const res = await app.handle(req('/')) + const res = await app.handle(req('/')).then((x) => x.text()) - expect(res instanceof Blob).toBeFalse() - expect(res instanceof Response).toBeTrue() + expect(res).toBe('A') }) }) diff --git a/test/analysis.test.ts b/test/aot/analysis.test.ts similarity index 92% rename from test/analysis.test.ts rename to test/aot/analysis.test.ts index 111f517e..5a414a41 100644 --- a/test/analysis.test.ts +++ b/test/aot/analysis.test.ts @@ -1,8 +1,9 @@ -import { Elysia, t } from '../src' +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { Elysia, t } from '../../src' import { describe, expect, it } from 'bun:test' -import { post, req } from './utils' -import { findElysiaMeta } from '../src/compose' +import { post, req } from '../utils' +import { hasType } from '../../src/compose' const payload = { hello: 'world' } @@ -113,27 +114,23 @@ describe('Static code analysis', () => { const schema = t.Object({ a: t.Object({ b: t.Object({ - c: t.Numeric() + c: t.File() }), d: t.String() }), id: t.Numeric(), b: t.Object({ - c: t.Numeric() + c: t.File() }) }) - expect(findElysiaMeta('Numeric', schema)).toEqual([ - 'a.b.c', - 'id', - 'b.c' - ]) + expect(hasType('File', schema)).toBeTrue() }) it('find Elysia Schema on root', () => { const schema = t.Numeric() - expect(findElysiaMeta('Numeric', schema)).toEqual('root') + expect(hasType('Numeric', schema)).toBeTrue }) it('find return null if Elysia Schema is not found', () => { @@ -150,7 +147,7 @@ describe('Static code analysis', () => { }) }) - expect(findElysiaMeta('Numeric', schema)).toBeNull() + expect(hasType('File', schema)).toBeFalse() }) it('restart server once analyze', async () => { diff --git a/test/aot/generation.test.ts b/test/aot/generation.test.ts new file mode 100644 index 00000000..94ffa628 --- /dev/null +++ b/test/aot/generation.test.ts @@ -0,0 +1,116 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { describe, it, expect } from 'bun:test' +import { Context, Elysia, t } from '../../src' +import { post, req } from '../utils' + +describe('code generation', () => { + it('fallback query if not presented', async () => { + const app = new Elysia().get('/', () => 'hi', { + query: t.Object({ + id: t.Optional(t.Number()) + }) + }) + + const res = await app.handle(req('/')).then((x) => x.text()) + + expect(res).toBe('hi') + }) + + it('process isFnUse', async () => { + const body = { hello: 'Wanderschaffen' } + + const app = new Elysia() + .post('/1', ({ body }) => body) + .post('/2', function ({ body }) { + return body + }) + .post('/3', (context) => { + return context.body + }) + .post('/4', (context) => { + const c = context + const { body } = c + + return body + }) + .post('/5', (context) => { + const _ = context, + a = context + const { body } = a + + return body + }) + .post('/6', () => body, { + transform({ body }) { + // not empty + } + }) + .post('/7', () => body, { + beforeHandle({ body }) { + // not empty + } + }) + .post('/8', () => body, { + afterHandle({ body }) { + // not empty + } + }) + .post('/9', ({ ...rest }) => rest.body) + + const from = (number: number) => + app.handle(post(`/${number}`, body)).then((r) => r.json()) + + const cases = Promise.all( + Array(9) + .fill(null) + .map((_, i) => from(i + 1)) + ) + + for (const unit of await cases) expect(unit).toEqual(body) + }) + + it('process isContextPassToUnknown', async () => { + const body = { hello: 'Wanderschaffen' } + + const handle = (context: Context) => context.body + + const app = new Elysia() + .post('/1', (context) => handle(context)) + .post('/2', function (context) { + return handle(context) + }) + .post('/3', (context) => { + const c = context + + return handle(c) + }) + .post('/4', (context) => { + const _ = context, + a = context + + return handle(a) + }) + .post('/5', () => '', { + beforeHandle(context) { + return handle(context) + } + }) + .post('/6', () => body, { + afterHandle(context) { + return handle(context) + } + }) + .post('/7', ({ ...rest }) => handle(rest)) + + const from = (number: number) => + app.handle(post(`/${number}`, body)).then((r) => r.json()) + + const cases = Promise.all( + Array(7) + .fill(null) + .map((_, i) => from(i + 1)) + ) + + for (const unit of await cases) expect(unit).toEqual(body) + }) +}) diff --git a/test/aot/has-transform.test.ts b/test/aot/has-transform.test.ts new file mode 100644 index 00000000..552de1ef --- /dev/null +++ b/test/aot/has-transform.test.ts @@ -0,0 +1,111 @@ +import { describe, it, expect } from 'bun:test' + +import { t } from '../../src' +import { hasTransform } from '../../src/compose' + +describe('Has Transform', () => { + it('find primitive', () => { + const schema = t + .Transform(t.String()) + .Decode((v) => v) + .Encode((v) => v) + + expect(hasTransform(schema)).toBe(true) + }) + + it('find in root object', () => { + const schema = t.Object({ + liyue: t + .Transform(t.String()) + .Decode((v) => v) + .Encode((v) => v) + }) + + expect(hasTransform(schema)).toBe(true) + }) + + it('find in nested object', () => { + const schema = t.Object({ + liyue: t.Object({ + id: t + .Transform(t.String()) + .Decode((v) => v) + .Encode((v) => v) + }) + }) + + expect(hasTransform(schema)).toBe(true) + }) + + it('find in Optional', () => { + const schema = t.Optional( + t.Object({ + prop1: t + .Transform(t.String()) + .Decode((v) => v) + .Encode((v) => v) + }) + ) + + expect(hasTransform(schema)).toBe(true) + }) + + it('find on multiple transform', () => { + const schema = t.Object({ + id: t + .Transform(t.String()) + .Decode((v) => v) + .Encode((v) => v), + name: t + .Transform(t.String()) + .Decode((v) => v) + .Encode((v) => v) + }) + + expect(hasTransform(schema)).toBe(true) + }) + + it('return false on not found', () => { + const schema = t.Object({ + name: t.String(), + age: t.Number() + }) + + expect(hasTransform(schema)).toBe(false) + }) + + it('found on Union', () => { + const schema = t.Object({ + id: t.Number(), + liyue: t.Union([ + t + .Transform(t.String()) + .Decode((v) => v) + .Encode((v) => v), + t.Number() + ]) + }) + + expect(hasTransform(schema)).toBe(true) + }) + + it('Found t.Numeric', () => { + const schema = t.Object({ + id: t.Numeric(), + liyue: t.String() + }) + + expect(hasTransform(schema)).toBe(true) + }) + + it('Found t.ObjectString', () => { + const schema = t.Object({ + id: t.String(), + liyue: t.ObjectString({ + name: t.String() + }) + }) + + expect(hasTransform(schema)).toBe(true) + }) +}) diff --git a/test/aot/has-type.test.ts b/test/aot/has-type.test.ts new file mode 100644 index 00000000..d0fc92a6 --- /dev/null +++ b/test/aot/has-type.test.ts @@ -0,0 +1,70 @@ +import { describe, it, expect } from 'bun:test' + +import { t } from '../../src' +import { hasType } from '../../src/compose' + +describe('Has Transform', () => { + it('find primitive', () => { + const schema = t + .Transform(t.File()) + .Decode((v) => v) + .Encode((v) => v) + + expect(hasType('File', schema)).toBe(true) + }) + + it('find in root object', () => { + const schema = t.Object({ + liyue: t.File() + }) + + expect(hasType('File', schema)).toBe(true) + }) + + it('find in nested object', () => { + const schema = t.Object({ + liyue: t.Object({ + id: t.File() + }) + }) + + expect(hasType('File', schema)).toBe(true) + }) + + it('find in Optional', () => { + const schema = t.Optional( + t.Object({ + prop1: t.File() + }) + ) + + expect(hasType('File', schema)).toBe(true) + }) + + it('find on multiple transform', () => { + const schema = t.Object({ + id: t.File(), + name: t.File() + }) + + expect(hasType('File', schema)).toBe(true) + }) + + it('return false on not found', () => { + const schema = t.Object({ + name: t.String(), + age: t.Number() + }) + + expect(hasType('File', schema)).toBe(false) + }) + + it('found on Union', () => { + const schema = t.Object({ + id: t.Number(), + liyue: t.Union([t.Number(), t.File()]) + }) + + expect(hasType('File', schema)).toBe(true) + }) +}) diff --git a/test/aot/response.test.ts b/test/aot/response.test.ts new file mode 100644 index 00000000..00b56127 --- /dev/null +++ b/test/aot/response.test.ts @@ -0,0 +1,250 @@ +import { describe, expect, it } from 'bun:test' +import { Elysia, t } from '../../src' +import { req } from '../utils' +import { sign } from 'cookie-signature' + +const secrets = 'We long for the seven wailings. We bear the koan of Jericho.' + +const getCookies = (response: Response) => + response.headers.getAll('Set-Cookie').map((x) => { + const value = decodeURIComponent(x).split('=') + + try { + return [value[0], JSON.parse(value[1])] + } catch { + return value + } + }) + +const app = new Elysia({ aot: false }) + .get( + '/council', + ({ cookie: { council } }) => + (council.value = [ + { + name: 'Rin', + affilation: 'Administration' + } + ]) + ) + .get('/create', ({ cookie: { name } }) => (name.value = 'Himari')) + .get('/multiple', ({ cookie: { name, president } }) => { + name.value = 'Himari' + president.value = 'Rio' + + return 'ok' + }) + .get( + '/update', + ({ cookie: { name } }) => { + name.value = 'seminar: Himari' + + return name.value + }, + { + cookie: t.Cookie( + { + name: t.Optional(t.String()) + }, + { + secrets, + sign: ['name'] + } + ) + } + ) + .get('/remove', ({ cookie }) => { + for (const self of Object.values(cookie)) self.remove() + + return 'Deleted' + }) + +describe('Dynamic Cookie Response', () => { + it('set cookie', async () => { + const response = await app.handle(req('/create')) + + expect(getCookies(response)).toEqual([['name', 'Himari']]) + }) + + it('set multiple cookie', async () => { + const response = await app.handle(req('/multiple')) + + expect(getCookies(response)).toEqual([ + ['name', 'Himari'], + ['president', 'Rio'] + ]) + }) + + it('set JSON cookie', async () => { + const response = await app.handle(req('/council')) + + expect(getCookies(response)).toEqual([ + [ + 'council', + [ + { + name: 'Rin', + affilation: 'Administration' + } + ] + ] + ]) + }) + + it('skip duplicate cookie value', async () => { + const response = await app.handle( + req('/council', { + headers: { + cookie: + 'council=' + + encodeURIComponent( + JSON.stringify([ + { + name: 'Rin', + affilation: 'Administration' + } + ]) + ) + } + }) + ) + + expect(getCookies(response)).toEqual([]) + }) + + it('write cookie on difference value', async () => { + const response = await app.handle( + req('/council', { + headers: { + cookie: + 'council=' + + encodeURIComponent( + JSON.stringify([ + { + name: 'Aoi', + affilation: 'Financial' + } + ]) + ) + } + }) + ) + + expect(getCookies(response)).toEqual([ + [ + 'council', + [ + { + name: 'Rin', + affilation: 'Administration' + } + ] + ] + ]) + }) + + it('remove cookie', async () => { + const response = await app.handle( + req('/remove', { + headers: { + cookie: + 'council=' + + encodeURIComponent( + JSON.stringify([ + { + name: 'Rin', + affilation: 'Administration' + } + ]) + ) + } + }) + ) + + expect(getCookies(response)).toEqual([ + ['council', '; Expires', expect.any(String)] + ]) + }) + + it('sign cookie', async () => { + const response = await app.handle(req('/update')) + + expect(getCookies(response)).toEqual([ + ['name', sign('seminar: Himari', secrets)] + ]) + }) + + it('sign/unsign cookie', async () => { + const response = await app.handle( + req('/update', { + headers: { + cookie: `name=${sign('seminar: Himari', secrets)}` + } + }) + ) + + expect(response.status).toBe(200) + }) + + it('inherits cookie settings', async () => { + const app = new Elysia({ + cookie: { + secrets, + sign: ['name'] + } + }).get( + '/update', + ({ cookie: { name } }) => { + if (!name.value) name.value = 'seminar: Himari' + + return name.value + }, + { + cookie: t.Cookie({ + name: t.Optional(t.String()) + }) + } + ) + + const response = await app.handle( + req('/update', { + headers: { + cookie: `name=${sign('seminar: Himari', secrets)}` + } + }) + ) + + expect(response.status).toBe(200) + }) + + it('sign all cookie', async () => { + const app = new Elysia({ + cookie: { + secrets, + sign: true + } + }).get( + '/update', + ({ cookie: { name } }) => { + if (!name.value) name.value = 'seminar: Himari' + + return name.value + }, + { + cookie: t.Cookie({ + name: t.Optional(t.String()) + }) + } + ) + + const response = await app.handle( + req('/update', { + headers: { + cookie: `name=${sign('seminar: Himari', secrets)}` + } + }) + ) + + expect(response.status).toBe(200) + }) +}) diff --git a/test/config.test.ts b/test/config.test.ts deleted file mode 100644 index 2bb0562e..00000000 --- a/test/config.test.ts +++ /dev/null @@ -1,6 +0,0 @@ -// import { Elysia } from '../src' - -// import { describe, expect, it } from 'bun:test' -// import { req } from './utils' - -// describe('Config', () => {}) diff --git a/test/cookie.test.ts b/test/cookie.test.ts deleted file mode 100644 index 4c20dfde..00000000 --- a/test/cookie.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { describe, it, expect } from 'bun:test' -import { Elysia } from '../src' -import { req } from './utils' - -const app = new Elysia() - .get('/single', ({ set }) => { - set.headers = { - 'Set-Cookie': 'a=b' - } - }) - .get('/multiple', ({ set }) => { - set.headers = { - // @ts-ignore - 'Set-Cookie': ['a=b', 'c=d'] - } - - return 'a' - }) - -describe('cookie', () => { - it('set single cookie', async () => { - const res = await app - .handle(req('/single')) - .then((r) => r.headers.getAll('Set-Cookie')) - - expect(res).toEqual(['a=b']) - }) - - it('set multiple cookie', async () => { - const res = await app - .handle(req('/multiple')) - .then((r) => r.headers.getAll('Set-Cookie')) - - expect(res).toEqual(['a=b', 'c=d']) - }) -}) diff --git a/test/cookie/explicit.test.ts b/test/cookie/explicit.test.ts new file mode 100644 index 00000000..965a20b4 --- /dev/null +++ b/test/cookie/explicit.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from 'bun:test' +import { Cookie, createCookieJar } from '../../src/cookie' +import type { Context } from '../../src' + +const create = () => { + const set: Context['set'] = { + cookie: {}, + headers: {} + } + + const cookie = createCookieJar({}, set) + + return { + cookie, + set + } +} + +describe('Explicit Cookie', () => { + it('create cookie', () => { + const { cookie, set } = create() + cookie.name = new Cookie('himari') + + expect(set.cookie?.name).toEqual({ + value: 'himari' + }) + }) + + it('add cookie attribute', () => { + const { cookie, set } = create() + cookie.name = new Cookie('himari') + + cookie.name.add({ + domain: 'millennium.sh' + }) + + expect(set.cookie?.name).toEqual({ + value: 'himari', + domain: 'millennium.sh' + }) + }) + + it('add cookie attribute without overwrite entire property', () => { + const { cookie, set } = create() + cookie.name = new Cookie('himari', { + domain: 'millennium.sh' + }).add({ + httpOnly: true, + path: '/' + }) + + expect(set.cookie?.name).toEqual({ + value: 'himari', + domain: 'millennium.sh', + httpOnly: true, + path: '/' + }) + }) + + it('set cookie attribute', () => { + const { cookie, set } = create() + cookie.name = new Cookie('himari', { + domain: 'millennium.sh' + }) + + cookie.name.set({ + httpOnly: true, + path: '/' + }) + + expect(set.cookie?.name).toEqual({ + value: 'himari', + httpOnly: true, + path: '/' + }) + }) + + it('add cookie overwrite attribute if duplicated', () => { + const { cookie, set } = create() + cookie.name = new Cookie('aru', { + domain: 'millennium.sh', + httpOnly: true + }).add({ + domain: 'gehenna.sh' + }) + + expect(set.cookie?.name).toEqual({ + value: 'aru', + domain: 'gehenna.sh', + httpOnly: true + }) + }) + + it('create cookie with empty string', () => { + const { cookie, set } = create() + cookie.name = new Cookie('') + + expect(set.cookie?.name).toEqual({ value: '' }) + }) + + it('overwrite existing cookie', () => { + const { cookie, set } = create() + cookie.name = new Cookie('aru') + cookie.name = new Cookie('himari') + + expect(set.cookie?.name).toEqual({ value: 'himari' }) + }) + + it('remove cookie', () => { + const { cookie, set } = create() + + cookie.name = new Cookie('himari') + cookie.name.remove() + + expect(set.cookie?.name.expires?.getTime()).toBeLessThanOrEqual( + Date.now() + ) + }) +}) diff --git a/test/cookie/implicit.test.ts b/test/cookie/implicit.test.ts new file mode 100644 index 00000000..8552a8b6 --- /dev/null +++ b/test/cookie/implicit.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, it } from 'bun:test' +import { createCookieJar } from '../../src/cookie' +import type { Context } from '../../src' + +const create = () => { + const set: Context['set'] = { + cookie: {}, + headers: {} + } + + const cookie = createCookieJar({}, set) + + return { + cookie, + set + } +} + +describe('Implicit Cookie', () => { + it('create cookie using setter', () => { + const { + cookie: { name }, + set + } = create() + + name.value = 'himari' + + expect(set.cookie?.name).toEqual({ + value: 'himari' + }) + }) + + it('create cookie set function', () => { + const { + cookie: { name }, + set + } = create() + + name.set({ + value: 'himari' + }) + + expect(set.cookie?.name).toEqual({ + value: 'himari' + }) + }) + + it('add cookie attribute using setter', () => { + const { + cookie: { name }, + set + } = create() + + name.value = 'himari' + name.domain = 'millennium.sh' + + expect(set.cookie?.name).toEqual({ + value: 'himari', + domain: 'millennium.sh' + }) + }) + + it('add cookie attribute using setter', () => { + const { + cookie: { name }, + set + } = create() + + name.value = 'himari' + name.add({ + domain: 'millennium.sh' + }) + + expect(set.cookie?.name).toEqual({ + value: 'himari', + domain: 'millennium.sh' + }) + }) + + it('add cookie attribute without overwrite entire property', () => { + const { + cookie: { name }, + set + } = create() + + name.set({ + value: 'himari', + domain: 'millennium.sh' + }).add({ + httpOnly: true, + path: '/' + }) + + expect(set.cookie?.name).toEqual({ + value: 'himari', + domain: 'millennium.sh', + httpOnly: true, + path: '/' + }) + }) + + it('set cookie attribute', () => { + const { + cookie: { name }, + set + } = create() + + name.set({ + value: 'himari', + domain: 'millennium.sh' + }).set({ + httpOnly: true, + path: '/' + }) + + expect(set.cookie?.name).toEqual({ + value: 'himari', + httpOnly: true, + path: '/' + }) + }) + + it('add cookie overwrite attribute if duplicated', () => { + const { + cookie: { name }, + set + } = create() + + name.set({ + value: 'aru', + domain: 'millennium.sh', + httpOnly: true + }).add({ + domain: 'gehenna.sh' + }) + + expect(set.cookie?.name).toEqual({ + value: 'aru', + domain: 'gehenna.sh', + httpOnly: true + }) + }) + + it('create cookie with empty string', () => { + const { + cookie: { name }, + set + } = create() + + name.value = '' + + expect(set.cookie?.name).toEqual({ value: '' }) + }) + + it('Remove cookie', () => { + const { + cookie: { name }, + set + } = create() + + name.value = 'himari' + name.remove() + + expect(set.cookie?.name.expires?.getTime()).toBeLessThanOrEqual( + Date.now() + ) + }) +}) diff --git a/test/cookie/response.test.ts b/test/cookie/response.test.ts new file mode 100644 index 00000000..05a12a69 --- /dev/null +++ b/test/cookie/response.test.ts @@ -0,0 +1,250 @@ +import { describe, expect, it } from 'bun:test' +import { Elysia, t } from '../../src' +import { req } from '../utils' +import { sign } from 'cookie-signature' + +const secrets = 'We long for the seven wailings. We bear the koan of Jericho.' + +const getCookies = (response: Response) => + response.headers.getAll('Set-Cookie').map((x) => { + const value = decodeURIComponent(x).split('=') + + try { + return [value[0], JSON.parse(value[1])] + } catch { + return value + } + }) + +const app = new Elysia() + .get( + '/council', + ({ cookie: { council } }) => + (council.value = [ + { + name: 'Rin', + affilation: 'Administration' + } + ]) + ) + .get('/create', ({ cookie: { name } }) => (name.value = 'Himari')) + .get('/multiple', ({ cookie: { name, president } }) => { + name.value = 'Himari' + president.value = 'Rio' + + return 'ok' + }) + .get( + '/update', + ({ cookie: { name } }) => { + name.value = 'seminar: Himari' + + return name.value + }, + { + cookie: t.Cookie( + { + name: t.Optional(t.String()) + }, + { + secrets, + sign: ['name'] + } + ) + } + ) + .get('/remove', ({ cookie }) => { + for (const self of Object.values(cookie)) self.remove() + + return 'Deleted' + }) + +describe('Cookie Response', () => { + it('set cookie', async () => { + const response = await app.handle(req('/create')) + + expect(getCookies(response)).toEqual([['name', 'Himari']]) + }) + + it('set multiple cookie', async () => { + const response = await app.handle(req('/multiple')) + + expect(getCookies(response)).toEqual([ + ['name', 'Himari'], + ['president', 'Rio'] + ]) + }) + + it('set JSON cookie', async () => { + const response = await app.handle(req('/council')) + + expect(getCookies(response)).toEqual([ + [ + 'council', + [ + { + name: 'Rin', + affilation: 'Administration' + } + ] + ] + ]) + }) + + it('skip duplicate cookie value', async () => { + const response = await app.handle( + req('/council', { + headers: { + cookie: + 'council=' + + encodeURIComponent( + JSON.stringify([ + { + name: 'Rin', + affilation: 'Administration' + } + ]) + ) + } + }) + ) + + expect(getCookies(response)).toEqual([]) + }) + + it('write cookie on difference value', async () => { + const response = await app.handle( + req('/council', { + headers: { + cookie: + 'council=' + + encodeURIComponent( + JSON.stringify([ + { + name: 'Aoi', + affilation: 'Financial' + } + ]) + ) + } + }) + ) + + expect(getCookies(response)).toEqual([ + [ + 'council', + [ + { + name: 'Rin', + affilation: 'Administration' + } + ] + ] + ]) + }) + + it('remove cookie', async () => { + const response = await app.handle( + req('/remove', { + headers: { + cookie: + 'council=' + + encodeURIComponent( + JSON.stringify([ + { + name: 'Rin', + affilation: 'Administration' + } + ]) + ) + } + }) + ) + + expect(getCookies(response)).toEqual([ + ['council', '; Expires', expect.any(String)] + ]) + }) + + it('sign cookie', async () => { + const response = await app.handle(req('/update')) + + expect(getCookies(response)).toEqual([ + ['name', sign('seminar: Himari', secrets)] + ]) + }) + + it('sign/unsign cookie', async () => { + const response = await app.handle( + req('/update', { + headers: { + cookie: `name=${sign('seminar: Himari', secrets)}` + } + }) + ) + + expect(response.status).toBe(200) + }) + + it('inherits cookie settings', async () => { + const app = new Elysia({ + cookie: { + secrets, + sign: ['name'] + } + }).get( + '/update', + ({ cookie: { name } }) => { + if (!name.value) name.value = 'seminar: Himari' + + return name.value + }, + { + cookie: t.Cookie({ + name: t.Optional(t.String()) + }) + } + ) + + const response = await app.handle( + req('/update', { + headers: { + cookie: `name=${sign('seminar: Himari', secrets)}` + } + }) + ) + + expect(response.status).toBe(200) + }) + + it('sign all cookie', async () => { + const app = new Elysia({ + cookie: { + secrets, + sign: true + } + }).get( + '/update', + ({ cookie: { name } }) => { + if (!name.value) name.value = 'seminar: Himari' + + return name.value + }, + { + cookie: t.Cookie({ + name: t.Optional(t.String()) + }) + } + ) + + const response = await app.handle( + req('/update', { + headers: { + cookie: `name=${sign('seminar: Himari', secrets)}` + } + }) + ) + + expect(response.status).toBe(200) + }) +}) diff --git a/test/cookie/set.test.ts b/test/cookie/set.test.ts new file mode 100644 index 00000000..35885caa --- /dev/null +++ b/test/cookie/set.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'bun:test' +import { parseSetCookies } from '../../src/handler' + +describe('Parse Set Cookie', () => { + it('should handle empty arrays', () => { + const headers = new Headers([]) + const setCookie: string[] = [] + const result = parseSetCookies(headers, setCookie) + expect(result).toEqual(headers) + }) + + it('should handle a setCookie array with one element containing a single key-value pair', () => { + const headers = new Headers([]) + const setCookie = ['key=value'] + const result = parseSetCookies(headers, setCookie) + expect(result.get('Set-Cookie')).toEqual('key=value') + }) + + it('should handle a setCookie array with multiple elements, each containing a single key-value pair', () => { + const headers = new Headers([]) + const setCookie = ['key1=value1', 'key2=value2'] + const result = parseSetCookies(headers, setCookie) + expect(result.get('Set-Cookie')).toEqual('key1=value1, key2=value2') + }) + + it('should handle a setCookie array with one element containing multiple key-value pairs', () => { + const headers = new Headers([]) + const setCookie = ['key1=value1; key2=value2'] + const result = parseSetCookies(headers, setCookie) + expect(result.get('Set-Cookie')).toEqual('key1=value1; key2=value2') + }) + + it('should handle a setCookie array with multiple elements, each containing multiple key-value pairs', () => { + const headers = new Headers([]) + const setCookie = [ + 'key1=value1; key2=value2', + 'key3=value3; key4=value4' + ] + const result = parseSetCookies(headers, setCookie) + expect(result.get('Set-Cookie')).toEqual( + 'key1=value1; key2=value2, key3=value3; key4=value4' + ) + }) + + it('should handle null values', () => { + const headers = null + const setCookie = null + // @ts-ignore + const result = parseSetCookies(headers, setCookie) + expect(result).toBeNull() + }) +}) diff --git a/test/cookie/signature.test.ts b/test/cookie/signature.test.ts new file mode 100644 index 00000000..0d3781e2 --- /dev/null +++ b/test/cookie/signature.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, it } from 'bun:test' +import { parseCookie, Cookie } from '../../src/cookie' +import { sign } from 'cookie-signature' + +describe('Parse Cookie', () => { + it('handle empty cookie', () => { + const set = { + headers: {}, + cookie: {} + } + const cookieString = '' + const result = parseCookie(set, cookieString) + + expect(result).toEqual({}) + }) + + it('create cookie jar from cookie string', () => { + const set = { + headers: {}, + cookie: {} + } + const cookieString = 'fischl=Princess; eula=Noble; amber=Knight' + const result = parseCookie(set, cookieString) + expect(result).toEqual({ + fischl: expect.any(Cookie), + eula: expect.any(Cookie), + amber: expect.any(Cookie) + }) + }) + + it('unsign cookie signature', () => { + const set = { + headers: {}, + cookie: {} + } + + const secret = 'Fischl von Luftschloss Narfidort' + + const fischl = sign('fischl', secret) + const cookieString = `fischl=${fischl}` + const result = parseCookie(set, cookieString, { + secret, + sign: ['fischl'] + }) + + expect(result.fischl.value).toEqual('fischl') + }) + + it('unsign multiple signature', () => { + const set = { + headers: {}, + cookie: {} + } + + const secret = 'Fischl von Luftschloss Narfidort' + + const fischl = sign('fischl', secret) + const eula = sign('eula', secret) + + const cookieString = `fischl=${fischl}; eula=${eula}` + const result = parseCookie(set, cookieString, { + secret, + sign: ['fischl', 'eula'] + }) + + expect(result.fischl.value).toEqual('fischl') + expect(result.eula.value).toEqual('eula') + }) + + it('parse JSON value', () => { + const set = { + headers: {}, + cookie: {} + } + + const value = { + eula: 'Vengeance will be mine' + } + + const cookieString = `letter=${encodeURIComponent( + JSON.stringify(value) + )}` + const result = parseCookie(set, cookieString) + expect(result.letter.value).toEqual(value) + }) + + it('parse true', () => { + const set = { + headers: {}, + cookie: {} + } + + const cookieString = `letter=true` + const result = parseCookie(set, cookieString) + expect(result.letter.value).toEqual(true) + }) + + it('parse false', () => { + const set = { + headers: {}, + cookie: {} + } + + const cookieString = `letter=false` + const result = parseCookie(set, cookieString) + expect(result.letter.value).toEqual(false) + }) + + it('parse number', () => { + const set = { + headers: {}, + cookie: {} + } + + const cookieString = `letter=123` + const result = parseCookie(set, cookieString) + expect(result.letter.value).toEqual(123) + }) + + it('Unsign signature via secret rotation', () => { + const set = { + headers: {}, + cookie: {} + } + + const secret = 'Fischl von Luftschloss Narfidort' + + const fischl = sign('fischl', secret) + const cookieString = `fischl=${fischl}` + const result = parseCookie(set, cookieString, { + secret: ['New Secret', secret], + sign: ['fischl'] + }) + + expect(result.fischl.value).toEqual('fischl') + }) +}) diff --git a/test/cookie/to-header.test.ts b/test/cookie/to-header.test.ts new file mode 100644 index 00000000..376ed69a --- /dev/null +++ b/test/cookie/to-header.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from 'bun:test' +import { cookieToHeader } from '../../src/handler' + +describe('Cookie to Header', () => { + it('return undefined on empty object', () => { + const cookies = {} + const result = cookieToHeader(cookies) + expect(result).toBeUndefined() + }) + + it('correctly serialize a single value cookie', () => { + const cookies = { + cookie1: { + value: 'value1' + } + } + const result = cookieToHeader(cookies) + expect(result).toEqual('cookie1=value1') + }) + + it('correctly serialize a multi-value cookie', () => { + const cookies = { + cookie1: { + value: ['value1', 'value2'] + } + } + + // @ts-ignore + const result = cookieToHeader(cookies) + expect(result).toEqual(['cookie1=value1', 'cookie1=value2']) + }) + + it('return undefined when the input is undefined', () => { + const cookies = undefined + // @ts-ignore + const result = cookieToHeader(cookies) + expect(result).toBeUndefined() + }) + + it('return undefined when the input is null', () => { + const cookies = null + // @ts-ignore + const result = cookieToHeader(cookies) + expect(result).toBeUndefined() + }) + + it('return undefined when the input is not an object', () => { + const cookies = 'invalid' + // @ts-ignore + const result = cookieToHeader(cookies) + expect(result).toBeUndefined() + }) + + it('return undefined when the input is an empty object', () => { + const cookies = {} + const result = cookieToHeader(cookies) + expect(result).toBeUndefined() + }) + + it('return undefined when the input is an empty object', () => { + const cookies = {} + const result = cookieToHeader(cookies) + expect(result).toBeUndefined() + }) + + it('return undefined when the input is an object with null values', () => { + const cookies = { + cookie1: null, + cookie2: null + } + + // @ts-ignore + const result = cookieToHeader(cookies) + expect(result).toBeUndefined() + }) + + it('return undefined when the input is an empty object', () => { + const cookies = {} + const result = cookieToHeader(cookies) + expect(result).toBeUndefined() + }) + + it('return undefined when the input is an object with non-string or non-array values', () => { + const cookies = { + key1: 123, + key2: true, + key3: { prop: 'value' }, + key4: [1, 2, 3] + } + // @ts-ignore + const result = cookieToHeader(cookies) + expect(result).toBeUndefined() + }) + + it('return undefined when the input is an empty object', () => { + const cookies = {} + const result = cookieToHeader(cookies) + expect(result).toBeUndefined() + }) + + it('return undefined when the input is an empty object', () => { + const cookies = {} + const result = cookieToHeader(cookies) + expect(result).toBeUndefined() + }) + + it('return undefined when the input is an empty object', () => { + const cookies = {} + const result = cookieToHeader(cookies) + expect(result).toBeUndefined() + }) + + it('return undefined when the input is an object with non-string keys', () => { + const cookies = { 1: 'value1', 2: 'value2' } + // @ts-ignore + const result = cookieToHeader(cookies) + expect(result).toBeUndefined() + }) +}) diff --git a/test/dynamic.test.ts b/test/core/dynamic.test.ts similarity index 97% rename from test/dynamic.test.ts rename to test/core/dynamic.test.ts index 6534a2ac..b6603cd2 100644 --- a/test/dynamic.test.ts +++ b/test/core/dynamic.test.ts @@ -1,7 +1,7 @@ -import { Elysia, NotFoundError, t } from '../src' +import { Elysia, NotFoundError, t } from '../../src' import { describe, expect, it } from 'bun:test' -import { post, req } from './utils' +import { post, req } from '../utils' describe('Dynamic Mode', () => { it('handle path', async () => { diff --git a/test/elysia.test.ts b/test/core/elysia.test.ts similarity index 96% rename from test/elysia.test.ts rename to test/core/elysia.test.ts index 2cd39393..91b078f3 100644 --- a/test/elysia.test.ts +++ b/test/core/elysia.test.ts @@ -1,9 +1,9 @@ -import { Elysia } from '../src' +import { Elysia } from '../../src' import { describe, expect, it } from 'bun:test' -import { req } from './utils' +import { req } from '../utils' -describe('Elysia', () => { +describe('Edge Case', () => { it('handle state', async () => { const app = new Elysia() .state('a', 'a') diff --git a/test/handle-error.test.ts b/test/core/handle-error.test.ts similarity index 90% rename from test/handle-error.test.ts rename to test/core/handle-error.test.ts index 2785b281..8a6e6cc2 100644 --- a/test/handle-error.test.ts +++ b/test/core/handle-error.test.ts @@ -1,7 +1,7 @@ -import { Elysia, InternalServerError, NotFoundError, t } from '../src' +import { Elysia, InternalServerError, NotFoundError, t } from '../../src' import { describe, expect, it } from 'bun:test' -import { req } from './utils' +import { req } from '../utils' const request = new Request('http://localhost:8080') @@ -9,9 +9,16 @@ describe('Handle Error', () => { it('handle NOT_FOUND', async () => { const res = await new Elysia() .get('/', () => 'Hi') - .handleError(request, new NotFoundError(), { - headers: {} - }) + // @ts-ignore + .handleError( + { + request, + set: { + headers: {} + } + }, + new NotFoundError() + ) expect(await res.text()).toBe('NOT_FOUND') expect(res.status).toBe(404) @@ -20,9 +27,16 @@ describe('Handle Error', () => { it('handle INTERNAL_SERVER_ERROR', async () => { const res = await new Elysia() .get('/', () => 'Hi') - .handleError(request, new InternalServerError(), { - headers: {} - }) + // @ts-ignore + .handleError( + { + request, + set: { + headers: {} + } + }, + new InternalServerError() + ) expect(await res.text()).toBe('INTERNAL_SERVER_ERROR') expect(res.status).toBe(500) diff --git a/test/modules.test.ts b/test/core/modules.test.ts similarity index 80% rename from test/modules.test.ts rename to test/core/modules.test.ts index d75f2363..c8411468 100644 --- a/test/modules.test.ts +++ b/test/core/modules.test.ts @@ -1,9 +1,9 @@ -import { Elysia } from '../src' +import { Elysia } from '../../src' import { describe, expect, it } from 'bun:test' -import { req } from './utils' +import { req } from '../utils' const asyncPlugin = async (app: Elysia) => app.get('/async', () => 'async') -const lazyPlugin = import('./modules') +const lazyPlugin = import('../modules') const lazyNamed = lazyPlugin.then((x) => x.lazy) describe('Modules', () => { @@ -30,7 +30,7 @@ describe('Modules', () => { }) it('inline import', async () => { - const app = new Elysia().use(import('./modules')) + const app = new Elysia().use(import('../modules')) await app.modules @@ -60,7 +60,7 @@ describe('Modules', () => { }) it('inline import non default', async () => { - const app = new Elysia().use(import('./modules')) + const app = new Elysia().use(import('../modules')) await app.modules @@ -71,7 +71,7 @@ describe('Modules', () => { it('register async and lazy path', async () => { const app = new Elysia() - .use(import('./modules')) + .use(import('../modules')) .use(asyncPlugin) .get('/', () => 'hi') @@ -82,9 +82,9 @@ describe('Modules', () => { expect(res.status).toEqual(200) }) - it('Count lazy module correctly', async () => { + it('count lazy module correctly', async () => { const app = new Elysia() - .use(import('./modules')) + .use(import('../modules')) .use(asyncPlugin) .get('/', () => 'hi') @@ -93,16 +93,16 @@ describe('Modules', () => { expect(awaited.length).toBe(2) }) - it('Handle other routes while lazy load', async () => { - const app = new Elysia().use(import('./timeout')).get('/', () => 'hi') + it('handle other routes while lazy load', async () => { + const app = new Elysia().use(import('../timeout')).get('/', () => 'hi') const res = await app.handle(req('/')).then((r) => r.text()) expect(res).toBe('hi') }) - it('Handle deferred import', async () => { - const app = new Elysia().use(import('./modules')) + it('handle deferred import', async () => { + const app = new Elysia().use(import('../modules')) await app.modules diff --git a/test/core/scoped.test.ts b/test/core/scoped.test.ts new file mode 100644 index 00000000..cc2e8bd7 --- /dev/null +++ b/test/core/scoped.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from 'bun:test' + +import { Elysia } from '../../src' +import { req } from '../utils' + +const scoped = new Elysia({ + name: 'scoped', + scoped: true +}) + .state('inner', 0) + .state('outer', 0) + .get('/scoped', ({ store }) => ({ + outer: store.outer, + inner: ++store.inner + })) + +describe('Scoped', () => { + it('sync store', async () => { + const app = new Elysia() + .state('outer', 0) + .use(scoped) + .get('/', ({ store }) => ++store.outer) + + expect(await app.handle(req('/')).then((x) => x.text())).toBe('1') + expect(await app.handle(req('/scoped')).then((x) => x.json())).toEqual({ + outer: 1, + inner: 1 + }) + expect(await app.handle(req('/')).then((x) => x.text())).toBe('2') + }) + + it('encapsulate request event', async () => { + let count = 0 + + const scoped = new Elysia({ + name: 'scoped', + scoped: true + }) + .onRequest(() => { + count++ + }) + .get('/scoped', () => 'A') + + const app = new Elysia().use(scoped).get('/', () => 'A') + + await app.handle(req('/')) + await app.handle(req('/scoped')) + + expect(count).toBe(1) + }) + + it('encapsulate request event', async () => { + let count = 0 + + const scoped = new Elysia({ + name: 'scoped', + scoped: true + }) + .onRequest(() => { + count++ + }) + .get('/scoped', () => 'A') + + const app = new Elysia().use(scoped).get('/', () => 'A') + + await app.handle(req('/')) + await app.handle(req('/scoped')) + + expect(count).toBe(1) + }) + + it('encapsulate afterhandle event', async () => { + let count = 0 + + const scoped = new Elysia({ + name: 'scoped', + scoped: true + }) + .onAfterHandle(() => { + count++ + }) + .get('/scoped', () => 'A') + + const app = new Elysia().use(scoped).get('/', () => 'A') + + await app.handle(req('/')) + await app.handle(req('/scoped')) + + expect(count).toBe(1) + }) +}) diff --git a/test/decorators.test.ts b/test/decorators.test.ts deleted file mode 100644 index 35c99cd0..00000000 --- a/test/decorators.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { describe, it, expect } from 'bun:test' -import { Elysia } from '../src' -import { req } from './utils' - -describe('decorators', () => { - it('work', async () => { - const app = new Elysia() - .decorate('hi', () => 'hi') - .get('/', ({ hi }) => hi()) - - const res = await app.handle(req('/')).then((r) => r.text()) - expect(res).toBe('hi') - }) - - it('inherits plugin', async () => { - const plugin = () => (app: Elysia) => app.decorate('hi', () => 'hi') - - const app = new Elysia().use(plugin()).get('/', ({ hi }) => hi()) - - const res = await app.handle(req('/')).then((r) => r.text()) - expect(res).toBe('hi') - }) - - it('accepts any type', async () => { - const app = new Elysia() - .decorate('hi', { - there: { - hello: 'world' - } - }) - .get('/', ({ hi }) => hi.there.hello) - - const res = await app.handle(req('/')).then((r) => r.text()) - expect(res).toBe('world') - }) - - it('accepts multiple', async () => { - const app = new Elysia() - .decorate({ - hello: 'world', - my: 'name' - }) - .get('/', ({ hello }) => hello) - - const res = await app.handle(req('/')).then((r) => r.text()) - expect(res).toBe('world') - }) -}) diff --git a/test/extends/decorators.test.ts b/test/extends/decorators.test.ts new file mode 100644 index 00000000..33347421 --- /dev/null +++ b/test/extends/decorators.test.ts @@ -0,0 +1,116 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { describe, it, expect } from 'bun:test' +import { Elysia } from '../../src' +import { req } from '../utils' + +describe('Decorate', () => { + it('decorate primitive', async () => { + const app = new Elysia() + .decorate('name', 'Ina') + .get('/', ({ name }) => name) + + const res = await app.handle(req('/')).then((r) => r.text()) + expect(res).toBe('Ina') + }) + + it('decorate multiple', async () => { + const app = new Elysia() + .decorate('name', 'Ina') + .decorate('job', 'artist') + .get('/', ({ name, job }) => ({ + name, + job + })) + + const res = await app.handle(req('/')).then((r) => r.json()) + expect(res).toEqual({ + name: 'Ina', + job: 'artist' + }) + }) + + it('decorate object', async () => { + const app = new Elysia() + .decorate({ + name: 'Ina', + job: 'artist' + }) + .get('/', ({ name, job }) => ({ + name, + job + })) + + const res = await app.handle(req('/')).then((r) => r.json()) + expect(res).toEqual({ + name: 'Ina', + job: 'artist' + }) + }) + + it('remap object', async () => { + const app = new Elysia() + .decorate({ + name: 'Ina', + job: 'artist' + }) + .decorate(({ job, ...rest }) => ({ + ...rest, + job: 'streamer' + })) + .get('/', ({ name, job }) => ({ + name, + job + })) + + const res = await app.handle(req('/')).then((r) => r.json()) + expect(res).toEqual({ + name: 'Ina', + job: 'streamer' + }) + }) + + it('inherits functional plugin', async () => { + const plugin = () => (app: Elysia) => app.decorate('hi', () => 'hi') + + const app = new Elysia().use(plugin()).get('/', ({ hi }) => hi()) + + const res = await app.handle(req('/')).then((r) => r.text()) + expect(res).toBe('hi') + }) + + it('inherits instance plugin', async () => { + const plugin = new Elysia().decorate('hi', () => 'hi') + + const app = new Elysia().use(plugin).get('/', ({ hi }) => hi()) + + const res = await app.handle(req('/')).then((r) => r.text()) + expect(res).toBe('hi') + }) + + it('accepts any type', async () => { + const app = new Elysia() + .decorate('hi', { + there: { + hello: 'world' + } + }) + .get('/', ({ hi }) => hi.there.hello) + + const res = await app.handle(req('/')).then((r) => r.text()) + expect(res).toBe('world') + }) + + it('remap', async () => { + const app = new Elysia() + .decorate('job', 'artist') + .decorate('name', 'Ina') + .decorate(({ job, ...decorators }) => ({ + ...decorators, + job: 'vtuber' + })) + .get('/', ({ job }) => job) + + const res = await app.handle(req('/')).then((r) => r.text()) + expect(res).toBe('vtuber') + }) +}) diff --git a/test/extends/error.test.ts b/test/extends/error.test.ts new file mode 100644 index 00000000..ea0f5f00 --- /dev/null +++ b/test/extends/error.test.ts @@ -0,0 +1,71 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { Elysia, t } from '../../src' + +import { describe, expect, it } from 'bun:test' +import { req } from '../utils' + +class CustomError extends Error { + constructor() { + super() + } +} + +const getErrors = (app: Elysia) => + // @ts-ignore + Object.keys(app.definitions.error) + +describe('Error', () => { + it('add single', async () => { + const app = new Elysia().error('CUSTOM_ERROR', CustomError) + + expect(getErrors(app)).toEqual(['CUSTOM_ERROR']) + }) + + it('add multiple', async () => { + const app = new Elysia() + .error('CUSTOM_ERROR', CustomError) + .error('CUSTOM_ERROR_2', CustomError) + + expect(getErrors(app)).toEqual(['CUSTOM_ERROR', 'CUSTOM_ERROR_2']) + }) + + it('add object', async () => { + const app = new Elysia().error({ + CUSTOM_ERROR: CustomError, + CUSTOM_ERROR_2: CustomError + }) + + expect(getErrors(app)).toEqual(['CUSTOM_ERROR', 'CUSTOM_ERROR_2']) + }) + + // it('remap error', async () => { + // const app = new Elysia() + // .error({ + // CUSTOM_ERROR: CustomError, + // CUSTOM_ERROR_2: CustomError + // }) + // .error(({ CUSTOM_ERROR, ...rest }) => ({ + // ...rest, + // CUSTOM_ERROR_3: CustomError + // })) + + // expect(getErrors(app)).toEqual(['CUSTOM_ERROR', 'CUSTOM_ERROR_2']) + // }) + + it('inherits functional plugin', async () => { + const plugin = () => (app: Elysia) => + app.error('CUSTOM_ERROR', CustomError) + + const app = new Elysia().use(plugin()) + + expect(getErrors(app)).toEqual(['CUSTOM_ERROR']) + }) + + it('inherits instance plugin', async () => { + const plugin = new Elysia().error('CUSTOM_ERROR', CustomError) + + const app = new Elysia().use(plugin) + + expect(getErrors(app)).toEqual(['CUSTOM_ERROR']) + }) +}) diff --git a/test/extends/models.test.ts b/test/extends/models.test.ts new file mode 100644 index 00000000..23945ce4 --- /dev/null +++ b/test/extends/models.test.ts @@ -0,0 +1,175 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { Elysia, t } from '../../src' + +import { describe, expect, it } from 'bun:test' +import { req } from '../utils' + +describe('Model', () => { + it('add single', async () => { + const app = new Elysia() + .model('string', t.String()) + // @ts-ignore + .route('GET', '/', (context) => Object.keys(context.defs), { + config: { + allowMeta: true + } + }) + + const res = await app.handle(req('/')).then((r) => r.json()) + expect(res).toEqual(['string']) + }) + + it('add multiple', async () => { + const app = new Elysia() + .model('string', t.String()) + .model('number', t.Number()) + // @ts-ignore + .route('GET', '/', (context) => Object.keys(context.defs), { + config: { + allowMeta: true + } + }) + + const res = await app.handle(req('/')).then((r) => r.json()) + expect(res).toEqual(['string', 'number']) + }) + + it('add object', async () => { + const app = new Elysia() + .model({ + string: t.String(), + number: t.Number() + }) + // @ts-ignore + .route('GET', '/', (context) => Object.keys(context.defs), { + config: { + allowMeta: true + } + }) + + const res = await app.handle(req('/')).then((r) => r.json()) + expect(res).toEqual(['string', 'number']) + }) + + it('add object', async () => { + const app = new Elysia() + .model({ + string: t.String(), + number: t.Number() + }) + .model(({ number, ...rest }) => ({ + ...rest, + boolean: t.Boolean() + })) + // @ts-ignore + .route('GET', '/', (context) => Object.keys(context.defs), { + config: { + allowMeta: true + } + }) + + const res = await app.handle(req('/')).then((r) => r.json()) + expect(res).toEqual(['string', 'boolean']) + }) + + it('inherits functional plugin', async () => { + const plugin = () => (app: Elysia) => app.model('string', t.String()) + + const app = new Elysia() + .use(plugin()) + // @ts-ignore + .route('GET', '/', (context) => Object.keys(context.defs), { + config: { + allowMeta: true + } + }) + + const res = await app.handle(req('/')).then((r) => r.json()) + expect(res).toEqual(['string']) + }) + + it('inherits instance plugin', async () => { + const plugin = () => (app: Elysia) => app.model('string', t.String()) + + const app = new Elysia() + .use(plugin()) + // @ts-ignore + .route('GET', '/', (context) => Object.keys(context.defs), { + config: { + allowMeta: true + } + }) + + const res = await app.handle(req('/')).then((r) => r.json()) + expect(res).toEqual(['string']) + }) + + it('inherits instance plugin', async () => { + const plugin = new Elysia().decorate('hi', () => 'hi') + + const app = new Elysia().use(plugin).get('/', ({ hi }) => hi()) + + const res = await app.handle(req('/')).then((r) => r.text()) + expect(res).toBe('hi') + }) + + it('validate reference model', async () => { + const app = new Elysia() + .model({ + number: t.Number() + }) + .post('/', ({ body: { data } }) => data, { + response: 'number', + body: t.Object({ + data: t.Number() + }) + }) + + const correct = await app.handle( + new Request('http://localhost/', { + method: 'POST', + headers: { + 'content-type': 'application/json' + }, + body: JSON.stringify({ + data: 1 + }) + }) + ) + + expect(correct.status).toBe(200) + + const wrong = await app.handle( + new Request('http://localhost/', { + method: 'POST', + headers: { + 'content-type': 'application/json' + }, + body: JSON.stringify({ + data: true + }) + }) + ) + + expect(wrong.status).toBe(400) + }) + + it('remap', async () => { + const app = new Elysia() + .model('string', t.String()) + .model('number', t.Number()) + .model(({ number, ...rest }) => ({ + ...rest, + numba: number + })) + // @ts-ignore + .route('GET', '/', (context) => Object.keys(context.defs), { + config: { + allowMeta: true + } + }) + + const res = await app.handle(req('/')).then((r) => r.json()) + expect(res).toEqual(['string', 'numba']) + }) +}) diff --git a/test/extends/store.test.ts b/test/extends/store.test.ts new file mode 100644 index 00000000..29697bc2 --- /dev/null +++ b/test/extends/store.test.ts @@ -0,0 +1,93 @@ +import { describe, it, expect } from 'bun:test' +import { Elysia } from '../../src' +import { req } from '../utils' + +describe('State', () => { + it('store primitive', async () => { + const app = new Elysia() + .state('name', 'Ina') + .get('/', ({ store }) => store) + + const res = await app.handle(req('/')).then((r) => r.json()) + expect(res).toEqual({ + name: 'Ina' + }) + }) + + it('store multiple', async () => { + const app = new Elysia() + .state('name', 'Ina') + .state('job', 'artist') + .get('/', ({ store }) => store) + + const res = await app.handle(req('/')).then((r) => r.json()) + expect(res).toEqual({ + name: 'Ina', + job: 'artist' + }) + }) + + it('store object', async () => { + const app = new Elysia() + .state({ + name: 'Ina', + job: 'artist' + }) + .get('/', ({ store }) => store) + + const res = await app.handle(req('/')).then((r) => r.json()) + expect(res).toEqual({ + name: 'Ina', + job: 'artist' + }) + }) + + it('inherits function plugin', async () => { + const plugin = () => (app: Elysia) => app.state('hi', () => 'hi') + + const app = new Elysia() + .use(plugin()) + .get('/', ({ store: { hi } }) => hi()) + + const res = await app.handle(req('/')).then((r) => r.text()) + expect(res).toBe('hi') + }) + + it('inherits instance plugin', async () => { + const plugin = new Elysia().state('name', 'Ina') + const app = new Elysia().use(plugin).get('/', ({ store }) => store) + + const res = await app.handle(req('/')).then((r) => r.json()) + expect(res).toEqual({ + name: 'Ina' + }) + }) + + it('accepts any type', async () => { + const app = new Elysia() + .state('hi', { + there: { + hello: 'world' + } + }) + .get('/', ({ store: { hi } }) => hi.there.hello) + + const res = await app.handle(req('/')).then((r) => r.text()) + expect(res).toBe('world') + }) + + it('remap', async () => { + const app = new Elysia() + .state('job', 'artist') + .state('name', 'Ina') + // eslint-disable-next-line @typescript-eslint/no-unused-vars + .state(({ job, ...state }) => ({ + ...state, + job: 'vtuber' + })) + .get('/', ({ store: { job } }) => job) + + const res = await app.handle(req('/')).then((r) => r.text()) + expect(res).toBe('vtuber') + }) +}) diff --git a/test/file.test.ts b/test/file.test.ts deleted file mode 100644 index 8afc66ea..00000000 --- a/test/file.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { Elysia, t } from '../src' -import { describe, expect, it } from 'bun:test' -import { upload } from './utils' - -const app = new Elysia() - .post('/single', ({ body: { file } }) => file.size, { - body: t.Object({ - file: t.File() - }) - }) - .post( - '/multiple', - ({ body: { files } }) => files.reduce((a, b) => a + b.size, 0), - { - body: t.Object({ - files: t.Files() - }) - } - ) - -describe('File', () => { - it('accept single image', async () => { - const { request, size } = upload('/single', { - file: 'millenium.jpg' - }) - const res = await app.handle(request) - - expect(await res.text()).toEqual(size.toString()) - }) - - it('accept multiple image', async () => { - const { request, size } = upload('/multiple', { - files: ['aris-yuzu.jpg', 'aris-yuzu.jpg'] - }) - const res = await app.handle(request) - - expect(await res.text()).toEqual(size.toString()) - }) -}) diff --git a/test/fn.test.ts b/test/fn.test.ts deleted file mode 100644 index c44a177d..00000000 --- a/test/fn.test.ts +++ /dev/null @@ -1,378 +0,0 @@ -// import { Elysia } from 'elysia' -// import { describe, expect, it } from 'bun:test' - -// import SuperJSON from 'superjson' - -// const app = new Elysia() -// .state('version', 1) -// .decorate('getVersion', () => 1) -// .decorate('mirrorDecorator', (v: T) => v) -// .fn(({ getVersion, mirrorDecorator, store: { version } }) => ({ -// ping: () => 'pong', -// mirror: (value: any) => value, -// version: () => version, -// getVersion, -// mirrorDecorator, -// nested: { -// data() { -// return 'a' -// } -// } -// })) -// .fn(({ permission }) => ({ -// authorized: permission({ -// value: () => 'authorized', -// check({ request: { headers } }) { -// if (!headers.has('Authorization')) -// throw new Error('Authorization is required') -// } -// }), -// prisma: permission({ -// value: { -// user: { -// create(name: T) { -// return name -// }, -// delete(name: T) { -// return name -// } -// } -// }, -// check({ key, params }) { -// if (key === 'user.delete' && params[0] === 'Arona') -// throw new Error('Forbidden') -// } -// }), -// a: permission({ -// value: { -// allow: () => true, -// deny: () => false -// }, -// allow: ['allow'] -// }), -// b: permission({ -// value: { -// allow: () => true, -// deny: () => false -// }, -// deny: ['deny'] -// }), -// c: permission({ -// value: { -// allow: () => true, -// deny: () => false -// }, -// check({ match }) { -// return match({ -// deny() { -// throw new Error('Denied') -// } -// }) -// } -// }), -// d: permission({ -// value: { -// allow: () => true, -// deny: () => false -// }, -// check({ match }) { -// return match({ -// allow() { -// return -// }, -// default() { -// throw new Error('Denied') -// } -// }) -// } -// }), -// e: permission({ -// value: { -// allow: () => true, -// deny: () => false -// }, -// allow: ['allow'], -// check({ match }) { -// return match({ -// default() { -// throw new Error('Denied') -// } -// }) -// } -// }), -// f: permission({ -// value: { -// allow: (a: T) => a, -// deny: () => false -// }, -// allow: ['allow'], -// check({ match }) { -// return match({ -// allow([param]) { -// if (param === true) throw new Error('Forbidden Value') -// } -// }) -// } -// }), -// g: permission({ -// value: { -// allow: () => true, -// allow2: (v: boolean) => v, -// deny: () => false -// }, -// allow: ['allow'], -// check({ match }) { -// return match({ -// allow2([param]) { -// if (param === false) throw new Error('False') - -// return -// }, -// default() { -// throw new Error('Forbidden') -// } -// }) -// } -// }) -// })) -// .listen(8080) - -// const fn = ( -// body: Array<{ n: string[] } | { n: string[]; p: any[] }>, -// headers: HeadersInit = {}, -// target: Elysia = app as Elysia -// ): Promise => -// target -// .handle( -// new Request('http://localhost/~fn', { -// method: 'POST', -// headers: { -// 'content-type': 'elysia/fn', -// ...headers -// }, -// body: SuperJSON.stringify(body) -// }) -// ) -// .then((x) => x.text()) -// .then((x) => SuperJSON.parse(x)) - -// describe('Elysia Fn', () => { -// it('handle non-parameter', async () => { -// const res = await fn([{ n: ['ping'] }]) - -// expect(res[0]).toEqual('pong') -// }) - -// it('handle parameter', async () => { -// const res = await fn([{ n: ['mirror'], p: [1] }]) - -// expect(res[0]).toEqual(1) -// }) - -// it('extends SuperJSON parameter', async () => { -// const res = await fn([{ n: ['mirror'], p: [new Set([1, 2, 3])] }]) - -// expect(res[0]).toEqual(new Set([1, 2, 3])) -// }) - -// it('multiple parameters', async () => { -// const res = await fn([ -// { n: ['ping'] }, -// { n: ['mirror'], p: [new Error('Hi')] } -// ]) - -// expect(res).toEqual(['pong', new Error('Hi')]) -// }) - -// it('preserved order', async () => { -// const arr = new Array(1000).fill(null).map((x, i) => i) -// const res = await fn(arr.map((p) => ({ n: ['mirror'], p: [p] }))) - -// expect(res).toEqual(arr) -// }) - -// it('handle nested procedure', async () => { -// const res = await fn([{ n: ['nested', 'data'] }]) - -// expect(res[0]).toEqual('a') -// }) - -// it('handle error separately', async () => { -// const date = new Date() - -// const res = await fn([ -// { n: ['nested', 'data'] }, -// { -// n: ['invalid'] -// }, -// { n: ['mirror'], p: [date] } -// ]) - -// expect(res).toEqual(['a', new Error('Invalid procedure'), date]) -// }) - -// it('handle request permission', async () => { -// const res = await fn([{ n: ['authorized'] }], { -// Authorization: 'Ar1s' -// }) - -// expect(res[0]).toEqual('authorized') -// }) - -// it('handle request parameters', async () => { -// const res = await fn([ -// { n: ['prisma', 'user', 'delete'], p: ['Yuuka'] }, -// { n: ['prisma', 'user', 'create'], p: ['Noa'] }, -// { n: ['prisma', 'user', 'delete'], p: ['Arona'] } -// ]) - -// expect(res).toEqual(['Yuuka', 'Noa', new Error('Forbidden')]) -// }) - -// it('inherits state and decorators', async () => { -// const res = await fn([ -// { n: ['getVersion'] }, -// { n: ['version'] }, -// { -// n: ['mirrorDecorator'], -// p: [1] -// } -// ]) - -// expect(res).toEqual([1, 1, 1]) -// }) - -// it('custom path', async () => { -// const app = new Elysia({ -// fn: '/custom' -// }).fn({ -// mirror: (v: T) => v -// }) - -// const fn = ( -// body: Array<{ n: string[] } | { n: string[]; p: any[] }>, -// headers: HeadersInit = {} -// ): Promise => -// app -// .handle( -// new Request('http://localhost/custom', { -// method: 'POST', -// headers: { -// 'content-type': 'elysia/fn', -// ...headers -// }, -// body: SuperJSON.stringify(body) -// }) -// ) -// .then((x) => x.text()) -// .then((x) => SuperJSON.parse(x)) - -// expect(await fn([{ n: ['mirror'], p: [1] }])).toEqual([1]) -// }) - -// it('allow', async () => { -// const res = await fn([ -// { -// n: ['a', 'allow'] -// }, -// { -// n: ['a', 'deny'] -// } -// ]) - -// expect(res).toEqual([true, new Error('Forbidden')]) -// }) - -// it('deny', async () => { -// const res = await fn([ -// { -// n: ['b', 'allow'] -// }, -// { -// n: ['b', 'deny'] -// } -// ]) - -// expect(res).toEqual([true, new Error('Forbidden')]) -// }) - -// it('match error', async () => { -// const res = await fn([ -// { -// n: ['c', 'allow'] -// }, -// { -// n: ['c', 'deny'] -// } -// ]) - -// expect(res).toEqual([true, new Error('Denied')]) -// }) - -// it('match default', async () => { -// const res = await fn([ -// { -// n: ['d', 'allow'] -// }, -// { -// n: ['d', 'deny'] -// } -// ]) - -// expect(res).toEqual([true, new Error('Denied')]) -// }) - -// it('skip check if on allow list when match default', async () => { -// const res = await fn([ -// { -// n: ['e', 'allow'] -// }, -// { -// n: ['e', 'deny'] -// } -// ]) - -// expect(res).toEqual([true, new Error('Denied')]) -// }) - -// it('validate both allow and check', async () => { -// const res = await fn([ -// { -// n: ['f', 'allow'], -// p: [true] -// }, -// { -// n: ['f', 'allow'], -// p: ['hello'] -// } -// ]) - -// expect(res).toEqual([new Error('Forbidden Value'), 'hello']) -// }) - -// it('allow method that not specified in allowlist but in check fn', async () => { -// const res = await fn([ -// { -// n: ['g', 'allow'] -// }, -// { -// n: ['g', 'allow2'], -// p: [true] -// }, -// { -// n: ['g', 'allow2'], -// p: [false] -// }, -// { -// n: ['g', 'deny'] -// } -// ]) - -// expect(res).toEqual([ -// true, -// true, -// new Error('False'), -// new Error('Forbidden') -// ]) -// }) -// }) diff --git a/test/generation.test.ts b/test/generation.test.ts deleted file mode 100644 index 28a99e47..00000000 --- a/test/generation.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -/* eslint-disable @typescript-eslint/no-unused-vars */ -import { describe, it, expect } from 'bun:test' -import { Elysia, t } from '../src' -import { post, req } from './utils' - -describe('code generation', () => { - it('fallback query if not presented', async () => { - const app = new Elysia().get('/', () => 'hi', { - query: t.Object({ - id: t.Optional(t.Number()) - }) - }) - - const res = await app.handle(req('/')).then((x) => x.text()) - - expect(res).toBe('hi') - }) - - it('process body', async () => { - const body = { hello: 'Wanderschaffen' } - - const app = new Elysia() - .post('/1', ({ body }) => body) - .post('/2', function ({ body }) { - return body - }) - .post('/3', (context) => { - return context.body - }) - .post('/4', (context) => { - const c = context - const { body } = c - - return body - }) - .post('/5', (context) => { - const _ = context, - a = context - const { body } = a - - return body - }) - .post('/6', () => body, { - transform({ body }) { - // not empty - } - }) - .post('/7', () => body, { - beforeHandle({ body }) { - // not empty - } - }) - .post('/8', () => body, { - afterHandle({ body }) { - // not empty - } - }) - - const from = (number: number) => - app.handle(post(`/${number}`, body)).then((r) => r.json()) - - expect(await from(1)).toEqual(body) - expect(await from(2)).toEqual(body) - expect(await from(3)).toEqual(body) - expect(await from(4)).toEqual(body) - expect(await from(5)).toEqual(body) - expect(await from(6)).toEqual(body) - expect(await from(7)).toEqual(body) - expect(await from(8)).toEqual(body) - }) -}) diff --git a/test/lifecycle.test.ts b/test/lifecycle.test.ts deleted file mode 100644 index b2163dfd..00000000 --- a/test/lifecycle.test.ts +++ /dev/null @@ -1,101 +0,0 @@ -// import { Elysia } from '../src' - -// import { describe, expect, it } from 'bun:test' -// import { req } from './utils' - -// describe('Life Cycle', () => { -// it('handle onStart', async () => { -// let started = false - -// const app = new Elysia() -// .get('/', () => 'hi') -// .onStart(() => { -// started = true -// }) -// .listen(8080) - -// app.stop() - -// expect(started).toBe(true) -// }) - -// it("handle .on('start')", async () => { -// let started = false - -// const app = new Elysia() -// .get('/', () => 'hi') -// .on('start', () => { -// started = true -// }) -// .listen(8080) - -// app.stop() - -// expect(started).toBe(true) -// }) - -// it('handle onStop', async () => { -// let stopped = false - -// const app = new Elysia() -// .get('/', () => 'hi') -// .onStop(() => { -// stopped = true -// }) -// .listen(8080) - -// app.stop() - -// expect(stopped).toBe(true) -// }) - -// it("handle .on('stop')", async () => { -// let started = false - -// const app = new Elysia() -// .get('/', () => 'hi') -// .on('stop', () => { -// started = true -// }) -// .listen(8080) - -// app.stop() - -// expect(started).toBe(true) -// }) - -// it('handle onError', async () => { -// const app = new Elysia({ -// forceErrorEncapsulation: true -// }) -// .get('/', () => { -// throw new Error('Something') -// }) -// .onError(({ error }) => { -// if (error.message === 'Something') return new Response(':P') -// }) - -// const res = await app.handle(req('/')) - -// expect(await res.text()).toBe(':P') -// }) - -// it('handle onResponse', async () => { -// const app = new Elysia() -// .state('report', {} as Record) -// .onResponse(({ store, path }) => { -// store.report[path] = true -// }) -// .get('/', () => 'Hello World') -// .get('/async', async () => 'Hello World') -// .get('/error', () => {}, { -// error() {} -// }) - -// const keys = ['/', '/async', '/error'] - -// for (const key of keys) await app.handle(req(key)) - -// expect(Object.keys(app.store.report)).toEqual(keys) -// }) -// }) diff --git a/test/before-handle.test.ts b/test/lifecycle/before-handle.test.ts similarity index 87% rename from test/before-handle.test.ts rename to test/lifecycle/before-handle.test.ts index b0bf249d..c64fb49f 100644 --- a/test/before-handle.test.ts +++ b/test/lifecycle/before-handle.test.ts @@ -1,10 +1,10 @@ -import { Elysia } from '../src' +import { Elysia } from '../../src' import { describe, expect, it } from 'bun:test' -import { req } from './utils' +import { delay, req } from '../utils' describe('Before Handle', () => { - it('Globally skip main handler', async () => { + it('globally skip main handler', async () => { const app = new Elysia() .onBeforeHandle<{ params: { @@ -20,7 +20,7 @@ describe('Before Handle', () => { expect(await res.text()).toBe('Cat') }) - it('Locally skip main handler', async () => { + it('locally skip main handler', async () => { const app = new Elysia().get( '/name/:name', ({ params: { name } }) => name, @@ -36,7 +36,7 @@ describe('Before Handle', () => { expect(await res.text()).toBe('Cat') }) - it('Group before handler', async () => { + it('group before handler', async () => { const app = new Elysia() .group('/type', (app) => app @@ -77,7 +77,7 @@ describe('Before Handle', () => { expect(await res.text()).toBe('Cat') }) - it('Before handle in order', async () => { + it('before handle in order', async () => { const app = new Elysia() .get('/name/:name', ({ params: { name } }) => name) .onBeforeHandle<{ @@ -93,7 +93,7 @@ describe('Before Handle', () => { expect(await res.text()).toBe('fubuki') }) - it('Globally and locally before handle', async () => { + it('globally and locally before handle', async () => { const app = new Elysia() .onBeforeHandle<{ params: { @@ -115,7 +115,7 @@ describe('Before Handle', () => { expect(await korone.text()).toBe('dog') }) - it('Accept multiple before handler', async () => { + it('accept multiple before handler', async () => { const app = new Elysia() .onBeforeHandle<{ params: { @@ -140,17 +140,13 @@ describe('Before Handle', () => { expect(await korone.text()).toBe('dog') }) - it('Handle async', async () => { + it('handle async', async () => { const app = new Elysia().get( '/name/:name', ({ params: { name } }) => name, { beforeHandle: async ({ params: { name } }) => { - await new Promise((resolve) => - setTimeout(() => { - resolve() - }, 1) - ) + await delay(5) if (name === 'Watame') return 'Warukunai yo ne' } @@ -162,7 +158,7 @@ describe('Before Handle', () => { expect(await res.text()).toBe('Warukunai yo ne') }) - it("Handle on('beforeHandle')", async () => { + it("handle on('beforeHandle')", async () => { const app = new Elysia() .on('beforeHandle', async ({ params: { name } }) => { await new Promise((resolve) => @@ -189,8 +185,8 @@ describe('Before Handle', () => { }>(({ params: { name } }) => { if (name === 'Fubuki') return 'Cat' }) - .onAfterHandle((context, response) => { - if (response === 'Cat') return 'Not cat' + .onAfterHandle((context) => { + if (context.response === 'Cat') return 'Not cat' }) .get('/name/:name', ({ params: { name } }) => name) diff --git a/test/derive.test.ts b/test/lifecycle/derive.test.ts similarity index 95% rename from test/derive.test.ts rename to test/lifecycle/derive.test.ts index 2bb0d0b9..efea9de5 100644 --- a/test/derive.test.ts +++ b/test/lifecycle/derive.test.ts @@ -1,7 +1,7 @@ -import { Elysia } from '../src' +import { Elysia } from '../../src' import { describe, expect, it } from 'bun:test' -import { req } from './utils' +import { req } from '../utils' describe('derive', () => { it('work', async () => { diff --git a/test/error.test.ts b/test/lifecycle/error.test.ts similarity index 68% rename from test/error.test.ts rename to test/lifecycle/error.test.ts index aa2ac8a0..2d1f1c26 100644 --- a/test/error.test.ts +++ b/test/lifecycle/error.test.ts @@ -1,6 +1,7 @@ -import { Elysia, InternalServerError, t } from '../src' +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { Elysia, InternalServerError, t } from '../../src' import { describe, expect, it } from 'bun:test' -import { post, req } from './utils' +import { post, req } from '../utils' describe('error', () => { it('use custom 404', async () => { @@ -23,6 +24,28 @@ describe('error', () => { expect(notFound).toBe('UwU') }) + // it('handle parse error', async () => { + // const app = new Elysia() + // .onError(({ code }) => { + // if (code === 'PARSE') return 'Why you no proper type' + // }) + // .post('/', ({ body }) => 'hello') + + // const root = await app + // .handle( + // new Request('http://localhost/', { + // method: 'POST', + // body: 'A', + // headers: { + // 'content-type': 'application/json' + // } + // }) + // ) + // .then((x) => x.text()) + + // expect(root).toBe('Why you no proper type') + // }) + it('custom validation error', async () => { const app = new Elysia() .onError(({ code, error, set }) => { diff --git a/test/parser.test.ts b/test/lifecycle/parser.test.ts similarity index 98% rename from test/parser.test.ts rename to test/lifecycle/parser.test.ts index 0c27fa23..42354a3e 100644 --- a/test/parser.test.ts +++ b/test/lifecycle/parser.test.ts @@ -1,4 +1,4 @@ -import { Elysia } from '../src' +import { Elysia } from '../../src' import { describe, expect, it } from 'bun:test' diff --git a/test/lifecycle/request.test.ts b/test/lifecycle/request.test.ts new file mode 100644 index 00000000..d8f1840b --- /dev/null +++ b/test/lifecycle/request.test.ts @@ -0,0 +1,31 @@ +import { Elysia } from '../../src' + +import { describe, expect, it } from 'bun:test' +import { req, delay } from '../utils' + +describe('On Request', () => { + it('inject headers to response', async () => { + const app = new Elysia() + .onRequest(({ set }) => { + set.headers['Access-Control-Allow-Origin'] = '*' + }) + .get('/', () => 'hi') + + const res = await app.handle(req('/')) + + expect(res.headers.get('Access-Control-Allow-Origin')).toBe('*') + }) + + it('handle async', async () => { + const app = new Elysia() + .onRequest(async ({ set }) => { + await delay(5) + set.headers.name = 'llama' + }) + .get('/', () => 'hi') + + const res = await app.handle(req('/')) + + expect(res.headers.get('name')).toBe('llama') + }) +}) diff --git a/test/lifecycle/response.test.ts b/test/lifecycle/response.test.ts new file mode 100644 index 00000000..f9d63653 --- /dev/null +++ b/test/lifecycle/response.test.ts @@ -0,0 +1,23 @@ +import { Elysia } from '../../src' + +import { describe, expect, it } from 'bun:test' +import { req } from '../utils' + +describe('On Response', () => { + it('inherits set if Response is return', async () => { + const app = new Elysia() + .onResponse(({ set }) => { + expect(set.status).toBe(401) + }) + .onError(() => { + return new Response('a', { + status: 401, + headers: { + awd: 'b' + } + }) + }) + + await app.handle(req('/')) + }) +}) diff --git a/test/transform.test.ts b/test/lifecycle/transform.test.ts similarity index 83% rename from test/transform.test.ts rename to test/lifecycle/transform.test.ts index 488d85f4..25aa7ae3 100644 --- a/test/transform.test.ts +++ b/test/lifecycle/transform.test.ts @@ -1,12 +1,16 @@ -import { Elysia, t } from '../src' +import { Elysia, t } from '../../src' import { describe, expect, it } from 'bun:test' -import { req } from './utils' +import { req } from '../utils' describe('Transform', () => { - it('Globally Transform', async () => { + it('globally Transform', async () => { const app = new Elysia() - .onTransform((request) => { + .onTransform<{ + params: { + id: number + } | null + }>((request) => { if (request.params?.id) request.params.id = +request.params.id }) .get('/id/:id', ({ params: { id } }) => typeof id) @@ -16,7 +20,7 @@ describe('Transform', () => { expect(await res.text()).toBe('number') }) - it('Locally transform', async () => { + it('locally transform', async () => { const app = new Elysia().get( '/id/:id', ({ params: { id } }) => typeof id, @@ -35,14 +39,14 @@ describe('Transform', () => { expect(await res.text()).toBe('number') }) - it('Group transform', async () => { + it('group transform', async () => { const app = new Elysia() .group('/scoped', (app) => app .onTransform<{ params: { - id?: number - } + id: number + } | null }>((request) => { if (request.params?.id) request.params.id = +request.params.id @@ -58,12 +62,12 @@ describe('Transform', () => { expect(await scoped.text()).toBe('number') }) - it('Transform from plugin', async () => { + it('transform from plugin', async () => { const transformId = (app: Elysia) => app.onTransform<{ params: { - id?: number - } + id: number + } | null }>((request) => { if (request.params?.id) request.params.id = +request.params.id }) @@ -77,7 +81,7 @@ describe('Transform', () => { expect(await res.text()).toBe('number') }) - it('Transform from on', async () => { + it('transform from on', async () => { const app = new Elysia() .on('transform', (request) => { if (request.params?.id) request.params.id = +request.params.id @@ -89,13 +93,13 @@ describe('Transform', () => { expect(await res.text()).toBe('number') }) - it('Transform in order', async () => { + it('transform in order', async () => { const app = new Elysia() .get('/id/:id', ({ params: { id } }) => typeof id) .onTransform<{ params: { - id?: number - } + id: number + } | null }>((request) => { if (request.params?.id) request.params.id = +request.params.id }) @@ -105,12 +109,12 @@ describe('Transform', () => { expect(await res.text()).toBe('string') }) - it('Globally and locally pre handle', async () => { + it('globally and locally pre handle', async () => { const app = new Elysia() .onTransform<{ params: { - id?: number - } + id: number + } | null }>((request) => { if (request.params?.id) request.params.id = +request.params.id }) @@ -132,19 +136,19 @@ describe('Transform', () => { expect(await res.text()).toBe('2') }) - it('Accept multiple transform', async () => { + it('accept multiple transform', async () => { const app = new Elysia() .onTransform<{ params: { - id?: number - } + id: number + } | null }>((request) => { if (request.params?.id) request.params.id = +request.params.id }) .onTransform<{ params: { - id?: number - } + id: number + } | null }>((request) => { if ( request.params?.id && @@ -159,7 +163,7 @@ describe('Transform', () => { expect(await res.text()).toBe('2') }) - it('Transform async', async () => { + it('transform async', async () => { const app = new Elysia().get( '/id/:id', ({ params: { id } }) => typeof id, @@ -184,12 +188,12 @@ describe('Transform', () => { expect(await res.text()).toBe('number') }) - it('Map returned value', async () => { + it('map returned value', async () => { const app = new Elysia() .onTransform<{ params: { - id?: number - } + id: number + } | null }>((request) => { if (request.params?.id) request.params.id = +request.params.id }) diff --git a/test/models.test.ts b/test/models.test.ts deleted file mode 100644 index b28fe8ef..00000000 --- a/test/models.test.ts +++ /dev/null @@ -1,327 +0,0 @@ -import { Elysia, t } from '../src' - -import { describe, expect, it } from 'bun:test' -import { req } from './utils' - -describe('Models', () => { - it('register models', async () => { - const app = new Elysia() - .model({ - string: t.String(), - number: t.Number() - }) - .model({ - boolean: t.Boolean() - }) - // @ts-ignore - .route('GET', '/', (context) => Object.keys(context.defs!), { - config: { - allowMeta: true - } - }) - - const res = await app.handle(req('/')).then((r) => r.json()) - - expect(res).toEqual(['string', 'number', 'boolean']) - }) - - // it('map model parameters as OpenAPI schema', async () => { - // const app = new Elysia() - // .model({ - // number: t.Number(), - // string: t.String(), - // boolean: t.Boolean(), - // object: t.Object({ - // string: t.String(), - // boolean: t.Boolean() - // }) - // }) - // .get('/defs', (context) => context[SCHEMA]) - // .get('/', () => 1, { - // schema: { - // query: 'object', - // body: 'object', - // params: 'object', - // response: { - // 200: 'boolean', - // 300: 'number' - // } - // } as const - // }) - - // const res = await app.handle(req('/defs')).then((r) => r.json()) - - // expect(res).toEqual({ - // '/defs': { - // get: {} - // }, - // '/': { - // get: { - // parameters: [ - // { - // in: 'path', - // name: 'string', - // type: 'string', - // required: true - // }, - // { - // in: 'path', - // name: 'boolean', - // type: 'boolean', - // required: true - // }, - // { - // in: 'query', - // name: 'string', - // type: 'string', - // required: true - // }, - // { - // in: 'query', - // name: 'boolean', - // type: 'boolean', - // required: true - // }, - // { - // in: 'body', - // name: 'body', - // required: true, - // schema: { - // $ref: '#/definitions/object' - // } - // } - // ], - // responses: { - // '200': { - // schema: { - // $ref: '#/definitions/boolean' - // } - // }, - // '300': { - // schema: { - // $ref: '#/definitions/number' - // } - // } - // } - // } - // } - // }) - // }) - - // it('map model and inline parameters as OpenAPI schema', async () => { - // const app = new Elysia() - // .model({ - // number: t.Number(), - // string: t.String(), - // boolean: t.Boolean(), - // object: t.Object({ - // string: t.String(), - // boolean: t.Boolean() - // }) - // }) - // .get('/defs', (context) => context[SCHEMA]) - // .get('/', () => 1, { - // schema: { - // query: 'object', - // body: t.Object({ - // number: t.Number() - // }), - // params: 'object', - // response: { - // 200: 'boolean', - // 300: 'number' - // } - // } as const - // }) - - // const res = await app.handle(req('/defs')).then((r) => r.json()) - - // expect(res).toEqual({ - // '/defs': { - // get: {} - // }, - // '/': { - // get: { - // parameters: [ - // { - // in: 'path', - // name: 'string', - // type: 'string', - // required: true - // }, - // { - // in: 'path', - // name: 'boolean', - // type: 'boolean', - // required: true - // }, - // { - // in: 'query', - // name: 'string', - // type: 'string', - // required: true - // }, - // { - // in: 'query', - // name: 'boolean', - // type: 'boolean', - // required: true - // }, - // { - // in: 'body', - // name: 'body', - // required: true, - // schema: { - // type: 'object', - // properties: { - // number: { - // type: 'number' - // } - // }, - // required: ['number'], - // additionalProperties: false - // } - // } - // ], - // responses: { - // '200': { - // schema: { - // $ref: '#/definitions/boolean' - // } - // }, - // '300': { - // schema: { - // $ref: '#/definitions/number' - // } - // } - // } - // } - // } - // }) - // }) - - // it('map model and inline response as OpenAPI schema', async () => { - // const app = new Elysia() - // .model({ - // number: t.Number(), - // string: t.String(), - // boolean: t.Boolean(), - // object: t.Object({ - // string: t.String(), - // boolean: t.Boolean() - // }) - // }) - // .get('/defs', (context) => context[SCHEMA]) - // .get('/', () => 1, { - // schema: { - // response: { - // 200: t.String(), - // 300: 'number' - // } - // } as const - // }) - - // const res = await app.handle(req('/defs')).then((r) => r.json()) - - // expect(res).toEqual({ - // '/defs': { - // get: {} - // }, - // '/': { - // get: { - // responses: { - // '200': { - // schema: { - // type: 'string' - // } - // }, - // '300': { - // schema: { - // $ref: '#/definitions/number' - // } - // } - // } - // } - // } - // }) - // }) - - // it('map model default response', async () => { - // const app = new Elysia() - // .model({ - // number: t.Number(), - // string: t.String(), - // boolean: t.Boolean(), - // object: t.Object({ - // string: t.String(), - // boolean: t.Boolean() - // }) - // }) - // .get('/defs', (context) => context[SCHEMA]) - // .get('/', () => 1, { - // schema: { - // response: 'number' - // } as const - // }) - - // const res = await app.handle(req('/defs')).then((r) => r.json()) - - // expect(res).toEqual({ - // '/defs': { - // get: {} - // }, - // '/': { - // get: { - // responses: { - // '200': { - // schema: { - // $ref: '#/definitions/number' - // } - // } - // } - // } - // } - // }) - // }) - - it('validate reference model', async () => { - const app = new Elysia() - .model({ - number: t.Number() - }) - .post('/', ({ body: { data } }) => data, { - response: 'number', - body: t.Object({ - data: t.Number() - }) - }) - - const correct = await app.handle( - new Request('http://localhost/', { - method: 'POST', - headers: { - 'content-type': 'application/json' - }, - body: JSON.stringify({ - data: 1 - }) - }) - ) - - expect(correct.status).toBe(200) - - const wrong = await app.handle( - new Request('http://localhost/', { - method: 'POST', - headers: { - 'content-type': 'application/json' - }, - body: JSON.stringify({ - data: true - }) - }) - ) - - expect(wrong.status).toBe(400) - }) -}) diff --git a/test/on-request.test.ts b/test/on-request.test.ts deleted file mode 100644 index 5250b9f4..00000000 --- a/test/on-request.test.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Elysia } from '../src' - -import { describe, expect, it } from 'bun:test' -import { req } from './utils' - -describe('On Request', () => { - it('Inject headers to response', async () => { - const app = new Elysia() - .onRequest(({ set }) => { - set.headers['Access-Control-Allow-Origin'] = '*' - }) - .get('/', () => 'hi') - - const res = await app.handle(req('/')) - - expect(res.headers.get('Access-Control-Allow-Origin')).toBe('*') - }) -}) diff --git a/test/on-response.test.ts b/test/on-response.test.ts deleted file mode 100644 index b7e725af..00000000 --- a/test/on-response.test.ts +++ /dev/null @@ -1,23 +0,0 @@ -// import { Elysia } from '../src' - -// import { describe, expect, it } from 'bun:test' -// import { req } from './utils' - -// describe('On Response', () => { -// it('Inherits set if Response is return', async () => { -// const app = new Elysia() -// .onResponse(({ set }) => { -// expect(set.status).toBe(401) -// }) -// .onError(() => { -// return new Response('a', { -// status: 401, -// headers: { -// awd: 'b' -// } -// }) -// }) - -// await app.handle(req('/')) -// }) -// }) diff --git a/test/group.test.ts b/test/path/group.test.ts similarity index 97% rename from test/group.test.ts rename to test/path/group.test.ts index f5fc06af..d9fdb90c 100644 --- a/test/group.test.ts +++ b/test/path/group.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'bun:test' -import { Elysia, t } from '../src' -import { post, req } from './utils' +import { Elysia, t } from '../../src' +import { post, req } from '../utils' describe('group', () => { it('delegate onRequest', async () => { @@ -127,6 +127,7 @@ describe('group', () => { response: t.String() }, (app) => + // @ts-ignore app .get('/correct', () => 'Hello') // @ts-ignore diff --git a/test/guard.test.ts b/test/path/guard.test.ts similarity index 97% rename from test/guard.test.ts rename to test/path/guard.test.ts index 46d700cf..cbb10f80 100644 --- a/test/guard.test.ts +++ b/test/path/guard.test.ts @@ -1,7 +1,7 @@ -import { Elysia, t } from '../src' +import { Elysia, t } from '../../src' import { describe, expect, it } from 'bun:test' -import { post, req } from './utils' +import { post, req } from '../utils' describe('guard', () => { it('inherits global', async () => { diff --git a/test/path.test.ts b/test/path/path.test.ts similarity index 86% rename from test/path.test.ts rename to test/path/path.test.ts index 1f0874a7..8d128b17 100644 --- a/test/path.test.ts +++ b/test/path/path.test.ts @@ -1,7 +1,7 @@ -import { Elysia, t } from '../src' +import { Elysia, t } from '../../src' import { describe, expect, it } from 'bun:test' -import { post, req } from './utils' +import { post, req } from '../utils' describe('Path', () => { it('handle root', async () => { @@ -18,21 +18,21 @@ describe('Path', () => { expect(await res.text()).toBe('Ok') }) - it('Return boolean', async () => { + it('return boolean', async () => { const app = new Elysia().get('/', () => true) const res = await app.handle(req('/')) expect(await res.text()).toBe('true') }) - it('Return number', async () => { + it('return number', async () => { const app = new Elysia().get('/', () => 617) const res = await app.handle(req('/')) expect(await res.text()).toBe('617') }) - it('Return json', async () => { + it('return json', async () => { const app = new Elysia().get('/', () => ({ name: 'takodachi' })) @@ -46,7 +46,7 @@ describe('Path', () => { expect(res.headers.get('content-type')).toContain('application/json') }) - it('Return response', async () => { + it('return response', async () => { const app = new Elysia().get( '/', () => @@ -64,30 +64,24 @@ describe('Path', () => { expect(res.headers.get('duck')).toBe('shuba duck') }) - it('Parse single param', async () => { + it('parse single param', async () => { const app = new Elysia().get('/id/:id', ({ params: { id } }) => id) const res = await app.handle(req('/id/123')) expect(await res.text()).toBe('123') }) - it('Parse multiple params', async () => { + it('parse multiple params', async () => { const app = new Elysia().get( '/id/:id/:name', - ({ params: { id, name } }) => `${id}/${name}`, - { - params: t.Object({ - id: t.String(), - name: t.String() - }) - } + ({ params: { id, name } }) => `${id}/${name}` ) const res = await app.handle(req('/id/fubuki/Elysia')) expect(await res.text()).toBe('fubuki/Elysia') }) - it('Accept wildcard', async () => { + it('accept wildcard', async () => { const app = new Elysia().get('/wildcard/*', () => 'Wildcard') const res = await app.handle(req('/wildcard/okayu')) @@ -95,7 +89,7 @@ describe('Path', () => { expect(await res.text()).toBe('Wildcard') }) - it('Custom error', async () => { + it('custom error', async () => { const app = new Elysia().onError((error) => { if (error.code === 'NOT_FOUND') return new Response('Not Stonk :(', { @@ -109,14 +103,14 @@ describe('Path', () => { expect(res.status).toBe(404) }) - it('Parse a querystring', async () => { + it('parse a querystring', async () => { const app = new Elysia().get('/', ({ query: { id } }) => id) const res = await app.handle(req('/?id=123')) expect(await res.text()).toBe('123') }) - it('Parse multiple querystrings', async () => { + it('parse multiple querystrings', async () => { const app = new Elysia().get( '/', ({ query: { first, last } }) => `${last} ${first}`, @@ -153,7 +147,7 @@ describe('Path', () => { expect(await res.text()).toBe('Botan') }) - it('Parse JSON body', async () => { + it('parse JSON body', async () => { const body = JSON.stringify({ name: 'Okayu' }) @@ -177,7 +171,7 @@ describe('Path', () => { expect(JSON.stringify(await res.json())).toBe(body) }) - it('Parse headers', async () => { + it('parse headers', async () => { const app = new Elysia().post('/', ({ request }) => request.headers.get('x-powered-by') ) @@ -269,7 +263,7 @@ describe('Path', () => { expect(text).toBe('route 2') }) - it('Return file', async () => { + it('return file', async () => { const app = new Elysia().get('/', ({ set }) => { set.headers.server = 'Elysia' @@ -325,26 +319,6 @@ describe('Path', () => { }) }) - // ? This is not used because fragment is strip out by default - // it('exclude fragment', async () => { - // const app = new Elysia().get('/', ({ query }) => query) - - // const res = await app.handle(req('/#hi')).then((r) => r.json()) - - // expect(res).toEqual({}) - // }) - - // ? This is not used because fragment is strip out by default - // it('exclude fragment on querystring', async () => { - // const app = new Elysia().get('/', ({ query }) => query) - - // const res = await app.handle(req('/?a=b#a')).then((r) => r.json()) - - // expect(res).toEqual({ - // a: 'b' - // }) - // }) - it('handle all method', async () => { const app = new Elysia().all('/', () => 'Hi') const res1 = await app.handle(req('/')).then((res) => res.text()) @@ -462,22 +436,6 @@ describe('Path', () => { } }) - it('handle array route - TRACE', async () => { - const paths = ['/', '/test', '/other/nested'] - const app = new Elysia().trace(paths, ({ path }) => { - return path - }) - - for (const path of paths) { - const res = await app.handle( - new Request('http://localhost' + path, { - method: 'TRACE' - }) - ) - expect(await res.text()).toBe(path) - } - }) - it('handle array route - CONNECT', async () => { const paths = ['/', '/test', '/other/nested'] const app = new Elysia().connect(paths, ({ path }) => { @@ -495,7 +453,7 @@ describe('Path', () => { }) it('handle array route - all', async () => { - const paths = ['/', '/test', '/other/nested'] + const paths = ['/', '/test', '/other/nested'] as const const app = new Elysia().all(paths, ({ path }) => { return path }) @@ -538,7 +496,8 @@ describe('Path', () => { }) it('handle array route - custom method', async () => { - const paths = ['/', '/test', '/other/nested'] + const paths = ['/', '/test', '/other/nested'] as const + // @ts-ignore const app = new Elysia().route('NOTIFY', paths, ({ path }) => { return path }) diff --git a/test/plugins/affix.test.ts b/test/plugins/affix.test.ts new file mode 100644 index 00000000..ccc438a1 --- /dev/null +++ b/test/plugins/affix.test.ts @@ -0,0 +1,93 @@ +import { Elysia, t } from '../../src' +import { describe, it, expect } from 'bun:test' + +const setup = new Elysia() + .decorate('decorate', 'decorate') + .state('state', 'state') + .model('model', t.String()) + .error('error', Error) + +describe('affix', () => { + it('should add prefix to all decorators, states, models, and errors', () => { + const app = new Elysia().use(setup).affix('prefix', 'all', 'p') + + // @ts-ignore + expect(app.decorators).toHaveProperty('pDecorate') + expect(app.store).toHaveProperty('pState') + // @ts-ignore + expect(app.definitions.type).toHaveProperty('pModel') + // @ts-ignore + expect(app.definitions.error).toHaveProperty('pError') + }) + + it('should add suffix to all decorators, states, models, and errors', () => { + const app = new Elysia().use(setup).affix('suffix', 'all', 'p') + + // @ts-ignore + expect(app.decorators).toHaveProperty('decorateP') + // @ts-ignore + expect(app.store).toHaveProperty('stateP') + // @ts-ignore + expect(app.definitions.type).toHaveProperty('modelP') + // @ts-ignore + expect(app.definitions.error).toHaveProperty('errorP') + }) + + it('should add suffix to all states', () => { + const app = new Elysia().use(setup).suffix('state', 'p') + + expect(app.store).toHaveProperty('stateP') + }) + + it('should add prefix to all decorators and errors', () => { + const app = new Elysia() + .use(setup) + .prefix('decorator', 'p') + .prefix('error', 'p') + + // @ts-ignore + expect(app.decorators).toHaveProperty('pDecorate') + + // @ts-ignore + expect(app.definitions.error).toHaveProperty('pError') + }) + + it('should add suffix to all decorators and errors', () => { + const app = new Elysia() + .use(setup) + .suffix('decorator', 'p') + .suffix('error', 'p') + + // @ts-ignore + expect(app.decorators).toHaveProperty('decorateP') + + // @ts-ignore + expect(app.definitions.error).toHaveProperty('errorP') + }) + + it('should add prefix to all models', () => { + const app = new Elysia().use(setup).prefix('model', 'p') + + // @ts-ignore + expect(app.definitions.type).toHaveProperty('pModel') + }) + + it('should add suffix to all models', () => { + const app = new Elysia().use(setup).affix('suffix', 'model', 'p') + + // @ts-ignore + expect(app.definitions.type).toHaveProperty('modelP') + }) + + it('should skip on empty', () => { + const app = new Elysia().use(setup).suffix('all', '') + + // @ts-ignore + expect(app.decorators).toHaveProperty('decorate') + expect(app.store).toHaveProperty('state') + // @ts-ignore + expect(app.definitions.type).toHaveProperty('model') + // @ts-ignore + expect(app.definitions.error).toHaveProperty('error') + }) +}) diff --git a/test/checksum.test.ts b/test/plugins/checksum.test.ts similarity index 97% rename from test/checksum.test.ts rename to test/plugins/checksum.test.ts index 2bb2f8f8..46563b2c 100644 --- a/test/checksum.test.ts +++ b/test/plugins/checksum.test.ts @@ -1,7 +1,7 @@ -import { Elysia, t } from '../src' +import { Elysia, t } from '../../src' import { describe, expect, it } from 'bun:test' -import { req } from './utils' +import { req } from '../utils' describe('Checksum', () => { it('deduplicate plugin', async () => { @@ -221,10 +221,7 @@ describe('Checksum', () => { ) ) - const app = new Elysia() - .use(plugin) - .get('/', () => 'A') - .listen(8080) + const app = new Elysia().use(plugin).get('/', () => 'A') await Promise.all( ['/v1', '/v1/v1', '/'].map((path) => app.handle(req(path))) @@ -273,7 +270,6 @@ describe('Checksum', () => { .get('/root', () => 'A') .use(plugin) .get('/all', () => 'A') - .listen(3000) await Promise.all( ['/root', '/1', '/2', '/3', '/all'].map((path) => diff --git a/test/headers.test.ts b/test/response/headers.test.ts similarity index 95% rename from test/headers.test.ts rename to test/response/headers.test.ts index 71749d89..44a5662c 100644 --- a/test/headers.test.ts +++ b/test/response/headers.test.ts @@ -1,7 +1,7 @@ -import { Elysia } from '../src' +import { Elysia } from '../../src' import { describe, expect, it } from 'bun:test' -import { req } from './utils' +import { req } from '../utils' describe('Response Headers', () => { it('add response headers', async () => { diff --git a/test/schema.test.ts b/test/schema.test.ts deleted file mode 100644 index 42bbe03b..00000000 --- a/test/schema.test.ts +++ /dev/null @@ -1,414 +0,0 @@ -import { Elysia, t } from '../src' - -import { describe, expect, it } from 'bun:test' -import { post, req, upload } from './utils' - -describe('Schema', () => { - it('validate query', async () => { - const app = new Elysia().get('/', ({ query: { name } }) => name, { - query: t.Object({ - name: t.String() - }) - }) - const res = await app.handle(req('/?name=sucrose')) - - expect(await res.text()).toBe('sucrose') - expect(res.status).toBe(200) - }) - - it('validate params', async () => { - const app = new Elysia().get( - '/hi/:id/:name', - ({ params: { name } }) => name, - { - params: t.Object({ - id: t.String(), - name: t.String() - }) - } - ) - const res = await app.handle(req('/hi/1/sucrose')) - - expect(await res.text()).toBe('sucrose') - expect(res.status).toBe(200) - }) - - it('validate headers', async () => { - const app = new Elysia().post('/', () => 'welcome back', { - headers: t.Object({ - authorization: t.String() - }) - }) - const res = await app.handle( - new Request('http://localhost/', { - method: 'post', - headers: { - authorization: 'Bearer 123', - // optional header should be allowed - 'x-forwarded-ip': '127.0.0.1' - } - }) - ) - - expect(await res.text()).toBe('welcome back') - expect(res.status).toBe(200) - }) - - it('validate body', async () => { - const app = new Elysia().post('/', ({ body }) => body, { - body: t.Object({ - username: t.String(), - password: t.String() - }) - }) - - const body = JSON.stringify({ - username: 'ceobe', - password: '12345678' - }) - - const res = await app.handle( - new Request('http://localhost/', { - method: 'post', - body, - headers: { - 'content-type': 'application/json' - } - }) - ) - - expect(await res.text()).toBe(body) - expect(res.status).toBe(200) - }) - - it('validate response', async () => { - const app = new Elysia() - .get('/', () => 'Mutsuki need correction 💢💢💢', { - response: t.String() - }) - .get('/invalid', () => 1 as any, { - response: t.String() - }) - const res = await app.handle(req('/')) - const invalid = await app.handle(req('/invalid')) - - expect(await res.text()).toBe('Mutsuki need correction 💢💢💢') - expect(res.status).toBe(200) - - expect(invalid.status).toBe(400) - }) - - it('validate beforeHandle', async () => { - const app = new Elysia() - .get('/', () => 'Mutsuki need correction 💢💢💢', { - beforeHandle() { - return 'Mutsuki need correction 💢💢💢' - }, - response: t.String() - }) - .get('/invalid', () => 1 as any, { - beforeHandle() { - return 1 as any - }, - response: t.String() - }) - const res = await app.handle(req('/')) - const invalid = await app.handle(req('/invalid')) - - expect(await res.text()).toBe('Mutsuki need correction 💢💢💢') - expect(res.status).toBe(200) - - expect(invalid.status).toBe(400) - }) - - it('validate afterHandle', async () => { - const app = new Elysia() - .get('/', () => 'Mutsuki need correction 💢💢💢', { - afterHandle: () => 'Mutsuki need correction 💢💢💢', - response: t.String() - }) - .get('/invalid', () => 1 as any, { - afterHandle: () => 1 as any, - response: t.String() - }) - const res = await app.handle(req('/')) - const invalid = await app.handle(req('/invalid')) - - expect(await res.text()).toBe('Mutsuki need correction 💢💢💢') - expect(res.status).toBe(200) - - expect(invalid.status).toBe(400) - }) - - it('validate beforeHandle with afterHandle', async () => { - const app = new Elysia() - .get('/', () => 'Mutsuki need correction 💢💢💢', { - beforeHandle() { - // Not Empty - }, - afterHandle() { - return 'Mutsuki need correction 💢💢💢' - }, - response: t.String() - }) - .get('/invalid', () => 1 as any, { - afterHandle() { - return 1 as any - }, - response: t.String() - }) - const res = await app.handle(req('/')) - const invalid = await app.handle(req('/invalid')) - - expect(await res.text()).toBe('Mutsuki need correction 💢💢💢') - expect(res.status).toBe(200) - - expect(invalid.status).toBe(400) - }) - - it('validate response per status', async () => { - const app = new Elysia().post( - '/', - ({ set, body: { status, response } }) => { - set.status = status - - return response - }, - { - body: t.Object({ - status: t.Number(), - response: t.Any() - }), - response: { - 200: t.String(), - 201: t.Number() - } - } - ) - - const r200valid = await app.handle( - post('/', { - status: 200, - response: 'String' - }) - ) - const r200invalid = await app.handle( - post('/', { - status: 200, - response: 1 - }) - ) - - const r201valid = await app.handle( - post('/', { - status: 201, - response: 1 - }) - ) - const r201invalid = await app.handle( - post('/', { - status: 201, - response: 'String' - }) - ) - - expect(r200valid.status).toBe(200) - expect(r200invalid.status).toBe(400) - expect(r201valid.status).toBe(201) - expect(r201invalid.status).toBe(400) - }) - - it('handle guard hook', async () => { - const app = new Elysia().guard( - { - query: t.Object({ - name: t.String() - }) - }, - (app) => - app - // Store is inherited - .post('/user', ({ query: { name } }) => name, { - body: t.Object({ - id: t.Number(), - username: t.String(), - profile: t.Object({ - name: t.String() - }) - }) - }) - ) - - const body = JSON.stringify({ - id: 6, - username: '', - profile: { - name: 'A' - } - }) - - const valid = await app.handle( - new Request('http://localhost/user?name=salt', { - method: 'POST', - body, - headers: { - 'content-type': 'application/json', - 'content-length': body.length.toString() - } - }) - ) - - expect(await valid.text()).toBe('salt') - expect(valid.status).toBe(200) - - const invalidQuery = await app.handle( - new Request('http://localhost/user', { - method: 'POST', - body: JSON.stringify({ - id: 6, - username: '', - profile: { - name: 'A' - } - }) - }) - ) - - expect(invalidQuery.status).toBe(400) - - // const invalidBody = await app.handle( - // new Request('http://localhost/user?name=salt', { - // method: 'POST', - // body: JSON.stringify({ - // id: 6, - // username: '', - // profile: {} - // }) - // }) - // ) - - // expect(invalidBody.status).toBe(400) - }) - - // https://github.com/elysiajs/elysia/issues/28 - // Error is possibly from reference object from `registerSchemaPath` - // Most likely missing an deep clone object - it('validate group response', async () => { - const app = new Elysia().group('/deep', (app) => - app - .get('/correct', () => 'a', { - response: { - 200: t.String(), - 400: t.String() - } - }) - .get('/wrong', () => 1 as any, { - response: { - 200: t.String(), - 400: t.String() - } - }) - ) - - const correct = await app - .handle(req('/deep/correct')) - .then((x) => x.status) - const wrong = await app.handle(req('/deep/wrong')).then((x) => x.status) - - expect(correct).toBe(200) - expect(wrong).toBe(400) - }) - - it('validate union', async () => { - const app = new Elysia().post('/', ({ body }) => body, { - body: t.Union([ - t.Object({ - password: t.String() - }), - t.Object({ - token: t.String() - }) - ]) - }) - - const r1 = await app - .handle( - post('/', { - password: 'a' - }) - ) - .then((x) => x.status) - const r2 = await app - .handle( - post('/', { - token: 'a' - }) - ) - .then((x) => x.status) - const r3 = await app - .handle( - post('/', { - notUnioned: true - }) - ) - .then((x) => x.status) - - expect(r1).toBe(200) - expect(r2).toBe(200) - expect(r3).toBe(400) - }) - - it('parse numeric params', async () => { - const app = new Elysia().get( - '/test/:id/:id2/:id3', - ({ params }) => params, - { - params: t.Object({ - id: t.Numeric(), - id2: t.Optional(t.Numeric()), - id3: t.String() - }) - } - ) - - const res = await app.handle(req('/test/1/2/3')).then((x) => x.json()) - - expect(res).toEqual({ - id: 1, - id2: 2, - id3: '3' - }) - }) - - it('convert File to Files automatically', async () => { - const app = new Elysia().post( - '/', - ({ body: { files } }) => Array.isArray(files), - { - body: t.Object({ - files: t.Files() - }) - } - ) - - expect( - await app - .handle( - upload('/', { - files: 'aris-yuzu.jpg' - }).request - ) - .then((x) => x.text()) - ).toEqual('true') - - expect( - await app - .handle( - upload('/', { - files: ['aris-yuzu.jpg', 'midori.png'] - }).request - ) - .then((x) => x.text()) - ).toEqual('true') - }) -}) diff --git a/test/store.test.ts b/test/store.test.ts deleted file mode 100644 index 9b8da48b..00000000 --- a/test/store.test.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { describe, it, expect } from 'bun:test' -import { Elysia } from '../src' -import { req } from './utils' - -describe('store', () => { - it('work', async () => { - const app = new Elysia() - .state('hi', () => 'hi') - .get('/', ({ store: { hi } }) => hi()) - - const res = await app.handle(req('/')).then((r) => r.text()) - expect(res).toBe('hi') - }) - - it('inherits plugin', async () => { - const plugin = () => (app: Elysia) => app.state('hi', () => 'hi') - - const app = new Elysia() - .use(plugin()) - .get('/', ({ store: { hi } }) => hi()) - - const res = await app.handle(req('/')).then((r) => r.text()) - expect(res).toBe('hi') - }) - - it('accepts any type', async () => { - const app = new Elysia() - .state('hi', { - there: { - hello: 'world' - } - }) - .get('/', ({ store: { hi } }) => hi.there.hello) - - const res = await app.handle(req('/')).then((r) => r.text()) - expect(res).toBe('world') - }) - - it('accepts multiple', async () => { - const app = new Elysia() - .state({ - hello: 'world', - my: 'name' - }) - .get('/', ({ store: { hello } }) => hello) - - const res = await app.handle(req('/')).then((r) => r.text()) - expect(res).toBe('world') - }) -}) diff --git a/test/types/index.ts b/test/types/index.ts index bb066303..20789b9d 100644 --- a/test/types/index.ts +++ b/test/types/index.ts @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ import { expect } from 'bun:test' -import { t, Elysia, TypedSchema } from '../../src' +import { t, Elysia, RouteSchema, Cookie } from '../../src' import { expectTypeOf } from 'expect-type' const app = new Elysia() @@ -8,7 +8,7 @@ const app = new Elysia() // ? default value of context app.get('/', ({ headers, query, params, body, store }) => { // ? default keyof params should be never - expectTypeOf().toBeNever() + expectTypeOf().toBeNever() // ? default headers should be Record expectTypeOf().toEqualTypeOf< @@ -16,7 +16,7 @@ app.get('/', ({ headers, query, params, body, store }) => { >() // ? default query should be Record - expectTypeOf().toEqualTypeOf>() + expectTypeOf().toEqualTypeOf>() // ? default body should be unknown expectTypeOf().toBeUnknown() @@ -32,7 +32,7 @@ app.model({ }) }).get( '/', - ({ headers, query, params, body }) => { + ({ headers, query, params, body, cookie }) => { // ? unwrap body type expectTypeOf<{ username: string @@ -57,6 +57,14 @@ app.model({ password: string }>().toEqualTypeOf() + // ? unwrap cookie + expectTypeOf< + Record> & { + username: Cookie + password: Cookie + } + >().toEqualTypeOf() + return body }, { @@ -64,7 +72,8 @@ app.model({ params: 't', query: 't', headers: 't', - response: 't' + response: 't', + cookie: 't' } ) @@ -97,7 +106,7 @@ app.get('/', () => '1', { }) // ? support pre-defined schema -app.schema({ +app.guard({ body: t.String() }).get('/', ({ body }) => { expectTypeOf().not.toBeUnknown() @@ -105,7 +114,7 @@ app.schema({ }) // ? override schema -app.schema({ +app.guard({ body: t.String() }).get( '/', @@ -132,7 +141,7 @@ app.model({ expectTypeOf().not.toBeUnknown() expectTypeOf().toBeString() }) - // ? override guard type + // // ? override guard type .get( '/', ({ body }) => { @@ -268,8 +277,8 @@ app.decorate('a', 'b') }>().toEqualTypeOf<{ a: 'b' b: 'c' - c: string - d: string + c: 'd' + d: 'e' }>() }) @@ -282,7 +291,8 @@ const b = app expectTypeOf().toEqualTypeOf<'a'>() }, { - body: 'a' + body: 'a', + transform() {} } ) // ? Infer multiple model @@ -431,28 +441,30 @@ app.use(plugin).group( // ? It inherits group type to Eden { - const server = app.group( - '/v1', - { - query: t.Object({ - name: t.String() - }) - }, - (app) => - app.guard( - { - headers: t.Object({ - authorization: t.String() - }) - }, - (app) => - app.get('/a', () => 1, { - body: t.String() - }) - ) - ) + const server = app + .group( + '/v1', + { + query: t.Object({ + name: t.String() + }) + }, + (app) => + app.guard( + { + headers: t.Object({ + authorization: t.String() + }) + }, + (app) => + app.get('/a', () => 1, { + body: t.String() + }) + ) + ) + .get('/', ({ params }) => params) - type App = (typeof server)['meta']['schema'] + type App = (typeof server)['schema'] type Route = App['/v1/a']['get'] expectTypeOf().toEqualTypeOf<{ @@ -463,9 +475,9 @@ app.use(plugin).group( query: { name: string } - params: undefined + params: unknown response: { - '200': number + 200: number } }>() } @@ -495,10 +507,8 @@ app.use(plugin).group( }) ) ) - - type App = (typeof server)['meta']['schema'] + type App = (typeof server)['schema'] type Route = App['/v1/a']['subscribe'] - expectTypeOf().toEqualTypeOf<{ headers: { authorization: string @@ -507,7 +517,7 @@ app.use(plugin).group( query: { name: string } - params: Record + params: unknown response: unknown }>() } @@ -516,16 +526,16 @@ app.use(plugin).group( { const server = app.get('/', () => 'Hello').get('/a', () => 'hi') - type App = (typeof server)['meta']['schema'] + type App = (typeof server)['schema'] type Route = App['/']['get'] expectTypeOf().toEqualTypeOf<{ body: unknown - headers: undefined - query: undefined - params: undefined + headers: unknown + query: unknown + params: unknown response: { - '200': string + 200: string } }>() } @@ -611,7 +621,7 @@ app.group( query: t.Object({ user: t.String() }), - beforeHandle({ body }) { + beforeHandle: ({ body }) => { expectTypeOf().toEqualTypeOf<{ username: string }>() @@ -648,72 +658,72 @@ app.group( ) // ? Reconcilation on state -{ - const a = app.state('a', 'a' as const) - const b = a.state('a', 'b' as const) - - expectTypeOf<(typeof a)['store']>().toEqualTypeOf<{ - a: 'a' - }>() - - expectTypeOf<(typeof b)['store']>().toEqualTypeOf<{ - a: 'b' - }>() -} - -// ? Reconcilation on decorator -{ - const a = app.decorate('a', 'a' as const) - const b = a.decorate('a', 'b' as const) - - expectTypeOf<(typeof a)['decorators']>().toEqualTypeOf<{ - a: 'a' - }>() - - expectTypeOf<(typeof b)['decorators']>().toEqualTypeOf<{ - a: 'b' - }>() -} - -// ? Reconcilation on model -{ - const a = app.model('a', t.String()) - const b = a.model('a', t.Number()) - - expectTypeOf<(typeof a)['meta']['defs']>().toEqualTypeOf<{ - a: string - }>() - - expectTypeOf<(typeof b)['meta']['defs']>().toEqualTypeOf<{ - a: number - }>() -} - -// ? Reconcilation on use -{ - const a = app - .state('a', 'a' as const) - .model('a', t.String()) - .decorate('a', 'b' as const) - .use((app) => - app - .state('a', 'b' as const) - .model('a', t.Number()) - .decorate('a', 'b' as const) - ) - - expectTypeOf<(typeof a)['store']>().toEqualTypeOf<{ - a: 'b' - }>() - - expectTypeOf<(typeof a)['decorators']>().toEqualTypeOf<{ - a: 'b' - }>() - - expectTypeOf<(typeof a)['meta']['defs']>().toEqualTypeOf<{ - a: number - }>() -} +// { +// const a = app.state('a', 'a' as const) +// const b = a.state('a', 'b' as const) + +// expectTypeOf<(typeof a)['store']>().toEqualTypeOf<{ +// a: 'a' +// }>() + +// expectTypeOf<(typeof b)['store']>().toEqualTypeOf<{ +// a: 'b' +// }>() +// } + +// // ? Reconcilation on decorator +// { +// const a = app.decorate('a', 'a' as const) +// const b = a.decorate('a', 'b' as const) + +// expectTypeOf<(typeof a)['decorators']>().toEqualTypeOf<{ +// a: 'a' +// }>() + +// expectTypeOf<(typeof b)['decorators']>().toEqualTypeOf<{ +// a: 'b' +// }>() +// } + +// // ? Reconcilation on model +// { +// const a = app.model('a', t.String()) +// const b = a.model('a', t.Number()) + +// expectTypeOf<(typeof a)['definitions']['type']>().toEqualTypeOf<{ +// a: string +// }>() + +// expectTypeOf<(typeof b)['definitions']['type']>().toEqualTypeOf<{ +// a: number +// }>() +// } + +// // ? Reconcilation on use +// { +// const a = app +// .state('a', 'a' as const) +// .model('a', t.String()) +// .decorate('a', 'b' as const) +// .use((app) => +// app +// .state('a', 'b' as const) +// .model('a', t.Number()) +// .decorate('a', 'b' as const) +// ) + +// expectTypeOf<(typeof a)['store']>().toEqualTypeOf<{ +// a: 'b' +// }>() + +// expectTypeOf<(typeof a)['decorators']>().toEqualTypeOf<{ +// a: 'b' +// }>() + +// expectTypeOf<(typeof a)['definitions']['type']>().toEqualTypeOf<{ +// a: number +// }>() +// } // ? Inherits plugin instance path { @@ -721,16 +731,16 @@ app.group( const server = app.use(plugin) - type App = (typeof server)['meta']['schema'] + type App = (typeof server)['schema'] type Route = App['/']['get'] expectTypeOf().toEqualTypeOf<{ body: unknown - headers: undefined - query: undefined - params: undefined + headers: unknown + query: unknown + params: unknown response: { - '200': string + 200: string } }>() } @@ -748,7 +758,7 @@ app.group( .get('/a', () => 'A') .listen(3000) - type Routes = keyof (typeof app)['meta']['schema'] + type Routes = keyof (typeof app)['schema'] expectTypeOf().toEqualTypeOf<'/api/a' | '/api/plugin/test-path'>() } @@ -763,16 +773,16 @@ app.group( const server = app.use(plugin) - type App = (typeof server)['meta']['schema'] + type App = (typeof server)['schema'] type Route = App['/v1/']['get'] expectTypeOf().toEqualTypeOf<{ body: unknown - headers: undefined - query: undefined - params: undefined + headers: unknown + query: unknown + params: unknown response: { - '200': string + 200: string } }>() } @@ -788,8 +798,68 @@ app.group( const app = new Elysia().use(test) - type App = (typeof app)['meta']['schema'] + type App = (typeof app)['schema'] type Routes = keyof App expectTypeOf().toEqualTypeOf<'/app/test'>() } + +// ? Merging identical plugin type +{ + const cookie = new Elysia({ + name: 'cookie' + }).derive(() => { + return { + cookie: 'A' + } + }) + + const controller = new Elysia().use(cookie).get('/', () => 'A') + + const app = new Elysia() + .use(cookie) + .use(controller) + .get('/', ({ cookie }) => { + expectTypeOf().toBeString() + }) +} + +// ? Prefer local schema over parent schema for nesting +{ + new Elysia().group( + '/id/:id', + { + params: t.Object({ + id: t.Numeric() + }), + beforeHandle({ params }) { + expectTypeOf().toEqualTypeOf<{ + id: number + }>() + } + }, + (app) => + app + .get('/awd', ({ params }) => { + expectTypeOf().toEqualTypeOf<{ + id: number + }>() + }) + .group( + '/name/:name', + { + params: t.Object({ + id: t.Numeric(), + name: t.String() + }) + }, + (app) => + app.get('/awd', ({ params }) => { + expectTypeOf().toEqualTypeOf<{ + id: number + name: string + }>() + }) + ) + ) +} diff --git a/test/units/map-early-response.test.ts b/test/units/map-early-response.test.ts new file mode 100644 index 00000000..91f2a3ae --- /dev/null +++ b/test/units/map-early-response.test.ts @@ -0,0 +1,280 @@ +import { describe, it, expect } from 'bun:test' +import { mapEarlyResponse } from '../../src/handler' + +const defaultContext = { + headers: {}, + status: 200, + cookie: {} +} + +const context = { + headers: { + 'x-powered-by': 'Elysia', + 'coffee-scheme': 'Coffee' + }, + status: 418, + cookie: {} +} + +class Student { + constructor(public name: string) {} +} + +describe('Map Early Response', () => { + it('map string', async () => { + const response = mapEarlyResponse('Shiroko', defaultContext) + + expect(response).toBeInstanceOf(Response) + expect(await response?.text()).toBe('Shiroko') + expect(response?.status).toBe(200) + }) + + it('map number', async () => { + const response = mapEarlyResponse(1, defaultContext) + + expect(response).toBeInstanceOf(Response) + expect(await response?.text()).toBe('1') + expect(response?.status).toBe(200) + }) + + it('map boolean', async () => { + const response = mapEarlyResponse(true, defaultContext) + + expect(response).toBeInstanceOf(Response) + expect(await response?.text()).toBe('true') + expect(response?.status).toBe(200) + }) + + it('map object', async () => { + const body = { + name: 'Shiroko' + } + + const response = mapEarlyResponse(body, defaultContext) + + expect(response).toBeInstanceOf(Response) + expect(await response?.json()).toEqual(body) + expect(response?.status).toBe(200) + }) + + it('map function', async () => { + const response = mapEarlyResponse(() => 1, defaultContext) + + expect(response).toBeInstanceOf(Response) + expect(await response?.text()).toBe('1') + expect(response?.status).toBe(200) + }) + + it('map Blob', async () => { + const file = Bun.file('./test/images/aris-yuzu.jpg') + + const response = mapEarlyResponse(file, defaultContext) + + expect(response).toBeInstanceOf(Response) + expect(await response?.arrayBuffer()).toEqual(await file.arrayBuffer()) + expect(response?.status).toBe(200) + }) + + it('map Promise', async () => { + const body = { + name: 'Shiroko' + } + + const response = await mapEarlyResponse( + new Promise((resolve) => resolve(body)), + defaultContext + ) + + expect(response).toBeInstanceOf(Response) + expect(await response?.json()).toEqual(body) + expect(response?.status).toBe(200) + }) + + it('map Response', async () => { + const response = mapEarlyResponse( + new Response('Shiroko'), + defaultContext + ) + + expect(response).toBeInstanceOf(Response) + expect(await response?.text()).toEqual('Shiroko') + expect(response?.status).toBe(200) + }) + + it('map custom class', async () => { + const response = mapEarlyResponse(new Student('Himari'), defaultContext) + + expect(response).toBeInstanceOf(Response) + expect(await response?.json()).toEqual({ + name: 'Himari' + }) + expect(response?.status).toBe(200) + }) + + it('map primitive with custom context', async () => { + const response = mapEarlyResponse('Shiroko', context) + + expect(response).toBeInstanceOf(Response) + expect(await response?.text()).toBe('Shiroko') + expect(response?.headers.toJSON()).toEqual(context.headers) + expect(response?.status).toBe(418) + }) + + it('map Function with custom context', async () => { + const response = await mapEarlyResponse(() => 1, context) + + expect(response).toBeInstanceOf(Response) + expect(await response?.text()).toEqual('1') + expect(response?.headers.toJSON()).toEqual({ + ...context.headers + }) + expect(response?.status).toBe(418) + }) + + it('map Promise with custom context', async () => { + const body = { + name: 'Shiroko' + } + + const response = await mapEarlyResponse( + new Promise((resolve) => resolve(body)), + context + ) + + expect(response).toBeInstanceOf(Response) + expect(await response?.json()).toEqual(body) + expect(response?.headers.toJSON()).toEqual({ + ...context.headers, + 'content-type': 'application/json;charset=utf-8' + }) + expect(response?.status).toBe(418) + }) + + it('map Error with custom context', async () => { + const response = mapEarlyResponse(new Error('Hello'), context) + + expect(response).toBeInstanceOf(Response) + expect(await response?.json()).toEqual({ + name: 'Error', + message: 'Hello' + }) + expect(response?.headers.toJSON()).toEqual(context.headers) + expect(response?.status).toBe(418) + }) + + it('map Response with custom context', async () => { + const response = mapEarlyResponse(new Response('Shiroko'), context) + const headers = response?.headers.toJSON() + + expect(response).toBeInstanceOf(Response) + expect(await response?.text()).toEqual('Shiroko') + expect(response?.headers.toJSON()).toEqual(headers) + }) + + it('map Response and merge Headers', async () => { + const response = mapEarlyResponse( + new Response('Shiroko', { + headers: { + Name: 'Himari' + } + }), + context + ) + const headers = response?.headers.toJSON() + + expect(response).toBeInstanceOf(Response) + expect(await response?.text()).toEqual('Shiroko') + expect(response?.headers.toJSON()).toEqual({ + ...headers, + name: 'Himari' + }) + }) + + it('map named status', async () => { + const response = mapEarlyResponse('Shiroko', { + status: "I'm a teapot", + headers: {}, + cookie: {} + }) + + expect(response).toBeInstanceOf(Response) + expect(await response?.text()).toBe('Shiroko') + expect(response?.status).toBe(418) + }) + + it('map redirect', async () => { + const response = mapEarlyResponse('Shiroko', { + status: "I'm a teapot", + cookie: {}, + headers: { + Name: 'Sorasaki Hina' + }, + redirect: 'https://cunny.school' + }) + expect(response).toBeInstanceOf(Response) + expect(await response?.text()).toEqual('Shiroko') + expect(response?.headers.toJSON()).toEqual({ + name: 'Sorasaki Hina', + location: 'https://cunny.school' + }) + + expect(response).toBeInstanceOf(Response) + expect(response?.status).toBe(302) + }) + + it('map undefined', async () => { + const response = mapEarlyResponse(undefined, defaultContext) + + expect(response).toBeUndefined() + }) + + it('map null', async () => { + const response = mapEarlyResponse(null, defaultContext) + + expect(response).toBeUndefined() + }) + + it('set cookie', async () => { + const response = mapEarlyResponse('Hina', { + status: 200, + headers: { + Name: 'Sorasaki Hina' + }, + cookie: { + name: { + value: 'hina' + } + } + }) + expect(response).toBeInstanceOf(Response) + expect(await response?.text()).toEqual('Hina') + expect(response?.headers.toJSON()).toEqual({ + name: 'Sorasaki Hina', + 'Set-Cookie': ['name=hina'] + }) + }) + + it('set multiple cookie', async () => { + const response = mapEarlyResponse('Hina', { + status: 200, + headers: { + Name: 'Sorasaki Hina' + }, + cookie: { + name: { + value: 'hina' + }, + affiliation: { + value: 'gehenna' + } + } + }) + expect(response).toBeInstanceOf(Response) + expect(await response?.text()).toEqual('Hina') + expect(response?.headers.toJSON()).toEqual({ + name: 'Sorasaki Hina', + 'Set-Cookie': ["name=hina", "affiliation=gehenna"] + }) + }) + +}) diff --git a/test/units/map-response.test.ts b/test/units/map-response.test.ts new file mode 100644 index 00000000..91c62fa6 --- /dev/null +++ b/test/units/map-response.test.ts @@ -0,0 +1,308 @@ +import { describe, it, expect } from 'bun:test' +import { mapResponse } from '../../src/handler' + +const defaultContext = { + cookie: {}, + headers: {}, + status: 200 +} + +const context = { + cookie: {}, + headers: { + 'x-powered-by': 'Elysia', + 'coffee-scheme': 'Coffee' + }, + status: 418 +} + +class Student { + constructor(public name: string) {} +} + +describe('Map Response', () => { + it('map string', async () => { + const response = mapResponse('Shiroko', defaultContext) + + expect(response).toBeInstanceOf(Response) + expect(await response.text()).toBe('Shiroko') + expect(response.status).toBe(200) + }) + + it('map number', async () => { + const response = mapResponse(1, defaultContext) + + expect(response).toBeInstanceOf(Response) + expect(await response.text()).toBe('1') + expect(response.status).toBe(200) + }) + + it('map boolean', async () => { + const response = mapResponse(true, defaultContext) + + expect(response).toBeInstanceOf(Response) + expect(await response.text()).toBe('true') + expect(response.status).toBe(200) + }) + + it('map object', async () => { + const body = { + name: 'Shiroko' + } + + const response = mapResponse(body, defaultContext) + + expect(response).toBeInstanceOf(Response) + expect(await response.json()).toEqual(body) + expect(response.status).toBe(200) + }) + + it('map function', async () => { + const response = mapResponse(() => 1, defaultContext) + + expect(response).toBeInstanceOf(Response) + expect(await response.text()).toBe('1') + expect(response.status).toBe(200) + }) + + it('map undefined', async () => { + const response = mapResponse(undefined, defaultContext) + + expect(response).toBeInstanceOf(Response) + expect(await response.text()).toEqual('') + expect(response.status).toBe(200) + }) + + it('map null', async () => { + const response = mapResponse(null, defaultContext) + + expect(response).toBeInstanceOf(Response) + expect(await response.text()).toEqual('') + expect(response.status).toBe(200) + }) + + it('map Blob', async () => { + const file = Bun.file('./test/images/aris-yuzu.jpg') + + const response = mapResponse(file, defaultContext) + + expect(response).toBeInstanceOf(Response) + expect(await response.arrayBuffer()).toEqual(await file.arrayBuffer()) + expect(response.status).toBe(200) + }) + + it('map Promise', async () => { + const body = { + name: 'Shiroko' + } + + const response = await mapResponse( + new Promise((resolve) => resolve(body)), + defaultContext + ) + + expect(response).toBeInstanceOf(Response) + expect(await response.json()).toEqual(body) + expect(response.status).toBe(200) + }) + + it('map Error', async () => { + const response = mapResponse(new Error('Hello'), defaultContext) + + expect(response).toBeInstanceOf(Response) + expect(await response.json()).toEqual({ + name: 'Error', + message: 'Hello' + }) + expect(response.status).toBe(500) + }) + + it('map Response', async () => { + const response = mapResponse(new Response('Shiroko'), defaultContext) + + expect(response).toBeInstanceOf(Response) + expect(await response.text()).toEqual('Shiroko') + expect(response.status).toBe(200) + }) + + it('map custom class', async () => { + const response = mapResponse(new Student('Himari'), defaultContext) + + expect(response).toBeInstanceOf(Response) + expect(await response.json()).toEqual({ + name: 'Himari' + }) + expect(response.status).toBe(200) + }) + + it('map primitive with custom context', async () => { + const response = mapResponse('Shiroko', context) + + expect(response).toBeInstanceOf(Response) + expect(await response.text()).toBe('Shiroko') + expect(response.headers.toJSON()).toEqual(context.headers) + expect(response.status).toBe(418) + }) + + it('map undefined with context', async () => { + const response = mapResponse(undefined, context) + + expect(response).toBeInstanceOf(Response) + expect(await response.text()).toEqual('') + expect(response.headers.toJSON()).toEqual(context.headers) + expect(response.status).toBe(418) + }) + + it('map null with custom context', async () => { + const response = mapResponse(null, context) + + expect(response).toBeInstanceOf(Response) + expect(await response.text()).toEqual('') + expect(response.headers.toJSON()).toEqual(context.headers) + expect(response.status).toBe(418) + }) + + it('map Function with custom context', async () => { + const response = await mapResponse(() => 1, context) + + expect(response).toBeInstanceOf(Response) + expect(await response.text()).toEqual('1') + expect(response.headers.toJSON()).toEqual({ + ...context.headers + }) + expect(response.status).toBe(418) + }) + + it('map Promise with custom context', async () => { + const body = { + name: 'Shiroko' + } + + const response = await mapResponse( + new Promise((resolve) => resolve(body)), + context + ) + + expect(response).toBeInstanceOf(Response) + expect(await response.json()).toEqual(body) + expect(response.headers.toJSON()).toEqual({ + ...context.headers, + 'content-type': 'application/json;charset=utf-8' + }) + expect(response.status).toBe(418) + }) + + it('map Error with custom context', async () => { + const response = mapResponse(new Error('Hello'), context) + + expect(response).toBeInstanceOf(Response) + expect(await response.json()).toEqual({ + name: 'Error', + message: 'Hello' + }) + expect(response.headers.toJSON()).toEqual(context.headers) + expect(response.status).toBe(418) + }) + + it('map Response with custom context', async () => { + const response = mapResponse(new Response('Shiroko'), context) + const headers = response.headers.toJSON() + + expect(response).toBeInstanceOf(Response) + expect(await response.text()).toEqual('Shiroko') + expect(response.headers.toJSON()).toEqual(headers) + }) + + it('map Response and merge Headers', async () => { + const response = mapResponse( + new Response('Shiroko', { + headers: { + Name: 'Himari' + } + }), + context + ) + const headers = response.headers.toJSON() + + expect(response).toBeInstanceOf(Response) + expect(await response.text()).toEqual('Shiroko') + expect(response.headers.toJSON()).toEqual({ + ...headers, + name: 'Himari' + }) + }) + + it('map named status', async () => { + const response = mapResponse('Shiroko', { + status: "I'm a teapot", + headers: {}, + cookie: {} + }) + + expect(response).toBeInstanceOf(Response) + expect(await response.text()).toBe('Shiroko') + expect(response.status).toBe(418) + }) + + it('map redirect', async () => { + const response = mapResponse('Shiroko', { + status: "I'm a teapot", + headers: { + Name: 'Sorasaki Hina' + }, + redirect: 'https://cunny.school', + cookie: {} + }) + + expect(response).toBeInstanceOf(Response) + expect(response.status).toBe(302) + expect(await response.text()).toEqual('Shiroko') + expect(response.headers.toJSON()).toEqual({ + name: 'Sorasaki Hina', + location: 'https://cunny.school' + }) + }) + + it('set cookie', async () => { + const response = mapResponse('Hina', { + status: 200, + headers: { + Name: 'Sorasaki Hina' + }, + cookie: { + name: { + value: 'hina' + } + } + }) + expect(response).toBeInstanceOf(Response) + expect(await response.text()).toEqual('Hina') + expect(response.headers.toJSON()).toEqual({ + name: 'Sorasaki Hina', + 'Set-Cookie': ['name=hina'] + }) + }) + + it('set multiple cookie', async () => { + const response = mapResponse('Hina', { + status: 200, + headers: { + Name: 'Sorasaki Hina' + }, + cookie: { + name: { + value: 'hina' + }, + affiliation: { + value: 'gehenna' + } + } + }) + expect(response).toBeInstanceOf(Response) + expect(await response.text()).toEqual('Hina') + expect(response.headers.toJSON()).toEqual({ + name: 'Sorasaki Hina', + 'Set-Cookie': ['name=hina', 'affiliation=gehenna'] + }) + }) +}) diff --git a/test/units/merge-deep.test.ts b/test/units/merge-deep.test.ts new file mode 100644 index 00000000..ddaf4628 --- /dev/null +++ b/test/units/merge-deep.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'bun:test' + +import { mergeDeep } from '../../src' + +describe('mergeDeep', () => { + it('merge empty object', () => { + const result = mergeDeep({}, {}) + expect(result).toEqual({}) + }) + + it('merge non-overlapping key', () => { + const result = mergeDeep({ key1: 'value1' }, { key2: 'value2' }) + + expect(result).toEqual({ key1: 'value1', key2: 'value2' }) + }) + + it('merge overlapping key', () => { + const result = mergeDeep( + { + name: 'Eula', + city: 'Mondstadt' + }, + { + name: 'Amber', + affiliation: 'Knight' + } + ) + + expect(result).toEqual({ + name: 'Amber', + city: 'Mondstadt', + affiliation: 'Knight' + }) + }) + + it('Maintain overlapping class', () => { + class Test { + readonly name = 'test' + + public foo() { + return this.name + } + } + + const target = { key1: Test } + const source = { key1: Test } + + const result = mergeDeep(target, source) + expect(result.key1).toBe(Test) + }) +}) diff --git a/test/utils.ts b/test/utils.ts index 1a6eb9c1..ef0df056 100644 --- a/test/utils.ts +++ b/test/utils.ts @@ -1,4 +1,5 @@ -export const req = (path: string) => new Request(`http://localhost${path}`) +export const req = (path: string, options?: RequestInit) => + new Request(`http://localhost${path}`, options) type MaybeArray = T | T[] @@ -45,3 +46,6 @@ export const post = (path: string, body: Record) => }, body: JSON.stringify(body) }) + +export const delay = (delay: number) => + new Promise((resolve) => setTimeout(resolve, delay)) diff --git a/test/validator/body.test.ts b/test/validator/body.test.ts new file mode 100644 index 00000000..8752138d --- /dev/null +++ b/test/validator/body.test.ts @@ -0,0 +1,221 @@ +import { Elysia, t } from '../../src' + +import { describe, expect, it } from 'bun:test' +import { post } from '../utils' + +describe('Body Validator', () => { + it('validate single', async () => { + const app = new Elysia().post('/', ({ body: { name } }) => name, { + body: t.Object({ + name: t.String() + }) + }) + const res = await app.handle( + post('/', { + name: 'sucrose' + }) + ) + + expect(await res.text()).toBe('sucrose') + expect(res.status).toBe(200) + }) + + it('validate multiple', async () => { + const app = new Elysia().post('/', ({ body }) => body, { + body: t.Object({ + name: t.String(), + job: t.String(), + trait: t.String() + }) + }) + const res = await app.handle( + post('/', { + name: 'sucrose', + job: 'alchemist', + trait: 'dog' + }) + ) + + expect(await res.json()).toEqual({ + name: 'sucrose', + job: 'alchemist', + trait: 'dog' + }) + expect(res.status).toBe(200) + }) + + it('parse without reference', async () => { + const app = new Elysia().post('/', () => '', { + body: t.Object({ + name: t.String(), + job: t.String(), + trait: t.String() + }) + }) + const res = await app.handle( + post('/', { + name: 'sucrose', + job: 'alchemist', + trait: 'dog' + }) + ) + + expect(res.status).toBe(200) + }) + + it('validate optional', async () => { + const app = new Elysia().post('/', ({ body }) => body, { + body: t.Object({ + name: t.String(), + job: t.String(), + trait: t.Optional(t.String()) + }) + }) + const res = await app.handle( + post('/', { + name: 'sucrose', + job: 'alchemist' + }) + ) + + expect(await res.json()).toEqual({ + name: 'sucrose', + job: 'alchemist' + }) + expect(res.status).toBe(200) + }) + + it('parse single numeric', async () => { + const app = new Elysia().post('/', ({ body }) => body, { + body: t.Object({ + name: t.String(), + job: t.String(), + trait: t.Optional(t.String()), + age: t.Numeric() + }) + }) + const res = await app.handle( + post('/', { + name: 'sucrose', + job: 'alchemist', + age: '16' + }) + ) + + expect(await res.json()).toEqual({ + name: 'sucrose', + job: 'alchemist', + age: 16 + }) + expect(res.status).toBe(200) + }) + + it('parse multiple numeric', async () => { + const app = new Elysia().post('/', ({ body }) => body, { + body: t.Object({ + name: t.String(), + job: t.String(), + trait: t.Optional(t.String()), + age: t.Numeric(), + rank: t.Numeric() + }) + }) + const res = await app.handle( + post('/', { + name: 'sucrose', + job: 'alchemist', + age: '16', + rank: '4' + }) + ) + + expect(await res.json()).toEqual({ + name: 'sucrose', + job: 'alchemist', + age: 16, + rank: 4 + }) + expect(res.status).toBe(200) + }) + + it('validate empty body', async () => { + const app = new Elysia().post('/', ({ body }) => body, { + body: t.Union([ + t.Undefined(), + t.Object({ + name: t.String(), + job: t.String(), + trait: t.Optional(t.String()) + }) + ]) + }) + const res = await app.handle( + new Request('http://localhost/', { + method: 'POST' + }) + ) + + expect(res.status).toBe(200) + expect(await res.text()).toBe('') + }) + + it('validate empty body with partial', async () => { + const app = new Elysia().post('/', ({ body }) => body, { + body: t.Union([ + t.Undefined(), + t.Object({ + name: t.String(), + job: t.String(), + trait: t.Optional(t.String()), + age: t.Numeric(), + rank: t.Numeric() + }) + ]) + }) + const res = await app.handle( + new Request('http://localhost/', { + method: 'POST' + }) + ) + + expect(res.status).toBe(200) + expect(await res.text()).toEqual('') + }) + + it('strictly validate by default', async () => { + const app = new Elysia().post('/', ({ body }) => body, { + body: t.Object({ + name: t.String() + }) + }) + + const res = await app.handle( + post('/', { + name: 'sucrose', + job: 'alchemist' + }) + ) + + expect(res.status).toBe(400) + }) + + it('validate maybe empty body', async () => { + const app = new Elysia().post('/', ({ body }) => body, { + body: t.MaybeEmpty( + t.Object({ + name: t.String(), + job: t.String(), + trait: t.Optional(t.String()) + }) + ) + }) + const res = await app.handle( + new Request('http://localhost/', { + method: 'POST' + }) + ) + + expect(res.status).toBe(200) + expect(await res.text()).toBe('') + }) +}) diff --git a/test/validator/header.test.ts b/test/validator/header.test.ts new file mode 100644 index 00000000..e1582d6e --- /dev/null +++ b/test/validator/header.test.ts @@ -0,0 +1,207 @@ +import { Elysia, t } from '../../src' + +import { describe, expect, it } from 'bun:test' +import { req } from '../utils' + +describe('Header Validator', () => { + it('validate single', async () => { + const app = new Elysia().get('/', ({ headers: { name } }) => name, { + headers: t.Object({ + name: t.String() + }) + }) + const res = await app.handle( + req('/', { + headers: { + name: 'sucrose' + } + }) + ) + + expect(await res.text()).toBe('sucrose') + expect(res.status).toBe(200) + }) + + it('validate multiple', async () => { + const app = new Elysia().get('/', ({ headers }) => headers, { + headers: t.Object({ + name: t.String(), + job: t.String(), + trait: t.String() + }) + }) + const res = await app.handle( + req('/', { + headers: { + name: 'sucrose', + job: 'alchemist', + trait: 'dog' + } + }) + ) + + expect(await res.json()).toEqual({ + name: 'sucrose', + job: 'alchemist', + trait: 'dog' + }) + expect(res.status).toBe(200) + }) + + it('parse without reference', async () => { + const app = new Elysia().get('/', () => '', { + headers: t.Object({ + name: t.String(), + job: t.String(), + trait: t.String() + }) + }) + const res = await app.handle( + req('/', { + headers: { + name: 'sucrose', + job: 'alchemist', + trait: 'dog' + } + }) + ) + + expect(res.status).toBe(200) + }) + + it('validate optional', async () => { + const app = new Elysia().get('/', ({ headers }) => headers, { + headers: t.Object({ + name: t.String(), + job: t.String(), + trait: t.Optional(t.String()) + }) + }) + const res = await app.handle( + req('/', { + headers: { + name: 'sucrose', + job: 'alchemist' + } + }) + ) + + expect(await res.json()).toEqual({ + name: 'sucrose', + job: 'alchemist' + }) + expect(res.status).toBe(200) + }) + + it('parse single numeric', async () => { + const app = new Elysia().get('/', ({ headers }) => headers, { + headers: t.Object({ + name: t.String(), + job: t.String(), + trait: t.Optional(t.String()), + age: t.Numeric() + }) + }) + const res = await app.handle( + req('/', { + headers: { + name: 'sucrose', + job: 'alchemist', + age: '16' + } + }) + ) + + expect(await res.json()).toEqual({ + name: 'sucrose', + job: 'alchemist', + age: 16 + }) + expect(res.status).toBe(200) + }) + + it('parse multiple numeric', async () => { + const app = new Elysia().get('/', ({ headers }) => headers, { + headers: t.Object({ + name: t.String(), + job: t.String(), + trait: t.Optional(t.String()), + age: t.Numeric(), + rank: t.Numeric() + }) + }) + const res = await app.handle( + req('/', { + headers: { + name: 'sucrose', + job: 'alchemist', + age: '16', + rank: '4' + } + }) + ) + + expect(await res.json()).toEqual({ + name: 'sucrose', + job: 'alchemist', + age: 16, + rank: 4 + }) + expect(res.status).toBe(200) + }) + + it('validate partial', async () => { + const app = new Elysia().get('/', ({ headers }) => headers, { + headers: t.Partial( + t.Object({ + name: t.String(), + job: t.String(), + trait: t.Optional(t.String()) + }) + ) + }) + const res = await app.handle(req('/')) + + expect(res.status).toBe(200) + expect(await res.json()).toEqual({}) + }) + + it('validate numberic with partial', async () => { + const app = new Elysia().get('/', ({ headers }) => headers, { + headers: t.Partial( + t.Object({ + name: t.String(), + job: t.String(), + trait: t.Optional(t.String()), + age: t.Numeric(), + rank: t.Numeric() + }) + ) + }) + const res = await app.handle(req('/')) + + expect(res.status).toBe(200) + expect(await res.json()).toEqual({}) + }) + + it('loosely validate by default', async () => { + const app = new Elysia().get('/', ({ headers }) => headers, { + headers: t.Object({ + name: t.String() + }) + }) + + const headers = { + name: 'sucrose', + job: 'alchemist' + } + const res = await app.handle( + req('/', { + headers + }) + ) + + expect(await res.json()).toEqual(headers) + expect(res.status).toBe(200) + }) +}) diff --git a/test/validator/params.test.ts b/test/validator/params.test.ts new file mode 100644 index 00000000..1d268c3c --- /dev/null +++ b/test/validator/params.test.ts @@ -0,0 +1,91 @@ +import { Elysia, t } from '../../src' + +import { describe, expect, it } from 'bun:test' +import { req } from '../utils' + +describe('Params Validator', () => { + it('parse params without validator', async () => { + const app = new Elysia().get('/id/:id', ({ params: { id } }) => id) + const res = await app.handle(req('/id/617')) + + expect(await res.text()).toBe('617') + expect(res.status).toBe(200) + }) + + it('validate single', async () => { + const app = new Elysia().get('/id/:id', ({ params: { id } }) => id, { + params: t.Object({ + id: t.String() + }) + }) + const res = await app.handle(req('/id/617')) + + expect(await res.text()).toBe('617') + expect(res.status).toBe(200) + }) + + it('validate multiple', async () => { + const app = new Elysia().get( + '/id/:id/name/:name', + ({ params }) => params, + { + params: t.Object({ + id: t.String(), + name: t.String() + }) + } + ) + const res = await app.handle(req('/id/617/name/Ga1ahad')) + + expect(await res.json()).toEqual({ + id: '617', + name: 'Ga1ahad' + }) + expect(res.status).toBe(200) + }) + + it('parse without reference', async () => { + const app = new Elysia().get('/id/:id', () => '', { + params: t.Object({ + id: t.String() + }) + }) + const res = await app.handle(req('/id/617')) + + expect(res.status).toBe(200) + }) + + it('parse single numeric', async () => { + const app = new Elysia().get('/id/:id', ({ params }) => params, { + params: t.Object({ + id: t.Numeric() + }) + }) + const res = await app.handle(req('/id/617')) + + expect(await res.json()).toEqual({ + id: 617 + }) + expect(res.status).toBe(200) + }) + + it('parse multiple numeric', async () => { + const app = new Elysia().get( + '/id/:id/chapter/:chapterId', + ({ params }) => params, + { + params: t.Object({ + id: t.Numeric(), + chapterId: t.Numeric() + }) + } + ) + const res = await app.handle(req('/id/617/chapter/12')) + + expect(await res.json()).toEqual({ + id: 617, + chapterId: 12 + }) + expect(res.status).toBe(200) + }) +}) diff --git a/test/validator/query.test.ts b/test/validator/query.test.ts new file mode 100644 index 00000000..4d96e445 --- /dev/null +++ b/test/validator/query.test.ts @@ -0,0 +1,157 @@ +import { Elysia, t } from '../../src' + +import { describe, expect, it } from 'bun:test' +import { req } from '../utils' + +describe('Query Validator', () => { + it('validate single', async () => { + const app = new Elysia().get('/', ({ query: { name } }) => name, { + query: t.Object({ + name: t.String() + }) + }) + const res = await app.handle(req('/?name=sucrose')) + + expect(await res.text()).toBe('sucrose') + expect(res.status).toBe(200) + }) + + it('validate multiple', async () => { + const app = new Elysia().get('/', ({ query }) => query, { + query: t.Object({ + name: t.String(), + job: t.String(), + trait: t.String() + }) + }) + const res = await app.handle( + req('/?name=sucrose&job=alchemist&trait=dog') + ) + + expect(await res.json()).toEqual({ + name: 'sucrose', + job: 'alchemist', + trait: 'dog' + }) + expect(res.status).toBe(200) + }) + + it('parse without reference', async () => { + const app = new Elysia().get('/', () => '', { + query: t.Object({ + name: t.String(), + job: t.String(), + trait: t.String() + }) + }) + const res = await app.handle( + req('/?name=sucrose&job=alchemist&trait=dog') + ) + + expect(res.status).toBe(200) + }) + + it('validate optional', async () => { + const app = new Elysia().get('/', ({ query }) => query, { + query: t.Object({ + name: t.String(), + job: t.String(), + trait: t.Optional(t.String()) + }) + }) + const res = await app.handle(req('/?name=sucrose&job=alchemist')) + + expect(await res.json()).toEqual({ + name: 'sucrose', + job: 'alchemist' + }) + expect(res.status).toBe(200) + }) + + it('parse single numeric', async () => { + const app = new Elysia().get('/', ({ query }) => query, { + query: t.Object({ + name: t.String(), + job: t.String(), + trait: t.Optional(t.String()), + age: t.Numeric() + }) + }) + const res = await app.handle(req('/?name=sucrose&job=alchemist&age=16')) + + expect(await res.json()).toEqual({ + name: 'sucrose', + job: 'alchemist', + age: 16 + }) + expect(res.status).toBe(200) + }) + + it('parse multiple numeric', async () => { + const app = new Elysia().get('/', ({ query }) => query, { + query: t.Object({ + name: t.String(), + job: t.String(), + trait: t.Optional(t.String()), + age: t.Numeric(), + rank: t.Numeric() + }) + }) + const res = await app.handle( + req('/?name=sucrose&job=alchemist&age=16&rank=4') + ) + + expect(await res.json()).toEqual({ + name: 'sucrose', + job: 'alchemist', + age: 16, + rank: 4 + }) + expect(res.status).toBe(200) + }) + + it('validate partial', async () => { + const app = new Elysia().get('/', ({ query }) => query, { + query: t.Partial( + t.Object({ + name: t.String(), + job: t.String(), + trait: t.Optional(t.String()) + }) + ) + }) + const res = await app.handle(req('/')) + + expect(res.status).toBe(200) + expect(await res.json()).toEqual({}) + }) + + it('parse numeric with partial', async () => { + const app = new Elysia().get('/', ({ query }) => query, { + query: t.Partial( + t.Object({ + name: t.String(), + job: t.String(), + trait: t.Optional(t.String()), + age: t.Numeric(), + rank: t.Numeric() + }) + ) + }) + const res = await app.handle(req('/')) + + expect(res.status).toBe(200) + expect(await res.json()).toEqual({}) + }) + + it('strictly validate by default', async () => { + const app = new Elysia().get('/', ({ query: { name } }) => name, { + query: t.Object({ + name: t.String() + }) + }) + const res = await app.handle(req('/?name=sucrose&job=alchemist')) + + expect(res.status).toBe(400) + }) +}) diff --git a/test/validator/response.test.ts b/test/validator/response.test.ts new file mode 100644 index 00000000..8adfdae5 --- /dev/null +++ b/test/validator/response.test.ts @@ -0,0 +1,292 @@ +import { Elysia, t } from '../../src' + +import { describe, expect, it } from 'bun:test' +import { post, req, upload } from '../utils' + +describe('Response Validator', () => { + it('validate primitive', async () => { + const app = new Elysia().get('/', () => 'sucrose', { + response: t.String() + }) + const res = await app.handle(req('/')) + + expect(await res.text()).toBe('sucrose') + expect(res.status).toBe(200) + }) + + it('validate number', async () => { + const app = new Elysia().get('/', () => 1, { + response: t.Number() + }) + const res = await app.handle(req('/')) + + expect(await res.text()).toBe('1') + expect(res.status).toBe(200) + }) + + it('validate boolean', async () => { + const app = new Elysia().get('/', () => true, { + response: t.Boolean() + }) + const res = await app.handle(req('/')) + + expect(await res.text()).toBe('true') + expect(res.status).toBe(200) + }) + + it('validate literal', async () => { + const app = new Elysia().get('/', () => 'A' as const, { + response: t.Literal('A') + }) + const res = await app.handle(req('/')) + + expect(await res.text()).toBe('A') + expect(res.status).toBe(200) + }) + + it('validate single', async () => { + const app = new Elysia().get( + '/', + () => ({ + name: 'sucrose' + }), + { + response: t.Object({ + name: t.String() + }) + } + ) + const res = await app.handle(req('/')) + + expect(await res.json()).toEqual({ name: 'sucrose' }) + expect(res.status).toBe(200) + }) + + it('validate multiple', async () => { + const app = new Elysia().get( + '/', + () => ({ + name: 'sucrose', + job: 'alchemist', + trait: 'dog' + }), + { + response: t.Object({ + name: t.String(), + job: t.String(), + trait: t.String() + }) + } + ) + const res = await app.handle(req('/')) + + expect(await res.json()).toEqual({ + name: 'sucrose', + job: 'alchemist', + trait: 'dog' + }) + expect(res.status).toBe(200) + }) + + it('parse without reference', async () => { + const app = new Elysia().get( + '/', + () => ({ + name: 'sucrose', + job: 'alchemist', + trait: 'dog' + }), + { + response: t.Object({ + name: t.String(), + job: t.String(), + trait: t.String() + }) + } + ) + const res = await app.handle(req('/')) + + expect(res.status).toBe(200) + }) + + it('validate optional', async () => { + const app = new Elysia().get( + '/', + () => ({ + name: 'sucrose', + job: 'alchemist' + }), + { + response: t.Object({ + name: t.String(), + job: t.String(), + trait: t.Optional(t.String()) + }) + } + ) + const res = await app.handle(req('/')) + + expect(await res.json()).toEqual({ + name: 'sucrose', + job: 'alchemist' + }) + expect(res.status).toBe(200) + }) + + it('allow undefined', async () => { + const app = new Elysia().get('/', () => {}, { + body: t.Union([ + t.Undefined(), + t.Object({ + name: t.String(), + job: t.String(), + trait: t.Optional(t.String()) + }) + ]) + }) + const res = await app.handle(req('/')) + + expect(res.status).toBe(200) + expect(await res.text()).toBe('') + }) + + it('strictly validate by default', async () => { + const app = new Elysia().get( + '/', + () => ({ + name: 'sucrose', + job: 'alchemist' + }), + { + response: t.Object({ + name: t.String() + }) + } + ) + + const res = await app.handle(req('/')) + + expect(res.status).toBe(400) + }) + + it('strictly validate by default', async () => { + const app = new Elysia().get( + '/', + () => ({ + name: 'sucrose', + job: 'alchemist' + }), + { + response: t.Object({ + name: t.String() + }) + } + ) + + const res = await app.handle(req('/')) + + expect(res.status).toBe(400) + }) + + it('handle File', async () => { + const app = new Elysia().post('/', ({ body: { file } }) => file.size, { + body: t.Object({ + file: t.File() + }) + }) + + expect( + await app + .handle( + upload('/', { + file: 'aris-yuzu.jpg' + }).request + ) + .then((x) => x.text()) + ).toBe(Bun.file('./test/images/aris-yuzu.jpg').size + '') + }) + + it('convert File to Files automatically', async () => { + const app = new Elysia().post( + '/', + ({ body: { files } }) => Array.isArray(files), + { + body: t.Object({ + files: t.Files() + }) + } + ) + + expect( + await app + .handle( + upload('/', { + files: 'aris-yuzu.jpg' + }).request + ) + .then((x) => x.text()) + ).toEqual('true') + + expect( + await app + .handle( + upload('/', { + files: ['aris-yuzu.jpg', 'midori.png'] + }).request + ) + .then((x) => x.text()) + ).toEqual('true') + }) + + it('validate response per status', async () => { + const app = new Elysia().post( + '/', + ({ set, body: { status, response } }) => { + set.status = status + + return response + }, + { + body: t.Object({ + status: t.Number(), + response: t.Any() + }), + response: { + 200: t.String(), + 201: t.Number() + } + } + ) + + const r200valid = await app.handle( + post('/', { + status: 200, + response: 'String' + }) + ) + const r200invalid = await app.handle( + post('/', { + status: 200, + response: 1 + }) + ) + + const r201valid = await app.handle( + post('/', { + status: 201, + response: 1 + }) + ) + const r201invalid = await app.handle( + post('/', { + status: 201, + response: 'String' + }) + ) + + expect(r200valid.status).toBe(200) + expect(r200invalid.status).toBe(400) + expect(r201valid.status).toBe(201) + expect(r201invalid.status).toBe(400) + }) +}) diff --git a/test/validator/validator.test.ts b/test/validator/validator.test.ts new file mode 100644 index 00000000..d489be17 --- /dev/null +++ b/test/validator/validator.test.ts @@ -0,0 +1,210 @@ +import { Elysia, t } from '../../src' + +import { describe, expect, it } from 'bun:test' +import { post, req } from '../utils' + +describe('Validator Additional Case', () => { + it('validate beforeHandle', async () => { + const app = new Elysia() + .get('/', () => 'Mutsuki need correction 💢💢💢', { + beforeHandle: () => 'Mutsuki need correction 💢💢💢', + response: t.String() + }) + .get('/invalid', () => 1 as any, { + beforeHandle() { + return 1 as any + }, + response: t.String() + }) + const res = await app.handle(req('/')) + const invalid = await app.handle(req('/invalid')) + + expect(await res.text()).toBe('Mutsuki need correction 💢💢💢') + expect(res.status).toBe(200) + + expect(invalid.status).toBe(400) + }) + + it('validate afterHandle', async () => { + const app = new Elysia() + .get('/', () => 'Mutsuki need correction 💢💢💢', { + afterHandle: () => 'Mutsuki need correction 💢💢💢', + response: t.String() + }) + .get('/invalid', () => 1 as any, { + afterHandle: () => 1 as any, + response: t.String() + }) + const res = await app.handle(req('/')) + const invalid = await app.handle(req('/invalid')) + + expect(await res.text()).toBe('Mutsuki need correction 💢💢💢') + expect(res.status).toBe(200) + + expect(invalid.status).toBe(400) + }) + + it('validate beforeHandle with afterHandle', async () => { + const app = new Elysia() + .get('/', () => 'Mutsuki need correction 💢💢💢', { + beforeHandle() {}, + afterHandle() { + return 'Mutsuki need correction 💢💢💢' + }, + response: t.String() + }) + .get('/invalid', () => 1 as any, { + afterHandle() { + return 1 as any + }, + response: t.String() + }) + const res = await app.handle(req('/')) + const invalid = await app.handle(req('/invalid')) + + expect(await res.text()).toBe('Mutsuki need correction 💢💢💢') + expect(res.status).toBe(200) + + expect(invalid.status).toBe(400) + }) + + it('handle guard hook', async () => { + const app = new Elysia().guard( + { + query: t.Object({ + name: t.String() + }) + }, + (app) => + app + // Store is inherited + .post('/user', ({ query: { name } }) => name, { + body: t.Object({ + id: t.Number(), + username: t.String(), + profile: t.Object({ + name: t.String() + }) + }) + }) + ) + + const body = JSON.stringify({ + id: 6, + username: '', + profile: { + name: 'A' + } + }) + + const valid = await app.handle( + new Request('http://localhost/user?name=salt', { + method: 'POST', + body, + headers: { + 'content-type': 'application/json', + 'content-length': body.length.toString() + } + }) + ) + + expect(await valid.text()).toBe('salt') + expect(valid.status).toBe(200) + + const invalidQuery = await app.handle( + new Request('http://localhost/user', { + method: 'POST', + body: JSON.stringify({ + id: 6, + username: '', + profile: { + name: 'A' + } + }) + }) + ) + + expect(invalidQuery.status).toBe(400) + + const invalidBody = await app.handle( + new Request('http://localhost/user?name=salt', { + method: 'POST', + body: JSON.stringify({ + id: 6, + username: '', + profile: {} + }) + }) + ) + + expect(invalidBody.status).toBe(400) + }) + + // https://github.com/elysiajs/elysia/issues/28 + // Error is possibly from reference object from `registerSchemaPath` + // Most likely missing an deep clone object + it('validate group response', async () => { + const app = new Elysia().group('/deep', (app) => + app + .get('/correct', () => 'a', { + response: { + 200: t.String(), + 400: t.String() + } + }) + .get('/wrong', () => 1 as any, { + response: { + 200: t.String(), + 400: t.String() + } + }) + ) + + const correct = await app + .handle(req('/deep/correct')) + .then((x) => x.status) + const wrong = await app.handle(req('/deep/wrong')).then((x) => x.status) + + expect(correct).toBe(200) + expect(wrong).toBe(400) + }) + + it('validate union', async () => { + const app = new Elysia().post('/', ({ body }) => body, { + body: t.Union([ + t.Object({ + password: t.String() + }), + t.Object({ + token: t.String() + }) + ]) + }) + + const r1 = await app + .handle( + post('/', { + password: 'a' + }) + ) + .then((x) => x.status) + const r2 = await app + .handle( + post('/', { + token: 'a' + }) + ) + .then((x) => x.status) + const r3 = await app + .handle( + post('/', { + notUnioned: true + }) + ) + .then((x) => x.status) + + expect(r1).toBe(200) + expect(r2).toBe(200) + expect(r3).toBe(400) + }) +}) diff --git a/test/ws/connection.test.ts b/test/ws/connection.test.ts new file mode 100644 index 00000000..08133ed6 --- /dev/null +++ b/test/ws/connection.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect } from 'bun:test' +import { Elysia } from '../../src' +import { newWebsocket, wsOpen, wsClose, wsClosed } from './utils' + +describe('WebSocket connection', () => { + it('should connect and close', async () => { + const app = new Elysia() + .ws('/ws', { + message() {} + }) + .listen(0) + + const ws = newWebsocket(app.server!) + + await wsOpen(ws) + await wsClosed(ws) + app.stop() + }) + + it('should close by server', async () => { + const app = new Elysia() + .ws('/ws', { + message(ws) { + ws.close() + } + }) + .listen(0) + + const ws = newWebsocket(app.server!) + + await wsOpen(ws) + + ws.send('close me!') + + const { wasClean, code } = await wsClose(ws) + expect(wasClean).toBe(false) + expect(code).toBe(1001) // going away -> https://www.rfc-editor.org/rfc/rfc6455.html#section-7.4.1 + + app.stop() + }) + + it('should terminate by server', async () => { + const app = new Elysia() + .ws('/ws', { + message(ws) { + ws.terminate() + } + }) + .listen(0) + + const ws = newWebsocket(app.server!) + + await wsOpen(ws) + + ws.send('close me!') + + const { wasClean, code } = await wsClose(ws) + expect(wasClean).toBe(false) + expect(code).toBe(1006) // closed abnormally -> https://www.rfc-editor.org/rfc/rfc6455.html#section-7.4.1 + + app.stop() + }) +}) diff --git a/test/ws/message.test.ts b/test/ws/message.test.ts new file mode 100644 index 00000000..1f88933e --- /dev/null +++ b/test/ws/message.test.ts @@ -0,0 +1,189 @@ +import { describe, it, expect } from 'bun:test' +import { Elysia, t } from '../../src' +import { newWebsocket, wsOpen, wsMessage, wsClosed } from './utils' + +describe('WebSocket message', () => { + it('should send & receive', async () => { + const app = new Elysia() + .ws('/ws', { + message(ws, message) { + ws.send(message) + } + }) + .listen(0) + + const ws = newWebsocket(app.server!) + + await wsOpen(ws) + + const message = wsMessage(ws) + + ws.send('Hello!') + + const { type, data } = await message + + expect(type).toBe('message') + expect(data).toBe('Hello!') + + await wsClosed(ws) + app.stop() + }) + + it('should respond with remoteAddress', async () => { + const app = new Elysia() + .ws('/ws', { + message(ws) { + ws.send(ws.remoteAddress) + } + }) + .listen(0) + + const ws = newWebsocket(app.server!) + + await wsOpen(ws) + + const message = wsMessage(ws) + + ws.send('Hello!') + + const { type, data } = await message + + expect(type).toBe('message') + expect(data).toBe('::1') + + await wsClosed(ws) + app.stop() + }) + + it('should subscribe & publish', async () => { + const app = new Elysia() + .ws('/ws', { + open(ws) { + ws.subscribe('asdf') + }, + message(ws) { + ws.publish('asdf', ws.isSubscribed('asdf')) + } + }) + .listen(0) + + const wsBob = newWebsocket(app.server!) + const wsAlice = newWebsocket(app.server!) + + await wsOpen(wsBob) + await wsOpen(wsAlice) + + const messageBob = wsMessage(wsBob) + + wsAlice.send('Hello!') + + const { type, data } = await messageBob + + expect(type).toBe('message') + expect(data).toBe('true') + + await wsClosed(wsBob) + await wsClosed(wsAlice) + app.stop() + }) + + it('should unsubscribe', async () => { + const app = new Elysia() + .ws('/ws', { + open(ws) { + ws.subscribe('asdf') + }, + message(ws, message) { + if (message === 'unsubscribe') { + ws.unsubscribe('asdf') + } + + ws.send(ws.isSubscribed('asdf')) + } + }) + .listen(0) + + const ws = newWebsocket(app.server!) + + await wsOpen(ws) + + const subscribedMessage = wsMessage(ws) + + ws.send('Hello!') + + const subscribed = await subscribedMessage + + expect(subscribed.type).toBe('message') + expect(subscribed.data).toBe('true') + + const unsubscribedMessage = wsMessage(ws) + + ws.send('unsubscribe') + + const unsubscribed = await unsubscribedMessage + + expect(unsubscribed.type).toBe('message') + expect(unsubscribed.data).toBe('false') + + await wsClosed(ws) + app.stop() + }) + + it('should validate success', async () => { + const app = new Elysia() + .ws('/ws', { + body: t.Object({ + message: t.String() + }), + message(ws, { message }) { + ws.send(message) + } + }) + .listen(0) + + const ws = newWebsocket(app.server!) + + await wsOpen(ws) + + const message = wsMessage(ws) + + ws.send(JSON.stringify({ message: 'Hello!' })) + + const { type, data } = await message + + expect(type).toBe('message') + expect(data).toBe('Hello!') + + await wsClosed(ws) + app.stop() + }) + + it('should validate fail', async () => { + const app = new Elysia() + .ws('/ws', { + body: t.Object({ + message: t.String() + }), + message(ws, { message }) { + ws.send(message) + } + }) + .listen(0) + + const ws = newWebsocket(app.server!) + + await wsOpen(ws) + + const message = wsMessage(ws) + + ws.send('Hello!') + + const { type, data } = await message + + expect(type).toBe('message') + expect(data).toStartWith('Invalid message') + + await wsClosed(ws) + app.stop() + }) +}) diff --git a/test/ws/utils.ts b/test/ws/utils.ts new file mode 100644 index 00000000..59192a81 --- /dev/null +++ b/test/ws/utils.ts @@ -0,0 +1,25 @@ +import type { Server } from 'bun' + +export const newWebsocket = (server: Server) => + new WebSocket(`ws://${server.hostname}:${server.port}/ws`, {}) + +export const wsOpen = (ws: WebSocket) => + new Promise((resolve) => { + ws.onopen = resolve + }) + +export const wsClose = async (ws: WebSocket) => + new Promise((resolve) => { + ws.onclose = resolve + }) + +export const wsClosed = async (ws: WebSocket) => { + const closed = wsClose(ws) + ws.close() + await closed +} + +export const wsMessage = (ws: WebSocket) => + new Promise>((resolve) => { + ws.onmessage = resolve + })