Repository: azurystudio/cheetah Branch: dev Commit: 74e046fca2b5 Files: 73 Total size: 160.7 KB Directory structure: gitextract_960kgzwh/ ├── .gitattributes ├── .github/ │ ├── dependabot.yml │ ├── funding.yml │ └── workflows/ │ ├── check.yml │ ├── publish.yml │ └── update.yml ├── .vscode/ │ ├── extensions.json │ └── settings.json ├── base.ts ├── cheetah.ts ├── cli/ │ ├── cmd/ │ │ ├── bundle/ │ │ │ ├── bundle.ts │ │ │ └── mod.ts │ │ ├── new/ │ │ │ └── mod.ts │ │ ├── random/ │ │ │ ├── create_crypto_key.ts │ │ │ ├── create_jwt_secret.ts │ │ │ └── mod.ts │ │ └── serve/ │ │ └── mod.ts │ ├── mod.ts │ └── utils.ts ├── codeowners ├── collection.ts ├── context.ts ├── contributing.md ├── crypto.ts ├── deno.json ├── env.d.ts ├── ext/ │ ├── compress.ts │ ├── debug.ts │ ├── favicon.ts │ ├── files.ts │ ├── firewall.ts │ ├── helmet.ts │ └── pretty.ts ├── extensions.ts ├── handler.ts ├── jwt.ts ├── license ├── location_data.ts ├── mod.ts ├── oauth/ │ ├── client.ts │ ├── get_session_data.ts │ ├── get_session_id.ts │ ├── get_session_token.ts │ ├── handle_callback.ts │ ├── is_signed_in.ts │ ├── mod.ts │ ├── sign_in.ts │ ├── sign_out.ts │ ├── store.ts │ └── types.ts ├── otp.ts ├── readme.md ├── render.ts ├── request_context.ts ├── response_context.ts ├── send_mail.ts └── test/ ├── context.test.ts ├── cors.test.ts ├── exception.test.ts ├── ext/ │ ├── favicon.test.ts │ ├── files.test.ts │ ├── firewall.test.ts │ └── pretty.test.ts ├── extensions.test.ts ├── jwt.test.ts ├── many_handlers.test.ts ├── preflight_mode.test.ts ├── render.test.tsx ├── request.test.ts ├── response.test.ts ├── routing/ │ └── versioning.test.ts ├── routing.test.ts └── validation.test.ts ================================================ FILE CONTENTS ================================================ ================================================ FILE: .gitattributes ================================================ * text=auto eol=lf ================================================ FILE: .github/dependabot.yml ================================================ version: 2 updates: - package-ecosystem: 'github-actions' directory: '/' schedule: interval: 'daily' ================================================ FILE: .github/funding.yml ================================================ github: 'boywithkeyboard' ================================================ FILE: .github/workflows/check.yml ================================================ name: check on: - push - pull_request jobs: check: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/cache@v3 with: path: | ~/.cache/deno ~/.deno key: ${{ runner.os }}-deno-${{ hashFiles('**/*') }} restore-keys: | ${{ runner.os }}-deno- - name: Setup Deno uses: denoland/setup-deno@v1 with: deno-version: v1.x - name: Run deno fmt run: deno fmt --check - name: Run deno lint run: deno lint - name: Run deno test run: deno task test --fail-fast ================================================ FILE: .github/workflows/publish.yml ================================================ name: publish on: workflow_dispatch: inputs: kind: description: Kind of release default: minor type: choice options: - prepatch - patch - preminor - minor - premajor - major required: true jobs: publish: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Publish update uses: boywithkeyboard/publisher@v2 with: kind: ${{github.event.inputs.kind}} mention_contributors: true ================================================ FILE: .github/workflows/update.yml ================================================ name: update on: schedule: - cron: '0 0 * * *' workflow_dispatch: permissions: contents: write pull-requests: write jobs: update: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Setup Deno uses: denoland/setup-deno@v1 with: deno-version: v1.x - name: Run update run: | deno run -Ar https://deno.land/x/update/mod.ts -c CHANGELOG=$(cat updates_changelog.md) echo "CHANGELOG<> $GITHUB_ENV echo "$CHANGELOG" >> $GITHUB_ENV echo "EOF" >> $GITHUB_ENV rm updates_changelog.md - name: Create pull request uses: peter-evans/create-pull-request@v5 with: title: 'refactor: update deps' author: 'github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>' committer: 'github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>' commit-message: 'refactor: update deps' body: '${{ env.CHANGELOG }}' labels: 'deps' delete-branch: true branch: 'refactor/deps' ================================================ FILE: .vscode/extensions.json ================================================ { "recommendations": [ "denoland.vscode-deno", "gruntfuggly.todo-tree", "wayou.vscode-todo-highlight" ] } ================================================ FILE: .vscode/settings.json ================================================ { "deno.enable": true, "deno.unstable": true, "editor.defaultFormatter": "denoland.vscode-deno", "[typescript]": { "editor.defaultFormatter": "denoland.vscode-deno" }, "[typescriptreact]": { "editor.defaultFormatter": "denoland.vscode-deno" }, "editor.formatOnSave": true } ================================================ FILE: base.ts ================================================ // Copyright 2023 Samuel Kopp. All rights reserved. Apache-2.0 license. import { bodylessHandler, handler, HandlerOrSchema } from './handler.ts' export type Method = | 'delete' | 'get' | 'head' | 'patch' | 'post' | 'put' export const METHODS: Method[] = [ 'delete', 'get', 'head', 'patch', 'post', 'put', ] export function base(): { new ( addRoute: ( method: Uppercase, pathname: string, handlers: HandlerOrSchema[], ) => unknown, ): & { [ M in ( | 'delete' | 'patch' | 'post' | 'put' ) ]: ReturnType> } & { [ M in ( | 'get' | 'head' ) ]: ReturnType> } } { return class { #a constructor( addRoute: ( method: Uppercase, pathname: string, handlers: HandlerOrSchema[], ) => unknown, ) { this.#a = addRoute } delete(pathname: string, ...handlers: HandlerOrSchema[]) { return this.#a('DELETE', pathname, handlers) } get(pathname: string, ...handlers: HandlerOrSchema[]) { return this.#a('GET', pathname, handlers) } head(pathname: string, ...handlers: HandlerOrSchema[]) { return this.#a('HEAD', pathname, handlers) } patch(pathname: string, ...handlers: HandlerOrSchema[]) { return this.#a('PATCH', pathname, handlers) } post(pathname: string, ...handlers: HandlerOrSchema[]) { return this.#a('POST', pathname, handlers) } put(pathname: string, ...handlers: HandlerOrSchema[]) { return this.#a('PUT', pathname, handlers) } } as never } ================================================ FILE: cheetah.ts ================================================ // Copyright 2023 Samuel Kopp. All rights reserved. Apache-2.0 license. import { base, Method } from './base.ts' import { Collection } from './collection.ts' import { Context, Exception } from './context.ts' import { Extension, validExtension } from './extensions.ts' import { Handler, HandlerOrSchema, Payload, Version, VersionRange, } from './handler.ts' import { OAuthStore } from './oauth/mod.ts' import { OAuthSessionData } from './oauth/types.ts' import { ResponseContext } from './response_context.ts' export type AppContext = { debugging: boolean gateway?: number env: Record | undefined ip: string proxy: AppConfig['proxy'] routes: cheetah['routes'] runtime: | 'cloudflare' | 'deno' request: { pathname: string querystring?: string } oauth: AppConfig['oauth'] versioning: AppConfig['versioning'] caching: AppConfig['cache'] } export type AppConfig = { /** * A prefix for all routes, e.g. `/api`. * * @default '/' */ base?: `/${string}` /** * Enable Cross-Origin Resource Sharing (CORS) for your app by setting a origin, e.g. `*`. */ cors?: string /** * If enabled, cheetah will attempt to find the matching `.get()` handler for an incoming HEAD request. Your existing `.head()` handlers won't be impacted. * * @default false * @since v0.11 */ preflight?: boolean /** * If you're using Cloudflare as a proxy, you should confirm it with this setting in order to unleash the full potential of cheetah. * * @default undefined */ proxy?: 'cloudflare' /** * Set a custom error handler. */ error?: (error: unknown, request: Request) => Response | Promise /** * Set a custom 404 handler. */ notFound?: (request: Request) => Response | Promise oauth?: { store: OAuthStore cookie?: Parameters[2] onSignIn?: ( c: Context, data: OAuthSessionData, ) => Promise | unknown onSignOut?: ( c: Context, identifier: string, ) => Promise | unknown } /** * If you enable debug mode, all errors will get logged to the console. * * @since v1.3 */ debug?: boolean versioning?: & ( | { type: 'uri' } | { type: 'header' header: string } ) & { current: Version } cache?: { /** A unique name for your cache. */ name: string } } export class cheetah extends base() { #base #cors #error #extensions: Set<[string, Extension]> #notFound #preflight #proxy #routes: Set<[Uppercase, string, RegExp, HandlerOrSchema[]]> #runtime: 'deno' | 'cloudflare' #onPlugIn #oauth #debug #versioning #cache constructor({ base, cors, preflight = false, proxy, error, notFound, oauth, debug = false, versioning, cache, }: AppConfig = {}) { super((method, pathname, handlers) => { pathname = this.#base ? this.#base + pathname : pathname this.#routes.add([ method, pathname, RegExp(`^${ (pathname .replace(/\/+(\/|$)/g, '$1')) .replace(/(\/?\.?):(\w+)\+/g, '($1(?<$2>*))') .replace(/(\/?\.?):(\w+)/g, '($1(?<$2>[^$1/]+?))') .replace(/\./g, '\\.') .replace(/(\/?)\*/g, '($1.*)?') }/*$`), handlers, ]) return this }) this.#base = base === '/' ? undefined : base this.#cors = cors this.#error = error this.#extensions = new Set() this.#notFound = notFound this.#preflight = preflight this.#proxy = proxy this.#routes = new Set() this.#runtime = typeof globalThis?.Deno?.serve !== 'function' ? 'cloudflare' : 'deno' this.#onPlugIn = false this.#oauth = oauth this.#debug = debug this.#versioning = versioning this.#cache = cache } /* use ---------------------------------------------------------------------- */ // deno-lint-ignore no-explicit-any use(...extensions: Extension[]): this use( prefix: `/${string}`, // deno-lint-ignore no-explicit-any ...extensions: Extension[] ): this use( prefix: `/${string}`, collection: C, // deno-lint-ignore no-explicit-any ...extensions: Extension[] ): this use( // deno-lint-ignore no-explicit-any ...elements: (`/${string}` | C | Extension)[] ) { let pre for (const e of elements) { if (typeof e === 'string') { // prefix pre = e } else if (e instanceof Collection) { // collection if (!pre || pre === '/') { pre = '' } for (const r of e.routes.values()) { let pathname = r[1] if (pathname === '/') { pathname = '' } pathname = this.#base ? this.#base + pre + pathname : pre + pathname this.#routes.add([ r[0], pathname, RegExp( `^${ (pathname .replace(/\/+(\/|$)/g, '$1')) .replace(/(\/?\.?):(\w+)\+/g, '($1(?<$2>*))') .replace(/(\/?\.?):(\w+)/g, '($1(?<$2>[^$1/]+?))') .replace(/\./g, '\\.') .replace(/(\/?)\*/g, '($1.*)?') }/*$`, ), r[2], ]) } } else if (validExtension(e)) { // extension if (!pre) { pre = '*' } // @ts-ignore: this.#extensions.add([pre, e]) } } return this } get routes() { return this.#routes } /* router ------------------------------------------------------------------- */ #parseVersion(headers: Headers, pathname: string) { if (!this.#versioning) { // for typescript throw new Error('Versioning not configured!') } const regex = /^v[1-9][0-9]?$|^100$/ if (this.#versioning.type === 'uri') { const arr = pathname.replace('/', '').split('/') if (this.#base) { if (regex.test(arr[1])) { const version = arr[1] arr.splice(1, 1) return { version, pathname: '/' + arr.join('/') } } } else { if (regex.test(arr[0])) { const version = arr[0] arr.shift() return { version, pathname: '/' + arr.join('/') } } } return { version: this.#versioning.current, pathname } } const header = headers.get(this.#versioning.header) if ( this.#versioning.type === 'header' && header !== null && regex.test(header) ) { return { version: header, pathname } } return { version: this.#versioning.current, pathname } } #match(request: Request, p: string) { for (const r of this.#routes.values()) { if ( request.method === r[0] || request.method === 'OPTIONS' || this.#preflight && request.method === 'HEAD' && r[0] === 'GET' ) { if (this.#versioning) { const { pathname, version } = this.#parseVersion(request.headers, p) if ( parseInt(version.replace('v', '')) > parseInt(this.#versioning.current.replace('v', '')) ) { break } const options = typeof r[3][0] !== 'function' ? r[3][0] : null if (options?.gateway !== undefined) { const result = pathname.match(r[2]) if (!result) { continue } const gateway = isVersionWithinRange( version as Version, options.gateway as VersionRange, ) if (!gateway) { break } return { handlers: r[3], params: result.groups ?? {}, gateway, } } else { const result = pathname.match(r[2]) if (!result) { continue } return { handlers: r[3], params: result.groups ?? {}, gateway: parseInt(version.replace('v', '')), } } } else { const result = p.match(r[2]) if (!result) { continue } return { handlers: r[3], params: result.groups ?? {}, gateway: undefined, } } } } return null } /* fetch -------------------------------------------------------------------- */ fetch = async ( req: Request, data: Record | Deno.ServeHandlerInfo = {}, context?: { waitUntil: (promise: Promise) => void }, ): Promise => { try { const ip = data?.remoteAddr && this.#runtime === 'deno' ? (data as Deno.ServeHandlerInfo).remoteAddr .hostname : req.headers.get('cf-connecting-ip') as string const parts = req.url.split('?') parts[0] = parts[0].slice(8) const __app: AppContext = { env: data as Record, ip, proxy: this.#proxy, request: { pathname: parts[0].substring(parts[0].indexOf('/')), querystring: parts[1], }, routes: this.#routes, runtime: this.#runtime, oauth: this.#oauth, versioning: this.#versioning, gateway: -1, debugging: this.#debug, caching: this.#cache, } if (this.#extensions.size > 0) { let body: Response | void = undefined for (const e of this.#extensions.values()) { if (!this.#onPlugIn && e[1].onPlugIn !== undefined) { await e[1].onPlugIn({ prefix: e[0], env: __app.env, routes: this.#routes, runtime: this.#runtime, settings: e[1].__config, setRoute: (method, pathname, ...handlers) => { this.#routes.add([ method.toUpperCase() as Uppercase, pathname, RegExp( `^${ (pathname .replace(/\/+(\/|$)/g, '$1')) .replace(/(\/?\.?):(\w+)\+/g, '($1(?<$2>*))') .replace(/(\/?\.?):(\w+)/g, '($1(?<$2>[^$1/]+?))') .replace(/\./g, '\\.') .replace(/(\/?)\*/g, '($1.*)?') }/*$`, ), // @ts-ignore: handlers, ]) }, }) } if ( e[0] !== '*' && __app.request.pathname.indexOf(e[0]) !== 0 ) { continue } if (e[1].onRequest !== undefined) { const result = await e[1].onRequest({ prefix: e[0], app: __app, req, _: e[1].__config, }) if (result !== undefined) { body = result } } } if (!this.#onPlugIn) { this.#onPlugIn = true } if (body !== undefined) { return body } } const route = this.#match( req, __app.request.pathname, ) __app.gateway = route?.gateway ?? -1 if (!route) { if (!this.#notFound) { throw new Exception('Not Found', undefined, 404) } if (req.method !== 'HEAD') { return await this.#notFound(req) } const response = await this.#notFound(req) return new Response(null, { headers: response.headers, status: response.status, statusText: response.statusText, }) } const response = await this.#handle( __app, req, context?.waitUntil ?? ((promise: Promise) => { // deno-fmt-ignore-line (async () => await promise)() }), route.params, route.handlers, ) if (req.method === 'HEAD') { return new Response(null, { headers: response.headers, status: response.status, statusText: response.statusText, }) } return response } catch (err) { let res: Response if (err instanceof Exception) { res = err.response(req) } else { if (this.#debug) { console.error(err) } if (this.#error) { res = await this.#error(err, req) } else { res = new Exception('Something Went Wrong', undefined, 500).response( req, ) } } if (req.method === 'HEAD') { return new Response(null, { headers: res.headers, status: res.status, statusText: res.statusText, }) } return res } } /* handle ------------------------------------------------------------------- */ async #handle( __app: AppContext, r: Request, waitUntil: (promise: Promise) => void, p: Record, handlers: HandlerOrSchema[], ) { const o = typeof handlers[0] !== 'function' ? handlers[0] : null // preflight cors request if ( r.method === 'OPTIONS' && r.headers.has('origin') && r.headers.has('access-control-request-method') ) { return new Response(null, { status: 204, headers: { ...((this.#cors || o?.cors) && { 'access-control-allow-origin': o?.cors ?? this.#cors }), 'access-control-allow-methods': '*', 'access-control-allow-headers': r.headers.get('access-control-request-headers') ?? '*', 'access-control-allow-credentials': 'false', 'access-control-max-age': '600', }, }) } // construct context const $: { b: Exclude | null c: number h: Headers } = { b: null, c: 200, h: new Headers({ ...(this.#cors && { 'access-control-allow-origin': this.#cors, }), }), } const context = new Context( __app, $, p, r, o, waitUntil, ) // handle request const len = handlers.length let next = true for (let i = 0; i < len; ++i) { if (typeof handlers[i] !== 'function') { continue } if (!next) { break } next = false const result = await (handlers[i] as Handler)( context, () => { next = true }, ) if (result) { $.b = result } } // construct response if ($.c.toString().indexOf('3') === 0) { return new Response(null, { headers: $.h, status: $.c, }) } if (!$.b) { return new Response(null, { headers: $.h, status: $.c, }) } for (const e of this.#extensions.values()) { if ( e[0] !== '*' && __app.request.pathname.indexOf(e[0]) !== 0 ) { continue } const { onResponse } = e[1] if (onResponse !== undefined) { onResponse({ prefix: e[0], app: __app, c: context, _: e[1].__config, }) } } switch ($.b.constructor.name) { case 'String': { if (!$.h.has('content-type')) { $.h.set('content-type', 'text/plain; charset=utf-8') } break } case 'Object': { try { if ((($.b as unknown) as { code: number }).code) { $.c = (($.b as unknown) as { code: number }).code } $.b = JSON.stringify($.b) if (!$.h.has('content-type')) { $.h.set('content-type', 'application/json; charset=utf-8') } } catch (err) { console.log(err) } break } case 'Array': { $.b = JSON.stringify($.b) if (!$.h.has('content-type')) { $.h.set('content-type', 'application/json; charset=utf-8') } break } default: break } return new Response($.b as BodyInit, { headers: $.h, status: $.c, }) } /* serve -------------------------------------------------------------------- */ serve({ hostname, port, }: { hostname?: string port?: number } = {}) { return Deno.serve({ hostname, port, }, (request, data) => { return this.fetch(request, data) }).finished } } function isVersionWithinRange( version: Version, r: VersionRange, ): number | undefined { const v = parseInt(version.replace('v', '')) if (parseInt(r.replace('v', '')) === v) { return v } if (r.startsWith('v') && r.includes('...')) { // from (min) ... to (max) const from = parseInt(r.split('...')[0].replace('v', '')) const to = parseInt(r.split('...')[1].replace('v', '')) return v >= from && v <= to ? v : undefined } else if (r.startsWith('> ')) { return v > parseInt(r.replace('> v', '')) ? v : undefined } else if (r.startsWith('< ')) { return v < parseInt(r.replace('< v', '')) ? v : undefined } else if (r.startsWith('>= ')) { return v >= parseInt(r.replace('>= v', '')) ? v : undefined } else if (r.startsWith('<= ')) { return v <= parseInt(r.replace('<= v', '')) ? v : undefined } return undefined } ================================================ FILE: cli/cmd/bundle/bundle.ts ================================================ // Copyright 2023 Samuel Kopp. All rights reserved. Apache-2.0 license. import { ensureFile } from 'std/fs/ensure_file.ts' import { join } from 'std/path/mod.ts' import * as esbuild from 'esbuild' export async function bundle({ input = './mod.ts', output = './mod.js', banner = '// deno-fmt-ignore-file\n// deno-lint-ignore-file', target = 'es2022', cwd = Deno.cwd(), runtime: _ = 'cloudflare', }: { /** * @default './mod.ts' */ input?: string /** * @default './mod.js' */ output?: string /** * @default '// deno-fmt-ignore-file\n// deno-lint-ignore-file' */ banner?: string /** * @default 'es2022' */ target?: | 'es2015' | 'es2016' | 'es2017' | 'es2018' | 'es2019' | 'es2020' | 'es2021' | 'es2022' /** * @default Deno.cwd() */ cwd?: string /** * @default 'deno' */ runtime?: | 'cloudflare' | 'deno' }) { const hasImportMap = async (path: string) => { try { const content = await Deno.readTextFile(join(cwd, path)) if (!content) { return false } return JSON.parse(content).imports !== undefined } catch (_) { return false } } let options: string[] = [] if (await hasImportMap('deno.json')) { options = ['--config', 'deno.json'] } else if (await hasImportMap('deno.jsonc')) { options = ['--config', 'deno.jsonc'] } else if (await hasImportMap('import_map.json')) { options = ['--import-map', 'deno.json'] } else if (await hasImportMap('importMap.json')) { options = ['--import-map', 'importMap.json'] } else if (await hasImportMap('imports.json')) { options = ['--import-map', 'imports.json'] } ensureFile(output) const cmd = new Deno.Command('deno', { args: ['bundle', '-q', '--unstable', ...options, input, output], cwd, }) await cmd.output() await esbuild.build({ entryPoints: [output], bundle: true, minify: true, format: 'esm', allowOverwrite: true, target, banner: { js: banner, }, outfile: output, absWorkingDir: cwd, }) esbuild.stop() return { outputSize: (await Deno.readTextFile(join(cwd, output))).length, } } ================================================ FILE: cli/cmd/bundle/mod.ts ================================================ // Copyright 2023 Samuel Kopp. All rights reserved. Apache-2.0 license. import { parse } from 'https://deno.land/std@0.203.0/flags/mod.ts' import { brightGreen, brightRed, brightYellow, gray, } from 'https://deno.land/std@0.203.0/fmt/colors.ts' import byte from 'https://deno.land/x/byte@v3.3.0/byte.ts' import { logError } from '../../utils.ts' import { bundle } from './bundle.ts' export async function bundleCommand(args: ReturnType) { const { _, target, runtime } = args const input = _[1] && typeof _[1] === 'string' ? _[1] : undefined const output = _[2] && typeof _[2] === 'string' ? _[2] : undefined try { const { outputSize } = await bundle({ input, output, // @ts-ignore: target: typeof target === 'string' ? target : undefined, // @ts-ignore: runtime: typeof runtime === 'string' ? runtime : undefined, }) console.info( gray( `file size - ${ outputSize < 1_000_000 ? brightGreen(byte(outputSize)) : outputSize < 5_000_000 ? brightYellow(byte(outputSize)) : brightRed(byte(outputSize)) }`, ), ) } catch (err) { if (err instanceof Error) { logError(err.message) } else { logError('something went wrong trying to bundle your app.') } } } ================================================ FILE: cli/cmd/new/mod.ts ================================================ // Copyright 2023 Samuel Kopp. All rights reserved. Apache-2.0 license. import { ensureFile } from 'https://deno.land/std@0.203.0/fs/ensure_file.ts' import { Select } from 'cliffy' type VSCodeSettings = { files: string[] vscode: Record imports: string[] } export async function newCommand() { const platform = await Select.prompt({ message: 'Where do you plan to deploy your app?', options: [ { name: 'Deno 🦕', value: 'deno' }, { name: 'Cloudflare Workers ⚡', value: 'cloudflare' }, ], }) let url = `https://raw.githubusercontent.com/boywithkeyboard/templates/dev/${platform}/` url += 'starter/' const meta: VSCodeSettings = JSON.parse( await (await fetch(url + 'meta.json')).text(), ) const importMap: Record = JSON.parse( await (await fetch( 'https://raw.githubusercontent.com/boywithkeyboard/templates/dev/deno.json', )).text(), ).imports for (const f of meta.files) { const res = await fetch(url + f) await ensureFile(f) await Deno.writeTextFile(f, await res.text()) } const imports = {} for (const i of meta.imports) { // @ts-ignore: imports[i] = importMap[i] } await ensureFile('.vscode/settings.json') await Deno.writeTextFile('.vscode/settings.json', JSON.stringify(meta.vscode)) if (platform === 'cloudflare') { await Deno.writeTextFile( 'wrangler.toml', `name = "" account_id = "" route = "" compatibility_date = "2023-03-24" [build] command = "deno task build"`, ) } const version = (await (await fetch('https://apiland.deno.dev/v2/modules/cheetah')).json()) .latest_version await Deno.writeTextFile( 'deno.json', JSON.stringify( { fmt: { semiColons: false, singleQuote: true, }, imports, tasks: { build: `deno run -A https://deno.land/x/cheetah@${version}/build.ts`, }, }, null, 2, ), ) const command = new Deno.Command('deno', { args: ['fmt', '--single-quote', '--no-semicolons'], stdout: 'piped', }) command.spawn() } ================================================ FILE: cli/cmd/random/create_crypto_key.ts ================================================ // Copyright 2023 Samuel Kopp. All rights reserved. Apache-2.0 license. import { encode } from 'std/encoding/base64.ts' export async function createCryptoKey() { const key = await crypto.subtle.generateKey( { name: 'AES-GCM', hash: 'SHA-512', length: 256 }, true, ['encrypt', 'decrypt'], ) const exportedKey = await crypto.subtle.exportKey('raw', key) return encode(exportedKey) } ================================================ FILE: cli/cmd/random/create_jwt_secret.ts ================================================ // Copyright 2023 Samuel Kopp. All rights reserved. Apache-2.0 license. import { encode } from 'std/encoding/base64.ts' export async function createJwtSecret() { const key = await crypto.subtle.generateKey( { name: 'HMAC', hash: 'SHA-512' }, true, ['sign', 'verify'], ) const exportedKey = await crypto.subtle.exportKey('raw', key) return encode(exportedKey) } ================================================ FILE: cli/cmd/random/mod.ts ================================================ // Copyright 2023 Samuel Kopp. All rights reserved. Apache-2.0 license. import { Select } from 'cliffy' import { gray, white } from 'std/fmt/colors.ts' import { createCryptoKey } from './create_crypto_key.ts' import { createJwtSecret } from './create_jwt_secret.ts' export async function randomCommand() { const type: string = await Select.prompt({ message: 'What do you want to create?', options: [ { name: 'JWT Secret', value: 'jwt_secret' }, { name: 'Crypto Key', value: 'crypto_key' }, ], }) if (type === 'jwt_secret') { console.info( gray(`🗝️ ${white(type.replace('_', ' '))} - ${await createJwtSecret()}`), ) } else { console.info( gray(`🗝️ ${white(type.replace('_', ' '))} - ${await createCryptoKey()}`), ) } } ================================================ FILE: cli/cmd/serve/mod.ts ================================================ // Copyright 2023 Samuel Kopp. All rights reserved. Apache-2.0 license. import { keypress, KeyPressEvent } from 'cliffy/keypress' import { loadSync } from 'std/dotenv/mod.ts' import { parse } from 'std/flags/mod.ts' import { brightGreen, gray, white } from 'std/fmt/colors.ts' import { cheetah } from '../../../cheetah.ts' import { logError } from '../../utils.ts' export async function serveCommand(args: ReturnType) { if (typeof args._[1] !== 'string') { return logError('please specify an entry point') } loadSync({ export: true }) Deno.env.set('DEV', 'true') const exports = await import(args._[1] as string) let childProcess: Deno.ChildProcess if (exports.fetch || exports.default && exports.default instanceof cheetah) { if (exports.fetch) { Deno.writeTextFileSync( './__app.js', `const { fetch: f } = await import('${ args._[1] }')\nDeno.serve((r, d) => f(r, d))`, ) const cmd = new Deno.Command('deno', { args: ['run', '-A', '--watch', '__app.js'], }) childProcess = cmd.spawn() } else { Deno.writeTextFileSync( './__app.js', `const { default: a } = await import('${ args._[1] }')\nDeno.serve((r, d) => a.fetch(r, d))`, ) const cmd = new Deno.Command('deno', { args: ['run', '-A', '--watch', '__app.js'], }) childProcess = cmd.spawn() } } else { const cmd = new Deno.Command('deno', { args: ['run', '-A', '--watch', args._[1]], }) childProcess = cmd.spawn() } console.info( gray(`${brightGreen('success')} - press ${white('CTRL+C')} to exit`), ) for await (const e of keypress()) { const { ctrlKey, key } = e as KeyPressEvent if (ctrlKey && key === 'c' || key === 'escape') { try { Deno.removeSync('./__app.js') } catch (_err) { // } childProcess.kill() Deno.exit() } } } ================================================ FILE: cli/mod.ts ================================================ // Copyright 2023 Samuel Kopp. All rights reserved. Apache-2.0 license. import { parse } from 'std/flags/mod.ts' import { bundleCommand } from './cmd/bundle/mod.ts' import { newCommand } from './cmd/new/mod.ts' import { randomCommand } from './cmd/random/mod.ts' import { serveCommand } from './cmd/serve/mod.ts' import { logError } from './utils.ts' const args = parse(Deno.args) if (args._[0] === 'bundle') { bundleCommand(args) } else if (args._[0] === 'random') { randomCommand() } else if (args._[0] === 'new') { newCommand() } else if (args._[0] === 'serve') { serveCommand(args) } else { logError('unknown command') } ================================================ FILE: cli/utils.ts ================================================ // Copyright 2023 Samuel Kopp. All rights reserved. Apache-2.0 license. import { brightRed, gray } from 'std/fmt/colors.ts' export function logError(message: string) { console.error(gray(`${brightRed('error')} - ${message}`)) } ================================================ FILE: codeowners ================================================ * @boywithkeyboard ================================================ FILE: collection.ts ================================================ // Copyright 2023 Samuel Kopp. All rights reserved. Apache-2.0 license. import { base, Method } from './base.ts' import { HandlerOrSchema } from './handler.ts' export class Collection extends base() { #cors: | string | undefined routes: Set<[ Uppercase, string, HandlerOrSchema[], ]> constructor({ cors, }: { /** * Enable Cross-Origin Resource Sharing (CORS) for this collection by setting a origin, e.g. `*`. * * @since v0.11 */ cors?: string } = {}) { super((method, pathname, handlers) => { if (this.#cors && typeof handlers[0] !== 'function') { if (!handlers[0].cors) { handlers[0].cors = this.#cors } } this.routes.add([method, pathname, handlers]) return this }) this.#cors = cors this.routes = new Set() } } ================================================ FILE: context.ts ================================================ // Copyright 2023 Samuel Kopp. All rights reserved. Apache-2.0 license. /// import { encode } from 'std/encoding/base64.ts' import { ZodType } from 'zod' import { AppContext } from './cheetah.ts' import { ObjectType, Payload } from './handler.ts' import { RequestContext } from './request_context.ts' import { ResponseContext } from './response_context.ts' const HTTP_MESSAGES = { 'Bad Request': 400, 'Unauthorized': 401, 'Access Denied': 403, 'Not Found': 404, 'Method Not Allowed': 405, 'Not Acceptable': 406, 'Request Timeout': 408, 'Conflict': 409, 'Gone': 410, 'Length Required': 411, 'Precondition Failed': 412, 'Content Too Large': 413, 'URI Too Long': 414, 'Unsupported Media Type': 415, 'Range Not Satisfiable': 416, 'Expectation Failed': 417, 'Teapot': 418, 'Misdirected': 421, 'Upgrade Required': 426, 'Precondition Required': 428, 'Rate Limit Exceeded': 429, 'Regional Ban': 451, 'Something Went Wrong': 500, } export class Context< Params extends Record = Record, ValidatedBody extends ZodType = never, ValidatedCookies extends ObjectType = never, ValidatedHeaders extends ObjectType = never, ValidatedQuery extends ObjectType = never, > { #a #c: Cache | undefined #i #p #r #s #req: | RequestContext< Params, ValidatedBody, ValidatedCookies, ValidatedHeaders, ValidatedQuery > | undefined #res: ResponseContext | undefined /** * Wait until the response is sent to the client, then resolve the promise. */ waitUntil: (promise: Promise) => void constructor( __app: AppContext, __internal: { b: Exclude | null c: number h: Headers }, p: Record, r: Request, s: { body?: ZodType | undefined cookies?: ObjectType | undefined headers?: ObjectType | undefined query?: ObjectType | undefined [key: string]: unknown } | null, waitUntil: (promise: Promise) => void, ) { this.#a = __app this.#i = __internal this.#p = p this.#r = r this.#s = s this.waitUntil = waitUntil } get __app(): AppContext { return this.#a } get cache(): Cache { if (this.#c) { return this.#c } this.#c = new Cache(this) return this.#c } get dev(): boolean { return this.#a.debugging || this.runtime === 'deno' && Deno.env.get('DEV') === 'true' } env(name: T): Variables[T] { return this.runtime === 'deno' ? Deno.env.get(name) : (this.#a.env as Variables)[name] } get req(): RequestContext< Params, ValidatedBody, ValidatedCookies, ValidatedHeaders, ValidatedQuery > { if (this.#req) { return this.#req } this.#req = new RequestContext( this.#a, this.#p, this.#r, this.#s, this.exception, ) return this.#req } get res(): ResponseContext { if (this.#res) { return this.#res } this.#res = new ResponseContext(this.#i) return this.#res } get runtime() { return this.#a.runtime } exception(error: keyof typeof HTTP_MESSAGES, description?: string) { const code = HTTP_MESSAGES[error] return new Exception(error, description, code) } } /** @private */ export class Exception { public response constructor( error: string, description: string | undefined, code: number, ) { this.response = (request: Request) => { const a = request.headers.get('accept') const json = a ? a.indexOf('application/json') > -1 || a.indexOf('*/*') > -1 : false return new Response( json ? JSON.stringify({ error, description, code }) : error, { headers: { 'content-type': `${ json ? 'application/json' : 'text/plain' }; charset=utf-8;`, }, status: code, }, ) } } } class Cache { #cache: globalThis.Cache | null #context #name constructor(c: Context) { this.#cache = null this.#context = c this.#name = c.__app.caching?.name ?? 'cheetah' } async set( key: string, value: string | Record | Uint8Array, options?: { maxAge?: number }, ) { if (this.#cache === null) { this.#cache = await caches.open(this.#name) } this.#context.waitUntil( this.#cache.put( `https://${this.#name}.com/${encode(key)}`, new Response( typeof value === 'string' || value instanceof Uint8Array ? value : JSON.stringify(value), { headers: { 'cache-control': `max-age=${options?.maxAge ?? 300}`, }, }, ), ), ) } get = Record>( key: string, type: 'json', ): Promise get( key: string, type: 'string', ): Promise get( key: string, type: 'buffer', ): Promise async get | string | Uint8Array>( key: string, type: 'string' | 'json' | 'buffer' = 'string', ): Promise { if (this.#cache === null) { this.#cache = await caches.open(this.#name) } try { const result = await this.#cache.match( `https://${this.#name}.com/${encode(key)}`, ) if (!result) { return undefined } const data = type === 'string' ? await result.text() : type === 'json' ? await result.json() : new Uint8Array(await result.arrayBuffer()) return data } catch (_err) { return undefined } } async has(key: string) { if (this.#cache === null) { this.#cache = await caches.open(this.#name) } const result = await this.#cache.match( `https://${this.#name}.com/${encode(key)}`, ) return result !== undefined } async delete(key: string) { if (this.#cache === null) { this.#cache = await caches.open(this.#name) } this.#context.waitUntil( this.#cache.delete( `https://${this.#name}.com/${encode(key)}`, ), ) } } ================================================ FILE: contributing.md ================================================ ### Follow Conventional Commits This repository enforces the [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) specification for writing pull requests and issues titles. ### Write a meaningful title We ask you to write a short and meaningful title while going into more detail about your changes in the description of the pull request. Typically, you should receive a response to your pull request within a few hours. ### Make sure nothing's broken After you've made your changes, please run `deno task check` to format & check your code and run the test suites. ================================================ FILE: crypto.ts ================================================ // Copyright 2023 Samuel Kopp. All rights reserved. Apache-2.0 license. import { decode } from 'std/encoding/base64.ts' import { Context } from './context.ts' export async function encrypt(c: Context, message: string) { const key = (c.env('crypto_key') ?? c.env('CRYPTO_KEY')) as string const iv = crypto.getRandomValues(new Uint8Array(12)) const ivStr = Array.from(iv) .map((byte) => String.fromCharCode(byte)) .join('') const alg = { name: 'AES-GCM', iv } const cryptoKey = await crypto.subtle.importKey( 'raw', decode(key).buffer, alg, true, ['encrypt', 'decrypt'], ) const cipherBuf = await crypto.subtle.encrypt( alg, cryptoKey, new TextEncoder().encode(message), ) const cipherArr = Array.from(new Uint8Array(cipherBuf)) const cipherStr = cipherArr.map((byte) => String.fromCharCode(byte)) .join('') return btoa(ivStr + cipherStr) } export async function decrypt(c: Context, message: string) { const key = (c.env('crypto_key') ?? c.env('CRYPTO_KEY')) as string const iv = atob(message).slice(0, 12) const alg = { name: 'AES-GCM', iv: new Uint8Array( Array.from(iv).map((char) => char.charCodeAt(0)), ), } const cryptoKey = await crypto.subtle.importKey( 'raw', decode(key).buffer, alg, true, ['encrypt', 'decrypt'], ) const cipherStr = atob(message).slice(12) const cipherBuf = new Uint8Array( Array.from(cipherStr).map((char) => char.charCodeAt(0)), ) const buf = await crypto.subtle.decrypt(alg, cryptoKey, cipherBuf) return new TextDecoder().decode(buf) } ================================================ FILE: deno.json ================================================ { "compilerOptions": { "jsxFactory": "h", "jsxFragmentFactory": "Fragment" }, "fmt": { "semiColons": false, "singleQuote": true, "exclude": [ "./changelog.md", "./readme.md" ] }, "imports": { "authenticus": "https://deno.land/x/authenticus@v2.0.3/mod.ts", "authenticus/preset": "https://deno.land/x/authenticus@v2.0.3/createPreset.ts", "aws4fetch": "https://esm.sh/aws4fetch@1.0.17?target=es2022", "brotli": "https://deno.land/x/brotli@0.1.7/mod.ts", "cliffy": "https://deno.land/x/cliffy@v0.25.7/mod.ts", "cliffy/keypress": "https://deno.land/x/cliffy@v0.25.7/keypress/mod.ts", "djwt": "https://deno.land/x/djwt@v2.8/mod.ts", "dom": "https://deno.land/x/deno_dom@v0.1.40/deno-dom-wasm.ts", "esbuild": "https://deno.land/x/esbuild@v0.19.3/mod.js", "foras": "https://deno.land/x/foras@v2.1.1/src/deno/mod.ts", "otpauth": "https://deno.land/x/otpauth@v9.1.4/dist/otpauth.esm.js", "preact": "https://esm.sh/preact@10.17.1?target=es2022", "preact/render-to-string": "https://esm.sh/preact-render-to-string@6.1.0?deps=preact@10.17.1&target=es2022", "std/": "https://deno.land/std@0.203.0/", "twind": "https://esm.sh/@twind/core@1.1.3", "twind/preset-autoprefix": "https://esm.sh/@twind/preset-autoprefix@1.0.7", "twind/preset-tailwind": "https://esm.sh/@twind/preset-tailwind@1.1.4", "worker": "https://cdn.jsdelivr.net/npm/@cloudflare/workers-types@4.20230922.0/index.ts", "upstash": "https://deno.land/x/upstash_redis@v1.22.0/mod.ts", "zod": "https://deno.land/x/zod@v3.21.4/mod.ts" }, "lock": false, "tasks": { "check": "deno fmt && deno lint --unstable && deno task test", "test": "deno test -A --unstable" } } ================================================ FILE: env.d.ts ================================================ declare global { type Variables = Record } export {} ================================================ FILE: ext/compress.ts ================================================ // Copyright 2023 Samuel Kopp. All rights reserved. Apache-2.0 license. import { compress as brotli } from 'brotli' import { deflate, gzip, initBundledOnce, InitOutput } from 'foras' import { createExtension } from '../mod.ts' type CompressionAlgorithm = { format: 'br' | 'deflate' | 'gzip' compress: (input: Uint8Array) => Uint8Array | Promise } let FORAS: InitOutput | undefined export const BROTLI: CompressionAlgorithm = { format: 'br', compress: brotli, } export const DEFLATE: CompressionAlgorithm = { format: 'deflate', async compress(input) { if (!FORAS) { FORAS = await initBundledOnce() } return deflate(input) }, } export const GZIP: CompressionAlgorithm = { format: 'gzip', async compress(input) { if (!FORAS) { FORAS = await initBundledOnce() } return gzip(input) }, } /** * An extension to compress the body of the response with [brotli](https://github.com/google/brotli), [gzip](https://www.gzip.org), or [deflate](https://www.ietf.org/rfc/rfc1951.txt), based on the `Accept-Encoding` header of the incoming request. * * @since v1.0 */ export const compress = createExtension<{ algorithm: CompressionAlgorithm | CompressionAlgorithm[] }>({ async onResponse({ c, _ }) { const alg = _.algorithm instanceof Array ? _.algorithm : [_.algorithm] const header = c.req.headers['accept-encoding'] if (c.res.body === null || !header || header === 'identity') { return } for (let i = 0; i < alg.length; ++i) { if (!header.includes(alg[i].format)) { continue } c.res.body = c.res.body instanceof Uint8Array ? await alg[i].compress(c.res.body) : c.res.body instanceof ArrayBuffer ? await alg[i].compress(new Uint8Array(c.res.body)) : typeof c.res.body === 'string' ? await alg[i].compress(new TextEncoder().encode(c.res.body)) : c.res.body if (c.res.body instanceof Uint8Array) { c.res.header('content-encoding', alg[i].format) } break } }, }) ================================================ FILE: ext/debug.ts ================================================ // Copyright 2023 Samuel Kopp. All rights reserved. Apache-2.0 license. import { format } from 'std/fmt/bytes.ts' import { brightBlue, brightGreen, brightRed, gray, white, } from 'std/fmt/colors.ts' import { createExtension } from '../mod.ts' /** * An extension to log every response (that didn't throw an exception). * * @since v1.0 */ export const debug = createExtension({ onResponse({ c }) { const code = c.res.code.toString() console.info( gray( `${ code.startsWith('3') ? brightBlue(c.res.code.toString()) : code.startsWith('2') ? brightGreen(c.res.code.toString()) : brightRed(c.res.code.toString()) } - ${c.req.method} ${white(new URL(c.req.raw.url).pathname)}${ c.res.bodySize > -1 ? ` (${format(c.res.bodySize)})` : '' }`, ), ) }, }) ================================================ FILE: ext/favicon.ts ================================================ // Copyright 2023 Samuel Kopp. All rights reserved. Apache-2.0 license. import { createExtension } from '../mod.ts' let FAVICON: ArrayBuffer | Uint8Array | undefined /** * An extension to set a neat [favicon](https://en.wikipedia.org/wiki/Favicon) for your app. * * The source for your favicon can be either a URL or a buffer. * * @since v1.1 */ export const favicon = createExtension<{ headers?: Record source: string | ArrayBuffer | Uint8Array }>({ async onRequest({ req, _: { headers, source, }, }) { const parts = req.url.split('?') parts[0] = parts[0].slice(8) const pathname = parts[0].substring(parts[0].indexOf('/')) if (pathname !== '/favicon.ico') { return } if (!FAVICON) { if (typeof source === 'string') { const response = await fetch(source) FAVICON = await response.arrayBuffer() } else { FAVICON = source } } return new Response(FAVICON, { headers, }) }, }) ================================================ FILE: ext/files.ts ================================================ // Copyright 2023 Samuel Kopp. All rights reserved. Apache-2.0 license. import { AwsClient } from 'aws4fetch' import { join } from 'std/path/mod.ts' import { R2Bucket } from 'worker' import { createExtension } from '../extensions.ts' import { AppContext } from '../mod.ts' type GeneralOptions = { cacheControl?: string etag?: boolean } type FsOptions = { type?: 'fs' directory: string } type R2Options = { type: 'r2' name: string } type S3Options = { type: 's3' endpoint?: string accessKeyId?: string secretAccessKey?: string } const awsClient = new AwsClient({ accessKeyId: '', secretAccessKey: '', }) function getVar( app: AppContext, name: string, ): T { return app.runtime === 'cloudflare' && app.env ? app.env[name] as T : Deno.env.get(name) as T } /** * An extension to serve static files from Cloudflare R2, an S3 bucket, or the local file system. * * @copyright [@not-ivy](https://github.com/not-ivy), [@boywithkeyboard](https://github.com/boywithkeyboard) * @since v1.2 */ export const files = createExtension<{ serve: GeneralOptions & (FsOptions | R2Options | S3Options) }>({ // onPlugIn({ settings }) { // if (settings.serve.type === 's3') { // awsClient = new AwsClient({ // accessKeyId: settings.serve.accessKeyId, // secretAccessKey: settings.serve.secretAccessKey, // }) // } // }, onRequest({ app, prefix, _: { serve, }, req: request, }) { switch (serve.type) { case 'r2': return handleR2Files(app, serve, prefix) case 's3': { const keyId = getVar(app, 'S3_ACCESS_KEY_ID') ?? getVar(app, 's3_access_key_id') ?? serve.accessKeyId if (!keyId) throw new Error('S3_ACCESS_KEY_ID is not set') const accessKey = getVar(app, 'S3_SECRET_ACCESS_KEY') ?? getVar(app, 's3_secret_access_key') ?? serve.secretAccessKey if (!accessKey) throw new Error('S3_SECRET_ACCESS_KEY is not set') awsClient.accessKeyId = keyId awsClient.secretAccessKey = accessKey return handleS3Files(app, serve, prefix, request) } case 'fs': default: return handleFsFiles(app, serve, prefix) } }, }) async function handleS3Files( app: AppContext, serve: GeneralOptions & S3Options, prefix: string, request: Request, ) { const path = join( prefix !== '*' ? app.request.pathname.substring(prefix.length + 1) : app.request.pathname, ) let response = await awsClient.fetch( `${serve.endpoint}${path}`, { headers: { ...(serve.etag !== false && { etag: request.headers.get('if-none-match') ?? '' }), 'cache-control': serve.cacheControl ?? 's-maxage=300', // 5m }, }, ) if (response.status === 404) { const indexPath = join(path, 'index.html') response = await awsClient.fetch( `${serve.endpoint}${indexPath}`, { headers: { ...(serve.etag !== false && { etag: request.headers.get('if-none-match') ?? '' }), 'cache-control': serve.cacheControl ?? 's-maxage=300', // 5m }, }, ) if (response.status === 404) { const indexPath = join(path, '404.html') response = await awsClient.fetch( `${serve.endpoint}${indexPath}`, { headers: { 'cache-control': serve.cacheControl ?? 's-maxage=300', // 5m }, }, ) } } return response.status === 404 ? undefined : response } async function handleR2Files( app: AppContext, serve: GeneralOptions & R2Options, prefix: string, ) { if (app.runtime !== 'cloudflare' || !app.env) { throw new Error( 'You need to use the Cloudflare Workers runtime to serve static files from an R2 bucket!', ) } const bucket = app.env[serve.name] as R2Bucket const path = prefix !== '*' ? app.request.pathname.substring(prefix.length + 1) : app.request.pathname let object = await bucket.get(path) if (object) { return new Response(object.body as ReadableStream, { headers: { ...(serve.etag !== false && { etag: object.httpEtag }), 'cache-control': serve.cacheControl ?? 's-maxage=300', // 5m }, }) } const indexPath = join(app.request.pathname, 'index.html') object = await bucket.get(indexPath) if (object) { return new Response(object.body as ReadableStream, { headers: { ...(serve.etag !== false && { etag: object.httpEtag }), 'cache-control': serve.cacheControl ?? 's-maxage=300', // 5m }, }) } const errorPath = join(prefix, '404.html') object = await bucket.get(errorPath) if (object) { return new Response(object.body as ReadableStream, { headers: { 'cache-control': serve.cacheControl ?? 's-maxage=300', // 5m }, }) } } async function handleFsFiles( app: AppContext, serve: GeneralOptions & FsOptions, prefix: string, ) { const path = join( serve.directory, prefix !== '*' ? app.request.pathname.substring(prefix.length + 1) : app.request.pathname, ) let stat: Deno.FileInfo let file: Deno.FsFile try { stat = await Deno.lstat(path) if (stat.isDirectory) { stat = await Deno.lstat(join(path, 'index.html')) file = await Deno.open(join(path, 'index.html'), { read: true }) } else { file = await Deno.open(path, { read: true }) } } catch { try { stat = await Deno.lstat(join(serve.directory, '404.html')) file = await Deno.open(join(serve.directory, '404.html'), { read: true }) } catch { return } } return new Response(file.readable, { headers: { ...(serve.etag !== false && { etag: await etag(stat) }), 'cache-control': serve.cacheControl ?? 's-maxage=300', // 5m }, }) } async function etag(stat: Deno.FileInfo) { const encoder = new TextEncoder() const data = encoder.encode( `${stat.birthtime?.getTime()}:${stat.mtime?.getTime()}:${stat.size}`, ) const hash = await crypto.subtle.digest({ name: 'SHA-1' }, data) const hashArray = Array.from(new Uint8Array(hash)) const hashHex = hashArray.map((b) => b.toString(16).padStart(2, '0')).join('') return hashHex } ================================================ FILE: ext/firewall.ts ================================================ import { createExtension } from '../mod.ts' let VPN_LIST: string[] = [] let DATACENTER_LIST: string[] = [] let LAST_UPDATE = 0 type FirewallOptions = { blockVPN?: boolean blockDatacenter?: boolean customRanges?: string[] } /** * An extension for blocking specific traffic to your app. * * @namespace ext * @since v1.4 */ export const firewall = createExtension({ async onRequest({ _: opts, app }) { await checkUpdate() if ( opts?.blockVPN && VPN_LIST.find((range) => isIpInRange(app.ip, range)) ) { return new Response(null, { status: 403 }) } if ( opts?.blockDatacenter && DATACENTER_LIST.find((range) => isIpInRange(app.ip, range)) ) return new Response(null, { status: 403 }) if ( opts?.customRanges && opts.customRanges.find((range) => isIpInRange(app.ip, range)) ) return new Response(null, { status: 403 }) }, }) async function checkUpdate() { if (Date.now() - LAST_UPDATE < 9e5 /* 15 minutes */) return VPN_LIST = (await (await fetch( 'https://raw.githubusercontent.com/X4BNet/lists_vpn/main/output/vpn/ipv4.txt', )).text()).split('\n') DATACENTER_LIST = (await (await fetch( 'https://raw.githubusercontent.com/X4BNet/lists_vpn/main/output/datacenter/ipv4.txt', )).text()).split('\n') LAST_UPDATE = Date.now() } function isIpInRange(ip: string, range: string) { const [rangeIp, rangeMask] = range.split('/') const rangeStart = ipToNumber(rangeIp) >>> 0 const rangeEnd = rangeStart + ((1 << (32 - parseInt(rangeMask))) - 1) const numIp = ipToNumber(ip) return numIp >= rangeStart && numIp <= rangeEnd } function ipToNumber(ip: string) { return ip.split('.').reduce((acc, val) => (acc << 8) | parseInt(val), 0) >>> 0 } ================================================ FILE: ext/helmet.ts ================================================ // Copyright 2023 Samuel Kopp. All rights reserved. Apache-2.0 license. import { createExtension } from '../mod.ts' /** * An extension to attach important security headers to the response. * * @since v1.0 */ export const helmet = createExtension<{ /** * Set the `Content-Security-Policy` header with a strict security policy. * * @default * true */ contentSecurityPolicy?: boolean /** * Set the `Cross-Origin-Embedder-Policy` header. * * @default * null */ crossOriginEmbedderPolicy?: | 'require-corp' | 'unsafe-none' | 'credentialless' | null /** * Set the `Cross-Origin-Opener-Policy` header. * * @default * 'same-origin' */ crossOriginOpenerPolicy?: | 'same-origin' | 'same-origin-allow-popups' | 'unsafe-none' | null /** * Set the `Cross-Origin-Resource-Policy` header. * * @default * 'same-origin' */ crossOriginResourcePolicy?: | 'same-origin' | 'same-site' | 'cross-origin' | null /** * Enable [DNS Prefetching](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-DNS-Prefetch-Control) at the expense of your users' privacy. * * @default * false */ dnsPrefetching?: boolean /** * Set the `X-Frame-Options` header to mitigate Clickjacking. * * @default * 'sameorigin' */ noFraming?: | 'deny' | 'sameorigin' | null /** * Set the `Strict-Transport-Security` header, which indicates to browsers to prefer a secure HTTPS connection. * * @default * { * maxAge: 31536000, // a year * includeSubDomains: true * } */ hsts?: { maxAge?: number preload?: boolean includeSubDomains?: boolean } | null /** * Set the `X-Content-Type-Options` header to `nosniff`. This mitigates Content Sniffing, which can cause security vulnerabilities. * * @default * true */ noSniffing?: boolean /** * Set the `Origin-Agent-Cluster` header, which provides a mechanism to allow web applications to isolate their origins. * * @default * true */ originAgentCluster?: boolean /** * Set the `X-Permitted-Cross-Domain-Policies` header, which tells some clients (mostly Adobe products) your domain's policy for loading cross-domain content. * * @default * 'none' */ crossDomainPolicy?: | 'none' | 'master-only' | 'by-content-type' | 'all' | null /** * Set the `Referrer-Policy` header to control what information is set in the `Referer` header. * * @default * 'no-referrer' */ referrerPolicy?: | 'no-referrer' | 'no-referrer-when-downgrade' | 'origin' | 'origin-when-cross-origin' | 'same-origin' | 'strict-origin' | 'strict-origin-when-cross-origin' | 'unsafe-url' | null }>({ onResponse({ c, _: { contentSecurityPolicy = true, crossOriginEmbedderPolicy = null, crossOriginOpenerPolicy = 'same-origin', crossOriginResourcePolicy = 'same-origin', dnsPrefetching = false, noFraming = 'sameorigin', hsts = { maxAge: 31536000, // a year includeSubDomains: true, }, noSniffing = true, originAgentCluster = true, crossDomainPolicy = 'none', referrerPolicy = 'no-referrer', } = {}, }) { if (contentSecurityPolicy) { c.res.header( 'content-security-policy', `default-src 'self'; base-uri 'self'; font-src 'self' https: data:; form-action 'self'; frame-ancestors 'self'; img-src 'self' data:; object-src 'none'; script-src 'self'; script-src-attr 'none'; style-src 'self' https: 'unsafe-inline'; upgrade-insecure-requests`, ) } if (crossOriginEmbedderPolicy !== null) { c.res.header('cross-origin-embedder-policy', crossOriginEmbedderPolicy) } if (crossOriginOpenerPolicy !== null) { c.res.header('cross-origin-opener-policy', crossOriginOpenerPolicy) } if (crossOriginResourcePolicy !== null) { c.res.header('cross-origin-resource-policy', crossOriginResourcePolicy) } if (dnsPrefetching !== null) { c.res.header('x-dns-prefetch-control', dnsPrefetching ? 'on' : 'off') } if (noFraming !== null) { c.res.header('x-frame-options', noFraming.toUpperCase()) } if (hsts !== null) { c.res.header( 'strict-transport-security', Object.entries(hsts) .map(([key, value]) => `${key.replace('max-age', 'maxAge')}${ typeof value === 'boolean' ? '' : `=${value}` }` ) .join('; '), ) } if (noSniffing) { c.res.header('x-content-type-options', 'nosniff') } if (originAgentCluster) { c.res.header('origin-agent-cluster', '?1') } if (crossDomainPolicy !== null) { c.res.header('x-permitted-cross-domain-policies', crossDomainPolicy) } if (referrerPolicy) { c.res.header('referrer-policy', 'no-referrer') } }, }) ================================================ FILE: ext/pretty.ts ================================================ // Copyright 2023 Samuel Kopp. All rights reserved. Apache-2.0 license. import { createExtension } from '../mod.ts' function sortObject(object: unknown) { if (typeof object != 'object' || object instanceof Array || object === null) { return object } const keys = Object.keys(object).sort() const newObject: Record = {} for (let i = 0; i < keys.length; i++) { newObject[keys[i]] = sortObject( (object as Record)[keys[i]], ) } return newObject } /** * An extension to jazz up the response body by adding indentation and sorting the keys alphabetically. * * @since v1.1 */ export const pretty = createExtension<{ /** * Apply indentation to your response body. Set it to `0` to disable indentation. * * @default 2 */ indentSize?: number /** * Whether to sort the fields alphabetically. * * @default false */ sort?: boolean }>({ onResponse({ c, _: { indentSize = 2, sort = true, } = {}, }) { let body = c.res.body if (body === null || body.constructor.name !== 'Object') { return } c.res.header('content-type', 'application/json; charset=utf-8') if (sort) { body = sortObject(body) as Record } c.res.body = JSON.stringify( body, null, indentSize, ) }, }) ================================================ FILE: extensions.ts ================================================ // Copyright 2023 Samuel Kopp. All rights reserved. Apache-2.0 license. import { ZodString, ZodUnion } from 'zod' import { Method } from './base.ts' import { Handler, ObjectType } from './handler.ts' import { AppContext, Context } from './mod.ts' type HasRequired = Partial extends T ? false : true type Req = Response | void | undefined type Res = void | undefined type ExtensionContext = { /** The prefix of the routes to which this extension is assigned. */ prefix: string } export type Extension< Config extends Record | unknown = never, > = { __config: Config | undefined onPlugIn: HasRequired extends true ? (( context: ExtensionContext & { env: AppContext['env'] routes: AppContext['routes'] runtime: AppContext['runtime'] setRoute: < Pathname extends `/${string}`, // deno-lint-ignore no-explicit-any ValidatedBody extends ObjectType | ZodString | ZodUnion, ValidatedCookies extends ObjectType, ValidatedHeaders extends ObjectType, ValidatedQuery extends ObjectType, >( method: Method, pathname: Pathname, ...handler: ( | { body?: ValidatedBody cookies?: ValidatedCookies headers?: ValidatedHeaders query?: ValidatedQuery cors?: string } | Handler< Pathname, ValidatedBody, ValidatedCookies, ValidatedHeaders, ValidatedQuery > )[] ) => void | Promise settings: Config }, ) => void | Promise) : (( context: ExtensionContext & { env: AppContext['env'] routes: AppContext['routes'] runtime: AppContext['runtime'] setRoute: < Pathname extends `/${string}`, // deno-lint-ignore no-explicit-any ValidatedBody extends ObjectType | ZodString | ZodUnion, ValidatedCookies extends ObjectType, ValidatedHeaders extends ObjectType, ValidatedQuery extends ObjectType, >( method: Method, pathname: Pathname, ...handlers: ( | { body?: ValidatedBody cookies?: ValidatedCookies headers?: ValidatedHeaders query?: ValidatedQuery cors?: string } | Handler< Pathname, ValidatedBody, ValidatedCookies, ValidatedHeaders, ValidatedQuery > )[] ) => void settings?: Config }, ) => void | Promise) onRequest?: HasRequired extends true ? (( context: ExtensionContext & { app: AppContext req: Request _: Config }, ) => Req | Promise) : (( context: ExtensionContext & { app: AppContext req: Request _?: Config }, ) => Req | Promise) onResponse?: HasRequired extends true ? (( context: ExtensionContext & { app: AppContext c: Context _: Config }, ) => Res | Promise) : (( context: ExtensionContext & { app: AppContext c: Context _?: Config }, ) => Res | Promise) } type ReturnFunction< Config extends Record | unknown = unknown, > = Config extends Record ? (HasRequired extends true ? ((config: Config) => Extension) : ((config?: Config) => Extension)) : (() => Extension) export function validExtension(ext: Record) { const symbol = Object.getOwnPropertySymbols(ext).find((s) => s.description === 'cheetah.extension' ) // @ts-ignore: return symbol !== undefined && ext[symbol] === 'v1.0' } export function createExtension< Config extends Record | unknown = unknown, >({ onPlugIn, onRequest, onResponse, }: { onPlugIn?: Extension['onPlugIn'] onRequest?: Extension['onRequest'] onResponse?: Extension['onResponse'] }) { return ((__config?: Config) => { return { __config, onPlugIn, onRequest, onResponse, [Symbol('cheetah.extension')]: 'v1.0', } }) as ReturnFunction } ================================================ FILE: handler.ts ================================================ // Copyright 2023 Samuel Kopp. All rights reserved. Apache-2.0 license. import { ZodObject, ZodRecord, ZodString, ZodType, ZodTypeDef, ZodUnion, } from 'zod' import { Context } from './mod.ts' export type ObjectType = // deno-lint-ignore no-explicit-any | ZodObject | ZodRecord export type BaseType = // deno-lint-ignore no-explicit-any ZodType type ExtractParam = Path extends `:${infer Param}` ? Record & NextPart : NextPart type ExtractParams = Path extends `${infer Segment}/${infer Rest}` ? ExtractParam> // deno-lint-ignore ban-types : ExtractParam export type Payload = | ArrayBuffer | Blob | FormData | ReadableStream | Record | Uint8Array | string | undefined | void type Number = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 export type Version = `v${ | Exclude | `${Exclude}${Number}`}` export type VersionRange = | Version // exact version | `> ${Version}` // greater than version | `< ${Version}` // smaller than version | `>= ${Version}` // greater than or equal to version | `<= ${Version}` // smaller than or equal to version | `${Version}...${Version}` // from (min) ... to (max) export type Handler< Pathname extends `/${string}` | unknown, // deno-lint-ignore no-explicit-any ParsedBody extends ObjectType | ZodString | ZodUnion = never, ParsedCookies extends ObjectType = never, ParsedHeaders extends ObjectType = never, ParsedQuery extends ObjectType = never, > = ( c: Context< ExtractParams, ParsedBody, ParsedCookies, ParsedHeaders, ParsedQuery >, next: () => void, ) => Payload | Promise export function handler() { return < Pathname extends `/${string}`, // deno-lint-ignore no-explicit-any ValidatedBody extends ObjectType | ZodString | ZodUnion = never, ValidatedCookies extends ObjectType = never, ValidatedHeaders extends ObjectType = never, ValidatedQuery extends ObjectType = never, >( // deno-lint-ignore no-unused-vars pathname: Pathname, // deno-lint-ignore no-unused-vars ...handler: ( | ({ body?: ValidatedBody cookies?: ValidatedCookies headers?: ValidatedHeaders query?: ValidatedQuery cors?: string params?: Partial, ZodType>> gateway?: VersionRange }) | Handler< Pathname, ValidatedBody, ValidatedCookies, ValidatedHeaders, ValidatedQuery > )[] ): T => { return null as T } } export type BodylessHandler< Pathname extends `/${string}` | unknown, // deno-lint-ignore no-explicit-any ParsedBody extends ObjectType | ZodString | ZodUnion = never, ParsedCookies extends ObjectType = never, ParsedHeaders extends ObjectType = never, ParsedQuery extends ObjectType = never, > = ( c: Context< ExtractParams, ParsedBody, ParsedCookies, ParsedHeaders, ParsedQuery >, next: () => void, ) => Payload | Promise export function bodylessHandler() { return < Pathname extends `/${string}`, ValidatedCookies extends ObjectType = never, ValidatedHeaders extends ObjectType = never, ValidatedQuery extends ObjectType = never, >( // deno-lint-ignore no-unused-vars pathname: Pathname, // deno-lint-ignore no-unused-vars ...handler: ( | { cookies?: ValidatedCookies headers?: ValidatedHeaders query?: ValidatedQuery cors?: string params?: Partial, ZodType>> gateway?: VersionRange } | BodylessHandler< Pathname, never, ValidatedCookies, ValidatedHeaders, ValidatedQuery > )[] ): T => { return null as T } } export type HandlerOrSchema = | { body?: ZodType cookies?: ObjectType headers?: ObjectType query?: ObjectType cors?: string params?: Record gateway?: VersionRange } | Handler | BodylessHandler ================================================ FILE: jwt.ts ================================================ // Copyright 2023 Samuel Kopp. All rights reserved. Apache-2.0 license. import * as Jwt from 'djwt' import { decode } from 'std/encoding/base64.ts' import { Context } from './mod.ts' interface Payload { iss?: string sub?: string aud?: string[] | string /** * A `Date` object or a `number` (in seconds) when the JWT will expire. */ exp?: Date | number /** * A `Date` object or a `number` (in seconds) until which the JWT will be invalid. */ nbf?: Date | number iat?: number jti?: string [key: string]: unknown } function importKey(key: string) { return crypto.subtle.importKey( 'raw', decode(key).buffer, { name: 'HMAC', hash: 'SHA-512' }, true, ['sign', 'verify'], ) } /** * Sign a payload. */ // deno-lint-ignore ban-types export async function sign = {}>( secret: string | Context, payload: T & Payload, ) { const key = typeof secret === 'string' ? await importKey(secret) : await importKey( (secret.env('jwt_secret') ?? secret.env('JWT_SECRET')) as string, ) const { exp, nbf, ...rest } = payload return await Jwt.create({ alg: 'HS512', typ: 'JWT' }, { ...(exp && { exp: Jwt.getNumericDate(exp) }), ...(nbf && { nbf: Jwt.getNumericDate(nbf) }), ...rest, }, key) } /** * Verify the validity of a JWT. */ export async function verify = Payload>( secret: string | Context, token: string, options?: Jwt.VerifyOptions, ) { try { const key = typeof secret === 'string' ? await importKey(secret) : await importKey( (secret.env('jwt_secret') ?? secret.env('JWT_SECRET')) as string, ) return await Jwt.verify(token, key, options) as Jwt.Payload & T } catch (_err) { return } } export default { sign, verify, } ================================================ FILE: license ================================================ Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS Copyright 2023 Samuel Kopp Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ================================================ FILE: location_data.ts ================================================ // Copyright 2023 Samuel Kopp. All rights reserved. Apache-2.0 license. import { IncomingRequestCfProperties } from 'worker' import { Context } from './context.ts' type CloudflareRequest = Request & { cf: IncomingRequestCfProperties } /** * Inspect the geolocation data of the incoming request. * * You must either deploy your app to [Cloudflare Workers](https://developers.cloudflare.com/workers/runtime-apis/request/#incomingrequestcfproperties) or use [Cloudflare as a proxy](https://developers.cloudflare.com/support/network/configuring-ip-geolocation/) to use the `LocationData` API. */ export class LocationData { #c: Context constructor(c: Context) { this.#c = c } /** * The city the request originated from. * * @example 'Austin' */ get city() { const city = (this.#c.req.raw as CloudflareRequest).cf?.city if (!city && this.#c.__app.proxy === 'cloudflare') { return this.#c.req.headers['cf-ipcity'] } return city } /** * If known, the ISO 3166-2 name for the first level region associated with the IP address of the incoming request. * * @example 'Texas' */ get region() { return (this.#c.req.raw as CloudflareRequest).cf?.region } /** * The [ISO 3166-1 Alpha 2](https://www.iso.org/iso-3166-country-codes.html) country code the request originated from. * * If you're using CLoudflare Workers and your worker is [configured to accept TOR connections](https://support.cloudflare.com/hc/en-us/articles/203306930-Understanding-Cloudflare-Tor-support-and-Onion-Routing), this may also be `T1`, indicating a request that originated over TOR. * * If Cloudflare is unable to determine where the request originated this property is omitted. * * @example 'GB' */ get country(): IncomingRequestCfProperties['country'] { const country = (this.#c.req.raw as CloudflareRequest).cf?.country if (!country && this.#c.__app.proxy === 'cloudflare') { return this.#c.req .headers['cf-ipcountry'] as IncomingRequestCfProperties['country'] } return country } /** * A two-letter code indicating the continent the request originated from. * * @example 'NA' */ get continent(): IncomingRequestCfProperties['continent'] { const continent = (this.#c.req.raw as CloudflareRequest).cf?.continent if (!continent && this.#c.__app.proxy === 'cloudflare') { return this.#c.req .headers['cf-ipcontinent'] as IncomingRequestCfProperties['continent'] } return continent } /** * If known, the ISO 3166-2 code for the first-level region associated with the IP address of the incoming request. * * @example 'TX' */ get regionCode(): IncomingRequestCfProperties['regionCode'] { return (this.#c.req.raw as CloudflareRequest).cf?.regionCode } /** * Latitude of the incoming request. * * @example '30.27130' */ get latitude(): IncomingRequestCfProperties['latitude'] { const latitude = (this.#c.req.raw as CloudflareRequest).cf?.latitude if (!latitude && this.#c.__app.proxy === 'cloudflare') { return this.#c.req.headers['cf-iplatitude'] } return latitude } /** * Longitude of the incoming request. * * @example '-97.74260' */ get longitude(): IncomingRequestCfProperties['longitude'] { const longitude = (this.#c.req.raw as CloudflareRequest).cf?.longitude if (!longitude && this.#c.__app.proxy === 'cloudflare') { return this.#c.req.headers['cf-iplongitude'] } return longitude } /** * Postal code of the incoming request. * * @example '78701' */ get postalCode(): IncomingRequestCfProperties['postalCode'] { return (this.#c.req.raw as CloudflareRequest).cf?.postalCode } /** * Timezone of the incoming request. * * @example 'America/Chicago' */ get timezone(): IncomingRequestCfProperties['timezone'] { return (this.#c.req.raw as CloudflareRequest).cf?.timezone } /** * The three-letter [IATA](https://en.wikipedia.org/wiki/IATA_airport_code) airport code of the data center that the request hit. * * @example 'DFW' */ get datacenter(): IncomingRequestCfProperties['colo'] { return (this.#c.req.raw as CloudflareRequest).cf?.colo } } ================================================ FILE: mod.ts ================================================ // Copyright 2023 Samuel Kopp. All rights reserved. Apache-2.0 license. export { cheetah as default } from './cheetah.ts' export type { AppConfig, AppContext } from './cheetah.ts' export { Collection } from './collection.ts' export { Context } from './context.ts' export { decrypt, encrypt } from './crypto.ts' export { createExtension } from './extensions.ts' export type { Extension } from './extensions.ts' export { default as jwt } from './jwt.ts' export { LocationData } from './location_data.ts' export { otp } from './otp.ts' export { h, Renderer } from './render.ts' export { sendMail } from './send_mail.ts' ================================================ FILE: oauth/client.ts ================================================ // Copyright 2023 Samuel Kopp. All rights reserved. Apache-2.0 license. import { GitHub as githubPreset, Google as googlePreset } from 'authenticus' import { Preset } from 'authenticus/preset' import { OAuthMethod } from './types.ts' export type OAuthClient = { name: OAuthMethod // deno-lint-ignore no-explicit-any preset: Preset } /** * @since v1.3 */ export const Google: OAuthClient = { name: 'google', preset: googlePreset, } /** * @since v1.3 */ export const GitHub: OAuthClient = { name: 'github', preset: githubPreset, } ================================================ FILE: oauth/get_session_data.ts ================================================ // Copyright 2023 Samuel Kopp. All rights reserved. Apache-2.0 license. import { getCookies } from 'std/http/cookie.ts' import { Context } from '../context.ts' import { verify } from '../jwt.ts' import { OAuthSessionData, OAuthSessionToken } from './types.ts' /** * Get the data associated with the current session if logged in. * * @namespace oauth * @since v1.3 */ export async function getSessionData( c: Context, ): Promise { if (!c.__app.oauth) { throw new Error('Please configure the oauth module for your app!') } const cookies = getCookies(c.req.raw.headers) if (!cookies.token) { return } const payload = await verify( c, cookies.token, { audience: 'oauth:session' }, ) if (!payload) { return } if (payload.ip !== c.req.ip) { await c.__app.oauth.store.delete(c, payload.identifier) c.res.deleteCookie('token', { path: c.__app.oauth.cookie?.path ?? '/', ...(c.__app.oauth.cookie?.domain && { domain: c.__app.oauth.cookie.domain }), }) if (typeof c.__app.oauth.onSignOut === 'function') { await c.__app.oauth.onSignOut(c, payload.identifier) } return } const session = await c.__app.oauth.store.get(c, payload.identifier) return session } ================================================ FILE: oauth/get_session_id.ts ================================================ // Copyright 2023 Samuel Kopp. All rights reserved. Apache-2.0 license. import { getCookies } from 'std/http/cookie.ts' import { Context } from '../context.ts' import { verify } from '../jwt.ts' import { OAuthSessionToken } from './types.ts' /** * Get the session identifier and verify it. * * @namespace oauth * @since v1.3 */ export async function getSessionId(c: Context): Promise { if (!c.__app.oauth) { throw new Error('Please configure the oauth module for your app!') } const cookies = getCookies(c.req.raw.headers) if (!cookies.token) { return } const payload = await verify( c, cookies.token, { audience: 'oauth:session' }, ) if (!payload) { return } if (payload.ip !== c.req.ip) { await c.__app.oauth.store.delete(c, payload.identifier) c.res.deleteCookie('token', { path: c.__app.oauth.cookie?.path ?? '/', ...(c.__app.oauth.cookie?.domain && { domain: c.__app.oauth.cookie.domain }), }) if (typeof c.__app.oauth.onSignOut === 'function') { await c.__app.oauth.onSignOut(c, payload.identifier) } return } if (await c.__app.oauth.store.has(c, payload.identifier)) { return payload.identifier } } ================================================ FILE: oauth/get_session_token.ts ================================================ // Copyright 2023 Samuel Kopp. All rights reserved. Apache-2.0 license. import { getCookies } from 'std/http/cookie.ts' import { Context } from '../context.ts' import { verify } from '../jwt.ts' /** * Get the session token without verifying the session. * * @namespace oauth * @since v1.3 */ export async function getSessionToken(c: Context) { const cookies = getCookies(c.req.raw.headers) if (!cookies.token) { return } const payload = await verify( c, cookies.token, { audience: 'oauth:session' }, ) if (payload) { return cookies.token } } ================================================ FILE: oauth/handle_callback.ts ================================================ // Copyright 2023 Samuel Kopp. All rights reserved. Apache-2.0 license. import { getNormalizedUser, getToken, getUser } from 'authenticus' import { UserAgent } from 'std/http/user_agent.ts' import { Context } from '../context.ts' import { sign, verify } from '../jwt.ts' import { LocationData } from '../location_data.ts' import { OAuthClient } from './client.ts' import { OAuthSessionData, OAuthSessionToken, OAuthSignInToken, } from './types.ts' export async function handleCallback( c: Context, client: OAuthClient, ) { if (!c.__app.oauth) { throw new Error('Please configure the oauth module for your app!') } // validate request if ( typeof c.req.query.state !== 'string' || typeof c.req.query.code !== 'string' ) { throw c.exception('Bad Request') } // validate state const payload = await verify( c, c.req.query.state, { audience: 'oauth:sign_in' }, ) if (!payload || payload.ip !== c.req.ip) { throw c.exception('Access Denied') } try { // fetch user const { accessToken } = await getToken(client.preset, { clientId: (c.env(`${client.name.toUpperCase()}_CLIENT_ID`) ?? c.env(`${client.name}_client_id`)) as string, clientSecret: (c.env(`${client.name.toUpperCase()}_CLIENT_SECRET`) ?? c.env(`${client.name}_client_secret`)) as string, code: c.req.query.code, redirectUri: payload.redirectUri, }) const user = getNormalizedUser( client.preset, // @ts-ignore: await getUser(client.preset, accessToken), ) // create session const identifier = crypto.randomUUID() const expirationDate = new Date(Date.now() + 7 * 24 * 60 * 60000) const token = await sign( c, { aud: 'oauth:session', exp: expirationDate, identifier, ip: c.req.ip, }, ) const userAgent = new UserAgent(c.req.headers['user-agent'] ?? '') const location = new LocationData(c) const data: OAuthSessionData = { identifier, email: user.email, method: client.name, userAgent: { browser: userAgent.browser, device: userAgent.device, os: userAgent.os, }, location: { ip: c.req.ip, city: location.city, region: location.region, regionCode: location.regionCode, country: location.country, continent: location.continent, }, expiresAt: expirationDate.getTime(), } c.__app.oauth.store.set(c, identifier, data, data.expiresAt) c.res.setCookie('token', token, { expires: expirationDate, httpOnly: true, secure: true, path: '/', ...c.__app.oauth.cookie, }) if (typeof c.__app.oauth.onSignIn === 'function') { await c.__app.oauth.onSignIn(c, data) } return { token, ...data, } } catch (_err) { throw c.exception('Bad Request') } } ================================================ FILE: oauth/is_signed_in.ts ================================================ // Copyright 2023 Samuel Kopp. All rights reserved. Apache-2.0 license. import { Context } from '../context.ts' import { getSessionId } from './get_session_id.ts' /** * Check if the user is logged in. * * @namespace oauth * @since v1.3 */ export async function isSignedIn(c: Context): Promise { return await getSessionId(c) !== undefined } ================================================ FILE: oauth/mod.ts ================================================ // Copyright 2023 Samuel Kopp. All rights reserved. Apache-2.0 license. export { GitHub, Google } from './client.ts' export type { OAuthClient } from './client.ts' export { getSessionData } from './get_session_data.ts' export { getSessionId } from './get_session_id.ts' export { getSessionToken } from './get_session_token.ts' export { handleCallback } from './handle_callback.ts' export { isSignedIn } from './is_signed_in.ts' export { signIn } from './sign_in.ts' export { signOut } from './sign_out.ts' export { kv, OAuthStore, upstash } from './store.ts' export type { OAuthMethod, OAuthSessionData } from './types.ts' ================================================ FILE: oauth/sign_in.ts ================================================ // Copyright 2023 Samuel Kopp. All rights reserved. Apache-2.0 license. import { createAuthorizeUrl } from 'authenticus' import { Context } from '../context.ts' import { sign } from '../jwt.ts' import { OAuthClient } from './client.ts' import { OAuthSignInToken } from './types.ts' /** * Start the login flow by redirecting the user. * * @namespace oauth * @since v1.3 */ export async function signIn( c: Context, client: OAuthClient, options: { redirectUri: string scopes?: string[] }, ) { if (!c.__app.oauth) { throw new Error('Please configure the oauth module for your app!') } const token = await sign( c, { aud: 'oauth:sign_in', exp: 300, // 5m ip: c.req.ip, redirectUri: options.redirectUri, }, ) const url = createAuthorizeUrl(client.preset, { clientId: c.env(`${client.name.toUpperCase()}_CLIENT_ID`) ?? c.env(`${client.name}_client_id`), scopes: options.scopes, state: token, redirectUri: options.redirectUri, }) c.res.redirect(url) } ================================================ FILE: oauth/sign_out.ts ================================================ // Copyright 2023 Samuel Kopp. All rights reserved. Apache-2.0 license. import { Context } from '../context.ts' import { getSessionData } from './get_session_data.ts' /** * Sign the user out if they're logged in. * * @namespace oauth * @since v1.3 */ export async function signOut(c: Context) { if (!c.__app.oauth) { throw new Error('Please configure the oauth module for your app!') } c.res.deleteCookie('token', { path: c.__app.oauth.cookie?.path ?? '/', ...(c.__app.oauth.cookie?.domain && { domain: c.__app.oauth.cookie.domain }), }) const data = await getSessionData(c) if (!data) { return } try { await c.__app.oauth.store.delete(c, data.identifier) } catch (_err) { // } if (typeof c.__app.oauth.onSignOut === 'function') { await c.__app.oauth.onSignOut(c, data.identifier) } } ================================================ FILE: oauth/store.ts ================================================ // Copyright 2023 Samuel Kopp. All rights reserved. Apache-2.0 license. import { Redis } from 'upstash' import { KVNamespace } from 'worker' import { Context } from '../context.ts' import { OAuthSessionData } from './types.ts' export class OAuthStore { set: ( c: Context, key: string, value: OAuthSessionData, expiresAt: number, // unix timestamp in ms ) => Promise get: (c: Context, key: string) => Promise delete: (c: Context, key: string) => Promise has: (c: Context, key: string) => Promise constructor( options: { set: OAuthStore['set'] get: OAuthStore['get'] delete: OAuthStore['delete'] has: OAuthStore['has'] }, ) { this.set = options.set this.get = options.get this.delete = options.delete this.has = options.has } } let KV: Deno.Kv | undefined /** * Use [Cloudflare KV](https://developers.cloudflare.com/workers/runtime-apis/kv) or [Deno KV](https://deno.com/kv) as session storage, depending on the runtime. * * @namespace oauth * @since v1.3 */ export const kv = new OAuthStore({ async set(c, key, value, expiresAt) { if (c.runtime === 'cloudflare') { await (c.__app.env as { oauth: KVNamespace }).oauth.put( key, JSON.stringify(value), { expiration: Math.round(expiresAt / 1000) }, ) } else { if (!KV) { KV = await Deno.openKv('oauth') } await KV.set([key], value) } }, async get(c, key) { if (c.runtime === 'cloudflare') { const kv = (c.__app.env as { oauth: KVNamespace }).oauth const value = await kv.get(key, 'json') if (value === null || value.expiresAt < Date.now()) { return } return value } else { if (!KV) { KV = await Deno.openKv('oauth') } const result = await KV.get([key], { consistency: 'strong', }) if (result.value === null) { return } if (result.value.expiresAt > Date.now()) { return result.value } await KV.delete([key]) } }, async has(c, key) { if (c.runtime === 'cloudflare') { const kv = (c.__app.env as { oauth: KVNamespace }).oauth const value = await kv.get(key, 'json') if (value === null) { return false } return value.expiresAt > Date.now() } else { if (!KV) { KV = await Deno.openKv('oauth') } const result = await KV.get([key], { consistency: 'strong', }) if (result.value === null) { return false } if (result.value.expiresAt > Date.now()) { return true } await KV.delete([key]) return false } }, async delete(c, key) { if (c.runtime === 'cloudflare') { const kv = (c.__app.env as { oauth: KVNamespace }).oauth await kv.delete(key) } else { if (!KV) { KV = await Deno.openKv('oauth') } await KV.delete([key]) } }, }) let REDIS: Redis | undefined /** * Use [Upstash](https://upstash.com) as session storage. * * @namespace oauth * @since v1.3 */ export const upstash = new OAuthStore({ async set(c, key, value, expiresAt) { if (!REDIS) { REDIS = new Redis({ url: c.env('UPSTASH_URL') as string, token: c.env('UPSTASH_TOKEN') as string, }) } await REDIS.set(key, value, { pxat: expiresAt, }) }, async get(c, key) { if (!REDIS) { REDIS = new Redis({ url: c.env('UPSTASH_URL') as string, token: c.env('UPSTASH_TOKEN') as string, }) } return await REDIS.get(key) ?? undefined }, async has(c, key) { if (!REDIS) { REDIS = new Redis({ url: c.env('UPSTASH_URL') as string, token: c.env('UPSTASH_TOKEN') as string, }) } return await REDIS.exists(key) === 1 }, async delete(c, key) { if (!REDIS) { REDIS = new Redis({ url: c.env('UPSTASH_URL') as string, token: c.env('UPSTASH_TOKEN') as string, }) } await REDIS.del(key) }, }) ================================================ FILE: oauth/types.ts ================================================ // Copyright 2023 Samuel Kopp. All rights reserved. Apache-2.0 license. import { UserAgent } from 'std/http/user_agent.ts' import { LocationData } from '../location_data.ts' export type OAuthMethod = | 'google' | 'github' export type OAuthSessionData = { identifier: string email: string method: OAuthMethod userAgent: { browser?: UserAgent['browser'] device?: UserAgent['device'] os?: UserAgent['os'] } location: { ip: string city?: LocationData['city'] region?: LocationData['region'] regionCode?: LocationData['regionCode'] country?: LocationData['country'] continent?: LocationData['continent'] } expiresAt: number } export type OAuthSessionToken = { aud: 'oauth:session' identifier: string ip: string } export type OAuthSignInToken = { aud: 'oauth:sign_in' ip: string redirectUri: string } ================================================ FILE: otp.ts ================================================ // Copyright 2023 Samuel Kopp. All rights reserved. Apache-2.0 license. import * as OTP from 'otpauth' export const otp = { /** * Create a random secret. */ secret(length = 64) { return [...Array(length)].map(() => Math.floor(Math.random() * 16).toString(16) ).join('') }, /** * Get the 6-digit token for a given timestamp. */ token(secret: string, timestamp?: number) { const totp = new OTP.TOTP({ algorithm: 'SHA1', digits: 6, period: 30, secret: OTP.Secret.fromHex(secret), }) return totp.generate({ timestamp }) }, /** * Create a URI that you can use, for example, for a QR code to scan with Google Authenticator. */ uri(label: string, issuer: string, secret: string) { const totp = new OTP.TOTP({ issuer, label, algorithm: 'SHA1', digits: 6, period: 30, secret: OTP.Secret.fromHex(secret), }) return totp.toString() }, /** * Determine if a given token is valid. */ validate(token: string, secret: string) { const totp = new OTP.TOTP({ algorithm: 'SHA1', digits: 6, period: 30, secret: OTP.Secret.fromHex(secret), }) return totp.validate({ token }) === 0 }, } ================================================ FILE: readme.md ================================================


