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<<EOF" >> $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<T>(): {
new (
addRoute: (
method: Uppercase<Method>,
pathname: string,
handlers: HandlerOrSchema[],
) => unknown,
):
& {
[
M in (
| 'delete'
| 'patch'
| 'post'
| 'put'
)
]: ReturnType<typeof handler<T>>
}
& {
[
M in (
| 'get'
| 'head'
)
]: ReturnType<typeof bodylessHandler<T>>
}
} {
return class {
#a
constructor(
addRoute: (
method: Uppercase<Method>,
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<string, unknown> | 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<Response>
/**
* Set a custom 404 handler.
*/
notFound?: (request: Request) => Response | Promise<Response>
oauth?: {
store: OAuthStore
cookie?: Parameters<ResponseContext['setCookie']>[2]
onSignIn?: (
c: Context,
data: OAuthSessionData,
) => Promise<unknown> | unknown
onSignOut?: (
c: Context,
identifier: string,
) => Promise<unknown> | 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<cheetah>() {
#base
#cors
#error
#extensions: Set<[string, Extension]>
#notFound
#preflight
#proxy
#routes: Set<[Uppercase<Method>, 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<C extends Collection>(...extensions: Extension<any>[]): this
use<C extends Collection>(
prefix: `/${string}`,
// deno-lint-ignore no-explicit-any
...extensions: Extension<any>[]
): this
use<C extends Collection>(
prefix: `/${string}`,
collection: C,
// deno-lint-ignore no-explicit-any
...extensions: Extension<any>[]
): this
use<C extends Collection>(
// deno-lint-ignore no-explicit-any
...elements: (`/${string}` | C | Extension<any>)[]
) {
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<string, unknown> | Deno.ServeHandlerInfo = {},
context?: {
waitUntil: (promise: Promise<unknown>) => void
},
): Promise<Response> => {
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<string, unknown>,
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<Method>,
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<unknown>) => {
// 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<unknown>) => void,
p: Record<string, string | undefined>,
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<Payload, void> | 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<unknown>)(
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<typeof parse>) {
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<string, unknown>
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<string, string> = 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 = "<name>"
account_id = "<account_id>"
route = "<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<typeof parse>) {
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<Collection>() {
#cors:
| string
| undefined
routes: Set<[
Uppercase<Method>,
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.
/// <reference types='./env.d.ts' />
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<string, unknown> = Record<string, unknown>,
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<unknown>) => void
constructor(
__app: AppContext,
__internal: {
b: Exclude<Payload, void> | null
c: number
h: Headers
},
p: Record<string, string | undefined>,
r: Request,
s: {
body?: ZodType | undefined
cookies?: ObjectType | undefined
headers?: ObjectType | undefined
query?: ObjectType | undefined
[key: string]: unknown
} | null,
waitUntil: (promise: Promise<unknown>) => 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<T extends keyof Variables>(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<string, unknown> | 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<T extends Record<string, unknown> = Record<string, unknown>>(
key: string,
type: 'json',
): Promise<T | undefined>
get<T extends string = string>(
key: string,
type: 'string',
): Promise<T | undefined>
get<T extends Uint8Array = Uint8Array>(
key: string,
type: 'buffer',
): Promise<T | undefined>
async get<T extends Record<string, unknown> | string | Uint8Array>(
key: string,
type: 'string' | 'json' | 'buffer' = 'string',
): Promise<T | undefined> {
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<string, unknown>
}
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<Uint8Array>
}
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<string, string>
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<T extends unknown = string | undefined>(
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<FirewallOptions>({
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<string, unknown> = {}
for (let i = 0; i < keys.length; i++) {
newObject[keys[i]] = sortObject(
(object as Record<string, unknown>)[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<string, unknown>
}
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<T> = Partial<T> 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<string, unknown> | unknown = never,
> = {
__config: Config | undefined
onPlugIn: HasRequired<Config> 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<any>,
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<void>
settings: Config
},
) => void | Promise<void>)
: ((
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<any>,
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<void>)
onRequest?: HasRequired<Config> extends true ? ((
context: ExtensionContext & {
app: AppContext
req: Request
_: Config
},
) => Req | Promise<Req>)
: ((
context: ExtensionContext & {
app: AppContext
req: Request
_?: Config
},
) => Req | Promise<Req>)
onResponse?: HasRequired<Config> extends true ? ((
context: ExtensionContext & {
app: AppContext
c: Context
_: Config
},
) => Res | Promise<Res>)
: ((
context: ExtensionContext & {
app: AppContext
c: Context
_?: Config
},
) => Res | Promise<Res>)
}
type ReturnFunction<
Config extends Record<string, unknown> | unknown = unknown,
> = Config extends Record<string, unknown>
? (HasRequired<Config> extends true ? ((config: Config) => Extension<Config>)
: ((config?: Config) => Extension<Config>))
: (() => Extension<Config>)
export function validExtension(ext: Record<string, unknown>) {
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<string, unknown> | unknown = unknown,
>({
onPlugIn,
onRequest,
onResponse,
}: {
onPlugIn?: Extension<Config>['onPlugIn']
onRequest?: Extension<Config>['onRequest']
onResponse?: Extension<Config>['onResponse']
}) {
return ((__config?: Config) => {
return {
__config,
onPlugIn,
onRequest,
onResponse,
[Symbol('cheetah.extension')]: 'v1.0',
}
}) as ReturnFunction<Config>
}
================================================
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<any>
| ZodRecord
export type BaseType<T extends ZodTypeDef = ZodTypeDef> =
// deno-lint-ignore no-explicit-any
ZodType<any, T, any>
type ExtractParam<Path, NextPart> = Path extends `:${infer Param}`
? Record<Param, string> & NextPart
: NextPart
type ExtractParams<Path> = Path extends `${infer Segment}/${infer Rest}`
? ExtractParam<Segment, ExtractParams<Rest>>
// deno-lint-ignore ban-types
: ExtractParam<Path, {}>
export type Payload =
| ArrayBuffer
| Blob
| FormData
| ReadableStream<unknown>
| Record<string, unknown>
| Uint8Array
| string
| undefined
| void
type Number = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9
export type Version = `v${
| Exclude<Number, 0>
| `${Exclude<Number, 0>}${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<any> = never,
ParsedCookies extends ObjectType = never,
ParsedHeaders extends ObjectType = never,
ParsedQuery extends ObjectType = never,
> = (
c: Context<
ExtractParams<Pathname>,
ParsedBody,
ParsedCookies,
ParsedHeaders,
ParsedQuery
>,
next: () => void,
) => Payload | Promise<Payload>
export function handler<T>() {
return <
Pathname extends `/${string}`,
// deno-lint-ignore no-explicit-any
ValidatedBody extends ObjectType | ZodString | ZodUnion<any> = 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<Record<keyof ExtractParams<Pathname>, 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<any> = never,
ParsedCookies extends ObjectType = never,
ParsedHeaders extends ObjectType = never,
ParsedQuery extends ObjectType = never,
> = (
c: Context<
ExtractParams<Pathname>,
ParsedBody,
ParsedCookies,
ParsedHeaders,
ParsedQuery
>,
next: () => void,
) => Payload | Promise<Payload>
export function bodylessHandler<T>() {
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<Record<keyof ExtractParams<Pathname>, 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<string, ZodType>
gateway?: VersionRange
}
| Handler<unknown>
| BodylessHandler<unknown>
================================================
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<T extends Record<string, unknown> = {}>(
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<T extends Record<string, unknown> = 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<any, any, any>
}
/**
* @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<OAuthSessionData | undefined> {
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<OAuthSessionToken>(
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<string | undefined> {
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<OAuthSessionToken>(
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<OAuthSignInToken>(
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<OAuthSessionToken>(
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<boolean> {
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<OAuthSignInToken>(
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<void>
get: (c: Context, key: string) => Promise<OAuthSessionData | undefined>
delete: (c: Context, key: string) => Promise<void>
has: (c: Context, key: string) => Promise<boolean>
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<OAuthSessionData>(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<OAuthSessionData>([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<OAuthSessionData>(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<OAuthSessionData>([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
================================================
<div align='center'>
<img src='https://cheetah.mod.land/cheetah.svg' width='128px' />
<br>
<br>
<h1>cheetah</h1>
</div>
<div align='center'>
<p><code>🛡️ secure</code> × <code>💎 simple</code> × <code>🪶 light</code></p>
</div>
<br>
> [!WARNING]
> cheetah is currently **not maintained**.
<br>
### 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.
<br>
---
<div align='center'>
<sup>A big thank you goes to</sup>
<br>
<br>
<br>
<a href='https://deco.cx'>
<img src='https://github.com/azurystudio/cheetah/blob/dev/.github/sponsors/deco.svg?raw=true' height='48px' />
<br>
<br>
<a href='https://deco.cx'><sup><b>Build fast stores and increase sales.</b></sup></a>
</a>
</div>
---
<br>
### 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.
<br>
### 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 ? `<style>${css}</style>` : ''}${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<typeof defineConfig>[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> = T extends ZodType ? z.infer<T>
: never
export class RequestContext<
Params extends Record<string, unknown> = Record<string, never>,
ValidatedBody extends ZodType = never,
ValidatedCookies extends ObjectType = never,
ValidatedHeaders extends ObjectType = never,
ValidatedQuery extends ObjectType = never,
> {
#c: Record<string, string | undefined> | undefined
#h: Record<string, string | undefined> | undefined
#a: AppContext
#p
#q: Record<string, unknown> | undefined
#r
#s
#e
constructor(
a: AppContext,
p: Record<string, string | undefined>,
r: Request,
s: {
body?: ZodType | undefined
cookies?: ObjectType | undefined
headers?: ObjectType | undefined
query?: ObjectType | undefined
params?: Record<string, ZodType>
[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<Method>
}
/**
* A method to retrieve the corresponding value of a parameter.
*/
param<T extends keyof Params>(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<ValidatedBody>
> {
if (!this.#s?.body) {
// @ts-ignore:
return undefined
}
let body
try {
if (
(this.#s.body as BaseType<ZodStringDef>)._def.typeName ===
'ZodString' ||
(this.#s.body as BaseType<ZodUnionDef>)._def.typeName === 'ZodUnion' &&
(this.#s.body as BaseType<ZodUnionDef>)._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<string, unknown>
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<ValidatedCookies> {
if (this.#c || !this.#s?.cookies) {
return this.#c as [ValidatedCookies] extends [never] ? never
: Static<ValidatedCookies>
}
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<string, string>, [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<ValidatedCookies>
}
/**
* The validated headers of the incoming request.
*/
get headers(): [ValidatedHeaders] extends [never]
? Record<string, string | undefined>
: Static<ValidatedHeaders> {
if (this.#h) {
return this.#h as [ValidatedHeaders] extends [never]
? Record<string, string | undefined>
: Static<ValidatedHeaders>
}
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<string, string | undefined>
: Static<ValidatedHeaders>
}
/**
* The validated query parameters of the incoming request.
*/
get query(): [ValidatedQuery] extends [never] ? Record<string, unknown>
: Static<ValidatedQuery> {
if (this.#q) {
return this.#q as [ValidatedQuery] extends [never]
? Record<string, unknown>
: Static<ValidatedQuery>
}
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<string, unknown>
: Static<ValidatedQuery>
}
/**
* 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<unknown> {
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<Payload, void> | null
c: number
h: Headers
}) {
this.#i = __internal
}
get body(): Exclude<Payload, void> | null {
return this.#i.b
}
set body(data: Exclude<Payload, void> | 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('<html>') ? '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 (
<html>
<head>
<title>This is a document!</title>
</head>
<body>
{children}
</body>
</html>
)
}
const Styled = () => {
return (
<Document>
<h3 class='text-sm italic' id='styled'>
styled <code class='font-mono'>h3</code> component
</h3>
</Document>
)
}
const Unstyled = () => {
return (
<Document>
<h3 id='unstyled'>
unstyled <code>h3</code> component
</h3>
</Document>
)
}
const MetaTagsWithoutWrappers = () => {
return (
<Fragment>
<title>This is a document!</title>
<meta charSet='utf-8' />
<h1>Hello world!</h1>
</Fragment>
)
}
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, <MetaTagsWithoutWrappers />)
}
render(
c,
type === 'styled' ? <Styled /> : <Unstyled />,
)
})
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)
})
})
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
SYMBOL INDEX (187 symbols across 43 files)
FILE: base.ts
type Method (line 4) | type Method =
constant METHODS (line 12) | const METHODS: Method[] = [
function base (line 21) | function base<T>(): {
FILE: cheetah.ts
type AppContext (line 17) | type AppContext = {
type AppConfig (line 36) | type AppConfig = {
class cheetah (line 114) | class cheetah extends base<cheetah>() {
method constructor (line 130) | constructor({
method use (line 196) | use<C extends Collection>(
method routes (line 248) | get routes() {
method #parseVersion (line 254) | #parseVersion(headers: Headers, pathname: string) {
method #match (line 297) | #match(request: Request, p: string) {
method #handle (line 546) | async #handle(
method serve (line 715) | serve({
function isVersionWithinRange (line 731) | function isVersionWithinRange(
FILE: cli/cmd/bundle/bundle.ts
function bundle (line 6) | async function bundle({
FILE: cli/cmd/bundle/mod.ts
function bundleCommand (line 13) | async function bundleCommand(args: ReturnType<typeof parse>) {
FILE: cli/cmd/new/mod.ts
type VSCodeSettings (line 5) | type VSCodeSettings = {
function newCommand (line 11) | async function newCommand() {
FILE: cli/cmd/random/create_crypto_key.ts
function createCryptoKey (line 4) | async function createCryptoKey() {
FILE: cli/cmd/random/create_jwt_secret.ts
function createJwtSecret (line 4) | async function createJwtSecret() {
FILE: cli/cmd/random/mod.ts
function randomCommand (line 7) | async function randomCommand() {
FILE: cli/cmd/serve/mod.ts
function serveCommand (line 9) | async function serveCommand(args: ReturnType<typeof parse>) {
FILE: cli/utils.ts
function logError (line 4) | function logError(message: string) {
FILE: collection.ts
class Collection (line 5) | class Collection extends base<Collection>() {
method constructor (line 16) | constructor({
FILE: context.ts
constant HTTP_MESSAGES (line 10) | const HTTP_MESSAGES = {
class Context (line 36) | class Context<
method constructor (line 64) | constructor(
method __app (line 90) | get __app(): AppContext {
method cache (line 94) | get cache(): Cache {
method dev (line 104) | get dev(): boolean {
method env (line 109) | env<T extends keyof Variables>(name: T): Variables[T] {
method req (line 115) | get req(): RequestContext<
method res (line 137) | get res(): ResponseContext {
method runtime (line 147) | get runtime() {
method exception (line 151) | exception(error: keyof typeof HTTP_MESSAGES, description?: string) {
class Exception (line 159) | class Exception {
method constructor (line 162) | constructor(
class Cache (line 190) | class Cache {
method constructor (line 195) | constructor(c: Context) {
method set (line 201) | async set(
method get (line 242) | async get<T extends Record<string, unknown> | string | Uint8Array>(
method has (line 271) | async has(key: string) {
method delete (line 283) | async delete(key: string) {
FILE: crypto.ts
function encrypt (line 5) | async function encrypt(c: Context, message: string) {
function decrypt (line 32) | async function decrypt(c: Context, message: string) {
FILE: env.d.ts
type Variables (line 2) | type Variables = Record<string, unknown>
FILE: ext/compress.ts
type CompressionAlgorithm (line 6) | type CompressionAlgorithm = {
constant FORAS (line 11) | let FORAS: InitOutput | undefined
constant BROTLI (line 13) | const BROTLI: CompressionAlgorithm = {
constant DEFLATE (line 18) | const DEFLATE: CompressionAlgorithm = {
method compress (line 20) | async compress(input) {
constant GZIP (line 29) | const GZIP: CompressionAlgorithm = {
method compress (line 31) | async compress(input) {
method onResponse (line 48) | async onResponse({ c, _ }) {
FILE: ext/debug.ts
method onResponse (line 18) | onResponse({ c }) {
FILE: ext/favicon.ts
constant FAVICON (line 4) | let FAVICON: ArrayBuffer | Uint8Array | undefined
method onRequest (line 17) | async onRequest({
FILE: ext/files.ts
type GeneralOptions (line 8) | type GeneralOptions = {
type FsOptions (line 13) | type FsOptions = {
type R2Options (line 18) | type R2Options = {
type S3Options (line 23) | type S3Options = {
function getVar (line 35) | function getVar<T extends unknown = string | undefined>(
method onRequest (line 61) | onRequest({
function handleS3Files (line 91) | async function handleS3Files(
function handleR2Files (line 142) | async function handleR2Files(
function handleFsFiles (line 190) | async function handleFsFiles(
function etag (line 230) | async function etag(stat: Deno.FileInfo) {
FILE: ext/firewall.ts
constant VPN_LIST (line 3) | let VPN_LIST: string[] = []
constant DATACENTER_LIST (line 4) | let DATACENTER_LIST: string[] = []
constant LAST_UPDATE (line 5) | let LAST_UPDATE = 0
type FirewallOptions (line 7) | type FirewallOptions = {
method onRequest (line 20) | async onRequest({ _: opts, app }) {
function checkUpdate (line 38) | async function checkUpdate() {
function isIpInRange (line 49) | function isIpInRange(ip: string, range: string) {
function ipToNumber (line 57) | function ipToNumber(ip: string) {
FILE: ext/helmet.ts
method onResponse (line 134) | onResponse({
FILE: ext/pretty.ts
function sortObject (line 4) | function sortObject(object: unknown) {
method onResponse (line 41) | onResponse({
FILE: extensions.ts
type HasRequired (line 7) | type HasRequired<T> = Partial<T> extends T ? false : true
type Req (line 9) | type Req = Response | void | undefined
type Res (line 10) | type Res = void | undefined
type ExtensionContext (line 12) | type ExtensionContext = {
type Extension (line 17) | type Extension<
type ReturnFunction (line 121) | type ReturnFunction<
function validExtension (line 128) | function validExtension(ext: Record<string, unknown>) {
function createExtension (line 137) | function createExtension<
FILE: handler.ts
type ObjectType (line 12) | type ObjectType =
type BaseType (line 17) | type BaseType<T extends ZodTypeDef = ZodTypeDef> =
type ExtractParam (line 21) | type ExtractParam<Path, NextPart> = Path extends `:${infer Param}`
type ExtractParams (line 25) | type ExtractParams<Path> = Path extends `${infer Segment}/${infer Rest}`
type Payload (line 30) | type Payload =
type Number (line 41) | type Number = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9
type Version (line 43) | type Version = `v${
type VersionRange (line 47) | type VersionRange =
type Handler (line 55) | type Handler<
function handler (line 73) | function handler<T>() {
type BodylessHandler (line 108) | type BodylessHandler<
function bodylessHandler (line 126) | function bodylessHandler<T>() {
type HandlerOrSchema (line 158) | type HandlerOrSchema =
FILE: jwt.ts
type Payload (line 6) | interface Payload {
function importKey (line 23) | function importKey(key: string) {
function sign (line 37) | async function sign<T extends Record<string, unknown> = {}>(
function verify (line 59) | async function verify<T extends Record<string, unknown> = Payload>(
FILE: location_data.ts
type CloudflareRequest (line 5) | type CloudflareRequest = Request & {
class LocationData (line 14) | class LocationData {
method constructor (line 17) | constructor(c: Context) {
method city (line 26) | get city() {
method region (line 41) | get region() {
method country (line 54) | get country(): IncomingRequestCfProperties['country'] {
method continent (line 70) | get continent(): IncomingRequestCfProperties['continent'] {
method regionCode (line 86) | get regionCode(): IncomingRequestCfProperties['regionCode'] {
method latitude (line 95) | get latitude(): IncomingRequestCfProperties['latitude'] {
method longitude (line 110) | get longitude(): IncomingRequestCfProperties['longitude'] {
method postalCode (line 125) | get postalCode(): IncomingRequestCfProperties['postalCode'] {
method timezone (line 134) | get timezone(): IncomingRequestCfProperties['timezone'] {
method datacenter (line 143) | get datacenter(): IncomingRequestCfProperties['colo'] {
FILE: oauth/client.ts
type OAuthClient (line 6) | type OAuthClient = {
FILE: oauth/get_session_data.ts
function getSessionData (line 13) | async function getSessionData(
FILE: oauth/get_session_id.ts
function getSessionId (line 13) | async function getSessionId(c: Context): Promise<string | undefined> {
FILE: oauth/get_session_token.ts
function getSessionToken (line 12) | async function getSessionToken(c: Context) {
FILE: oauth/handle_callback.ts
function handleCallback (line 14) | async function handleCallback(
FILE: oauth/is_signed_in.ts
function isSignedIn (line 11) | async function isSignedIn(c: Context): Promise<boolean> {
FILE: oauth/sign_in.ts
function signIn (line 14) | async function signIn(
FILE: oauth/sign_out.ts
function signOut (line 11) | async function signOut(c: Context) {
FILE: oauth/store.ts
class OAuthStore (line 7) | class OAuthStore {
method constructor (line 18) | constructor(
method set (line 42) | async set(c, key, value, expiresAt) {
method get (line 58) | async get(c, key) {
method has (line 90) | async has(c, key) {
method delete (line 124) | async delete(c, key) {
constant REDIS (line 139) | let REDIS: Redis | undefined
method set (line 148) | async set(c, key, value, expiresAt) {
method get (line 161) | async get(c, key) {
method has (line 172) | async has(c, key) {
method delete (line 183) | async delete(c, key) {
FILE: oauth/types.ts
type OAuthMethod (line 5) | type OAuthMethod =
type OAuthSessionData (line 9) | type OAuthSessionData = {
type OAuthSessionToken (line 29) | type OAuthSessionToken = {
type OAuthSignInToken (line 35) | type OAuthSignInToken = {
FILE: otp.ts
method secret (line 8) | secret(length = 64) {
method token (line 17) | token(secret: string, timestamp?: number) {
method uri (line 31) | uri(label: string, issuer: string, secret: string) {
method validate (line 47) | validate(token: string, secret: string) {
FILE: render.ts
function render (line 10) | function render(c: Context, Component: VNode) {
class Renderer (line 30) | class Renderer {
method constructor (line 33) | constructor(options?: Parameters<typeof defineConfig>[0]) {
FILE: request_context.ts
type Static (line 11) | type Static<T extends ZodType> = T extends ZodType ? z.infer<T>
class RequestContext (line 14) | class RequestContext<
method constructor (line 30) | constructor(
method gateway (line 51) | get gateway(): number {
method ip (line 55) | get ip(): string {
method method (line 65) | get method() {
method param (line 72) | param<T extends keyof Params>(name: T): Params[T] {
method raw (line 91) | get raw() {
method body (line 98) | async body(options?: {
method cookies (line 159) | get cookies(): [ValidatedCookies] extends [never] ? never
method headers (line 200) | get headers(): [ValidatedHeaders] extends [never]
method query (line 241) | get query(): [ValidatedQuery] extends [never] ? Record<string, unknown>
method blob (line 292) | async blob(deadline = 2500) {
method buffer (line 307) | async buffer(deadline = 2500) {
method json (line 324) | async json(deadline = 2500): Promise<unknown> {
method formData (line 339) | async formData(deadline = 2500) {
method text (line 356) | async text(deadline = 2500) {
method stream (line 369) | get stream() {
FILE: response_context.ts
class ResponseContext (line 4) | class ResponseContext {
method constructor (line 7) | constructor(__internal: {
method body (line 15) | get body(): Exclude<Payload, void> | null {
method body (line 19) | set body(data: Exclude<Payload, void> | null) {
method bodySize (line 26) | get bodySize() {
method code (line 73) | get code() {
method code (line 77) | set code(code: number) {
method setCookie (line 86) | setCookie(name: string, value: string, options?: {
method deleteCookie (line 125) | deleteCookie(name: string, options?: { path?: string; domain?: string ...
method header (line 135) | header(name: string, value: string | undefined) {
method redirect (line 149) | redirect(destination: string, code = 307) {
FILE: send_mail.ts
type MailContact (line 2) | type MailContact =
function sendMail (line 11) | function sendMail(
FILE: test/extensions.test.ts
method onPlugIn (line 9) | onPlugIn({ setRoute }) {
method onRequest (line 14) | onRequest({ req }) {
method onResponse (line 20) | onResponse({ c, prefix }) {
method onRequest (line 28) | onRequest({ req }) {
FILE: test/preflight_mode.test.ts
method notFound (line 58) | notFound() {
method error (line 101) | error() {
FILE: test/routing/versioning.test.ts
function get (line 15) | async function get(path: string) {
function get2 (line 98) | async function get2(path: string) {
function get (line 127) | async function get(path: string) {
Condensed preview — 73 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (175K chars).
[
{
"path": ".gitattributes",
"chars": 19,
"preview": "* text=auto eol=lf\n"
},
{
"path": ".github/dependabot.yml",
"chars": 117,
"preview": "version: 2\nupdates:\n - package-ecosystem: 'github-actions'\n directory: '/'\n schedule:\n interval: 'daily'\n"
},
{
"path": ".github/funding.yml",
"chars": 26,
"preview": "github: 'boywithkeyboard'\n"
},
{
"path": ".github/workflows/check.yml",
"chars": 650,
"preview": "name: check\n\non:\n - push\n - pull_request\n\njobs:\n check:\n runs-on: ubuntu-latest\n\n steps:\n - uses: actions/"
},
{
"path": ".github/workflows/publish.yml",
"chars": 551,
"preview": "name: publish\n\non:\n workflow_dispatch:\n inputs:\n kind:\n description: Kind of release\n default: mi"
},
{
"path": ".github/workflows/update.yml",
"chars": 1147,
"preview": "name: update\n\non:\n schedule:\n - cron: '0 0 * * *'\n workflow_dispatch:\n\npermissions:\n contents: write\n pull-reques"
},
{
"path": ".vscode/extensions.json",
"chars": 122,
"preview": "{\n \"recommendations\": [\n \"denoland.vscode-deno\",\n \"gruntfuggly.todo-tree\",\n \"wayou.vscode-todo-highlight\"\n ]\n"
},
{
"path": ".vscode/settings.json",
"chars": 298,
"preview": "{\n \"deno.enable\": true,\n \"deno.unstable\": true,\n \"editor.defaultFormatter\": \"denoland.vscode-deno\",\n \"[typescript]\":"
},
{
"path": "base.ts",
"chars": 1737,
"preview": "// Copyright 2023 Samuel Kopp. All rights reserved. Apache-2.0 license.\nimport { bodylessHandler, handler, HandlerOrSche"
},
{
"path": "cheetah.ts",
"chars": 17727,
"preview": "// Copyright 2023 Samuel Kopp. All rights reserved. Apache-2.0 license.\nimport { base, Method } from './base.ts'\nimport "
},
{
"path": "cli/cmd/bundle/bundle.ts",
"chars": 2207,
"preview": "// Copyright 2023 Samuel Kopp. All rights reserved. Apache-2.0 license.\nimport { ensureFile } from 'std/fs/ensure_file.t"
},
{
"path": "cli/cmd/bundle/mod.ts",
"chars": 1346,
"preview": "// Copyright 2023 Samuel Kopp. All rights reserved. Apache-2.0 license.\nimport { parse } from 'https://deno.land/std@0.2"
},
{
"path": "cli/cmd/new/mod.ts",
"chars": 2173,
"preview": "// Copyright 2023 Samuel Kopp. All rights reserved. Apache-2.0 license.\nimport { ensureFile } from 'https://deno.land/st"
},
{
"path": "cli/cmd/random/create_crypto_key.ts",
"chars": 404,
"preview": "// Copyright 2023 Samuel Kopp. All rights reserved. Apache-2.0 license.\nimport { encode } from 'std/encoding/base64.ts'\n"
},
{
"path": "cli/cmd/random/create_jwt_secret.ts",
"chars": 384,
"preview": "// Copyright 2023 Samuel Kopp. All rights reserved. Apache-2.0 license.\nimport { encode } from 'std/encoding/base64.ts'\n"
},
{
"path": "cli/cmd/random/mod.ts",
"chars": 782,
"preview": "// Copyright 2023 Samuel Kopp. All rights reserved. Apache-2.0 license.\nimport { Select } from 'cliffy'\nimport { gray, w"
},
{
"path": "cli/cmd/serve/mod.ts",
"chars": 1976,
"preview": "// Copyright 2023 Samuel Kopp. All rights reserved. Apache-2.0 license.\nimport { keypress, KeyPressEvent } from 'cliffy/"
},
{
"path": "cli/mod.ts",
"chars": 637,
"preview": "// Copyright 2023 Samuel Kopp. All rights reserved. Apache-2.0 license.\nimport { parse } from 'std/flags/mod.ts'\nimport "
},
{
"path": "cli/utils.ts",
"chars": 231,
"preview": "// Copyright 2023 Samuel Kopp. All rights reserved. Apache-2.0 license.\nimport { brightRed, gray } from 'std/fmt/colors."
},
{
"path": "codeowners",
"chars": 19,
"preview": "* @boywithkeyboard\n"
},
{
"path": "collection.ts",
"chars": 873,
"preview": "// Copyright 2023 Samuel Kopp. All rights reserved. Apache-2.0 license.\nimport { base, Method } from './base.ts'\nimport "
},
{
"path": "context.ts",
"chars": 6445,
"preview": "// Copyright 2023 Samuel Kopp. All rights reserved. Apache-2.0 license.\n/// <reference types='./env.d.ts' />\nimport { en"
},
{
"path": "contributing.md",
"chars": 590,
"preview": "### Follow Conventional Commits\n\nThis repository enforces the\n[Conventional Commits](https://www.conventionalcommits.org"
},
{
"path": "crypto.ts",
"chars": 1601,
"preview": "// Copyright 2023 Samuel Kopp. All rights reserved. Apache-2.0 license.\nimport { decode } from 'std/encoding/base64.ts'\n"
},
{
"path": "deno.json",
"chars": 1752,
"preview": "{\n \"compilerOptions\": {\n \"jsxFactory\": \"h\",\n \"jsxFragmentFactory\": \"Fragment\"\n },\n \"fmt\": {\n \"semiColons\": f"
},
{
"path": "env.d.ts",
"chars": 73,
"preview": "declare global {\n type Variables = Record<string, unknown>\n}\n\nexport {}\n"
},
{
"path": "ext/compress.ts",
"chars": 2068,
"preview": "// Copyright 2023 Samuel Kopp. All rights reserved. Apache-2.0 license.\nimport { compress as brotli } from 'brotli'\nimpo"
},
{
"path": "ext/debug.ts",
"chars": 880,
"preview": "// Copyright 2023 Samuel Kopp. All rights reserved. Apache-2.0 license.\nimport { format } from 'std/fmt/bytes.ts'\nimport"
},
{
"path": "ext/favicon.ts",
"chars": 1028,
"preview": "// Copyright 2023 Samuel Kopp. All rights reserved. Apache-2.0 license.\nimport { createExtension } from '../mod.ts'\n\nlet"
},
{
"path": "ext/files.ts",
"chars": 6321,
"preview": "// Copyright 2023 Samuel Kopp. All rights reserved. Apache-2.0 license.\nimport { AwsClient } from 'aws4fetch'\nimport { j"
},
{
"path": "ext/firewall.ts",
"chars": 1762,
"preview": "import { createExtension } from '../mod.ts'\n\nlet VPN_LIST: string[] = []\nlet DATACENTER_LIST: string[] = []\nlet LAST_UPD"
},
{
"path": "ext/helmet.ts",
"chars": 5016,
"preview": "// Copyright 2023 Samuel Kopp. All rights reserved. Apache-2.0 license.\nimport { createExtension } from '../mod.ts'\n\n/**"
},
{
"path": "ext/pretty.ts",
"chars": 1371,
"preview": "// Copyright 2023 Samuel Kopp. All rights reserved. Apache-2.0 license.\nimport { createExtension } from '../mod.ts'\n\nfun"
},
{
"path": "extensions.ts",
"chars": 4465,
"preview": "// Copyright 2023 Samuel Kopp. All rights reserved. Apache-2.0 license.\nimport { ZodString, ZodUnion } from 'zod'\nimport"
},
{
"path": "handler.ts",
"chars": 4285,
"preview": "// Copyright 2023 Samuel Kopp. All rights reserved. Apache-2.0 license.\nimport {\n ZodObject,\n ZodRecord,\n ZodString,\n"
},
{
"path": "jwt.ts",
"chars": 1836,
"preview": "// Copyright 2023 Samuel Kopp. All rights reserved. Apache-2.0 license.\nimport * as Jwt from 'djwt'\nimport { decode } fr"
},
{
"path": "license",
"chars": 10756,
"preview": " Apache License\n Version 2.0, January 2004\n "
},
{
"path": "location_data.ts",
"chars": 4282,
"preview": "// Copyright 2023 Samuel Kopp. All rights reserved. Apache-2.0 license.\nimport { IncomingRequestCfProperties } from 'wor"
},
{
"path": "mod.ts",
"chars": 617,
"preview": "// Copyright 2023 Samuel Kopp. All rights reserved. Apache-2.0 license.\nexport { cheetah as default } from './cheetah.ts"
},
{
"path": "oauth/client.ts",
"chars": 565,
"preview": "// Copyright 2023 Samuel Kopp. All rights reserved. Apache-2.0 license.\nimport { GitHub as githubPreset, Google as googl"
},
{
"path": "oauth/get_session_data.ts",
"chars": 1313,
"preview": "// Copyright 2023 Samuel Kopp. All rights reserved. Apache-2.0 license.\nimport { getCookies } from 'std/http/cookie.ts'\n"
},
{
"path": "oauth/get_session_id.ts",
"chars": 1264,
"preview": "// Copyright 2023 Samuel Kopp. All rights reserved. Apache-2.0 license.\nimport { getCookies } from 'std/http/cookie.ts'\n"
},
{
"path": "oauth/get_session_token.ts",
"chars": 585,
"preview": "// Copyright 2023 Samuel Kopp. All rights reserved. Apache-2.0 license.\nimport { getCookies } from 'std/http/cookie.ts'\n"
},
{
"path": "oauth/handle_callback.ts",
"chars": 2974,
"preview": "// Copyright 2023 Samuel Kopp. All rights reserved. Apache-2.0 license.\nimport { getNormalizedUser, getToken, getUser } "
},
{
"path": "oauth/is_signed_in.ts",
"chars": 357,
"preview": "// Copyright 2023 Samuel Kopp. All rights reserved. Apache-2.0 license.\nimport { Context } from '../context.ts'\nimport {"
},
{
"path": "oauth/mod.ts",
"chars": 623,
"preview": "// Copyright 2023 Samuel Kopp. All rights reserved. Apache-2.0 license.\nexport { GitHub, Google } from './client.ts'\nexp"
},
{
"path": "oauth/sign_in.ts",
"chars": 1064,
"preview": "// Copyright 2023 Samuel Kopp. All rights reserved. Apache-2.0 license.\nimport { createAuthorizeUrl } from 'authenticus'"
},
{
"path": "oauth/sign_out.ts",
"chars": 854,
"preview": "// Copyright 2023 Samuel Kopp. All rights reserved. Apache-2.0 license.\nimport { Context } from '../context.ts'\nimport {"
},
{
"path": "oauth/store.ts",
"chars": 4213,
"preview": "// Copyright 2023 Samuel Kopp. All rights reserved. Apache-2.0 license.\nimport { Redis } from 'upstash'\nimport { KVNames"
},
{
"path": "oauth/types.ts",
"chars": 867,
"preview": "// Copyright 2023 Samuel Kopp. All rights reserved. Apache-2.0 license.\nimport { UserAgent } from 'std/http/user_agent.t"
},
{
"path": "otp.ts",
"chars": 1253,
"preview": "// Copyright 2023 Samuel Kopp. All rights reserved. Apache-2.0 license.\nimport * as OTP from 'otpauth'\n\nexport const otp"
},
{
"path": "readme.md",
"chars": 1865,
"preview": "<div align='center'>\n <img src='https://cheetah.mod.land/cheetah.svg' width='128px' />\n <br>\n <br>\n <h1>cheetah</h1>"
},
{
"path": "render.ts",
"chars": 1446,
"preview": "// Copyright 2023 Samuel Kopp. All rights reserved. Apache-2.0 license.\nimport { VNode } from 'preact'\nimport renderToSt"
},
{
"path": "request_context.ts",
"chars": 8802,
"preview": "// Copyright 2023 Samuel Kopp. All rights reserved. Apache-2.0 license.\nimport {\n deadline as resolveWithDeadline,\n De"
},
{
"path": "response_context.ts",
"chars": 3090,
"preview": "// Copyright 2023 Samuel Kopp. All rights reserved. Apache-2.0 license.\nimport { Payload } from './handler.ts'\n\nexport c"
},
{
"path": "send_mail.ts",
"chars": 2510,
"preview": "// Copyright 2023 Samuel Kopp. All rights reserved. Apache-2.0 license.\ntype MailContact =\n | { name?: string; email: s"
},
{
"path": "test/context.test.ts",
"chars": 477,
"preview": "// Copyright 2023 Samuel Kopp. All rights reserved. Apache-2.0 license.\nimport { assertEquals } from 'std/assert/mod.ts'"
},
{
"path": "test/cors.test.ts",
"chars": 1099,
"preview": "// Copyright 2023 Samuel Kopp. All rights reserved. Apache-2.0 license.\nimport { assertEquals } from 'std/assert/mod.ts'"
},
{
"path": "test/exception.test.ts",
"chars": 42,
"preview": "// TODO create test suite for c.exception\n"
},
{
"path": "test/ext/favicon.test.ts",
"chars": 615,
"preview": "// Copyright 2023 Samuel Kopp. All rights reserved. Apache-2.0 license.\nimport { assertEquals } from 'std/assert/mod.ts'"
},
{
"path": "test/ext/files.test.ts",
"chars": 1578,
"preview": "// Copyright 2023 Samuel Kopp. All rights reserved. Apache-2.0 license.\nimport { assertEquals } from 'std/assert/mod.ts'"
},
{
"path": "test/ext/firewall.test.ts",
"chars": 1205,
"preview": "import { assertEquals } from 'std/assert/mod.ts'\nimport { firewall } from '../../ext/firewall.ts'\nimport cheetah from '."
},
{
"path": "test/ext/pretty.test.ts",
"chars": 1540,
"preview": "// Copyright 2023 Samuel Kopp. All rights reserved. Apache-2.0 license.\nimport { assertEquals } from 'std/assert/mod.ts'"
},
{
"path": "test/extensions.test.ts",
"chars": 1876,
"preview": "// Copyright 2023 Samuel Kopp. All rights reserved. Apache-2.0 license.\nimport { assertEquals } from 'std/assert/mod.ts'"
},
{
"path": "test/jwt.test.ts",
"chars": 1014,
"preview": "// Copyright 2023 Samuel Kopp. All rights reserved. Apache-2.0 license.\nimport { assertEquals } from 'std/assert/mod.ts'"
},
{
"path": "test/many_handlers.test.ts",
"chars": 1066,
"preview": "// Copyright 2023 Samuel Kopp. All rights reserved. Apache-2.0 license.\nimport { assertEquals } from 'std/assert/mod.ts'"
},
{
"path": "test/preflight_mode.test.ts",
"chars": 3062,
"preview": "// Copyright 2023 Samuel Kopp. All rights reserved. Apache-2.0 license.\nimport { assertEquals } from 'std/assert/mod.ts'"
},
{
"path": "test/render.test.tsx",
"chars": 4823,
"preview": "// Copyright 2023 Samuel Kopp. All rights reserved. Apache-2.0 license.\n/** @jsx h */\nimport { DOMParser } from 'dom'\nim"
},
{
"path": "test/request.test.ts",
"chars": 6879,
"preview": "// Copyright 2023 Samuel Kopp. All rights reserved. Apache-2.0 license.\nimport { assertEquals } from 'std/assert/mod.ts'"
},
{
"path": "test/response.test.ts",
"chars": 4031,
"preview": "// Copyright 2023 Samuel Kopp. All rights reserved. Apache-2.0 license.\nimport { assertEquals } from 'std/assert/mod.ts'"
},
{
"path": "test/routing/versioning.test.ts",
"chars": 6668,
"preview": "import { assertEquals } from 'std/assert/mod.ts'\nimport { cheetah } from '../../cheetah.ts'\n\nDeno.test('versioning', asy"
},
{
"path": "test/routing.test.ts",
"chars": 1235,
"preview": "// Copyright 2023 Samuel Kopp. All rights reserved. Apache-2.0 license.\nimport { assertEquals } from 'std/assert/mod.ts'"
},
{
"path": "test/validation.test.ts",
"chars": 5100,
"preview": "// Copyright 2023 Samuel Kopp. All rights reserved. Apache-2.0 license.\nimport { assertEquals } from 'std/assert/mod.ts'"
}
]
About this extraction
This page contains the full source code of the azurystudio/cheetah GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 73 files (160.7 KB), approximately 44.5k tokens, and a symbol index with 187 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.