0
0
mirror of https://github.com/honojs/hono.git synced 2024-11-21 18:18:57 +01:00

Migrate to TypeScript (#21)

* Migrate to TypeScript

* ready for publish
This commit is contained in:
Yusuke Wada 2022-01-05 18:41:29 +09:00 committed by GitHub
parent ce6bd99118
commit e6039f69f9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
36 changed files with 833 additions and 1033 deletions

3
.gitignore vendored
View File

@ -1,3 +1,5 @@
dist
# Cloudflare Workers
worker
@ -92,7 +94,6 @@ out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/

View File

@ -1,3 +1,5 @@
example
benchmark
.github
.github
tsconfig.json
src

View File

@ -16,19 +16,21 @@ app.fire()
## Feature
- Fast - the router is implemented with Trie-Tree structure.
- Tiny - use only standard API.
- Portable - zero dependencies.
- Flexible - you can make your own middlewares.
- Optimized - for Cloudflare Workers and Fastly Compute@Edge.
- Easy - simple API, builtin middleware, and TypeScript support.
- Optimized - for Cloudflare Workers or Fastly Compute@Edge.
## Benchmark
Hono is fastest!!
```
hono x 813,001 ops/sec ±2.96% (75 runs sampled)
itty-router x 160,415 ops/sec ±3.31% (85 runs sampled)
sunder x 307,438 ops/sec ±4.77% (73 runs sampled)
hono x 758,264 ops/sec ±5.41% (75 runs sampled)
itty-router x 158,359 ops/sec ±3.21% (89 runs sampled)
sunder x 297,581 ops/sec ±4.74% (83 runs sampled)
Fastest is hono
✨ Done in 37.03s.
✨ Done in 42.84s.
```
## Install
@ -45,8 +47,8 @@ $ npm install hono
## Methods
- app.**HTTP_METHOD**(path, callback)
- app.**all**(path, callback)
- app.**HTTP_METHOD**(path, handler)
- app.**all**(path, handler)
- app.**route**(path)
- app.**use**(path, middleware)
@ -120,25 +122,25 @@ const { Hono, Middleware } = require('hono')
...
app.use('*', Middleware.poweredBy)
app.use('*', Middleware.poweredBy())
app.use('*', Middleware.logger())
```
### Custom Middleware
```js
const logger = async (c, next) => {
// Custom logger
app.use('*', async (c, next) => {
console.log(`[${c.req.method}] ${c.req.url}`)
await next()
}
})
const addHeader = async (c, next) => {
// Add custom header
app.use('/message/*', async (c, next) => {
await next()
await c.res.headers.add('x-message', 'This is middleware!')
}
app.use('*', logger)
app.use('/message/*', addHeader)
})
app.get('/message/hello', () => 'Hello Middleware!')
```
@ -146,20 +148,18 @@ app.get('/message/hello', () => 'Hello Middleware!')
### Custom 404 Response
```js
const customNotFound = async (c, next) => {
app.use('*', async (c, next) => {
await next()
if (c.res.status === 404) {
c.res = new Response('Custom 404 Not Found', { status: 404 })
}
}
app.use('*', customNotFound)
})
```
### Complex Pattern
```js
// Log response time
// Output response time
app.use('*', async (c, next) => {
await next()
const responseTime = await c.res.headers.get('X-Response-Time')
@ -180,38 +180,41 @@ app.use('*', async (c, next) => {
### req
```js
// Get Request object
app.get('/hello', (c) => {
const userAgent = c.req.headers.get('User-Agent')
...
})
// Query params
app.get('/search', (c) => {
const query = c.req.query('q')
...
})
// Captured params
app.get('/entry/:id', (c) => {
const id = c.req.params('id')
...
})
```
### res
```js
// Response object
app.use('/', (c, next) => {
next()
c.res.headers.append('X-Debug', 'Debug message')
})
```
## Request
### query
### text
```js
app.get('/search', (c) => {
const query = c.req.query('q')
...
})
```
### params
```js
app.get('/entry/:id', (c) => {
const id = c.req.params('id')
...
app.get('/say', (c) => {
return c.text('Hello!')
})
```
@ -219,7 +222,7 @@ app.get('/entry/:id', (c) => {
Create your first Cloudflare Workers with Hono from scratch.
### Demo
### How to setup
![Demo](https://user-images.githubusercontent.com/10682/147877447-ff5907cd-49be-4976-b3b4-5df2ac6dfda4.gif)

View File

@ -1,6 +1,6 @@
import Benchmark from 'benchmark'
import { makeEdgeEnv } from 'edge-mock'
import { Hono } from '../../src/hono.js'
import { Hono } from '../../dist/index'
import itty from 'itty-router'
const { Router: IttyRouter } = itty
import { Router as SunderRouter, Sunder } from 'sunder'

View File

@ -1,4 +1,4 @@
const { Hono } = require('../../../src/hono')
const { Hono } = require('../../../dist/index')
const hono = new Hono()
hono.get('/user', () => new Response('User'))

View File

@ -1,17 +1,18 @@
const { Hono, Middleware } = require('../../src/hono')
const { Hono, Middleware } = require('../../dist/index')
// or install from npm:
// const { Hono, Middleware } = require('hono')
const app = new Hono()
// Mount Builtin Middleware
app.use('*', Middleware.poweredBy)
app.use('*', Middleware.poweredBy())
app.use('*', Middleware.logger())
// Custom Middleware
// Add Custom Header
const addHeader = async (c, next) => {
app.use('/hello/*', async (c, next) => {
await next()
await c.res.headers.append('X-message', 'This is addHeader middleware!')
}
app.use('/hello/*', addHeader)
})
// Log response time
app.use('*', async (c, next) => {
@ -29,7 +30,7 @@ app.use('*', async (c, next) => {
})
// Routing
app.get('/', () => 'Hono!!')
app.get('/', () => new Response('Hono!!'))
app.get('/hello', () => new Response('This is /hello'))
app.get('/entry/:id', (c) => {
const id = c.req.params('id')
@ -42,6 +43,7 @@ app.get('/fetch-url', async () => {
return new Response(`https://example.com/ is ${response.status}`)
})
// Request headers
app.get('/user-agent', (c) => {
const userAgent = c.req.headers.get('User-Agent')
return new Response(`Your UserAgent is ${userAgent}`)

View File

@ -1,7 +1,7 @@
const { Hono } = require('hono')
const app = new Hono()
app.use('*', (c, next) => {
app.use('*', async (c, next) => {
console.log(`[${c.req.method}] ${c.req.url}`)
next()
})

6
jest.config.js Normal file
View File

@ -0,0 +1,6 @@
module.exports = {
testMatch: ['**/test/**/*.+(ts|tsx|js)', '**/src/**/(*.)+(spec|test).+(ts|tsx|js)'],
transform: {
'^.+\\.(ts|tsx)$': 'ts-jest',
},
}

View File

@ -1,10 +1,17 @@
{
"name": "hono",
"version": "0.0.10",
"description": "Minimal web framework for Cloudflare Workers and Fastly Compute@Edge",
"main": "src/hono.js",
"version": "0.0.11",
"description": "Tiny web framework for Cloudflare Workers and others.",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"files": [
"dist"
],
"scripts": {
"test": "jest"
"test": "jest",
"build": "rimraf dist && tsc",
"watch": "tsc -w",
"prepublishOnly": "yarn build"
},
"author": "Yusuke Wada <yusuke@kamawada.com> (https://github.com/yusukebe)",
"license": "MIT",
@ -26,7 +33,13 @@
"compute@edge"
],
"devDependencies": {
"edge-mock": "^0.0.15",
"jest": "^27.4.5"
"@cloudflare/workers-types": "^3.3.0",
"@types/jest": "^27.4.0",
"@types/service-worker-mock": "^2.0.1",
"jest": "^27.4.5",
"rimraf": "^3.0.2",
"service-worker-mock": "^2.0.5",
"ts-jest": "^27.1.2",
"typescript": "^4.5.4"
}
}
}

View File

@ -1,9 +1,9 @@
// Based on the code in the MIT licensed `koa-compose` package.
const compose = (middleware) => {
return function (context, next) {
export const compose = (middleware: any) => {
return function (context: any, next?: Function) {
let index = -1
return dispatch(0)
function dispatch(i) {
function dispatch(i: number): any {
if (i <= index) return Promise.reject(new Error('next() called multiple times'))
index = i
let fn = middleware[i]
@ -17,5 +17,3 @@ const compose = (middleware) => {
}
}
}
module.exports = compose

67
src/hono.d.ts vendored
View File

@ -1,67 +0,0 @@
type Result = {
handler: any
params: {}
}
declare class Node {
method: string
handler: any
children: Node[]
middlewares: any[]
insert(method: string, path: string, handler: any): Node
search(method: string, path: string): Result
}
declare class Context {
req: Request
res: Response
newResponse(params: {}): Response
}
type Handler = (c: Context, next: () => void) => Response | void
declare class Router {
tempPath: string
node: Node
add(method: string, path: string, handler: Handler): Node
match(method: string, path: string): Node
}
export class Hono {
router: Router
middlewareRouters: Router[]
use(path: string, middleware: Handler): void
route(path: string): Hono
fire(): void
all(path: string, handler: Handler): Hono
get(path: string, handler: Handler): Hono
post(path: string, handler: Handler): Hono
put(path: string, handler: Handler): Hono
head(path: string, handler: Handler): Hono
delete(path: string, handler: Handler): Hono
notFound(): Response
getRouter(): Router
addRoute(method: string, args: any[]): Hono
matchRoute(method: string, path: string): Promise<Node>
createContext(req: Request, res: Response): Promise<Context>
dispatch(req: Request, res: Response): Promise<Response>
handleEvent(event: FetchEvent): Promise<Response>
}
export class Middleware {
static defaultFilter: Handler
// Add builtin middlewares
static poweredBy: Handler
}
interface FetchEvent extends Event {
request: Request
respondWith(response: Promise<Response> | Response): Promise<Response>
}

View File

@ -1,141 +0,0 @@
'use strict'
const Node = require('./node')
const Middleware = require('./middleware')
const compose = require('./compose')
const methods = require('./methods')
const { getPathFromURL } = require('./util')
const METHOD_NAME_OF_ALL = 'ALL'
class Router {
constructor() {
this.node = new Node()
this.tempPath = '/'
}
add(method, path, ...handlers) {
this.node.insert(method, path, handlers)
}
match(method, path) {
return this.node.search(method, path)
}
}
class Hono {
constructor() {
this.router = new Router()
this.middlewareRouters = []
for (const method of methods) {
this[method] = (...args) => {
return this.addRoute(method, ...args)
}
}
}
all(...args) {
this.addRoute('ALL', ...args)
}
getRouter() {
return this.router
}
addRoute(method, ...args) {
method = method.toUpperCase()
if (args.length === 1) {
this.router.add(method, this.router.tempPath, ...args)
} else {
this.router.add(method, ...args)
}
return this
}
route(path) {
this.router.tempPath = path
return this
}
use(path, middleware) {
if (middleware.constructor.name !== 'AsyncFunction') {
throw new TypeError('middleware must be a async function!')
}
const router = new Router()
router.add(METHOD_NAME_OF_ALL, path, middleware)
this.middlewareRouters.push(router)
}
async matchRoute(method, path) {
return this.router.match(method, path)
}
// XXX
async createContext(req, res) {
return {
req: req,
res: res,
newResponse: (params) => {
return new Response(params)
},
}
}
async dispatch(request, response) {
const [method, path] = [request.method, getPathFromURL(request.url)]
const result = await this.matchRoute(method, path)
request.params = (key) => result.params[key]
let handler = result ? result.handler[0] : this.notFound // XXX
const middleware = []
for (const mr of this.middlewareRouters) {
const mwResult = mr.match(METHOD_NAME_OF_ALL, path)
if (mwResult) {
middleware.push(...mwResult.handler)
}
}
let wrappedHandler = async (context, next) => {
context.res = await handler(context)
await next()
}
middleware.push(Middleware.defaultFilter)
middleware.push(wrappedHandler)
const composed = compose(middleware)
const c = await this.createContext(request, response)
await composed(c)
return c.res
}
async handleEvent(event) {
return this.dispatch(event.request, {}) // XXX
}
fire() {
addEventListener('fetch', (event) => {
event.respondWith(this.handleEvent(event))
})
}
notFound() {
return new Response('Not Found', { status: 404 })
}
}
// Default Export
module.exports = Hono
exports = module.exports
// Named Export
exports.Hono = Hono
exports.Middleware = Middleware

196
src/hono.ts Normal file
View File

@ -0,0 +1,196 @@
import { Node, Result } from './node'
import { compose } from './compose'
import { getPathFromURL } from './util'
import { Middleware } from './middleware'
export { Middleware }
const METHOD_NAME_OF_ALL = 'ALL'
declare global {
interface Request {
params: (key: string) => any
query: (key: string) => string | null
}
}
export class Context {
req: Request
res: Response
constructor(req: Request, res: Response) {
this.req = req
this.res = res
}
newResponse(body?: BodyInit | null | undefined, init?: ResponseInit | undefined): Response {
return new Response(body, init)
}
text(body: string) {
return this.newResponse(body, {
status: 200,
headers: {
'Content-Type': 'text/plain',
},
})
}
}
type Handler = (c: Context, next?: Function) => Response | Promise<Response>
type MiddlwareHandler = (c: Context, next: Function) => Promise<void>
export class Router<T> {
node: Node<T>
constructor() {
this.node = new Node()
}
add(method: string, path: string, handler: T) {
this.node.insert(method, path, handler)
}
match(method: string, path: string): Result<T> | null {
return this.node.search(method, path)
}
}
export class Hono {
router: Router<Handler[]>
middlewareRouters: Router<MiddlwareHandler>[]
tempPath: string
constructor() {
this.router = new Router()
this.middlewareRouters = []
this.tempPath = '/'
}
/* HTTP METHODS */
get(arg: string | Handler, ...args: Handler[]): Hono {
return this.addRoute('get', arg, ...args)
}
post(arg: string | Handler, ...args: Handler[]): Hono {
return this.addRoute('post', arg, ...args)
}
put(arg: string | Handler, ...args: Handler[]): Hono {
return this.addRoute('put', arg, ...args)
}
head(arg: string | Handler, ...args: Handler[]): Hono {
return this.addRoute('head', arg, ...args)
}
delete(arg: string | Handler, ...args: Handler[]): Hono {
return this.addRoute('delete', arg, ...args)
}
options(arg: string | Handler, ...args: Handler[]): Hono {
return this.addRoute('options', arg, ...args)
}
patch(arg: string | Handler, ...args: Handler[]): Hono {
return this.addRoute('patch', arg, ...args)
}
/*
trace
copy
lock
purge
unlock
report
checkout
merge
notify
subscribe
unsubscribe
search
connect
*/
all(arg: string | Handler, ...args: Handler[]): Hono {
return this.addRoute('all', arg, ...args)
}
route(path: string): Hono {
this.tempPath = path
return this
}
use(path: string, middleware: MiddlwareHandler): void {
if (middleware.constructor.name !== 'AsyncFunction') {
throw new TypeError('middleware must be a async function!')
}
const router = new Router<MiddlwareHandler>()
router.add(METHOD_NAME_OF_ALL, path, middleware)
this.middlewareRouters.push(router)
}
// addRoute('get', '/', handler)
addRoute(method: string, arg: string | Handler, ...args: Handler[]): Hono {
method = method.toUpperCase()
if (typeof arg === 'string') {
this.router.add(method, arg, args)
} else {
args.unshift(arg)
this.router.add(method, this.tempPath, args)
}
return this
}
async matchRoute(method: string, path: string): Promise<Result<Handler[]>> {
return this.router.match(method, path)
}
async dispatch(request: Request, response?: Response) {
const [method, path] = [request.method, getPathFromURL(request.url)]
const result = await this.matchRoute(method, path)
request.params = (key: string): string => {
if (result) {
return result.params[key]
}
return ''
}
let handler = result ? result.handler[0] : this.notFound // XXX
const middleware = []
for (const mr of this.middlewareRouters) {
const mwResult = mr.match(METHOD_NAME_OF_ALL, path)
if (mwResult) {
middleware.push(mwResult.handler)
}
}
let wrappedHandler = async (context: Context, next: Function) => {
context.res = await handler(context)
await next()
}
middleware.push(Middleware.defaultFilter)
middleware.push(wrappedHandler)
const composed = compose(middleware)
const c = new Context(request, response)
await composed(c)
return c.res
}
async handleEvent(event: FetchEvent): Promise<Response> {
return this.dispatch(event.request)
}
fire() {
addEventListener('fetch', (event: FetchEvent): void => {
event.respondWith(this.handleEvent(event))
})
}
notFound() {
return new Response('Not Found', { status: 404 })
}
}

1
src/index.ts Normal file
View File

@ -0,0 +1 @@
export { Hono, Middleware, Context } from './hono'

View File

@ -1,4 +1,4 @@
const methods = [
export const methods = [
'get',
'post',
'put',
@ -26,5 +26,3 @@ const methods = [
'search',
'connect',
]
module.exports = methods

View File

@ -1,11 +0,0 @@
const defaultFilter = require('./middleware/defaultFilter')
const poweredBy = require('./middleware/poweredBy/poweredBy')
const logger = require('./middleware/logger/logger')
function Middleware() {}
Middleware.defaultFilter = defaultFilter
Middleware.poweredBy = poweredBy
Middleware.logger = logger
module.exports = Middleware

9
src/middleware.ts Normal file
View File

@ -0,0 +1,9 @@
import { defaultFilter } from './middleware/defaultFilter'
import { poweredBy } from './middleware/poweredBy/poweredBy'
import { logger } from './middleware/logger/logger'
export class Middleware {
static defaultFilter = defaultFilter
static poweredBy = poweredBy
static logger = logger
}

View File

@ -1,19 +0,0 @@
const defaultFilter = async (c, next) => {
c.req.query = (key) => {
const url = new URL(c.req.url)
return url.searchParams.get(key)
}
await next()
if (typeof c.res === 'string') {
c.res = new Response(c.res, {
status: 200,
headers: {
'Conten-Type': 'text/plain',
},
})
}
}
module.exports = defaultFilter

View File

@ -0,0 +1,10 @@
import { Context } from '../hono'
export const defaultFilter = async (c: Context, next: Function) => {
c.req.query = (key: string) => {
const url = new URL(c.req.url)
return url.searchParams.get(key)
}
await next()
}

View File

@ -1,13 +1,14 @@
const { makeEdgeEnv } = require('edge-mock')
const { Hono, Middleware } = require('../../hono')
import makeServiceWorkerEnv from 'service-worker-mock'
import { Hono, Middleware } from '../../hono'
makeEdgeEnv()
declare var global: any
Object.assign(global, makeServiceWorkerEnv())
describe('Logger by Middleware', () => {
const app = new Hono()
let log = ''
const logFn = (str) => {
const logFn = (str: string) => {
log = str
}

View File

@ -1,6 +1,7 @@
const { getPathFromURL } = require('../../util')
import { getPathFromURL } from '../../util'
import { Context } from '../../hono'
const humanize = (n, opts) => {
const humanize = (n: string[], opts?: any) => {
const options = opts || {}
const d = options.delimiter || ','
const s = options.separator || '.'
@ -9,9 +10,9 @@ const humanize = (n, opts) => {
return n.join(s)
}
const time = (start) => {
const time = (start: number) => {
const delta = Date.now() - start
return humanize(delta < 10000 ? delta + 'ms' : Math.round(delta / 1000) + 's')
return humanize([delta < 10000 ? delta + 'ms' : Math.round(delta / 1000) + 's'])
}
const LogPrefix = {
@ -20,8 +21,8 @@ const LogPrefix = {
Error: 'xxx',
}
const colorStatus = (status) => {
const out = {
const colorStatus = (status: number = 0) => {
const out: { [key: number]: string } = {
7: `\x1b[35m${status}\x1b[0m`,
5: `\x1b[31m${status}\x1b[0m`,
4: `\x1b[33m${status}\x1b[0m`,
@ -32,7 +33,7 @@ const colorStatus = (status) => {
}
return out[(status / 100) | 0]
}
function log(fn, prefix, method, path, status, elasped) {
function log(fn: Function, prefix: string, method: string, path: string, status?: number, elasped?: string) {
const out =
prefix === LogPrefix.Incoming
? ` ${prefix} ${method} ${path}`
@ -40,8 +41,8 @@ function log(fn, prefix, method, path, status, elasped) {
fn(out)
}
const logger = (fn = console.log) => {
return async (c, next) => {
export const logger = (fn = console.log) => {
return async (c: Context, next: Function) => {
const { method } = c.req
const path = getPathFromURL(c.req.url)
@ -59,5 +60,3 @@ const logger = (fn = console.log) => {
log(fn, LogPrefix.Outgoing, method, path, c.res.status, time(start))
}
}
module.exports = logger

View File

@ -1,6 +0,0 @@
const poweredBy = async (c, next) => {
await next()
await c.res.headers.append('X-Powered-By', 'Hono')
}
module.exports = poweredBy

View File

@ -1,12 +1,13 @@
const { makeEdgeEnv } = require('edge-mock')
const { Hono, Middleware } = require('../../hono')
import makeServiceWorkerEnv from 'service-worker-mock'
import { Hono, Middleware } from '../../hono'
makeEdgeEnv()
declare var global: any
Object.assign(global, makeServiceWorkerEnv())
describe('Powered by Middleware', () => {
const app = new Hono()
app.use('*', Middleware.poweredBy)
app.use('*', Middleware.poweredBy())
app.get('/', () => new Response('root'))
it('Response headers include X-Powered-By', async () => {

View File

@ -0,0 +1,9 @@
import { Context } from '../../hono'
export const poweredBy = () => {
return async (c: Context, next: Function) => {
await next()
// await c.res.headers.append('X-Powered-By', 'Hono')
c.res.headers.append('X-Powered-By', 'Hono')
}
}

View File

@ -1,97 +0,0 @@
'use strict'
const { splitPath, getPattern } = require('./util')
const METHOD_NAME_OF_ALL = 'ALL'
const createResult = (handler, params) => {
return { handler: handler, params: params }
}
const noRoute = () => {
return null
}
function Node(method, handler, children) {
this.children = children || {}
this.method = {}
if (method && handler) {
this.method[method] = handler
}
this.middlewares = []
}
Node.prototype.insert = function (method, path, handler) {
let curNode = this
const parts = splitPath(path)
for (let i = 0; i < parts.length; i++) {
const p = parts[i]
if (Object.keys(curNode.children).includes(p)) {
curNode = curNode.children[p]
continue
}
curNode.children[p] = new Node(method, handler)
curNode = curNode.children[p]
}
curNode.method[method] = handler
return curNode
}
Node.prototype.search = function (method, path) {
let curNode = this
const params = {}
const parts = splitPath(path)
for (let i = 0; i < parts.length; i++) {
const p = parts[i]
const nextNode = curNode.children[p]
if (nextNode) {
curNode = nextNode
continue
}
let isWildcard = false
let isParamMatch = false
const keys = Object.keys(curNode.children)
for (let j = 0; j < keys.length; j++) {
const key = keys[j]
// Wildcard
if (key === '*') {
curNode = curNode.children['*']
isWildcard = true
break
}
const pattern = getPattern(key)
if (pattern) {
const match = p.match(new RegExp(pattern[1]))
if (match) {
const k = pattern[0]
params[k] = match[1]
curNode = curNode.children[key]
isParamMatch = true
break
}
return noRoute()
}
}
if (isWildcard) {
break
}
if (isParamMatch === false) {
return noRoute()
}
}
const handler = curNode.method[METHOD_NAME_OF_ALL] || curNode.method[method]
if (!handler) {
return noRoute()
}
return createResult(handler, params)
}
module.exports = Node

123
src/node.ts Normal file
View File

@ -0,0 +1,123 @@
import { splitPath, getPattern } from './util'
const METHOD_NAME_OF_ALL = 'ALL'
export class Result<T> {
handler: T
params: { [key: string]: string }
constructor(handler: T, params: { [key: string]: string }) {
this.handler = handler
this.params = params
}
}
const noRoute = (): null => {
return null
}
export class Node<T> {
method: { [key: string]: T }
handler: T
children: { [key: string]: Node<T> }
middlewares: []
constructor(method?: string, handler?: any, children?: { [key: string]: Node<T> }) {
this.children = children || {}
this.method = {}
if (method && handler) {
this.method[method] = handler
}
this.middlewares = []
}
insert(method: string, path: string, handler: T): Node<T> {
let curNode: Node<T> = this
const parts = splitPath(path)
for (let i = 0, len = parts.length; i < len; i++) {
const p: string = parts[i]
if (Object.keys(curNode.children).includes(p)) {
curNode = curNode.children[p]
continue
}
curNode.children[p] = new Node()
curNode = curNode.children[p]
}
curNode.method[method] = handler
return curNode
}
search(method: string, path: string): Result<T> {
let curNode: Node<T> = this
const params: { [key: string]: string } = {}
let parts = splitPath(path)
for (let i = 0, len = parts.length; i < len; i++) {
const p: string = parts[i]
// '*' => match any path
if (curNode.children['*']) {
const astNode = curNode.children['*']
if (Object.keys(astNode.children).length === 0) {
curNode = astNode
break
}
}
const nextNode = curNode.children[p]
if (nextNode) {
curNode = nextNode
// '/hello/*' => match '/hello'
if (!(i == len - 1 && nextNode.children['*'])) {
continue
}
}
let isWildcard = false
let isParamMatch = false
const keys = Object.keys(curNode.children)
for (let j = 0, len = keys.length; j < len; j++) {
const key: string = keys[j]
// Wildcard
// '/hello/*/foo' => match /hello/bar/foo
if (key === '*') {
curNode = curNode.children['*']
isWildcard = true
break
}
const pattern = getPattern(key)
// Named match
if (pattern) {
const match = p.match(new RegExp(pattern[1]))
if (match) {
const k: string = pattern[0]
params[k] = match[1]
curNode = curNode.children[key]
isParamMatch = true
break
}
return noRoute()
}
}
if (isWildcard && i === len - 1) {
break
}
if (isWildcard === false && isParamMatch === false) {
return noRoute()
}
}
const handler = curNode.method[METHOD_NAME_OF_ALL] || curNode.method[method]
if (!handler) {
return noRoute()
}
return new Result<T>(handler, params)
}
}

View File

@ -1,12 +1,12 @@
const splitPath = (path) => {
path = path.split(/\//) // faster than path.split('/')
if (path[0] === '') {
path.shift()
export const splitPath = (path: string): string[] => {
const paths = path.split(/\//) // faster than path.split('/')
if (paths[0] === '') {
paths.shift()
}
return path
return paths
}
const getPattern = (label) => {
export const getPattern = (label: string): string[] | null => {
// :id{[0-9]+} => ([0-9]+)
// :id => (.+)
//const name = ''
@ -18,16 +18,14 @@ const getPattern = (label) => {
return [match[1], '(.+)']
}
}
return null
}
const getPathFromURL = (url) => {
export const getPathFromURL = (url: string) => {
// XXX
const match = url.match(/^(([^:\/?#]+):)?(\/\/([^\/?#]*))?([^?#]*)(\?([^#]*))?(#(.*))?/)
return match[5]
}
module.exports = {
splitPath,
getPattern,
getPathFromURL,
if (match) {
return match[5]
}
return ''
}

View File

@ -1,21 +1,26 @@
const compose = require('../src/compose')
import { compose } from '../src/compose'
class C {
req: { [key: string]: any }
res: { [key: string]: any }
}
describe('compose middleware', () => {
const middleware = []
const middleware: Function[] = []
const a = async (c, next) => {
const a = async (c: C, next: Function) => {
c.req['log'] = 'log'
await next()
}
middleware.push(a)
const b = async (c, next) => {
const b = async (c: C, next: Function) => {
await next()
c.res['header'] = `${c.res.header}-custom-header`
c.res['headers'] = `${c.res.headers}-custom-header`
}
middleware.push(b)
const handler = async (c, next) => {
const handler = async (c: C, next: Function) => {
c.req['log'] = `${c.req.log} message`
await next()
c.res = { message: 'new response' }
@ -26,14 +31,14 @@ describe('compose middleware', () => {
const response = {}
it('Request', async () => {
const c = { req: request, res: response }
const c: C = { req: request, res: response }
const composed = compose(middleware)
await composed(c)
expect(c.req['log']).not.toBeNull()
expect(c.req['log']).toBe('log message')
})
it('Response', async () => {
const c = { req: request, res: response }
const c: C = { req: request, res: response }
const composed = compose(middleware)
await composed(c)
expect(c.res['header']).not.toBeNull()

View File

@ -1,11 +1,11 @@
const { makeEdgeEnv } = require('edge-mock')
const { Hono } = require('../src/hono')
import makeServiceWorkerEnv from 'service-worker-mock'
import { Hono, Context } from '../src/hono'
makeEdgeEnv()
declare var global: any
Object.assign(global, makeServiceWorkerEnv())
describe('GET Request', () => {
const app = new Hono()
app.get('/hello', () => {
return new Response('hello', {
status: 200,
@ -31,6 +31,42 @@ describe('GET Request', () => {
})
})
describe('Routing', () => {
const app = new Hono()
it('Return it self', async () => {
const appRes = app.get('/', () => new Response('get /'))
expect(appRes).not.toBeUndefined()
appRes.delete('/', () => new Response('delete /'))
let req = new Request('/', { method: 'DELETE' })
const res = await appRes.dispatch(req)
expect(res).not.toBeNull()
expect(res.status).toBe(200)
expect(await res.text()).toBe('delete /')
})
it('Chained route', async () => {
app
.route('/route')
.get(() => new Response('get /route'))
.post(() => new Response('post /route'))
.put(() => new Response('put /route'))
let req = new Request('/route', { method: 'GET' })
let res = await app.dispatch(req)
expect(res.status).toBe(200)
expect(await res.text()).toBe('get /route')
req = new Request('/route', { method: 'POST' })
res = await app.dispatch(req)
expect(res.status).toBe(200)
expect(await res.text()).toBe('post /route')
req = new Request('/route', { method: 'DELETE' })
res = await app.dispatch(req)
expect(res.status).toBe(404)
})
})
describe('params and query', () => {
const app = new Hono()
@ -65,29 +101,27 @@ describe('params and query', () => {
describe('Middleware', () => {
const app = new Hono()
const logger = async (c, next) => {
// Custom Logger
app.use('*', async (c, next) => {
console.log(`${c.req.method} : ${c.req.url}`)
await next()
}
})
const rootHeader = async (c, next) => {
// Apeend Custom Header
app.use('*', async (c, next) => {
await next()
await c.res.headers.append('x-custom', 'root')
}
})
const customHeader = async (c, next) => {
app.use('/hello', async (c, next) => {
await next()
await c.res.headers.append('x-message', 'custom-header')
}
const customHeader2 = async (c, next) => {
await next()
c.res.headers.append('x-message-2', 'custom-header-2')
}
})
app.use('*', logger)
app.use('*', rootHeader)
app.use('/hello', customHeader)
app.use('/hello/*', customHeader2)
app.use('/hello/*', async (c, next) => {
await next()
await c.res.headers.append('x-message-2', 'custom-header-2')
})
app.get('/hello', () => {
return new Response('hello')
@ -120,21 +154,21 @@ describe('Middleware', () => {
describe('Custom 404', () => {
const app = new Hono()
const customNotFound = async (c, next) => {
await next()
if (c.res.status === 404) {
c.res = new Response('Custom 404 Not Found', { status: 404 })
}
}
app.notFound = () => {
return new Response('Default 404 Nout Found', { status: 404 })
}
app.use('*', customNotFound)
app.use('*', async (c, next) => {
await next()
if (c.res.status === 404) {
c.res = new Response('Custom 404 Not Found', { status: 404 })
}
})
app.get('/hello', () => {
return new Response('hello')
})
it('Custom 404 Not Found', async () => {
let req = new Request('/hello')
let res = await app.dispatch(req)
@ -146,15 +180,16 @@ describe('Custom 404', () => {
})
})
describe('Error Handling', () => {
describe('Context', () => {
const app = new Hono()
app.get('/', (c) => {
return c.text('get /')
})
it('Middleware must be async function', () => {
expect(() => {
app.use('*', {})
}).toThrow(TypeError)
expect(() => {
app.use('*', () => '')
}).toThrow(TypeError)
it('c.text', async () => {
let req = new Request('/')
let res = await app.dispatch(req)
expect(res.status).toBe(200)
expect(await res.text()).toBe('get /')
})
})

View File

@ -1,12 +1,13 @@
const { makeEdgeEnv } = require('edge-mock')
const { Hono, Middleware } = require('../src/hono')
import makeServiceWorkerEnv from 'service-worker-mock'
import { Hono, Middleware } from '../src/hono'
makeEdgeEnv()
declare var global: any
Object.assign(global, makeServiceWorkerEnv())
describe('Builtin Middleware', () => {
const app = new Hono()
app.use('*', Middleware.poweredBy)
app.use('*', Middleware.poweredBy())
app.get('/', () => new Response('root'))
it('Builtin Powered By Middleware', async () => {

View File

@ -1,4 +1,4 @@
const Node = require('../src/node')
import { Node } from '../src/node'
describe('Root Node', () => {
const node = new Node()
@ -11,7 +11,7 @@ describe('Root Node', () => {
})
})
describe('Root Node id not defined', () => {
describe('Root Node is not defined', () => {
const node = new Node()
node.insert('get', '/hello', 'get hello')
it('get /', () => {
@ -57,8 +57,11 @@ describe('Basic Usage', () => {
describe('Name path', () => {
const node = new Node()
node.insert('get', '/entry/:id', 'get entry')
node.insert('get', '/entry/:id/comment/:comment_id', 'get comment')
node.insert('get', '/map/:location/events', 'get events')
it('get /entry/123', () => {
node.insert('get', '/entry/:id', 'get entry')
let res = node.search('get', '/entry/123')
expect(res).not.toBeNull()
expect(res.handler).toBe('get entry')
@ -68,13 +71,11 @@ describe('Name path', () => {
})
it('get /entry/456/comment', () => {
node.insert('get', '/entry/:id', 'get entry')
let res = node.search('get', '/entry/456/comment')
expect(res).toBeNull()
})
it('get /entry/789/comment/123', () => {
node.insert('get', '/entry/:id/comment/:comment_id', 'get comment')
let res = node.search('get', '/entry/789/comment/123')
expect(res).not.toBeNull()
expect(res.handler).toBe('get comment')
@ -83,7 +84,6 @@ describe('Name path', () => {
})
it('get /map/:location/events', () => {
node.insert('get', '/map/:location/events', 'get events')
let res = node.search('get', '/map/yokohama/events')
expect(res).not.toBeNull()
expect(res.handler).toBe('get events')
@ -93,30 +93,36 @@ describe('Name path', () => {
describe('Wildcard', () => {
const node = new Node()
node.insert('get', '/wildcard-abc/*/wildcard-efg', 'wildcard')
it('/wildcard-abc/xxxxxx/wildcard-efg', () => {
node.insert('get', '/wildcard-abc/*/wildcard-efg', 'wildcard')
let res = node.search('get', '/wildcard-abc/xxxxxx/wildcard-efg')
expect(res).not.toBeNull()
expect(res.handler).toBe('wildcard')
})
node.insert('get', '/wildcard-abc/*/wildcard-efg/hijk', 'wildcard')
it('/wildcard-abc/xxxxxx/wildcard-efg/hijk', () => {
let res = node.search('get', '/wildcard-abc/xxxxxx/wildcard-efg/hijk')
expect(res).not.toBeNull()
expect(res.handler).toBe('wildcard')
})
})
describe('Regexp', () => {
const node = new Node()
node.insert('get', '/regex-abc/:id{[0-9]+}/comment/:comment_id{[a-z]+}', 'regexp')
it('/regexp-abc/123/comment/abc', () => {
res = node.search('get', '/regex-abc/123/comment/abc')
const res = node.search('get', '/regex-abc/123/comment/abc')
expect(res).not.toBeNull()
expect(res.handler).toBe('regexp')
expect(res.params['id']).toBe('123')
expect(res.params['comment_id']).toBe('abc')
})
it('/regexp-abc/abc', () => {
res = node.search('get', '/regex-abc/abc')
const res = node.search('get', '/regex-abc/abc')
expect(res).toBeNull()
})
it('/regexp-abc/123/comment/123', () => {
res = node.search('get', '/regex-abc/123/comment/123')
const res = node.search('get', '/regex-abc/123/comment/123')
expect(res).toBeNull()
})
})
@ -125,7 +131,7 @@ describe('All', () => {
const node = new Node()
node.insert('ALL', '/all-methods', 'all methods') // ALL
it('/all-methods', () => {
res = node.search('get', '/all-methods')
let res = node.search('get', '/all-methods')
expect(res).not.toBeNull()
expect(res.handler).toBe('all methods')
res = node.search('put', '/all-methods')
@ -133,3 +139,39 @@ describe('All', () => {
expect(res.handler).toBe('all methods')
})
})
describe('Special Wildcard', () => {
const node = new Node()
node.insert('ALL', '*', 'match all')
it('/foo', () => {
let res = node.search('get', '/foo')
expect(res).not.toBeNull()
expect(res.handler).toBe('match all')
})
it('/hello', () => {
let res = node.search('get', '/hello')
expect(res).not.toBeNull()
expect(res.handler).toBe('match all')
})
it('/hello/foo', () => {
let res = node.search('get', '/hello/foo')
expect(res).not.toBeNull()
expect(res.handler).toBe('match all')
})
})
describe('Special Wildcard deeply', () => {
const node = new Node()
node.insert('ALL', '/hello/*', 'match hello')
it('/hello', () => {
let res = node.search('get', '/hello')
expect(res).not.toBeNull()
expect(res.handler).toBe('match hello')
})
it('/hello/foo', () => {
let res = node.search('get', '/hello/foo')
expect(res).not.toBeNull()
expect(res.handler).toBe('match hello')
})
})

View File

@ -1,88 +0,0 @@
const { Hono } = require('../src/hono')
describe('Basic Usage', () => {
const router = new Hono()
it('get, post hello', async () => {
router.get('/hello', 'get hello')
router.post('/hello', 'post hello')
let res = await router.matchRoute('GET', '/hello')
expect(res).not.toBeNull()
expect(res.handler[0]).toBe('get hello')
res = await router.matchRoute('POST', '/hello')
expect(res).not.toBeNull()
expect(res.handler[0]).toBe('post hello')
res = await router.matchRoute('PUT', '/hello')
expect(res).toBeNull()
res = await router.matchRoute('GET', '/')
expect(res).toBeNull()
})
})
describe('Complex', () => {
let router = new Hono()
it('Named Param', async () => {
router.get('/entry/:id', 'get entry')
res = await router.matchRoute('GET', '/entry/123')
expect(res).not.toBeNull()
expect(res.handler[0]).toBe('get entry')
expect(res.params['id']).toBe('123')
})
it('Wildcard', async () => {
router.get('/wild/*/card', 'get wildcard')
res = await router.matchRoute('GET', '/wild/xxx/card')
expect(res).not.toBeNull()
expect(res.handler[0]).toBe('get wildcard')
})
it('Regexp', async () => {
router.get('/post/:date{[0-9]+}/:title{[a-z]+}', 'get post')
res = await router.matchRoute('GET', '/post/20210101/hello')
expect(res).not.toBeNull()
expect(res.handler[0]).toBe('get post')
expect(res.params['date']).toBe('20210101')
expect(res.params['title']).toBe('hello')
res = await router.matchRoute('GET', '/post/onetwothree')
expect(res).toBeNull()
res = await router.matchRoute('GET', '/post/123/123')
expect(res).toBeNull()
})
})
describe('Chained Route', () => {
let router = new Hono()
it('Return rooter object', async () => {
router = router.patch('/hello', 'patch hello')
expect(router).not.toBeNull()
router = router.delete('/hello', 'delete hello')
res = await router.matchRoute('DELETE', '/hello')
expect(res).not.toBeNull()
expect(res.handler[0]).toBe('delete hello')
})
it('Chain with route method', async () => {
router.route('/api/book').get('get book').post('post book').put('put book')
res = await router.matchRoute('GET', '/api/book')
expect(res).not.toBeNull()
expect(res.handler[0]).toBe('get book')
res = await router.matchRoute('POST', '/api/book')
expect(res).not.toBeNull()
expect(res.handler[0]).toBe('post book')
res = await router.matchRoute('PUT', '/api/book')
expect(res).not.toBeNull()
expect(res.handler[0]).toBe('put book')
res = await router.matchRoute('DELETE', '/api/book')
expect(res).toBeNull()
})
})

56
test/router.test.ts Normal file
View File

@ -0,0 +1,56 @@
import { Router } from '../src/hono'
describe('Basic Usage', () => {
const router = new Router<string>()
router.add('GET', '/hello', 'get hello')
router.add('POST', '/hello', 'post hello')
it('get, post hello', async () => {
let res = router.match('GET', '/hello')
expect(res).not.toBeNull()
expect(res.handler).toBe('get hello')
res = router.match('POST', '/hello')
expect(res).not.toBeNull()
expect(res.handler).toBe('post hello')
res = router.match('PUT', '/hello')
expect(res).toBeNull()
res = router.match('GET', '/')
expect(res).toBeNull()
})
})
describe('Complex', () => {
const router = new Router<string>()
it('Named Param', async () => {
router.add('GET', '/entry/:id', 'get entry')
let res = router.match('GET', '/entry/123')
expect(res).not.toBeNull()
expect(res.handler).toBe('get entry')
expect(res.params['id']).toBe('123')
})
it('Wildcard', async () => {
router.add('GET', '/wild/*/card', 'get wildcard')
let res = router.match('GET', '/wild/xxx/card')
expect(res).not.toBeNull()
expect(res.handler).toBe('get wildcard')
})
it('Regexp', async () => {
router.add('GET', '/post/:date{[0-9]+}/:title{[a-z]+}', 'get post')
let res = router.match('GET', '/post/20210101/hello')
expect(res).not.toBeNull()
expect(res.handler).toBe('get post')
expect(res.params['date']).toBe('20210101')
expect(res.params['title']).toBe('hello')
res = router.match('GET', '/post/onetwothree')
expect(res).toBeNull()
res = router.match('GET', '/post/123/123')
expect(res).toBeNull()
})
})

View File

@ -1,4 +1,4 @@
const { splitPath, getPattern, getPathFromURL } = require('../src/util')
import { splitPath, getPattern, getPathFromURL } from '../src/util'
describe('Utility methods', () => {
it('splitPath', () => {
@ -20,6 +20,7 @@ describe('Utility methods', () => {
it('getPattern', () => {
let res = getPattern(':id')
expect(res).not.toBeNull()
expect(res[0]).toBe('id')
expect(res[1]).toBe('(.+)')
res = getPattern(':id{[0-9]+}')

24
tsconfig.json Normal file
View File

@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "es2017",
"module": "commonjs",
"declaration": true,
"outDir": "./dist",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true,
"strictPropertyInitialization": false,
"strictNullChecks": false,
"types": [
"jest",
"@cloudflare/workers-types"
]
},
"include": [
"src/**/*"
],
"exclude": [
"src/**/*.test.ts"
]
}

595
yarn.lock

File diff suppressed because it is too large Load Diff