cheetah

🛡️ secure × 💎 simple × 🪶 light


> [!WARNING] > cheetah is currently **not maintained**.
### Sneak Peek 👾 ```ts import cheetah from 'https://deno.land/x/cheetah/mod.ts' import { z } from 'https://deno.land/x/zod/mod.ts' const app = new cheetah() .post('/', { body: z.object({ // < scheme validation name: z.string() }) }, async c => { const body = await c.req.body() return `Hey, ${body.name}!` // < response body }) app.serve() // < launch app ``` ❔ Please read our [guide](https://cheetah.mod.land) or [join our Discord](https://discord.gg/2rCya9EWGv) to learn more.
--- ---
### Release Schedule 🗓️ We strictly adhere to [SemVer](https://semver.org) and post updates **weekly**. - ◆ **current** *(e.g. v0.1.0)* The current channel is dedicated to stable releases and is safe for use in production. - ◇ **canary** *(e.g. v0.1.0-canary.0)* The canary channel is meant for pre-releases that lack features for a stable release or contain features that are still a prototype. These releases are **not suited for production** and only meant for testing purposes.
### Contributing 😘 We appreciate your help! 💕 To contribute, please read our [contributing guidelines](https://github.com/azurystudio/cheetah/blob/dev/contributing.md) first. ================================================ FILE: render.ts ================================================ // Copyright 2023 Samuel Kopp. All rights reserved. Apache-2.0 license. import { VNode } from 'preact' import renderToString from 'preact/render-to-string' import { brightYellow, gray } from 'std/fmt/colors.ts' import { defineConfig, extract, install } from 'twind' import presetAutoPrefix from 'twind/preset-autoprefix' import presetTailwind from 'twind/preset-tailwind' import { Context } from './mod.ts' export function render(c: Context, Component: VNode) { const htmlString = renderToString(Component) try { const { html, css } = extract(htmlString) c.res.body = `${css.length > 0 ? `` : ''}${html}` } catch (_err) { if (c.dev) { console.warn( gray( `${ brightYellow('warning') } - twind is not installed, thus styles might not be applied`, ), ) } c.res.body = htmlString } c.res.header('content-type', 'text/html; charset=utf-8') } export class Renderer { render constructor(options?: Parameters[0]) { if (options) { options.presets = options.presets ? [presetAutoPrefix(), presetTailwind(), ...options.presets] : [presetAutoPrefix(), presetTailwind()] } install(defineConfig( options ?? { presets: [presetAutoPrefix(), presetTailwind()], }, )) this.render = render } } export { h } from 'https://esm.sh/preact@10.17.1?target=es2022' ================================================ FILE: request_context.ts ================================================ // Copyright 2023 Samuel Kopp. All rights reserved. Apache-2.0 license. import { deadline as resolveWithDeadline, DeadlineError, } from 'std/async/deadline.ts' import { z, ZodStringDef, ZodType, ZodUnionDef } from 'zod' import { Method } from './base.ts' import { BaseType, ObjectType } from './handler.ts' import { AppContext, Context } from './mod.ts' type Static = T extends ZodType ? z.infer : never export class RequestContext< Params extends Record = Record, ValidatedBody extends ZodType = never, ValidatedCookies extends ObjectType = never, ValidatedHeaders extends ObjectType = never, ValidatedQuery extends ObjectType = never, > { #c: Record | undefined #h: Record | undefined #a: AppContext #p #q: Record | undefined #r #s #e constructor( a: AppContext, p: Record, r: Request, s: { body?: ZodType | undefined cookies?: ObjectType | undefined headers?: ObjectType | undefined query?: ObjectType | undefined params?: Record [key: string]: unknown } | null, e: Context['exception'], ) { this.#a = a this.#p = p this.#r = r this.#s = s this.#e = e } get gateway(): number { return this.#a.gateway ?? -1 } get ip(): string { return this.#a.ip } /** * The method of the incoming request. * * @example 'GET' * @since v0.12 */ get method() { return this.#r.method as Uppercase } /** * A method to retrieve the corresponding value of a parameter. */ param(name: T): Params[T] { if (this.#s?.params && this.#s.params[name as string]) { const result = this.#s.params[name as string].safeParse(this.#p[name]) if (!result.success) { throw this.#e('Bad Request') } return result.data as Params[T] } else { return this.#p[name as string] as Params[T] } } /** * Retrieve the original request object. * * @since v1.0 */ get raw() { return this.#r } /** * The validated body of the incoming request. */ async body(options?: { /** * This enables the conversion of a FormData request body into a JSON object (if the request body has the MIME type `multipart/form-data`). * * @default false */ transform: boolean }): Promise< [ValidatedBody] extends [never] ? unknown : Static > { if (!this.#s?.body) { // @ts-ignore: return undefined } let body try { if ( (this.#s.body as BaseType)._def.typeName === 'ZodString' || (this.#s.body as BaseType)._def.typeName === 'ZodUnion' && (this.#s.body as BaseType)._def.options.every(( { _def }, ) => _def.typeName === 'ZodString') ) { body = await resolveWithDeadline(this.#r.text(), 2500) } else { if ( (options?.transform === true || this.#s?.transform === true) && this.#r.headers.get('content-type') === 'multipart/form-data' ) { const formData = await resolveWithDeadline(this.#r.formData(), 2500) body = {} as Record for (const [key, value] of formData.entries()) { body[key] = value } } else { body = await resolveWithDeadline(this.#r.json(), 2500) } } } catch (err: unknown) { throw this.#e( err instanceof DeadlineError ? 'Content Too Large' : 'Bad Request', ) } const result = this.#s.body.safeParse(body) if (!result.success) { throw this.#e('Bad Request') } return result.data } /** * The validated cookies of the incoming request. */ get cookies(): [ValidatedCookies] extends [never] ? never : Static { if (this.#c || !this.#s?.cookies) { return this.#c as [ValidatedCookies] extends [never] ? never : Static } try { const header = this.#r.headers.get('cookies') ?? '' if (header.length > 1000) { throw this.#e('Content Too Large') } this.#c = header .split(/;\s*/) .map((pair) => pair.split(/=(.+)/)) .reduce((acc: Record, [k, v]) => { acc[k] = v return acc }, {}) delete this.#c[''] } catch (_err) { this.#c = {} } const isValid = this.#s.cookies.safeParse(this.#c).success if (!isValid) { throw this.#e('Bad Request') } return this.#c as [ValidatedCookies] extends [never] ? never : Static } /** * The validated headers of the incoming request. */ get headers(): [ValidatedHeaders] extends [never] ? Record : Static { if (this.#h) { return this.#h as [ValidatedHeaders] extends [never] ? Record : Static } this.#h = {} let num = 0 for (const [key, value] of this.#r.headers) { if (num === 50) { break } if (!this.#h[key.toLowerCase()]) { this.#h[key.toLowerCase()] = value } num++ } if (this.#s?.headers) { const isValid = this.#s.headers.safeParse(this.#h).success if (!isValid) { throw this.#e('Bad Request') } } return this.#h as [ValidatedHeaders] extends [never] ? Record : Static } /** * The validated query parameters of the incoming request. */ get query(): [ValidatedQuery] extends [never] ? Record : Static { if (this.#q) { return this.#q as [ValidatedQuery] extends [never] ? Record : Static } this.#q = {} if (this.#a.request.querystring) { const arr = this.#a.request.querystring.split('&') for (let i = 0; i < arr.length; i++) { const [key, value] = arr[i].split('=') if (!key) { continue } if (typeof value === 'undefined') { this.#q[key] = true continue } try { this.#q[key] = JSON.parse(decodeURIComponent(value)) } catch (_err) { this.#q[key] = decodeURIComponent(value) } } } if (this.#s?.query) { const isValid = this.#s.query.safeParse(this.#q).success if (!isValid) { throw this.#e('Bad Request') } } return this.#q as [ValidatedQuery] extends [never] ? Record : Static } /** * Parse the request body as an `ArrayBuffer` with a set time limit in ms. * * @param deadline (default `2500`) */ async blob(deadline = 2500) { try { const promise = this.#r.blob() return await resolveWithDeadline(promise, deadline) } catch (_err) { return null } } /** * Parse the request body as an `ArrayBuffer` with a set time limit in ms. * * @param deadline (default `2500`) */ async buffer(deadline = 2500) { try { const promise = this.#r.arrayBuffer() return await resolveWithDeadline(promise, deadline) } catch (_err) { return null } } /** * Parse the request body as JSON with a set time limit in ms. * * **If you have defined a validation schema, use `c.req.body()` instead!** * * @param deadline (default `2500`) */ async json(deadline = 2500): Promise { try { const promise = this.#r.json() return await resolveWithDeadline(promise, deadline) } catch (_err) { return null } } /** * Parse the request body as a `FormData` object with a set time limit in ms. * * @param deadline (default `2500`) */ async formData(deadline = 2500) { try { const promise = this.#r.formData() return await resolveWithDeadline(promise, deadline) } catch (_err) { return null } } /** * Parse the request body as a `string` with a set time limit in ms. * * **If you have defined a validation schema, use `c.req.body()` instead!** * * @param deadline (default `2500`) */ async text(deadline = 2500) { try { const promise = this.#r.text() return await resolveWithDeadline(promise, deadline) } catch (_err) { return null } } /** * A readable stream of the request body. */ get stream() { return this.#r.body } } ================================================ FILE: response_context.ts ================================================ // Copyright 2023 Samuel Kopp. All rights reserved. Apache-2.0 license. import { Payload } from './handler.ts' export class ResponseContext { #i constructor(__internal: { b: Exclude | null c: number h: Headers }) { this.#i = __internal } get body(): Exclude | null { return this.#i.b } set body(data: Exclude | null) { this.#i.b = data } /** * The size of the response body (`-1` if it cannot be calculated). */ get bodySize() { if ( this.#i.b === null ) { return 0 } let s switch (this.#i.b.constructor.name) { case 'String': { s = (this.#i.b as string).length break } case 'Object': { s = JSON.stringify(this.#i.b).length break } case 'ArrayBuffer': { s = (this.#i.b as ArrayBuffer).byteLength break } case 'Uint8Array': { s = (this.#i.b as ArrayBuffer).byteLength break } case 'Blob': { s = (this.#i.b as Blob).size break } default: { s = -1 break } } return s } /** * The status code of the response. */ get code() { return this.#i.c } set code(code: number) { this.#i.c = code } /** * Attach a cookie to the response. * * @since v1.4 */ setCookie(name: string, value: string, options?: { expires?: Date maxAge?: number domain?: string path?: string secure?: boolean httpOnly?: boolean sameSite?: | 'strict' | 'lax' | 'none' }) { let cookie = `${name}=${value};` this.#i.h.append( 'Set-Cookie', ( options?.expires && (cookie += ` Expires=${options.expires.toUTCString()};`), options?.maxAge && (cookie += ` Max-Age=${options.maxAge};`), options?.domain && (cookie += ` Domain=${options.domain};`), options?.path && (cookie += ` Path=${options.path};`), options?.secure && (cookie += ' Secure;'), options?.httpOnly && (cookie += ' HttpOnly;'), options?.sameSite && (cookie += ` SameSite=${ options.sameSite.charAt(0).toUpperCase() + options.sameSite.slice(1) };`), cookie ), ) } /** * Set an empty `Set-Cookie` header to delete the cookie. * * @since v1.4 */ deleteCookie(name: string, options?: { path?: string; domain?: string }) { this.setCookie(name, '', { expires: new Date(0), ...options, }) } /** * Attach a header to the response. */ header(name: string, value: string | undefined) { if (value === undefined) { this.#i.h.delete(name) } else { this.#i.h.set(name, value) } } /** * Redirect the incoming request. * * @param destination e.g. https://google.com * @param code e.g. 301, default 307 */ redirect(destination: string, code = 307) { this.#i.c = code this.#i.h.set('location', destination) } } ================================================ FILE: send_mail.ts ================================================ // Copyright 2023 Samuel Kopp. All rights reserved. Apache-2.0 license. type MailContact = | { name?: string; email: string } | { name?: string; email: string }[] | string | string[] /** * Send an email through [mailchannels](https://blog.cloudflare.com/sending-email-from-workers-with-mailchannels). */ export function sendMail( options: { subject: string message: string from: { name: string email: string } to: MailContact cc?: MailContact bcc?: MailContact reply?: boolean dkim?: { domain?: string privateKey?: string selector?: string } }, ) { // to const to: { name?: string; email: string }[] = [] if (typeof options.to === 'string') { to.push({ email: options.to }) } else if (options.to instanceof Array) { for (const recipient of options.to) { to.push(typeof recipient === 'string' ? { email: recipient } : recipient) } } else { to.push(options.to) } // cc const cc: { name?: string; email: string }[] = [] if (typeof options.cc === 'string') { cc.push({ email: options.cc }) } else if (options.cc instanceof Array) { for (const recipient of options.cc) { cc.push(typeof recipient === 'string' ? { email: recipient } : recipient) } } else if (options.cc) { cc.push(options.cc) } // bcc const bcc: { name?: string; email: string }[] = [] if (typeof options.bcc === 'string') { bcc.push({ email: options.bcc }) } else if (options.bcc instanceof Array) { for (const recipient of options.bcc) { bcc.push( typeof recipient === 'string' ? { email: recipient } : recipient, ) } } else if (options.bcc) { bcc.push(options.bcc) } return fetch('https://api.mailchannels.net/tx/v1/send', { method: 'POST', headers: { 'content-type': 'application/json', }, body: JSON.stringify({ personalizations: [{ to, ...(cc.length > 0 && { cc }), ...(bcc.length > 0 && { bcc }), ...(options.dkim?.domain && { dkim_domain: options.to }), ...(options.dkim?.privateKey && { dkim_private_key: options.to }), ...(options.dkim?.selector && { dkim_selector: options.to }), }], from: options.from, subject: options.subject, content: [{ type: options.message.startsWith('') ? 'text/html' : 'text/plain', value: options.message, }], ...(options.reply && { reply_to: to[0] }), }), }) } ================================================ FILE: test/context.test.ts ================================================ // Copyright 2023 Samuel Kopp. All rights reserved. Apache-2.0 license. import { assertEquals } from 'std/assert/mod.ts' import cheetah from '../mod.ts' Deno.test('Context', async (t) => { Deno.env.set('cheetah_test', 'test') const app = new cheetah() await t.step('c.runtime', async () => { app.get('/runtime', (c) => c.runtime) assertEquals( await (await app.fetch(new Request('http://localhost/runtime'))) .text(), 'deno', ) }) }) ================================================ FILE: test/cors.test.ts ================================================ // Copyright 2023 Samuel Kopp. All rights reserved. Apache-2.0 license. import { assertEquals } from 'std/assert/mod.ts' import cheetah from '../mod.ts' Deno.test('CORS', async (t) => { const app = new cheetah({ cors: '*', }) await t.step('Global CORS', async () => { app.get('/global', () => 'test') const result = await app.fetch( new Request('http://localhost/global', { method: 'OPTIONS', headers: { origin: 'custom.com', 'access-control-request-method': 'GET', }, }), ) assertEquals(result.headers.get('access-control-allow-origin'), '*') }) await t.step('Per-Route CORS', async () => { app.get('/per-route', { cors: 'custom.com' }, () => 'test') const result = await app.fetch( new Request('http://localhost/per-route', { method: 'OPTIONS', headers: { origin: 'foobar.com', 'access-control-request-method': 'GET', }, }), ) assertEquals( result.headers.get('access-control-allow-origin'), 'custom.com', ) }) }) ================================================ FILE: test/exception.test.ts ================================================ // TODO create test suite for c.exception ================================================ FILE: test/ext/favicon.test.ts ================================================ // Copyright 2023 Samuel Kopp. All rights reserved. Apache-2.0 license. import { assertEquals } from 'std/assert/mod.ts' import { favicon } from '../../ext/favicon.ts' import cheetah from '../../mod.ts' Deno.test('ext/favicon', async () => { const app = new cheetah() app.use(favicon({ source: await Deno.readFile('./.github/cheetah.svg'), })) app.get('/', () => 'Hello World') const res = await app.fetch(new Request('http://localhost/favicon.ico')) assertEquals( await res.text(), await Deno.readTextFile('./.github/cheetah.svg'), ) assertEquals( res.status, 200, ) }) ================================================ FILE: test/ext/files.test.ts ================================================ // Copyright 2023 Samuel Kopp. All rights reserved. Apache-2.0 license. import { assertEquals } from 'std/assert/mod.ts' import { files } from '../../ext/files.ts' import cheetah from '../../mod.ts' Deno.test('ext/files', async (t) => { await t.step('root', async () => { const app = new cheetah() app.use(files({ serve: { directory: './test', }, })) app.get('/', () => 'Hello World') const res1 = await app.fetch(new Request('http://localhost')) assertEquals( await res1.text(), 'Hello World', ) assertEquals( res1.status, 200, ) const res2 = await app.fetch( new Request('http://localhost/render.test.tsx'), ) assertEquals( await res2.text(), await Deno.readTextFile('./test/render.test.tsx'), ) assertEquals( res2.status, 200, ) }) await t.step('with prefix', async () => { const app = new cheetah() app.use( '/t', files({ serve: { directory: './test', }, }), ) app.get('/', () => 'Hello World') const res1 = await app.fetch(new Request('http://localhost')) assertEquals( await res1.text(), 'Hello World', ) assertEquals( res1.status, 200, ) const res2 = await app.fetch( new Request('http://localhost/t/ext/pretty.test.ts'), ) assertEquals( await res2.text(), await Deno.readTextFile('./test/ext/pretty.test.ts'), ) assertEquals( res2.status, 200, ) }) }) ================================================ FILE: test/ext/firewall.test.ts ================================================ import { assertEquals } from 'std/assert/mod.ts' import { firewall } from '../../ext/firewall.ts' import cheetah from '../../mod.ts' Deno.test('ext/firewall', async (t) => { await t.step('vpn', async () => { const app = new cheetah({ proxy: 'cloudflare' }) app.use(firewall({ blockVPN: true, })) const res = await app.fetch( new Request('http://localhost', { headers: { 'cf-connecting-ip': '2.56.16.0', }, }), ) assertEquals(res.status, 403) }) }) Deno.test('datacenters', async () => { const app = new cheetah({ proxy: 'cloudflare' }) app.use(firewall({ blockDatacenter: true, })) const res = await app.fetch( new Request('http://localhost', { headers: { 'cf-connecting-ip': '1.12.32.0', }, }), ) assertEquals(res.status, 403) }) Deno.test('customRanges', async () => { const app = new cheetah({ proxy: 'cloudflare' }) app.use(firewall({ customRanges: [ '1.2.3.4/32', ], })) const res = await app.fetch( new Request('http://localhost', { headers: { 'cf-connecting-ip': '1.2.3.4', }, }), ) assertEquals(res.status, 403) }) ================================================ FILE: test/ext/pretty.test.ts ================================================ // Copyright 2023 Samuel Kopp. All rights reserved. Apache-2.0 license. import { assertEquals } from 'std/assert/mod.ts' import { pretty } from '../../ext/pretty.ts' import cheetah from '../../mod.ts' Deno.test('ext/pretty', async (t) => { const obj = { lastname: 'Doe', firstname: 'John', data: { height: 180, age: 20, }, } await t.step('indent', async () => { const app = new cheetah() .use(pretty({ indentSize: 4, sort: false, })) app.get('/', () => obj) const result = await app.fetch(new Request('http://localhost')) assertEquals( await result.text(), JSON.stringify( { lastname: 'Doe', firstname: 'John', data: { height: 180, age: 20, }, }, null, 4, ), ) assertEquals( result.headers.get('content-type'), 'application/json; charset=utf-8', ) }) await t.step('sort', async () => { const app = new cheetah() .use(pretty()) app.get('/', () => obj) const result = await app.fetch(new Request('http://localhost')) assertEquals( await result.text(), JSON.stringify( { data: { age: 20, height: 180, }, firstname: 'John', lastname: 'Doe', }, null, 2, ), ) assertEquals( result.headers.get('content-type'), 'application/json; charset=utf-8', ) }) }) ================================================ FILE: test/extensions.test.ts ================================================ // Copyright 2023 Samuel Kopp. All rights reserved. Apache-2.0 license. import { assertEquals } from 'std/assert/mod.ts' import cheetah, { createExtension } from '../mod.ts' Deno.test('Extensions', async (t) => { let count = 0 const a = createExtension({ onPlugIn({ setRoute }) { setRoute('get', '/cookie', () => '🍪') count++ }, onRequest({ req }) { req.headers.set('custom', 'test') }, }) const b = createExtension({ onResponse({ c, prefix }) { c.res.body = 'custom' assertEquals(prefix, '/b') }, }) const c = createExtension({ onRequest({ req }) { const url = new URL(req.url) if (url.pathname.startsWith('/foo')) { return new Response('hello') } }, }) const app = new cheetah() .use(a(), c()) .use('/b', b()) .get('/a', (c) => { assertEquals(c.req.headers.custom, 'test') return 'test' }) .get('/b', (c) => { assertEquals(c.req.headers.custom, 'test') return 'test' }) await t.step('onPlugIn (part 1)', async () => { const result = await app.fetch(new Request('http://localhost/cookie')) assertEquals(await result.text(), '🍪') }) await t.step('onRequest', async () => { const result = await app.fetch(new Request('http://localhost/a')) assertEquals(await result.text(), 'test') }) await t.step('onResponse', async () => { const result1 = await app.fetch(new Request('http://localhost/b')) assertEquals(await result1.text(), 'custom') const result2 = await app.fetch(new Request('http://localhost/foo/bar')) assertEquals(await result2.text(), 'hello') }) await t.step('onPlugIn (part 2)', async () => { const result = await app.fetch(new Request('http://localhost/cookie')) assertEquals(await result.text(), '🍪') assertEquals(count, 1) }) }) ================================================ FILE: test/jwt.test.ts ================================================ // Copyright 2023 Samuel Kopp. All rights reserved. Apache-2.0 license. import { assertEquals } from 'std/assert/mod.ts' import { cheetah } from '../cheetah.ts' import { createJwtSecret } from '../cli/cmd/random/create_jwt_secret.ts' import { jwt } from '../mod.ts' Deno.test('jwt', async () => { const key = await createJwtSecret() const token = await jwt.sign(key, { example: 'object' }) assertEquals( await jwt.verify(await createJwtSecret(), token) === undefined, true, ) assertEquals(await jwt.verify(key, token) !== undefined, true) Deno.env.set('jwt_secret', key) const app = new cheetah() app.get('/one', async (c) => { assertEquals(await jwt.verify(c, token) !== undefined, true) }) await app.fetch(new Request('http://localhost/one')) Deno.env.delete('jwt_secret') Deno.env.set('JWT_SECRET', key) app.get('/two', async (c) => { assertEquals(await jwt.verify(c, token) !== undefined, true) }) await app.fetch(new Request('http://localhost/two')) }) ================================================ FILE: test/many_handlers.test.ts ================================================ // Copyright 2023 Samuel Kopp. All rights reserved. Apache-2.0 license. import { assertEquals } from 'std/assert/mod.ts' import cheetah from '../mod.ts' Deno.test('Many Handlers', async () => { const app = new cheetah() app.get('/a', (_, next) => { next() return 'a' }, (c) => { if (c.req.headers.foo !== undefined) { return 'b' } }) app.get('/b', (c) => { c.res.header('foo', 'bar') }, (c) => { c.res.header('foo', 'foo') }) app.get('/c', (c, next) => { c.res.header('foo', 'bar') next() }, (c) => { c.res.header('foo', 'foo') }) assertEquals( await (await app.fetch(new Request('http://localhost/a'))).text(), 'a', ) assertEquals( await (await app.fetch( new Request('http://localhost/a', { headers: { 'foo': 'bar' } }), )).text(), 'b', ) assertEquals( (await app.fetch(new Request('http://localhost/b'))).headers.get('foo'), 'bar', ) assertEquals( (await app.fetch(new Request('http://localhost/c'))).headers.get('foo'), 'foo', ) }) ================================================ FILE: test/preflight_mode.test.ts ================================================ // Copyright 2023 Samuel Kopp. All rights reserved. Apache-2.0 license. import { assertEquals } from 'std/assert/mod.ts' import cheetah from '../mod.ts' Deno.test('preflight mode', async (t) => { await t.step('basic', async () => { const app = new cheetah({ preflight: true }) app.get('/foo1', () => { return 'bar1' }) app.post('/foo2', () => { return 'bar2' }) const res1 = await app.fetch(new Request('https://deno.com/foo1')) assertEquals(res1.body === null, false) const res2 = await app.fetch( new Request('https://deno.com/foo1', { method: 'HEAD' }), ) assertEquals(res2.body === null, true) const res3 = await app.fetch( new Request('https://deno.com/foo2', { method: 'POST' }), ) assertEquals(res3.body === null, false) const res4 = await app.fetch( new Request('https://deno.com/foo2', { method: 'HEAD' }), ) assertEquals(res4.status, 404) assertEquals(res4.body === null, true) }) await t.step('not found', async () => { const app = new cheetah({ preflight: true }) const res1 = await app.fetch(new Request('https://deno.com/foo')) assertEquals(res1.body === null, false) const res2 = await app.fetch( new Request('https://deno.com/foo', { method: 'HEAD' }), ) assertEquals(res2.body === null, true) }) await t.step('not found (custom)', async () => { const app = new cheetah({ preflight: true, notFound() { return new Response('custom', { headers: { foo: 'bar', }, }) }, }) const res1 = await app.fetch(new Request('https://deno.com')) assertEquals(res1.body === null, false) assertEquals(res1.headers.get('foo'), 'bar') const res2 = await app.fetch( new Request('https://deno.com', { method: 'HEAD' }), ) assertEquals(res2.body === null, true) assertEquals(res2.headers.get('foo'), 'bar') }) await t.step('error', async () => { const app = new cheetah({ preflight: true }) app.get('/foo', () => { throw new Error() }) const res1 = await app.fetch(new Request('https://deno.com/foo')) assertEquals(res1.body === null, false) const res2 = await app.fetch( new Request('https://deno.com/foo', { method: 'HEAD' }), ) assertEquals(res2.body === null, true) }) await t.step('error (custom)', async () => { const app = new cheetah({ preflight: true, error() { return new Response('custom', { headers: { foo: 'bar', }, }) }, }) app.get('/foo', () => { throw new Error() }) const res1 = await app.fetch(new Request('https://deno.com/foo')) assertEquals(res1.body === null, false) assertEquals(res1.headers.get('foo'), 'bar') const res2 = await app.fetch( new Request('https://deno.com/foo', { method: 'HEAD' }), ) assertEquals(res2.body === null, true) assertEquals(res2.headers.get('foo'), 'bar') }) }) ================================================ FILE: test/render.test.tsx ================================================ // Copyright 2023 Samuel Kopp. All rights reserved. Apache-2.0 license. /** @jsx h */ import { DOMParser } from 'dom' import { Fragment, h, VNode } from 'preact' import { assert, assertEquals } from 'std/assert/mod.ts' import { z } from 'zod' import cheetah, { Renderer } from '../mod.ts' const Document = ({ children }: { children: VNode }) => { return ( This is a document! {children} ) } const Styled = () => { return (

