🎉 feat: 0.25

This commit is contained in:
saltyaom
2022-09-09 11:44:45 +07:00
parent aca661b11e
commit 653b70a262
12 changed files with 282 additions and 219 deletions
+5 -1
View File
@@ -1,10 +1,14 @@
# 0.0.0-experimental.25 - 28 Aug 2022
New Feature:
- `onRequest` now accept async function
- Auto infer typed for `params`, `state`, `ref`
- `onRequest` now accept async function
- `refFn` syntax sugar for adding fn as reference instead of `() => () => value`
Improvement:
- Switch from `@saltyaom/trek-router` to `@medley/router`
- Using `clone` instead of flatten object
- Refactor path fn for inline cache
- Refactor `Context` to class
Bug fix:
- `.ref()` throw error when accept function
BIN
View File
Binary file not shown.
+3 -2
View File
@@ -3,8 +3,9 @@ import KingWorld from '../src'
import cookie from '../src/index'
const app = new KingWorld()
.get('/', ({ responseHeaders }) => {
// responseHeaders.append('a', 'b')
.get('/', ({ status, responseHeaders }) => {
responseHeaders.append('a', 'b')
status(400)
return new Response('Ok')
})
+22 -14
View File
@@ -9,8 +9,6 @@ const loggerPlugin: Plugin<
{
store: {
fromPlugin: 'From Logger'
}
request: {
log: () => void
}
}
@@ -18,12 +16,12 @@ const loggerPlugin: Plugin<
app
.get('/hi', () => 'Hi')
.state('fromPlugin', 'From Logger')
.transform((request) => {
request.log = () => {
.transform(({ responseHeaders }, store) => {
store.log = () => {
console.log('From Logger')
}
request.responseHeaders.append('X-POWERED-BY', 'KINGWORLD')
responseHeaders.append('X-POWERED-BY', 'KINGWORLD')
})
.group(prefix, (app) => {
app.get('/plugin', () => 'From Plugin')
@@ -35,21 +33,27 @@ const app = new KingWorld<{
date: number
}
}>()
.get('/', () => 'KINGWORLD')
.use(loggerPlugin)
.get('/tako', () => Bun.file('./example/takodachi.png'))
.state('build', Date.now())
.ref('date', () => Date.now())
.refFn('a', (a: string) => a)
.get('/', () => 'KINGWORLD')
.get('/tako', () => Bun.file('./example/takodachi.png'))
.get('/json', () => ({
hi: 'world'
}))
.get('/root/plugin/log', (_, { log, a }) => {
log()
return a('B')
})
.get('/wildcard/*', () => 'Hi Wildcard')
.get<{
query: {
name: string
}
}>('/kw', (ctx, store) => 'KINGWORLD', {
preHandler: ({ query, log }, { fromPlugin }) => {
preHandler: ({ query }, { fromPlugin, log }) => {
console.log('Name:', query?.name)
if (query?.name === 'aom') return 'Hi saltyaom'
@@ -79,8 +83,8 @@ const app = new KingWorld<{
id: number
}
}>('/id/:id', (request, store) => request.params.id, {
transform(request, store) {
request.params.id = +request.params.id
transform({ params }, store) {
params.id = +params.id
}
})
.post<{
@@ -90,7 +94,7 @@ const app = new KingWorld<{
params: {
id: number
}
}>('/new/:id', async ({ request }) => request.json())
}>('/new/:id', async ({ body }) => body)
.group('/group', (app) => {
app.preHandler<{
query: {
@@ -103,11 +107,15 @@ const app = new KingWorld<{
.get('/kingworld', () => 'Welcome to KINGWORLD')
.get('/fbk', () => 'FuBuKing')
})
.get('/response-header', (ctx) => {
ctx.responseHeaders.append("a", "b")
.get('/response-header', ({ status, responseHeaders }) => {
status(404)
responseHeaders.append('a', 'b')
return "A"
// console.log(r.status)
return 'A'
})
.get('/this/is/my/deep/nested/root', () => 'Hi')
.get('/build', (_, { build }) => build)
.get('/ref', (_, { date }) => date)
.get('/response', () => new Response('Hi'))
-43
View File
@@ -1,43 +0,0 @@
const handle = async (request: Request) => {
const bodySize = request.headers.get('content-length')
// ? For some reason, this prevent segmentation fault (Bun 0.1.10)
const body = !bodySize
? undefined
: request.headers.get('content-type') === 'application/json'
? await request.json()
: await request.text()
const a = {
body
}
return new Response('done')
}
const body = JSON.stringify({
tako: 'dachi'
})
handle(
new Request('http://localhost:3000/json', {
method: 'POST',
body,
headers: {
'content-type': 'application/json',
'content-length': body.length.toString()
}
})
)
for (let i = 0; i < 10_000_000; i++)
handle(
new Request('http://localhost:3000/json', {
method: 'POST',
body,
headers: {
'content-type': 'application/json',
'content-length': body.length.toString()
}
})
)
+1
View File
@@ -34,6 +34,7 @@
"release": "npm run build && npm run test && npm publish"
},
"dependencies": {
"@medley/router": "^0.2.1",
"@saltyaom/trek-router": "0.0.7"
},
"devDependencies": {
+37
View File
@@ -0,0 +1,37 @@
import { TypedRoute } from './types'
export default class Context<Route extends TypedRoute = TypedRoute> {
_status = 200
_headers: Headers | undefined
request: Request
query: Route['query'] extends Record<string, any>
? Route['query']
: Record<string, string>
params: Route['params']
body: Route['body']
constructor(a: {
request: Request
query: Route['query'] extends Record<string, any>
? Route['query']
: Record<string, string>
params: Route['params']
body: Route['body']
}) {
this.request = a.request
this.params = a.params
this.query = a.query
this.body = a.body
}
status = (code: number) => {
this._status = code
}
get responseHeaders(): Headers {
if (!this._headers) this._headers = new Headers()
return this._headers
}
}
+34 -45
View File
@@ -1,4 +1,4 @@
import type { Context } from './types'
import type Context from './context'
const json = new Headers()
json.append('Content-Type', 'application/json')
@@ -7,28 +7,21 @@ export const jsonHeader = {
}
// We don't want to assign new variable to be used only once here
export const mapEarlyResponse = (
headerAvailable: boolean,
response: unknown,
context: Context,
status: number
) => {
if (headerAvailable)
export const mapEarlyResponse = (response: unknown, context: Context) => {
if (context._headers || context._status !== 200)
switch (typeof response) {
case 'string':
return new Response(response, {
status,
headers: context.responseHeaders
status: context._status,
headers: context._headers
})
case 'object':
if (response instanceof Error)
return errorToResponse(response, context.responseHeaders)
return errorToResponse(response, context._headers)
if (response instanceof Response) {
for (const [
key,
value
] of context.responseHeaders.entries())
for (const [key, value] of context._headers?.entries() ??
[])
response.headers.append(key, value)
return response
@@ -40,19 +33,19 @@ export const mapEarlyResponse = (
)
return new Response(JSON.stringify(response), {
status,
headers: context.responseHeaders
status: context._status,
headers: context._headers
})
// ? Maybe response or Blob
case 'function':
if (response instanceof Blob)
return new Response(response, {
status,
headers: context.responseHeaders
status: context._status,
headers: context._headers
})
for (const [key, value] of context.responseHeaders.entries())
for (const [key, value] of context._headers?.entries() ?? [])
(response as unknown as Response).headers.append(key, value)
return response as unknown as Response
@@ -60,8 +53,8 @@ export const mapEarlyResponse = (
case 'number':
case 'boolean':
return new Response(response.toString(), {
status,
headers: context.responseHeaders
status: context._status,
headers: context._headers
})
default:
@@ -75,7 +68,7 @@ export const mapEarlyResponse = (
case 'object':
if (response instanceof Response) return response
if (response instanceof Error)
return errorToResponse(response, context.responseHeaders)
return errorToResponse(response, context._headers)
return new Response(JSON.stringify(response), jsonHeader)
@@ -94,25 +87,21 @@ export const mapEarlyResponse = (
}
}
export const mapResponse = (
headerAvailable: boolean,
response: unknown,
context: Context,
status: number
) => {
if (headerAvailable)
export const mapResponse = (response: unknown, context: Context) => {
if (context._headers || context._status !== 200)
switch (typeof response) {
case 'string':
return new Response(response, {
status,
headers: context.responseHeaders
status: context._status,
headers: context._headers
})
case 'object':
if (response instanceof Error)
return errorToResponse(response, context.responseHeaders)
return errorToResponse(response, context._headers)
if (response instanceof Response) {
for (const [key, value] of context.responseHeaders!.entries())
for (const [key, value] of context._headers?.entries() ??
[])
response.headers.append(key, value)
return response
@@ -124,16 +113,16 @@ export const mapResponse = (
)
return new Response(JSON.stringify(response), {
status,
headers: context.responseHeaders
status: context._status,
headers: context._headers
})
// ? Maybe response function or Blob
case 'function':
if (response instanceof Blob)
return new Response(response, {
status,
headers: context.responseHeaders
status: context._status,
headers: context._headers
})
return response()
@@ -141,20 +130,20 @@ export const mapResponse = (
case 'number':
case 'boolean':
return new Response(response.toString(), {
status,
headers: context.responseHeaders
status: context._status,
headers: context._headers
})
case 'undefined':
return new Response('', {
status,
headers: context.responseHeaders
status: context._status,
headers: context._headers
})
default:
return new Response(response as any, {
status,
headers: context.responseHeaders
status: context._status,
headers: context._headers
})
}
else
@@ -165,7 +154,7 @@ export const mapResponse = (
case 'object':
if (response instanceof Response) return response
if (response instanceof Error)
return errorToResponse(response, context.responseHeaders)
return errorToResponse(response, context._headers)
return new Response(JSON.stringify(response), jsonHeader)
+86 -83
View File
@@ -1,7 +1,17 @@
import { default as Router, type HTTPMethod } from '@saltyaom/trek-router'
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
import MedleyRouter from '@medley/router'
import Context from './context'
import { mapResponse, mapEarlyResponse } from './handler'
import { mergeHook, isPromise, clone, mapArrayObject } from './utils'
import {
mergeHook,
isPromise,
clone,
parseQuery,
getQuery,
getPath
} from './utils'
import type {
Handler,
@@ -11,28 +21,40 @@ import type {
PreRequestHandler,
TypedRoute,
Plugin,
Context,
KingWorldInstance,
ComposedHandler,
KingWorldConfig,
KWKey,
ExtractKWPath
ExtractKWPath,
HTTPMethod
} from './types'
import type { Serve } from 'bun'
export default class KingWorld<
Instance extends KingWorldInstance = KingWorldInstance
> {
router: Router<ComposedHandler>
store: Instance['store']
hook: Hook<Instance>
store: Instance['store'] = {}
hook: Hook<Instance> = {
onRequest: [],
transform: [],
preHandler: []
}
config: KingWorldConfig = {
bodyLimit: 1048576
}
private _ref: [keyof Instance['store'], any][]
private _default: Handler
private router = new MedleyRouter()
protected routes: [
HTTPMethod,
string,
Handler<any, Instance>,
RegisterHook<any, Instance>
][] = []
private _ref: [keyof Instance['store'], any][] = []
private _default: Handler = () =>
new Response('Not Found', {
status: 404
})
constructor(
config: KingWorldConfig = {
@@ -44,22 +66,7 @@ export default class KingWorld<
bodyLimit: 1048576
}
) {
this.router = new Router()
this.store = {} as Instance['store']
this._ref = []
this.hook = {
onRequest: [],
transform: [],
preHandler: []
}
this.config = config
this._default = () =>
new Response('Not Found', {
status: 404
})
}
private _addHandler<Route extends TypedRoute = TypedRoute>(
@@ -68,10 +75,17 @@ export default class KingWorld<
handler: Handler<Route, Instance>,
hook?: RegisterHook<Route, Instance>
) {
this.router.add(method, path, [
this.routes.push([
method,
path,
handler,
mergeHook(clone(this.hook) as Hook, hook as RegisterHook)
])
this.router.register(path)[method] = {
handle: handler,
hooks: mergeHook(clone(this.hook) as Hook, hook as RegisterHook)
}
}
onRequest(handler: PreRequestHandler<Instance['store']>) {
@@ -125,9 +139,9 @@ export default class KingWorld<
this.store = { ...this.store, ...instance.store }
Object.values(instance.router.routes).forEach(
([method, path, handler]) => {
this.router.add(method, `${prefix}${path}`, handler)
Object.values(instance.routes).forEach(
([method, path, handler, hook]) => {
this._addHandler(method, `${prefix}${path}`, handler, hook)
}
)
@@ -143,11 +157,9 @@ export default class KingWorld<
this.store = { ...this.store, ...instance.store }
Object.values(instance.router.routes).forEach(
([method, path, handler]) => {
this.router.add(method, path, handler)
}
)
Object.values(instance.routes).forEach(([method, path, handler]) => {
this._addHandler(method, path, handler)
})
return this
}
@@ -375,13 +387,31 @@ export default class KingWorld<
return this as unknown as NewInstance
}
// ? Need to be arrow function otherwise, `this` won't work for some reason
async handle(request: Request): Promise<Response> {
refFn<
Key extends KWKey = keyof Instance['store'],
Value = Instance['store'][keyof Instance['store']],
ReturnValue = Value extends () => infer Returned
? Returned extends Promise<infer AsyncReturned>
? AsyncReturned
: Returned
: Value,
NewInstance extends KingWorldInstance = KingWorld<
KingWorldInstance<{
store: Instance['store'] & { [key in Key]: ReturnValue }
request: Instance['request'] extends Record<string, any>
? Instance['request']
: Record<string, any>
}>
>
>(name: Key, value: Value): NewInstance {
this._ref.push([name, () => value])
return this as unknown as NewInstance
}
// ? Need to be arrow function otherwise `this` won't work for some reason
handle = async (request: Request): Promise<Response> => {
const store: Partial<Instance['store']> = clone(this.store)
const [handler, _params, query] = this.router.find(
request.method as HTTPMethod,
request.url
)
if (this._ref.length)
for (const [key, value] of this._ref)
@@ -401,56 +431,37 @@ export default class KingWorld<
if (bodySize && +bodySize > this.config.bodyLimit)
return new Response('Exceed body limit')
let headerAvailable = false
let responseHeaders: Headers | undefined
let status = 200
const body = !bodySize
const body: string | Object | undefined = !bodySize
? undefined
: request.headers.get('content-type') === 'application/json'
? await request.json()
: await request.text()
// ? Might have additional field attach from plugin, so forced type cast here
const context: Context = {
request,
params: _params.length ? mapArrayObject(_params) : {},
query,
status(value: number) {
status = value
headerAvailable = true
},
body,
get responseHeaders() {
if (!responseHeaders) {
responseHeaders = new Headers()
headerAvailable = true
}
const route = this.router.find(getPath(request.url))
const handler = route?.store[request.method]
return responseHeaders
}
} as Context
const context: Context = new Context({
request,
params: route?.params ?? {},
query: parseQuery(getQuery(request.url)),
body
})
if (!handler) {
let response = this._default(context, store)
if (isPromise(response)) response = await response
return mapResponse(headerAvailable, response, context, status)
return mapResponse(response, context)
}
const [handle, hooks] = handler
const { handle, hooks } = handler
if (hooks.transform.length)
for (const transform of hooks.transform) {
let response = transform(context, store)
if (isPromise(response)) response = await response
const result = mapEarlyResponse(
headerAvailable,
response,
context,
status
)
const result = mapEarlyResponse(response, context)
if (result) return result
}
@@ -459,35 +470,28 @@ export default class KingWorld<
let response = preHandler(context, store)
if (isPromise(response)) response = await response
const result = mapEarlyResponse(
headerAvailable,
response,
context,
status
)
const result = mapEarlyResponse(response, context)
if (result) return result
}
let response = handle(context, store)
if (isPromise(response)) response = await response
return mapResponse(headerAvailable, response, context, status)
return mapResponse(response, context)
}
listen(options: number | Omit<Serve, 'fetch'>) {
if (!Bun) throw new Error('Bun to run')
const fetch = this.handle.bind(this)
Bun.serve(
typeof options === 'number'
? {
port: options,
fetch
fetch: this.handle
}
: {
...options,
fetch
fetch: this.handle
}
)
}
@@ -498,7 +502,6 @@ export type {
Hook,
HookEvent,
RegisterHook,
Context,
PreRequestHandler,
TypedRoute,
Plugin
+47 -16
View File
@@ -1,4 +1,5 @@
import type KingWorld from './index'
import type Context from './context'
export type KWKey = string | number | symbol
@@ -15,17 +16,6 @@ export interface KingWorldInstance<
store: Instance['store']
}
export type Context<Route extends TypedRoute = TypedRoute> = {
request: Request
query: Route['query'] extends Record<string, any>
? Route['query']
: Record<string, string>
params: Route['params']
body: Route['body']
status(statusCode: number): void
responseHeaders: Headers
} & Omit<Route, 'body' | 'query' | 'header' | 'body'>
export type Handler<
Route extends TypedRoute = TypedRoute,
Instance extends KingWorldInstance = KingWorldInstance
@@ -66,11 +56,14 @@ export interface TypedRoute {
export type Plugin<
T = Record<string, unknown>,
PluginInstance extends KingWorldInstance = KingWorldInstance,
BaseInstance extends KingWorldInstance = KingWorldInstance
BaseInstance extends KingWorldInstance = KingWorldInstance,
ReturnedInstance extends KingWorld<
BaseInstance & PluginInstance
> = KingWorld<BaseInstance & PluginInstance>
> = (
app: KingWorld<BaseInstance & PluginInstance>,
config?: T
) => KingWorld<BaseInstance & PluginInstance>
) => ReturnedInstance
export type ComposedHandler = [handle: Handler<any, any>, hooks: Hook<any>]
@@ -78,7 +71,45 @@ export interface KingWorldConfig {
bodyLimit: number
}
export type IsKWPathParameter<Part> = Part extends `:${infer Parameter}` ? Parameter : never;
export type IsKWPathParameter<Part> = Part extends `:${infer Parameter}`
? Parameter
: never
export type ExtractKWPath<Path> = Path extends `${infer A}/${infer B}`
? IsKWPathParameter<A> | ExtractKWPath<B>
: IsKWPathParameter<Path>;
? IsKWPathParameter<A> | ExtractKWPath<B>
: IsKWPathParameter<Path>
export type HTTPMethod =
| 'ACL'
| 'BIND'
| 'CHECKOUT'
| 'CONNECT'
| 'COPY'
| 'DELETE'
| 'GET'
| 'HEAD'
| 'LINK'
| 'LOCK'
| 'M-SEARCH'
| 'MERGE'
| 'MKACTIVITY'
| 'MKCALENDAR'
| 'MKCOL'
| 'MOVE'
| 'NOTIFY'
| 'OPTIONS'
| 'PATCH'
| 'POST'
| 'PROPFIND'
| 'PROPPATCH'
| 'PURGE'
| 'PUT'
| 'REBIND'
| 'REPORT'
| 'SEARCH'
| 'SOURCE'
| 'SUBSCRIBE'
| 'TRACE'
| 'UNBIND'
| 'UNLINK'
| 'UNLOCK'
| 'UNSUBSCRIBE'
+31 -6
View File
@@ -18,11 +18,13 @@ export const isPromise = <T>(
export const clone = <T extends Object | any[] = Object | any[]>(
aObject: T
): T => {
const bObject: Record<string, any> = Array.isArray(aObject) ? [] : {}
const bObject: Record<string, any> = Array.isArray(aObject)
? []
: Object.create(null)
let value: Partial<T>
for (const key in aObject) {
value = aObject[key]
value = aObject[key] as any
bObject[key as any] =
typeof value === 'object' ? clone(value as T) : value
@@ -31,10 +33,33 @@ export const clone = <T extends Object | any[] = Object | any[]>(
return bObject as T
}
export const mapArrayObject = (value: [string, string][]) => {
const object: Record<string, string> = {}
// export const splitOnce = (char: string, s: string) => {
// const i = s.indexOf(char)
value.forEach(([key, value]) => (object[key] = value))
// return i === -1 ? [s, ''] : [s.slice(0, i), s.slice(i + 1)]
// }
return object
export const getPath = (url: string): string => {
const queryIndex = url.indexOf('?')
return url.substring(
url.charCodeAt(0) === 47 ? 0 : url.indexOf('/', 11),
queryIndex === -1 ? url.length : queryIndex
)
}
export const getQuery = (url: string): string => {
const queryIndex = url.indexOf('?') + 1
return queryIndex ? url.substring(queryIndex) : ''
}
export const parseQuery = (search: string) =>
!search
? {}
: search.split('&').reduce((result, each) => {
const i = each.indexOf('=')
result[each.slice(0, i)] = each.slice(i + 1)
return result
}, {} as Record<string, string>)
+16 -9
View File
@@ -13,11 +13,10 @@ describe('Path', () => {
})
it('Handle multiple level', async () => {
const app = new KingWorld().get<{
params: {
id: string
}
}>('/this/is/my/deep/nested/root', () => 'Ok')
const app = new KingWorld().get(
'/this/is/my/deep/nested/root',
() => 'Ok'
)
const res = await app.handle(req('/this/is/my/deep/nested/root'))
expect(await res.text()).toBe('Ok')
@@ -140,11 +139,18 @@ describe('Path', () => {
it('Handle body', async () => {
const app = new KingWorld().post<{
body: string
}>('/', ({ request }) => request.text())
}>('/', ({ body }) => body)
const body = 'Botan'
const res = await app.handle(
new Request('/', {
method: 'POST',
body: 'Botan'
body,
headers: {
'content-type': 'text/plain',
'content-length': body.length.toString()
}
})
)
@@ -160,13 +166,14 @@ describe('Path', () => {
body: {
name: string
}
}>('/', ({ request }) => request.json())
}>('/', ({ body }) => body)
const res = await app.handle(
new Request('/', {
method: 'POST',
body,
headers: {
'content-type': 'application/json'
'content-type': 'application/json',
'content-length': body.length.toString()
}
})
)