styled h3 component

) } const Unstyled = () => { return (

unstyled h3 component

) } const MetaTagsWithoutWrappers = () => { return ( This is a document!

Hello world!

) } const app = new cheetah() const { render } = new Renderer() app.get('/render', { query: z.object({ type: z.union([ z.literal('styled'), z.literal('unstyled'), z.literal('meta-tags-without-wrappers'), ]), }), }, (c) => { const type = c.req.query.type if (type === 'meta-tags-without-wrappers') { return render(c, ) } render( c, type === 'styled' ? : , ) }) Deno.test('render', async (test) => { await test.step('Twind styles are applied to the resulting HTML correctly.', async () => { const renderResponse = await app.fetch( new Request('http://localhost/render?type=styled'), ) const htmlText = await renderResponse.text() const document = new DOMParser().parseFromString( htmlText, 'text/html', ) assert(document) assert([...document.getElementsByTagName('style')].length === 1) assertEquals( document.getElementById('styled')?.innerText, 'styled h3 component', ) assertEquals( renderResponse.headers.get('content-type'), 'text/html; charset=utf-8', ) }) const renderResponse = await app.fetch( new Request('http://localhost/render?type=unstyled'), ) const htmlText = await renderResponse.text() const document = new DOMParser().parseFromString( htmlText, 'text/html', ) assert(document) await test.step('No empty style tag is injected if no Twind styles are utilised.', () => { assertEquals( [...document.getElementsByTagName('style')].length, 0, ) assertEquals( document.getElementById('unstyled')?.innerText, 'unstyled h3 component', ) assertEquals( renderResponse.headers.get('content-type'), 'text/html; charset=utf-8', ) }) await test.step('Head meta tags are able to be injected into the HTML output properly.', () => { const headElementsInDocument = [...document.getElementsByTagName('head')] assert(headElementsInDocument.length === 1) const headElementInDocument = headElementsInDocument.at(0) assert(headElementInDocument) assert( [...headElementInDocument.children].find((childNode) => childNode.tagName === 'TITLE' && childNode.textContent === 'This is a document!' ), ) }) await test.step('Even without the essential html, head and body tags in the JSX input, the JSX input is injected correctly into the appropriate places.', async () => { const renderResponse = await app.fetch( new Request('http://localhost/render?type=meta-tags-without-wrappers'), ) const htmlText = await renderResponse.text() const document = new DOMParser().parseFromString( htmlText, 'text/html', ) assert(document) const { documentElement } = document assert(documentElement) const headElementsInDocument = documentElement.getElementsByTagName('head') const [headElement] = headElementsInDocument assert(headElement) const titleElementInDocument = headElement.getElementsByTagName('title').at( 0, ) assert( titleElementInDocument && titleElementInDocument.textContent === 'This is a document!', ) const metaTagsInDocument = documentElement.getElementsByTagName('meta') assert( metaTagsInDocument.find((metaTag) => metaTag.getAttribute('charset') === 'utf-8' ), ) const [bodyElement] = documentElement.getElementsByTagName('body') assert(bodyElement) const [headingElementInBody] = [...bodyElement.children] assert( headingElementInBody !== undefined && headingElementInBody.tagName === 'H1' && headingElementInBody.textContent === 'Hello world!', ) }) }) ================================================ FILE: test/request.test.ts ================================================ // Copyright 2023 Samuel Kopp. All rights reserved. Apache-2.0 license. import { assertEquals } from 'std/assert/mod.ts' import { z } from 'zod' import cheetah from '../mod.ts' Deno.test('Request', async (t) => { await t.step('req.param()', async () => { const app = new cheetah() app.get('/users/:name', (c) => c.req.param('name')) assertEquals( await (await app.fetch( new Request('http://localhost/users/johndoe'), )).text(), 'johndoe', ) }) // await t.step('req.ip', async () => { // const app = new cheetah() // app.get('/ip', (c) => { // return c.req.ip // }) // const result = await (await app.fetch(new Request('http://localhost/ip'))) // .text() // assertEquals(result, '127.0.0.1') // }) await t.step('req.raw()', async () => { const app = new cheetah() app.post('/raw', (c) => c.req.raw.method) assertEquals( await (await app.fetch( new Request('http://localhost/raw', { method: 'POST' }), )).text(), 'POST', ) }) await t.step('req.body()', async (t) => { const app = new cheetah() const text = async (pathname: string, body: BodyInit) => { try { return await (await app.fetch( new Request(`http://localhost/${pathname}`, { method: 'POST', body, }), )).text() } catch (_err) { return null } } await t.step('req.body() - union (object, object)', async () => { app.post('/b', { body: z.union([ z.object({ bar: z.literal('foo'), }), z.object({ foo: z.literal('bar'), }), ]), }, async (c) => { await c.req.body() return 'ok' }) assertEquals( await text( 'b', 'test', ), 'Bad Request', ) assertEquals( await text( 'b', JSON.stringify({ foo: 'bar', }), ), 'ok', ) assertEquals( await text( 'b', JSON.stringify({ bar: 'foo', }), ), 'ok', ) assertEquals( await text( 'b', JSON.stringify({ abc: 'def', }), ), 'Bad Request', ) }) await t.step('req.body() - union (string, string)', async () => { app.post('/c', { body: z.union([ z.string().startsWith('http://'), z.string().startsWith('https://'), ]), }, async (c) => { await c.req.body() return 'ok' }) assertEquals( await text('c', 'foo'), 'Bad Request', ) assertEquals( await text( 'c', JSON.stringify({ foo: 'bar', }), ), 'Bad Request', ) assertEquals( await text('c', 'https://foo.bar'), 'ok', ) assertEquals( await text('c', 'http://foo.bar'), 'ok', ) }) await t.step('req.body() - string', async () => { app.post('/d', { body: z.string().max(3), }, async (c) => { await c.req.body() return 'ok' }) assertEquals( await text('d', 'foo'), 'ok', ) assertEquals( await text('d', 'bar'), 'ok', ) assertEquals( await text( 'd', 'foo bar', ), 'Bad Request', ) }) await t.step('req.body() - object', async () => { app.post('/e', { body: z.object({ foo: z.literal('bar'), }), }, async (c) => { await c.req.body() return 'ok' }) assertEquals( await text('e', 'test'), 'Bad Request', ) assertEquals( await text( 'e', JSON.stringify({ foo: 'bar', }), ), 'ok', ) assertEquals( await text( 'e', JSON.stringify({ abc: 'def', }), ), 'Bad Request', ) }) await t.step('req.body() - record', async () => { app.post('/f', { body: z.record(z.string()), }, async (c) => { await c.req.body() return 'ok' }) assertEquals( await text('f', 'test'), 'Bad Request', ) assertEquals( await text( 'f', JSON.stringify({ foo: 'bar', }), ), 'ok', ) assertEquals( await text( 'f', JSON.stringify({ abc: 'def', }), ), 'ok', ) }) }) await t.step('req.buffer()', async () => { const app = new cheetah() app.post( '/buffer', async (c) => await c.req.buffer() instanceof ArrayBuffer ? 'ok' : 'error', ) assertEquals( await (await app.fetch( new Request('http://localhost/buffer', { method: 'POST', body: 'test', }), )).text(), 'ok', ) }) await t.step('req.blob()', async () => { const app = new cheetah() app.post( '/blob', async (c) => await c.req.blob() instanceof Blob ? 'ok' : 'error', ) assertEquals( await (await app.fetch( new Request('http://localhost/blob', { method: 'POST', body: 'test', }), )).text(), 'ok', ) }) await t.step('req.formData()', async () => { const app = new cheetah() app.post( '/formData', async (c) => await c.req.formData() instanceof FormData ? 'ok' : 'error', ) app.post( '/notFormData', async (c) => await c.req.formData() === null ? 'ok' : 'error', ) assertEquals( await (await app.fetch( new Request('http://localhost/formData', { method: 'POST', body: new FormData(), }), )).text(), 'ok', ) assertEquals( await (await app.fetch( new Request('http://localhost/notFormData', { method: 'POST', body: 'test', }), )).text(), 'ok', ) }) await t.step('req.stream()', async () => { const app = new cheetah() app.post('/stream', async (c) => { const stream = c.req.stream const text = stream !== null && stream instanceof ReadableStream ? 'ok' : 'error' if (stream !== null) { await stream.cancel() } return text }) assertEquals( await (await app.fetch( new Request('http://localhost/stream', { method: 'POST', body: new ReadableStream(), }), )).text(), 'ok', ) }) }) ================================================ FILE: test/response.test.ts ================================================ // Copyright 2023 Samuel Kopp. All rights reserved. Apache-2.0 license. import { assertEquals } from 'std/assert/mod.ts' import cheetah from '../mod.ts' Deno.test('Response', async (t) => { const app = new cheetah() await t.step('res.code', async () => { app.get('/code-explicit', (c) => { c.res.code = 101 }) assertEquals( (await app.fetch(new Request('http://localhost/code-explicit'))).status, 101, ) app.get('/code-implicit', () => { return { code: 403, } }) assertEquals( (await app.fetch(new Request('http://localhost/code-implicit'))).status, 403, ) }) await t.step('res.bodySize', async () => { app.get('/body_size', (c) => { c.res.body = 'hello world' return c.res.bodySize.toString() }) const result = await app.fetch( new Request('http://localhost/body_size'), ) assertEquals( await result.text(), 'hello world'.length.toString(), ) }) await t.step('res.setCookie()', async () => { app.get('/cookie', (c) => c.res.setCookie('custom', 'test')) assertEquals( (await app.fetch(new Request('http://localhost/cookie'))).headers .get('set-cookie'), 'custom=test;', ) }) // TODO add test suite for res.deleteCookie() await t.step('res.header()', async () => { app.get('/header', (c) => c.res.header('custom', 'test')) assertEquals( (await app.fetch(new Request('http://localhost/header'))).headers .get('custom'), 'test', ) }) await t.step('res.redirect()', async () => { // temporary redirect app.get('/temporaryredirect', (c) => c.res.redirect('https://deno.com')) const response1 = await app.fetch( new Request('http://localhost/temporaryredirect'), ) assertEquals(response1.status, 307) assertEquals(response1.headers.get('location'), 'https://deno.com') // permanent redirect app.get( '/permanentredirect', (c) => c.res.redirect('https://deno.com', 301), ) const response2 = await app.fetch( new Request('http://localhost/permanentredirect'), ) assertEquals(response2.status, 301) assertEquals(response2.headers.get('location'), 'https://deno.com') }) await t.step('blob', async () => { const blob = new Blob([new TextEncoder().encode('test')]) app.get('/blob', () => { return blob }) assertEquals( new TextDecoder().decode( await (await (await app.fetch( new Request('http://localhost/blob'), )).blob()).arrayBuffer(), ), 'test', ) }) await t.step('stream', async () => { app.get('/stream', () => { return new ReadableStream() }) assertEquals( (await app.fetch(new Request('http://localhost/stream'))).body !== null, true, ) }) await t.step('formData', async () => { const formData = new FormData() formData.append('one', 'field') app.get('/formData', () => { return formData }) assertEquals( await (await app.fetch(new Request('http://localhost/formData'))) .formData(), formData, ) }) await t.step('buffer', async () => { const buffer = new TextEncoder().encode('test') const arrayBuffer = buffer.buffer app.get('/buffer', () => { return buffer }) assertEquals( await (await app.fetch(new Request('http://localhost/buffer'))) .arrayBuffer(), arrayBuffer, ) }) await t.step('json', async () => { app.get('/json', () => { return { message: 'test', } }) assertEquals( await (await app.fetch( new Request('http://localhost/json'), )).json(), { message: 'test' }, ) }) await t.step('text', async () => { app.get('/text', () => { return 'test' }) assertEquals( await (await app.fetch( new Request('http://localhost/text'), )).text(), 'test', ) }) }) ================================================ FILE: test/routing/versioning.test.ts ================================================ import { assertEquals } from 'std/assert/mod.ts' import { cheetah } from '../../cheetah.ts' Deno.test('versioning', async (t) => { /* uri ---------------------------------------------------------------------- */ await t.step('uri', async () => { const app = new cheetah({ versioning: { current: 'v4', // latest type: 'uri', }, }) async function get(path: string) { const res = await app.fetch(new Request(`http://localhost${path}`)) return await res.text() } // unspecified app.get('/1', (c) => `hello from v${c.req.gateway}`) assertEquals(await get('/v1/1'), 'hello from v1') assertEquals(await get('/v2/1'), 'hello from v2') assertEquals(await get('/v3/1'), 'hello from v3') assertEquals(await get('/v4/1'), 'hello from v4') assertEquals(await get('/v5/1') !== 'hello from v5', true) // exact app.get('/2', { gateway: 'v3' }, (c) => `hello from v${c.req.gateway}`) assertEquals(await get('/v1/2') !== 'hello from v1', true) assertEquals(await get('/v2/2') !== 'hello from v2', true) assertEquals(await get('/v3/2'), 'hello from v3') assertEquals(await get('/v4/2') !== 'hello from v4', true) // smaller than app.get('/3', { gateway: '< v3' }, (c) => `hello from v${c.req.gateway}`) assertEquals(await get('/v1/3'), 'hello from v1') assertEquals(await get('/v2/3'), 'hello from v2') assertEquals(await get('/v3/3') !== 'hello from v3', true) assertEquals(await get('/v4/3') !== 'hello from v4', true) // smaller than or equal to app.get('/4', { gateway: '<= v3' }, (c) => `hello from v${c.req.gateway}`) assertEquals(await get('/v1/4'), 'hello from v1') assertEquals(await get('/v2/4'), 'hello from v2') assertEquals(await get('/v3/4'), 'hello from v3') assertEquals(await get('/v4/4') !== 'hello from v4', true) // greater than app.get('/5', { gateway: '> v3' }, (c) => `hello from v${c.req.gateway}`) assertEquals(await get('/v1/5') !== 'hello from v1', true) assertEquals(await get('/v2/5') !== 'hello from v2', true) assertEquals(await get('/v3/5') !== 'hello from v3', true) assertEquals(await get('/v4/5'), 'hello from v4') // greater than or equal app.get('/6', { gateway: '>= v3' }, (c) => `hello from v${c.req.gateway}`) assertEquals(await get('/v1/6') !== 'hello from v1', true) assertEquals(await get('/v2/6') !== 'hello from v2', true) assertEquals(await get('/v3/6'), 'hello from v3') assertEquals(await get('/v4/6'), 'hello from v4') // from (min) ... to (max) app.get( '/7', { gateway: 'v2...v3' }, (c) => `hello from v${c.req.gateway}`, ) assertEquals(await get('/v1/7') !== 'hello from v1', true) assertEquals(await get('/v2/7'), 'hello from v2') assertEquals(await get('/v3/7'), 'hello from v3') assertEquals(await get('/v4/7') !== 'hello from v4', true) // with base path const app2 = new cheetah({ base: '/api', versioning: { current: 'v4', // latest type: 'uri', }, }) async function get2(path: string) { const res = await app2.fetch(new Request(`http://localhost${path}`)) return await res.text() } app2.get( '/7', { gateway: 'v2...v3' }, (c) => `hello from v${c.req.gateway}`, ) assertEquals(await get2('/api/v1/7') !== 'hello from v1', true) assertEquals(await get2('/api/v2/7'), 'hello from v2') assertEquals(await get2('/api/v3/7'), 'hello from v3') assertEquals(await get2('/api/v4/7') !== 'hello from v4', true) }) /* header ------------------------------------------------------------------- */ await t.step('header', async () => { const app = new cheetah({ versioning: { current: 'v4', // latest type: 'header', header: 'x-version', }, }) async function get(path: string) { path = path.replace('/', '') const res = await app.fetch( new Request(`http://localhost/${path.split('/')[1]}`, { headers: { 'x-version': `${path.split('/')[0]}`, }, }), ) return await res.text() } // unspecified app.get('/1', (c) => `hello from v${c.req.gateway}`) assertEquals(await get('/v1/1'), 'hello from v1') assertEquals(await get('/v2/1'), 'hello from v2') assertEquals(await get('/v3/1'), 'hello from v3') assertEquals(await get('/v4/1'), 'hello from v4') assertEquals(await get('/v5/1') !== 'hello from v5', true) // exact app.get('/2', { gateway: 'v3' }, (c) => `hello from v${c.req.gateway}`) assertEquals(await get('/v1/2') !== 'hello from v1', true) assertEquals(await get('/v2/2') !== 'hello from v2', true) assertEquals(await get('/v3/2'), 'hello from v3') assertEquals(await get('/v4/2') !== 'hello from v4', true) // smaller than app.get('/3', { gateway: '< v3' }, (c) => `hello from v${c.req.gateway}`) assertEquals(await get('/v1/3'), 'hello from v1') assertEquals(await get('/v2/3'), 'hello from v2') assertEquals(await get('/v3/3') !== 'hello from v3', true) assertEquals(await get('/v4/3') !== 'hello from v4', true) // smaller than or equal to app.get('/4', { gateway: '<= v3' }, (c) => `hello from v${c.req.gateway}`) assertEquals(await get('/v1/4'), 'hello from v1') assertEquals(await get('/v2/4'), 'hello from v2') assertEquals(await get('/v3/4'), 'hello from v3') assertEquals(await get('/v4/4') !== 'hello from v4', true) // greater than app.get('/5', { gateway: '> v3' }, (c) => `hello from v${c.req.gateway}`) assertEquals(await get('/v1/5') !== 'hello from v1', true) assertEquals(await get('/v2/5') !== 'hello from v2', true) assertEquals(await get('/v3/5') !== 'hello from v3', true) assertEquals(await get('/v4/5'), 'hello from v4') // greater than or equal app.get('/6', { gateway: '>= v3' }, (c) => `hello from v${c.req.gateway}`) assertEquals(await get('/v1/6') !== 'hello from v1', true) assertEquals(await get('/v2/6') !== 'hello from v2', true) assertEquals(await get('/v3/6'), 'hello from v3') assertEquals(await get('/v4/6'), 'hello from v4') // from (min) ... to (max) app.get( '/7', { gateway: 'v2...v3' }, (c) => `hello from v${c.req.gateway}`, ) assertEquals(await get('/v1/7') !== 'hello from v1', true) assertEquals(await get('/v2/7'), 'hello from v2') assertEquals(await get('/v3/7'), 'hello from v3') assertEquals(await get('/v4/7') !== 'hello from v4', true) }) }) ================================================ FILE: test/routing.test.ts ================================================ // Copyright 2023 Samuel Kopp. All rights reserved. Apache-2.0 license. import { assertEquals } from 'std/assert/mod.ts' import cheetah, { Collection } from '../mod.ts' Deno.test('Nesting/Routing', async (t) => { const collection = new Collection() .get('/', () => 'nested root route') .get('/nestedroute', () => 'nested route') await t.step('Root with Base Path', async () => { const r1 = new cheetah({ base: '/api', }) .use('/collection', collection) assertEquals( await (await r1.fetch( new Request('http://localhost/api/collection'), )).text(), 'nested root route', ) assertEquals( await (await r1.fetch( new Request('http://localhost/api/collection/nestedroute'), )).text(), 'nested route', ) }) await t.step('Root without Base Path', async () => { const r2 = new cheetah() .use('/collection', collection) assertEquals( await (await r2.fetch(new Request('http://localhost/collection'))) .text(), 'nested root route', ) assertEquals( await (await r2.fetch( new Request('http://localhost/collection/nestedroute'), )).text(), 'nested route', ) }) }) ================================================ FILE: test/validation.test.ts ================================================ // Copyright 2023 Samuel Kopp. All rights reserved. Apache-2.0 license. import { assertEquals } from 'std/assert/mod.ts' import { z } from 'zod' import cheetah from '../mod.ts' Deno.test('Validation', async (t) => { await t.step('transform', async () => { const app = new cheetah() app.post('/transform', { body: z.object({ message: z.string(), }), }, async (c) => { assertEquals(await c.req.body({ transform: true }), { message: 'Hello World', }) return 'test' }) const form = new FormData() form.append('message', 'Hello World') await (await app.fetch( new Request('http://localhost/transform', { method: 'POST', body: form, }), )).text() await (await app.fetch( new Request('http://localhost/transform', { method: 'POST', body: JSON.stringify({ message: 'Hello World' }), }), )).text() }) await t.step('cookies', async () => { const app = new cheetah() app.get('/cookies', { cookies: z.object({ custom: z.string().min(4).max(16) }).strict(), }, (c) => { return c.req.cookies }) assertEquals( (await app.fetch( new Request('http://localhost/cookies', { headers: { cookies: 'custom=test;' }, }), )).status, 200, ) assertEquals( (await app.fetch( new Request('http://localhost/cookies', { headers: { cookie: 'custom=te;' }, }), )).status, 400, ) assertEquals( (await app.fetch( new Request('http://localhost/cookies', { headers: { cookie: 'invalid=abc;' }, }), )).status, 400, ) assertEquals( (await app.fetch( new Request('http://localhost/cookies', { headers: { cookie: 'custom=test; another=cookie;' }, }), )).status, 400, ) }) await t.step('headers', async () => { const app = new cheetah() app.get( '/headers', { headers: z.object({ custom: z.string().email() }) }, (c) => { return c.req.headers }, ) const res1 = await app.fetch( new Request('http://localhost/headers', { headers: { random: 'bullshit' }, }), ) const res2 = await app.fetch( new Request('http://localhost/headers', { headers: { random: 'bullshit', custom: 'tes@t' }, }), ) const res3 = await app.fetch( new Request('http://localhost/headers', { headers: { custom: '' }, }), ) const res4 = await app.fetch( new Request('http://localhost/headers', { headers: { custom: 'test@email.com' }, }), ) assertEquals(res1.status, 400) assertEquals(res2.status, 400) assertEquals(res3.status, 400) assertEquals(res4.status, 200) assertEquals(await res4.json(), { custom: 'test@email.com' }) }) await t.step('query', async () => { const app = new cheetah() app.get('/query', { query: z.object({ first: z.string().optional(), second: z.boolean(), third: z.number(), }), }, (c) => { const d = c.req.query d.first return c.req.query }) assertEquals( (await app.fetch( new Request('http://localhost/query?first=test&second&third=69'), )).status, 200, ) assertEquals( (await app.fetch( new Request('http://localhost/query?second=false&third=e9'), )).status, 400, ) assertEquals( (await app.fetch( new Request('http://localhost/query?second=false&third=69'), )).status, 200, ) }) await t.step('query (no schema)', async () => { const app = new cheetah() app.get('/query', (c) => { const d = c.req.query d.lol return d }) assertEquals( await (await app.fetch( new Request('http://localhost/query?first=test&second&third=69'), )).json(), { first: 'test', second: true, third: 69 }, ) assertEquals( await (await app.fetch( new Request('http://localhost/query?second=false&third=e9'), )).json(), { second: false, third: 'e9' }, ) assertEquals( await (await app.fetch( new Request('http://localhost/query?second=false&third=69'), )).json(), { second: false, third: 69 }, ) }) await t.step('params', async () => { const app = new cheetah() app.get( '/animals/:name', { params: { name: z.union([z.literal('cat'), z.literal('dog')]), }, }, (c) => { return c.req.param('name') }, ) const res1 = await app.fetch( new Request('http://localhost/animals/cat'), ) assertEquals(res1.status, 200) const res2 = await app.fetch( new Request('http://localhost/animals/dog'), ) assertEquals(res2.status, 200) const res3 = await app.fetch( new Request('http://localhost/animals/rabbit'), ) assertEquals(res3.status, 400) }) })