From 45fea1082a27d931d60a163381eb0bc6a0854bd7 Mon Sep 17 00:00:00 2001 From: Kevin Whitley Date: Thu, 14 Mar 2024 13:33:29 -0500 Subject: [PATCH 01/16] flowrouter is the new router and ittyrouter is the old router --- example/bun-autorouter.ts | 12 ++ example/bun-flowrouter.ts | 22 +++ example/request-types.ts | 2 +- package.json | 10 ++ src/AutoRouter.ts | 12 ++ src/{Router.spec.ts => IttyRouter.spec.ts} | 2 +- src/IttyRouter.ts | 125 +++++++++++++++ src/Router.ts | 168 ++++++++------------- src/createCors.ts | 4 +- src/index.ts | 2 +- src/withContent.ts | 2 +- src/withCookies.ts | 2 +- src/withParams.ts | 2 +- 13 files changed, 256 insertions(+), 109 deletions(-) create mode 100644 example/bun-autorouter.ts create mode 100644 example/bun-flowrouter.ts create mode 100644 src/AutoRouter.ts rename src/{Router.spec.ts => IttyRouter.spec.ts} (99%) create mode 100644 src/IttyRouter.ts diff --git a/example/bun-autorouter.ts b/example/bun-autorouter.ts new file mode 100644 index 00000000..45f389c4 --- /dev/null +++ b/example/bun-autorouter.ts @@ -0,0 +1,12 @@ +import { AutoRouter } from '../src/AutoRouter' + +const router = AutoRouter({ port: 3001 }) + +router + .get('/basic', () => new Response('Success!')) + .get('/text', () => 'Success!') + .get('/params/:foo', ({ foo }) => foo) + .get('/json', () => ({ foo: 'bar' })) + .get('/throw', (a) => a.b.c) + +export default router diff --git a/example/bun-flowrouter.ts b/example/bun-flowrouter.ts new file mode 100644 index 00000000..849cdec2 --- /dev/null +++ b/example/bun-flowrouter.ts @@ -0,0 +1,22 @@ +import { FlowRouter } from '../src/Router' +import { error } from '../src/error' +import { json } from '../src/json' +import { withParams } from '../src/withParams' + +const router = FlowRouter({ + port: 3001, + before: [withParams], + onError: [error], + after: [json], + missing: () => error(404, 'Are you sure about that?'), +}) + +router + .get('/basic', () => new Response('Success!')) + .get('/text', () => 'Success!') + .get('/params/:foo', ({ foo }) => foo) + .get('/json', () => ({ foo: 'bar' })) + .get('/throw', (a) => a.b.c) + // .all('*', () => error(404)) // still works + +export default router diff --git a/example/request-types.ts b/example/request-types.ts index 45033bb1..bbe407c1 100644 --- a/example/request-types.ts +++ b/example/request-types.ts @@ -1,4 +1,4 @@ -import { IRequest, IRequestStrict, Router } from '../src/Router' +import { IRequest, IRequestStrict, Router } from '../src/IttyRouter' type FooRequest = { foo: string diff --git a/package.json b/package.json index b40ed382..d8727a64 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,11 @@ "require": "./index.js", "types": "./index.d.ts" }, + "./AutoRouter": { + "import": "./AutoRouter.mjs", + "require": "./AutoRouter.js", + "types": "./AutoRouter.d.ts" + }, "./createCors": { "import": "./createCors.mjs", "require": "./createCors.js", @@ -26,6 +31,11 @@ "require": "./error.js", "types": "./error.d.ts" }, + "./FlowRouter": { + "import": "./FlowRouter.mjs", + "require": "./FlowRouter.js", + "types": "./FlowRouter.d.ts" + }, "./html": { "import": "./html.mjs", "require": "./html.js", diff --git a/src/AutoRouter.ts b/src/AutoRouter.ts new file mode 100644 index 00000000..301d5547 --- /dev/null +++ b/src/AutoRouter.ts @@ -0,0 +1,12 @@ +import { error } from 'error' +import { json } from 'json' +import { withParams } from 'withParams' +import { Router, RouterOptions} from './Router' + +export const AutoRouter = (options?: RouterOptions) => Router({ + before: [withParams], + onError: [error], + after: [json], + missing: () => error(404, 'Are you sure about that?'), + ...options, +}) diff --git a/src/Router.spec.ts b/src/IttyRouter.spec.ts similarity index 99% rename from src/Router.spec.ts rename to src/IttyRouter.spec.ts index 010e6247..9d12d0e2 100644 --- a/src/Router.spec.ts +++ b/src/IttyRouter.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import { createTestRunner, extract, toReq } from '../test' -import { Router } from './Router' +import { IttyRouter as Router } from './IttyRouter' const ERROR_MESSAGE = 'Error Message' diff --git a/src/IttyRouter.ts b/src/IttyRouter.ts new file mode 100644 index 00000000..fcd6cca3 --- /dev/null +++ b/src/IttyRouter.ts @@ -0,0 +1,125 @@ +export type GenericTraps = { + [key: string]: any +} + +export type RequestLike = { + method: string, + url: string, +} & GenericTraps + +export type IRequestStrict = { + method: string, + url: string, + route: string, + params: { + [key: string]: string, + }, + query: { + [key: string]: string | string[] | undefined, + }, + proxy?: any, +} & Request + +export type IRequest = IRequestStrict & GenericTraps + +export type IttyRouterOptions = { + base?: string + routes?: RouteEntry[] +} & Record + +export type RouteHandler = { + (request: I, ...args: A): any +} + +export type RouteEntry = [ + httpMethod: string, + match: RegExp, + handlers: RouteHandler[], + path?: string, +] + +// this is the generic "Route", which allows per-route overrides +export type Route = ( + path: string, + ...handlers: RouteHandler[] +) => RT + +// this is an alternative UniveralRoute, accepting generics (from upstream), but without +// per-route overrides +export type UniversalRoute = ( + path: string, + ...handlers: RouteHandler[] +) => RouterType, Args> + +// helper function to detect equality in types (used to detect custom Request on router) +export type Equal = (() => T extends X ? 1 : 2) extends (() => T extends Y ? 1 : 2) ? true : false; + +export type CustomRoutes = { + [key: string]: R, +} + +export type RouterType = { + __proto__: RouterType, + routes: RouteEntry[], + fetch: (request: RequestLike, ...extra: Equal extends true ? A : Args) => Promise + handle: (request: RequestLike, ...extra: Equal extends true ? A : Args) => Promise + all: R, + delete: R, + get: R, + head: R, + options: R, + patch: R, + post: R, + put: R, +} & CustomRoutes & Record + +export const IttyRouter = < + RequestType = IRequest, + Args extends any[] = any[], + RouteType = Equal extends true ? Route : UniversalRoute +>({ base = '', routes = [], ...other }: IttyRouterOptions = {}): RouterType => + // @ts-expect-error TypeScript doesn't know that Proxy makes this work + ({ + __proto__: new Proxy({}, { + // @ts-expect-error (we're adding an expected prop "path" to the get) + get: (target: any, prop: string, receiver: RouterType, path: string) => + prop == 'handle' ? receiver.fetch : + // @ts-expect-error - unresolved type + (route: string, ...handlers: RouteHandler[]) => + routes.push( + [ + prop.toUpperCase?.(), + RegExp(`^${(path = (base + route) + .replace(/\/+(\/|$)/g, '$1')) // strip double & trailing splash + .replace(/(\/?\.?):(\w+)\+/g, '($1(?<$2>*))') // greedy params + .replace(/(\/?\.?):(\w+)/g, '($1(?<$2>[^$1/]+?))') // named params and image format + .replace(/\./g, '\\.') // dot in path + .replace(/(\/?)\*/g, '($1.*)?') // wildcard + }/*$`), + handlers, // embed handlers + path, // embed clean route path + ] + ) && receiver + }), + routes, + ...other, + async fetch (request: RequestLike, ...args) { + let response, + match, + url = new URL(request.url), + query: Record = request.query = { __proto__: null } + + // 1. parse query params + for (let [k, v] of url.searchParams) + query[k] = query[k] ? ([] as string[]).concat(query[k], v) : v + + // 2. then test routes + for (let [method, regex, handlers, path] of routes) + if ((method == request.method || method == 'ALL') && (match = url.pathname.match(regex))) { + request.params = match.groups || {} // embed params in request + request.route = path // embed route path in request + for (let handler of handlers) + if ((response = await handler(request.proxy ?? request, ...args)) != null) return response + } + }, + }) diff --git a/src/Router.ts b/src/Router.ts index ac97b7fc..49e0b020 100644 --- a/src/Router.ts +++ b/src/Router.ts @@ -1,77 +1,21 @@ -export type GenericTraps = { - [key: string]: any -} +import { + Equal, + IRequest, + RequestLike, + Route, + RouteHandler, + IttyRouterOptions, + RouterType, + UniversalRoute, +} from './IttyRouter' -export type RequestLike = { - method: string, - url: string, -} & GenericTraps - -export type IRequestStrict = { - method: string, - url: string, - route: string, - params: { - [key: string]: string, - }, - query: { - [key: string]: string | string[] | undefined, - }, - proxy?: any, -} & Request - -export type IRequest = IRequestStrict & GenericTraps +export type ErrorHandler = (input: Input) => void export type RouterOptions = { - base?: string - routes?: RouteEntry[] -} & Record - -export type RouteHandler = { - (request: I, ...args: A): any -} - -export type RouteEntry = [ - httpMethod: string, - match: RegExp, - handlers: RouteHandler[], - path?: string, -] - -// this is the generic "Route", which allows per-route overrides -export type Route = ( - path: string, - ...handlers: RouteHandler[] -) => RT - -// this is an alternative UniveralRoute, accepting generics (from upstream), but without -// per-route overrides -export type UniversalRoute = ( - path: string, - ...handlers: RouteHandler[] -) => RouterType, Args> - -// helper function to detect equality in types (used to detect custom Request on router) -type Equal = (() => T extends X ? 1 : 2) extends (() => T extends Y ? 1 : 2) ? true : false; - -export type CustomRoutes = { - [key: string]: R, -} - -export type RouterType = { - __proto__: RouterType, - routes: RouteEntry[], - fetch: (request: RequestLike, ...extra: Equal extends true ? A : Args) => Promise - handle: (request: RequestLike, ...extra: Equal extends true ? A : Args) => Promise - all: R, - delete: R, - get: R, - head: R, - options: R, - patch: R, - post: R, - put: R, -} & CustomRoutes & Record + before?: Function[] + onError?: ErrorHandler[] + after?: Function[] +} & IttyRouterOptions export const Router = < RequestType = IRequest, @@ -83,40 +27,62 @@ export const Router = < __proto__: new Proxy({}, { // @ts-expect-error (we're adding an expected prop "path" to the get) get: (target: any, prop: string, receiver: RouterType, path: string) => - prop == 'handle' ? receiver.fetch : - // @ts-expect-error - unresolved type - (route: string, ...handlers: RouteHandler[]) => - routes.push( - [ - prop.toUpperCase?.(), - RegExp(`^${(path = (base + route) - .replace(/\/+(\/|$)/g, '$1')) // strip double & trailing splash - .replace(/(\/?\.?):(\w+)\+/g, '($1(?<$2>*))') // greedy params - .replace(/(\/?\.?):(\w+)/g, '($1(?<$2>[^$1/]+?))') // named params and image format - .replace(/\./g, '\\.') // dot in path - .replace(/(\/?)\*/g, '($1.*)?') // wildcard - }/*$`), - handlers, // embed handlers - path, // embed clean route path - ] - ) && receiver + // prop == 'handle' ? receiver.fetch : + // @ts-expect-error - unresolved type + (route: string, ...handlers: RouteHandler[]) => + routes.push( + [ + prop.toUpperCase?.(), + RegExp(`^${(path = (base + route) + .replace(/\/+(\/|$)/g, '$1')) // strip double & trailing splash + .replace(/(\/?\.?):(\w+)\+/g, '($1(?<$2>*))') // greedy params + .replace(/(\/?\.?):(\w+)/g, '($1(?<$2>[^$1/]+?))') // named params and image format + .replace(/\./g, '\\.') // dot in path + .replace(/(\/?)\*/g, '($1.*)?') // wildcard + }/*$`), + handlers, // embed handlers + path, // embed clean route path + ] + ) && receiver }), routes, ...other, - async fetch (request: RequestLike, ...args) { - let response, match, url = new URL(request.url), query: Record = request.query = { __proto__: null } + async fetch (request: RequestLike, ...args) { + let response, + match, + url = new URL(request.url), + query: Record = request.query = { __proto__: null } + + try { + // 1. parse query params + for (let [k, v] of url.searchParams) + query[k] = query[k] ? ([] as string[]).concat(query[k], v) : v + + for (let handler of other.before || []) + if ((response = await handler(request.proxy ?? request, ...args)) != null) break + + // 2. then test routes + outer: for (let [method, regex, handlers, path] of routes) + if ((method == request.method || method == 'ALL') && (match = url.pathname.match(regex))) { + request.params = match.groups || {} // embed params in request + request.route = path // embed route path in request + + for (let handler of handlers) + if ((response = await handler(request.proxy ?? request, ...args)) != null) break outer + } + + // 3. respond with missing hook if available + needed + response = response ?? other.missing?.(request.proxy ?? request, ...args) + } catch (err) { + if (!other.onError) throw err + + for (let handler of other.onError || []) + response = await handler(response ?? err) + } - // 1. parse query params - for (let [k, v] of url.searchParams) - query[k] = query[k] ? ([] as string[]).concat(query[k], v) : v + for (let handler of other.after || []) + response = await handler(response) - // 2. then test routes - for (let [method, regex, handlers, path] of routes) - if ((method == request.method || method == 'ALL') && (match = url.pathname.match(regex))) { - request.params = match.groups || {} // embed params in request - request.route = path // embed route path in request - for (let handler of handlers) - if ((response = await handler(request.proxy ?? request, ...args)) != null) return response - } + return response }, }) diff --git a/src/createCors.ts b/src/createCors.ts index 76e5a8c6..2ef2f424 100644 --- a/src/createCors.ts +++ b/src/createCors.ts @@ -1,4 +1,4 @@ -import { IRequest } from './Router' +import { IRequest } from './IttyRouter' export type CorsOptions = { origins?: string[] | ((origin: string) => boolean) @@ -13,7 +13,7 @@ export const createCors = (options: CorsOptions = {}) => { const { origins = ['*'], maxAge, methods = ['GET'], headers = {} } = options let allowOrigin: any - const isAllowOrigin = typeof origins === 'function' + const isAllowOrigin = typeof origins === 'function' ? origins : (origin: string) => (origins.includes(origin) || origins.includes('*')) diff --git a/src/index.ts b/src/index.ts index e9770245..c298465c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,4 @@ -export * from './Router' +export * from './IttyRouter' // classes export * from './StatusError' diff --git a/src/withContent.ts b/src/withContent.ts index 914b70f1..1ad363aa 100644 --- a/src/withContent.ts +++ b/src/withContent.ts @@ -1,4 +1,4 @@ -import { IRequest, IRequestStrict } from './Router' +import { IRequest, IRequestStrict } from './IttyRouter' export type HasContent = { content: ContentType diff --git a/src/withCookies.ts b/src/withCookies.ts index 5bbc2485..e668b9e9 100644 --- a/src/withCookies.ts +++ b/src/withCookies.ts @@ -1,4 +1,4 @@ -import { IRequest } from './Router' +import { IRequest } from './IttyRouter' type KVPair = [string, string?] diff --git a/src/withParams.ts b/src/withParams.ts index de1d7ad8..9b3aa617 100644 --- a/src/withParams.ts +++ b/src/withParams.ts @@ -1,4 +1,4 @@ -import { IRequest } from './Router' +import { IRequest } from './IttyRouter' export const withParams = (request: IRequest): void => { request.proxy = new Proxy(request.proxy || request, { From bd8ee7bec1b65351122842b287247cdecbfbd2a9 Mon Sep 17 00:00:00 2001 From: Kevin Whitley Date: Thu, 14 Mar 2024 14:49:17 -0500 Subject: [PATCH 02/16] further evolving the standard --- example/bun-autorouter.ts | 24 +++++++++++++++++++++++- example/bun-flowrouter.ts | 11 +++++++---- package.json | 10 +++++----- src/AutoRouter.ts | 32 ++++++++++++++++++++++++++++---- src/IttyRouter.ts | 2 -- src/Router.ts | 13 +++++-------- src/index.ts | 3 +++ 7 files changed, 71 insertions(+), 24 deletions(-) diff --git a/example/bun-autorouter.ts b/example/bun-autorouter.ts index 45f389c4..f2680078 100644 --- a/example/bun-autorouter.ts +++ b/example/bun-autorouter.ts @@ -1,6 +1,27 @@ +import { text } from 'text' +import { json } from 'json' import { AutoRouter } from '../src/AutoRouter' +import { error } from 'error' +import { IRequest } from 'IttyRouter' +import { withParams } from 'withParams' -const router = AutoRouter({ port: 3001 }) +const router = AutoRouter({ + port: 3001, + missing: () => error(404, 'Are you sure about that?'), + before: [ + (r: any) => { r.date = new Date }, + ], + after: [ + (r: Response, request: IRequest) => + console.log(r.status, request.method, request.url, 'delivered in', Date.now() - request.date, 'ms from', request.date.toLocaleString()), + ] +}) + +const childRouter = AutoRouter({ + base: '/child', + missing: () => {}, +}) + .get('/:id', ({ id }) => [ Number(id), Number(id) / 2 ]) router .get('/basic', () => new Response('Success!')) @@ -8,5 +29,6 @@ router .get('/params/:foo', ({ foo }) => foo) .get('/json', () => ({ foo: 'bar' })) .get('/throw', (a) => a.b.c) + .get('/child/*', childRouter.fetch) export default router diff --git a/example/bun-flowrouter.ts b/example/bun-flowrouter.ts index 849cdec2..f546b25d 100644 --- a/example/bun-flowrouter.ts +++ b/example/bun-flowrouter.ts @@ -1,14 +1,17 @@ -import { FlowRouter } from '../src/Router' +import { Router } from '../src/Router' import { error } from '../src/error' import { json } from '../src/json' import { withParams } from '../src/withParams' -const router = FlowRouter({ +const router = Router({ port: 3001, before: [withParams], onError: [error], - after: [json], - missing: () => error(404, 'Are you sure about that?'), + after: [ + (r: any) => r ?? error(404, 'Are you sure about that?'), + json + ], + // missing: () => error(404, 'Are you sure about that?'), }) router diff --git a/package.json b/package.json index d8727a64..02995860 100644 --- a/package.json +++ b/package.json @@ -31,16 +31,16 @@ "require": "./error.js", "types": "./error.d.ts" }, - "./FlowRouter": { - "import": "./FlowRouter.mjs", - "require": "./FlowRouter.js", - "types": "./FlowRouter.d.ts" - }, "./html": { "import": "./html.mjs", "require": "./html.js", "types": "./html.d.ts" }, + "./IttyRouter": { + "import": "./IttyRouter.mjs", + "require": "./IttyRouter.js", + "types": "./IttyRouter.d.ts" + }, "./jpeg": { "import": "./jpeg.mjs", "require": "./jpeg.js", diff --git a/src/AutoRouter.ts b/src/AutoRouter.ts index 301d5547..c3289590 100644 --- a/src/AutoRouter.ts +++ b/src/AutoRouter.ts @@ -3,10 +3,34 @@ import { json } from 'json' import { withParams } from 'withParams' import { Router, RouterOptions} from './Router' -export const AutoRouter = (options?: RouterOptions) => Router({ - before: [withParams], +// MORE FINE-GRAINED/SIMPLIFIED CONTROL, BUT CANNOT FULLY REPLACE BEFORE/AFTER STAGES +export const AutoRouter = ({ + format = json, + missing = () => error(404), + after = [], + before = [], + ...options }: RouterOptions = {} +) => Router({ + before: [ + withParams, + ...before + ], onError: [error], - after: [json], - missing: () => error(404, 'Are you sure about that?'), + after: [ + (r: any) => r ?? missing(), + format, + ...after, + ], ...options, }) + +// LESS FINE-GRAINED CONTROL, BUT CAN COMPLETELY REPLACE BEFORE/AFTER STAGES +// export const AutoRouter2 = ({ ...options }: RouterOptions = {}) => Router({ +// before: [withParams], +// onError: [error], +// after: [ +// (r: any) => r ?? error(404), +// json, +// ], +// ...options, +// }) diff --git a/src/IttyRouter.ts b/src/IttyRouter.ts index fcd6cca3..536203a0 100644 --- a/src/IttyRouter.ts +++ b/src/IttyRouter.ts @@ -62,7 +62,6 @@ export type RouterType = { __proto__: RouterType, routes: RouteEntry[], fetch: (request: RequestLike, ...extra: Equal extends true ? A : Args) => Promise - handle: (request: RequestLike, ...extra: Equal extends true ? A : Args) => Promise all: R, delete: R, get: R, @@ -83,7 +82,6 @@ export const IttyRouter = < __proto__: new Proxy({}, { // @ts-expect-error (we're adding an expected prop "path" to the get) get: (target: any, prop: string, receiver: RouterType, path: string) => - prop == 'handle' ? receiver.fetch : // @ts-expect-error - unresolved type (route: string, ...handlers: RouteHandler[]) => routes.push( diff --git a/src/Router.ts b/src/Router.ts index 49e0b020..ab338074 100644 --- a/src/Router.ts +++ b/src/Router.ts @@ -9,11 +9,11 @@ import { UniversalRoute, } from './IttyRouter' -export type ErrorHandler = (input: Input) => void +// export type ErrorHandler = (input: Input) => void export type RouterOptions = { before?: Function[] - onError?: ErrorHandler[] + onError?: Function[] after?: Function[] } & IttyRouterOptions @@ -27,7 +27,7 @@ export const Router = < __proto__: new Proxy({}, { // @ts-expect-error (we're adding an expected prop "path" to the get) get: (target: any, prop: string, receiver: RouterType, path: string) => - // prop == 'handle' ? receiver.fetch : + prop == 'handle' ? receiver.fetch : // @ts-expect-error - unresolved type (route: string, ...handlers: RouteHandler[]) => routes.push( @@ -70,18 +70,15 @@ export const Router = < for (let handler of handlers) if ((response = await handler(request.proxy ?? request, ...args)) != null) break outer } - - // 3. respond with missing hook if available + needed - response = response ?? other.missing?.(request.proxy ?? request, ...args) } catch (err) { if (!other.onError) throw err for (let handler of other.onError || []) - response = await handler(response ?? err) + response = await handler(response ?? err) ?? response } for (let handler of other.after || []) - response = await handler(response) + response = await handler(response, request.proxy ?? request, ...args) ?? response return response }, diff --git a/src/index.ts b/src/index.ts index c298465c..9886f0c0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,7 @@ +// routers export * from './IttyRouter' +export * from './Router' +export * from './AutoRouter' // classes export * from './StatusError' From 4d8183c95e670cfb1061085a7687f0e97245e943 Mon Sep 17 00:00:00 2001 From: Kevin Whitley Date: Thu, 14 Mar 2024 16:40:57 -0500 Subject: [PATCH 03/16] added shared router tests for feature parity between IttyRouter and Rotuer --- example/bun-autorouter.ts | 1 + example/bun-flowrouter.ts | 8 +- src/AutoRouter.ts | 9 +- src/IttyRouter.spec.ts | 709 ------------------------------------- src/Router.spec.ts | 86 +++++ src/Router.ts | 14 +- src/SharedRouter.spec.ts | 712 ++++++++++++++++++++++++++++++++++++++ test/index.ts | 2 +- 8 files changed, 814 insertions(+), 727 deletions(-) delete mode 100644 src/IttyRouter.spec.ts create mode 100644 src/Router.spec.ts create mode 100644 src/SharedRouter.spec.ts diff --git a/example/bun-autorouter.ts b/example/bun-autorouter.ts index f2680078..138283c1 100644 --- a/example/bun-autorouter.ts +++ b/example/bun-autorouter.ts @@ -8,6 +8,7 @@ import { withParams } from 'withParams' const router = AutoRouter({ port: 3001, missing: () => error(404, 'Are you sure about that?'), + format: () => {}, before: [ (r: any) => { r.date = new Date }, ], diff --git a/example/bun-flowrouter.ts b/example/bun-flowrouter.ts index f546b25d..573da7bc 100644 --- a/example/bun-flowrouter.ts +++ b/example/bun-flowrouter.ts @@ -7,11 +7,7 @@ const router = Router({ port: 3001, before: [withParams], onError: [error], - after: [ - (r: any) => r ?? error(404, 'Are you sure about that?'), - json - ], - // missing: () => error(404, 'Are you sure about that?'), + after: [json], }) router @@ -20,6 +16,6 @@ router .get('/params/:foo', ({ foo }) => foo) .get('/json', () => ({ foo: 'bar' })) .get('/throw', (a) => a.b.c) - // .all('*', () => error(404)) // still works + .all('*', () => error(404)) export default router diff --git a/src/AutoRouter.ts b/src/AutoRouter.ts index c3289590..f9aec3ce 100644 --- a/src/AutoRouter.ts +++ b/src/AutoRouter.ts @@ -1,6 +1,6 @@ -import { error } from 'error' -import { json } from 'json' -import { withParams } from 'withParams' +import { error } from './error' +import { json } from './json' +import { withParams } from './withParams' import { Router, RouterOptions} from './Router' // MORE FINE-GRAINED/SIMPLIFIED CONTROL, BUT CANNOT FULLY REPLACE BEFORE/AFTER STAGES @@ -17,7 +17,8 @@ export const AutoRouter = ({ ], onError: [error], after: [ - (r: any) => r ?? missing(), + // @ts-ignore + (r: any, ...args) => r ?? missing(r, ...args), format, ...after, ], diff --git a/src/IttyRouter.spec.ts b/src/IttyRouter.spec.ts deleted file mode 100644 index 9d12d0e2..00000000 --- a/src/IttyRouter.spec.ts +++ /dev/null @@ -1,709 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { createTestRunner, extract, toReq } from '../test' -import { IttyRouter as Router } from './IttyRouter' - -const ERROR_MESSAGE = 'Error Message' - -const testRoutes = createTestRunner(Router) - -describe('Router', () => { - const router = Router() - - const routes = [ - { path: '/', callback: vi.fn(extract), method: 'get' }, - { path: '/foo/first', callback: vi.fn(extract), method: 'get' }, - { path: '/foo/:id', callback: vi.fn(extract), method: 'get' }, - { path: '/foo', callback: vi.fn(extract), method: 'post' }, - { - path: '/passthrough', - callback: vi.fn(({ method, name }) => ({ method, name })), - method: 'get', - }, - ] - - const applyRoutes = (router, routes) => { - for (const route of routes) { - router[route.method](route.path, route.callback) - } - - return router - } - - applyRoutes(router, routes) - - it('is exported as { Router } from module', () => { - expect(typeof Router).toBe('function') - }) - - it('allows introspection', () => { - const r = [] - const config = { routes: r } - const router = Router(config) - - router - .get('/foo', () => {}) - .patch('/bar', () => {}) - .post('/baz', () => {}) - - expect(r.length).toBe(3) // can pass in the routes directly through "r" - expect(config.routes.length).toBe(3) // or just look at the mututated config - expect(router.routes.length).toBe(3) // accessible off the main router - }) - - it('can serialize router without throwing', () => { - const router = Router().get('/', () => 'foo') - - expect(() => router.toString()).not.toThrow() - }) - - it('router.handle (legacy) is an alias for router.fetch (new)', () => { - expect(router.fetch).toBe(router.handle) - }) - - it('allows preloading advanced routes', async () => { - const basicHandler = vi.fn((req) => req.params) - const customHandler = vi.fn((req) => req.params) - - const router = Router({ - routes: [ - ['GET', /^\/test\.(?[^/]+)\/*$/, [basicHandler], '/test'], - ['GET', /^\/custom-(?\d{2,4})$/, [customHandler], '/custom'], - ], - }) - - await router.handle(toReq('/test.a.b')) - expect(basicHandler).toHaveReturnedWith({ x: 'a.b' }) - - await router.handle(toReq('/custom-12345')) - expect(customHandler).not.toHaveBeenCalled() // custom route mismatch - - await router.handle(toReq('/custom-123')) - expect(customHandler).toHaveReturnedWith({ custom: '123' }) // custom route hit - }) - - it('allows loading advanced routes after config', async () => { - const handler = vi.fn((req) => req.params) - - const router = Router() - - // allows manual loading (after config) - router.routes.push(['GET', /^\/custom2-(?\w\d{3})$/, [handler], '/custom']) - - await router.handle(toReq('/custom2-a456')) - expect(handler).toHaveReturnedWith({ custom: 'a456' }) // custom route hit - }) - - describe('.{method}(route: string, handler1: function, ..., handlerN: function)', () => { - it('can accept multiple handlers (each mutates request)', async () => { - const r = Router() - const handler1 = vi.fn((req) => { - req.a = 1 - }) - const handler2 = vi.fn((req) => { - req.b = 2 - - return req - }) - const handler3 = vi.fn((req) => ({ c: 3, ...req })) - r.get('/multi/:id', handler1, handler2, handler3) - - await r.handle(toReq('/multi/foo')) - - expect(handler2).toHaveBeenCalled() - expect(handler3).not.toHaveBeenCalled() - }) - }) - - describe(`.handle({ method = 'GET', url })`, () => { - it('always returns a Promise', () => { - const syncRouter = Router() - syncRouter.get('/foo', () => 3) - - const response = syncRouter.handle(toReq('/foo')) - - expect(typeof response?.then).toBe('function') - expect(typeof response?.catch).toBe('function') - }) - - it('returns { path, query } from match', async () => { - const route = routes.find((r) => r.path === '/foo/:id') - await router.handle(toReq('/foo/13?foo=bar&cat=dog')) - - expect(route?.callback).toHaveReturnedWith({ - params: { id: '13' }, - query: { foo: 'bar', cat: 'dog' }, - }) - }) - - it('BUG: avoids toString prototype bug', async () => { - const route = routes.find((r) => r.path === '/foo/:id') - await router.handle(toReq('/foo/13?toString=value')) - - expect(route?.callback).toHaveReturnedWith({ - params: { id: '13' }, - query: { toString: 'value' }, - }) - }) - - it('requires exact route match', async () => { - const route = routes.find((r) => r.path === '/') - - await router.handle(toReq('/foo')) - - expect(route?.callback).not.toHaveBeenCalled() - }) - - it('returns { method, route } from matched route', async () => { - const route1 = '/foo/bar/:baz+' - const route2 = '/items' - const handler = vi.fn(({ method, route }) => ({ method, route })) - - const router = Router() - router.get(route1, handler).post(route2, handler) - - await router.handle(toReq(route1)) - expect(handler).toHaveReturnedWith({ method: 'GET', route: route1 }) - - await router.handle(toReq(`POST ${route2}`)) - expect(handler).toHaveReturnedWith({ method: 'POST', route: route2 }) - }) - - it('match earliest routes that match', async () => { - const router = Router() - const handler1 = vi.fn(() => 1) - const handler2 = vi.fn(() => 1) - router.get('/foo/static', handler1) - router.get('/foo/:id', handler2) - - await router.handle(toReq('/foo/static')) - expect(handler1).toHaveBeenCalled() - expect(handler2).not.toHaveBeenCalled() - - await router.handle(toReq('/foo/3')) - expect(handler1).toHaveBeenCalledTimes(1) - expect(handler2).toHaveBeenCalled() - }) - - it('honors correct method (e.g. GET, POST, etc)', async () => { - const route = routes.find((r) => r.path === '/foo' && r.method === 'post') - await router.handle(toReq('POST /foo')) - - expect(route?.callback).toHaveBeenCalled() - }) - - it('passes the entire original request through to the handler', async () => { - const route = routes.find((r) => r.path === '/passthrough') - await router.handle({ ...toReq('/passthrough'), name: 'miffles' }) - - expect(route?.callback).toHaveReturnedWith({ - method: 'GET', - name: 'miffles', - }) - }) - - it('allows missing handler later in flow with "all" channel', async () => { - const missingHandler = vi.fn() - const matchHandler = vi.fn() - - const router1 = Router() - const router2 = Router({ base: '/nested' }) - - router2.get('/foo', matchHandler) - router1.all('/nested/*', router2.handle).all('*', missingHandler) - - await router1.handle(toReq('/foo')) - expect(missingHandler).toHaveBeenCalled() - - await router1.handle(toReq('/nested/foo')) - expect(matchHandler).toHaveBeenCalled() - }) - - it(`won't throw on unknown method`, () => { - expect(() => - router.handle({ method: 'CUSTOM', url: 'https://example.com/foo' }) - ).not.toThrow() - }) - - it('can match multiple routes if earlier handlers do not return (as middleware)', async () => { - const r = Router() - - const middleware = (req) => { - req.user = { id: 13 } - } - - const handler = vi.fn((req) => req.user.id) - - r.get('/middleware/*', middleware) - r.get('/middleware/:id', handler) - - await r.handle(toReq('/middleware/foo')) - - expect(handler).toHaveBeenCalled() - expect(handler).toHaveReturnedWith(13) - }) - - it('can accept a basepath for routes', async () => { - const router = Router({ base: '/api' }) - const handler = vi.fn() - router.get('/foo/:id?', handler) - - await router.handle(toReq('/api/foo')) - expect(handler).toHaveBeenCalled() - - await router.handle(toReq('/api/foo/13')) - expect(handler).toHaveBeenCalledTimes(2) - }) - - it('basepath works with "/"', async () => { - const router = Router({ base: '/' }) - const handler = vi.fn() - router.get('/foo/:id?', handler) - - await router.handle(toReq('/foo')) - expect(handler).toHaveBeenCalled() - }) - - it('can pull route params from the basepath as well', async () => { - const router = Router({ base: '/:collection' }) - const handler = vi.fn((req) => req.params) - router.get('/:id', handler) - - await router.handle(toReq('/todos/13')) - expect(handler).toHaveBeenCalled() - expect(handler).toHaveReturnedWith({ collection: 'todos', id: '13' }) - }) - - it('allows any method to match an "all" route', async () => { - const router = Router() - const handler = vi.fn() - router.all('/crud/*', handler) - - await router.handle(toReq('/crud/foo')) - expect(handler).toHaveBeenCalled() - - await router.handle(toReq('POST /crud/bar')) - expect(handler).toHaveBeenCalledTimes(2) - }) - - it('stops at a handler that throws', async () => { - const router = Router() - const handler1 = vi.fn() - const handler2 = vi.fn(() => { - throw new Error() - }) - const handler3 = vi.fn() - router.get('/foo', handler1, handler2, handler3) - - const escape = (err) => err - - await router.handle(toReq('/foo')).catch(escape) - - expect(handler1).toHaveBeenCalled() - expect(handler2).toHaveBeenCalled() - expect(handler3).not.toHaveBeenCalled() - }) - - it('can throw an error and still handle if using catch', async () => { - const router = Router() - const handlerWithError = vi.fn(() => { - throw new Error(ERROR_MESSAGE) - }) - const errorHandler = vi.fn((err) => err.message) - - router.get('/foo', handlerWithError) - - await router.handle(toReq('/foo')).catch(errorHandler) - - expect(handlerWithError).toHaveBeenCalled() - expect(errorHandler).toHaveBeenCalled() - expect(errorHandler).toHaveReturnedWith(ERROR_MESSAGE) - }) - - it('can throw method not allowed error', async () => { - const router = Router() - const okText = 'OK' - const errorResponse = new Response(JSON.stringify({ foo: 'bar' }), { - headers: { 'content-type': 'application/json;charset=UTF-8' }, - status: 405, - statusText: 'Method not allowed', - }) - const handler = vi.fn(() => new Response(okText)) - const middleware = vi.fn() - const errorHandler = vi.fn(() => errorResponse) - - router.post('*', middleware, handler).all('*', errorHandler) - - // creates a request (with passed method) with JSON body - const createRequest = (method) => - new Request('https://foo.com/foo', { - method, - headers: { - 'content-type': 'application/json', - }, - body: JSON.stringify({ foo: 'bar' }), - }) - - // test POST with JSON body (catch by post handler) - let response = await router.handle(createRequest('post')) - - expect(handler).toHaveBeenCalled() - expect(middleware).toHaveBeenCalled() - expect(errorHandler).not.toHaveBeenCalled() - expect(await response.text()).toBe(okText) - - // test PUT with json body (will flow to all/errorHandler) - response = await router.handle(createRequest('put')) - - expect(handler).toHaveBeenCalledTimes(1) - expect(errorHandler).toHaveBeenCalled() - expect(await response.json()).toEqual({ foo: 'bar' }) - }) - - it('allows chaining', () => { - const router = Router() - - expect(() => { - router.get('/foo', vi.fn()).get('/foo', vi.fn()) - }).not.toThrow() - }) - }) - - describe(`.handle({ method = 'GET', url }, ...args)`, () => { - it('passes extra args to each handler', async () => { - const r = Router() - const h = (req, a, b) => { - req.a = a - req.b = b - } - const originalA = 'A' - const originalB = {} - r.get('*', h) - const req: any = toReq('/foo') - - await r.handle(req, originalA, originalB) - - expect(req.a).toBe(originalA) - expect(req.b).toBe(originalB) - }) - - it('will pass request.proxy instead of request if found', async () => { - const router = Router() - const handler = vi.fn((req) => req) - let proxy - - const withProxy = (request) => { - request.proxy = proxy = new Proxy(request, {}) - } - - router.get('/foo', withProxy, handler) - - await router.handle(toReq('/foo')) - - expect(handler).toHaveReturnedWith(proxy) - }) - - it('can handle POST body even if not used', async () => { - const router = Router() - const handler = vi.fn((req) => req.json()) - const errorHandler = vi.fn() - - router.post('/foo', handler).all('*', errorHandler) - - const createRequest = (method) => - new Request('https://foo.com/foo', { - method, - headers: { - 'content-type': 'application/json', - }, - body: JSON.stringify({ foo: 'bar' }), - }) - - await router.handle(createRequest('put')) - expect(errorHandler).toHaveBeenCalled() - - const response = await router.handle(createRequest('post')) - expect(handler).toHaveBeenCalled() - expect(await response).toEqual({ foo: 'bar' }) - }) - }) - - it('can get query params', async () => { - const router = Router() - const handler = vi.fn((req) => req.query) - - router.get('/foo', handler) - - const request = new Request( - 'https://foo.com/foo?cat=dog&foo=bar&foo=baz&missing=' - ) - - await router.handle(request) - expect(handler).toHaveReturnedWith({ - cat: 'dog', - foo: ['bar', 'baz'], - missing: '', - }) - }) - - it('can still get query params with POST or non-GET HTTP methods', async () => { - const router = Router() - const handler = vi.fn((req) => req.query) - - router.post('/foo', handler) - - const request = new Request('https://foo.com/foo?cat=dog&foo=bar&foo=baz', { - method: 'POST', - headers: { - 'content-type': 'application/json', - }, - body: JSON.stringify({ success: true }), - }) - - await router.handle(request) - expect(handler).toHaveReturnedWith({ cat: 'dog', foo: ['bar', 'baz'] }) - }) -}) - -describe('CUSTOM ROUTERS/PROPS', () => { - it('allows overloading custom properties via options', () => { - const router = Router({ port: 3001 }) - - expect(router.port).toBe(3001) - }) - - it('allows overloading custom properties via direct access', () => { - const router = Router() - router.port = 3001 - - expect(router.port).toBe(3001) - }) - - it('allows overloading custom methods with access to "this"', () => { - const router = Router({ - getMethods: function() { return Array.from(this.routes.reduce((acc, [method]) => acc.add(method), new Set())) } - }).get('/', () => {}) - .post('/', () => {}) - - expect(router.getMethods()).toEqual(['GET', 'POST']) - }) - - it('allows easy custom Router creation', async () => { - const logger = vi.fn() // vitest spy function - - // create a CustomRouter that creates a Router with some predefined options - const CustomRouter = (options = {}) => Router({ - ...options, // we still want to pass in any real options - - // but let's add one to - getMethods: function() { return Array.from(this.routes.reduce((acc, [method]) => acc.add(method), new Set())) }, - - // and a chaining function to "rewire" and intercept fetch requests - addLogging: function(logger = () => {}) { - const ogFetch = this.fetch - this.fetch = (...args) => { - logger(...args) - return ogFetch(...args) - } - - return this // this let's us chain - } - }) - - // implement the CustomRouter - const router = CustomRouter() - .get('/', () => 'foo') - .post('/', () => {}) - .addLogging(logger) // we added this! - - const response = await router.fetch(toReq('/')) - - expect(router.getMethods()).toEqual(['GET', 'POST']) - expect(response).toBe('foo') - expect(logger).toHaveBeenCalled() - }) -}) - -describe('NESTING', () => { - it('can handle legacy nested routers (with explicit base path)', async () => { - const router1 = Router() - const router2 = Router({ base: '/nested' }) - const handler1 = vi.fn() - const handler2 = vi.fn() - const handler3 = vi.fn() - router1.get('/pet', handler1) - router1.get('/nested/*', router2.handle) - router2.get('/', handler3) - router2.get('/bar/:id?', handler2) - - await router1.handle(toReq('/pet')) - expect(handler1).toHaveBeenCalled() - - await router1.handle(toReq('/nested/bar')) - expect(handler2).toHaveBeenCalled() - - await router1.handle(toReq('/nested')) - expect(handler3).toHaveBeenCalled() - }) - - it('can nest with route params on the nested route if given router.handle and base path', async () => { - const child = Router({ base: '/child/:bar' }).get('/', () => 'child') - const parent = Router() - .get('/', () => 'parent') - .all('/child/:bar/*', child.handle) - - expect(await parent.handle(toReq('/'))).toBe('parent') - expect(await parent.handle(toReq('/child/kitten'))).toBe('child') - }) -}) - -describe('MIDDLEWARE', () => { - it('calls any handler until a return', async () => { - const router = Router() - const h1 = vi.fn() - const h2 = vi.fn() - const h3 = vi.fn(() => true) - - router.get('*', h1, h2, h3) - - const results = await router.handle(toReq('/')) - expect(h1).toHaveBeenCalled() - expect(h2).toHaveBeenCalled() - expect(h3).toHaveBeenCalled() - expect(results).toBe(true) - }) -}) - -describe('ROUTE MATCHING', () => { - describe('allowed characters', () => { - const chars = `/foo/-.abc!@%&_=:;',~|/bar` - testRoutes([{ route: chars, path: chars }]) - }) - - describe('dots', () => { - testRoutes([ - { route: '/foo.json', path: '/foo.json' }, - { route: '/foo.json', path: '/fooXjson', returns: false }, - ]) - }) - - describe('greedy params', () => { - testRoutes([ - { route: '/foo/:id+', path: '/foo/14', returns: { id: '14' } }, - { route: '/foo/:id+', path: '/foo/bar/baz', returns: { id: 'bar/baz' } }, - { - route: '/foo/:id+', - path: '/foo/https://foo.bar', - returns: { id: 'https://foo.bar' }, - }, - ]) - }) - - describe('formats/extensions', () => { - testRoutes([ - { route: '/:id.:format', path: '/foo', returns: false }, - { - route: '/:id.:format', - path: '/foo.jpg', - returns: { id: 'foo', format: 'jpg' }, - }, - { - route: '/:id.:format', - path: '/foo.bar.jpg', - returns: { id: 'foo.bar', format: 'jpg' }, - }, - { route: '/:id.:format?', path: '/foo', returns: { id: 'foo' } }, - { - route: '/:id.:format?', - path: '/foo.bar.jpg', - returns: { id: 'foo.bar', format: 'jpg' }, - }, - { - route: '/:id.:format?', - path: '/foo.jpg', - returns: { id: 'foo', format: 'jpg' }, - }, - { route: '/:id.:format?', path: '/foo', returns: { id: 'foo' } }, - { route: '/:id.:format.:compress', path: '/foo.gz', returns: false }, - { - route: '/:id.:format.:compress', - path: '/foo.txt.gz', - returns: { id: 'foo', format: 'txt', compress: 'gz' }, - }, - { - route: '/:id.:format.:compress?', - path: '/foo.txt', - returns: { id: 'foo', format: 'txt' }, - }, - { - route: '/:id.:format?.:compress', - path: '/foo.gz', - returns: { id: 'foo', compress: 'gz' }, - }, - ]) - }) - - describe('optional params', () => { - testRoutes([ - { route: '/foo/abc:id?', path: '/foo/abcbar', returns: { id: 'bar' } }, - { route: '/foo/:id?', path: '/foo' }, - { route: '/foo/:id?', path: '/foo/' }, - { route: '/foo/:id?', path: '/foo/bar', returns: { id: 'bar' } }, - ]) - }) - - describe('regex', () => { - testRoutes([ - { route: '/foo|bar/baz', path: '/foo/baz' }, - { route: '/foo|bar/baz', path: '/bar/baz' }, - { route: '/foo(bar|baz)', path: '/foobar' }, - { route: '/foo(bar|baz)', path: '/foobaz' }, - { route: '/foo(bar|baz)', path: '/foo', returns: false }, - { route: '/foo:bar?', path: '/foXbar', returns: false }, - { route: '/foo+', path: '/foo' }, - { route: '/foo+', path: '/fooooooo' }, - { route: '/foo?', path: '/foo' }, - { route: '/foo?', path: '/fo' }, - { route: '/foo?', path: '/fooo', returns: false }, - { route: '/.', path: '/', returns: false }, - { route: '/x|y', path: '/y', returns: true }, - { route: '/x|y', path: '/x', returns: true }, - { route: '/x/y|z', path: '/z', returns: true }, // should require second path as y or z - { route: '/x/y|z', path: '/x/y', returns: true }, // shouldn't allow the weird pipe - { route: '/x/y|z', path: '/x/z', returns: true }, // shouldn't allow the weird pipe - { route: '/xy*', path: '/x', returns: false }, - { route: '/xy*', path: '/xyz', returns: true }, - { route: '/:x.y', path: '/a.x.y', returns: { x: 'a.x' } }, - { route: '/x.y', path: '/xay', returns: false }, // dots are enforced as dots, not any character (e.g. extensions) - { route: '/xy{2}', path: '/xyxy', returns: false }, // no regex repeating supported - { route: '/xy{2}', path: '/xy/xy', returns: false }, // no regex repeating supported - { route: '/:x.:y', path: '/a.b.c', returns: { x: 'a.b', y: 'c' } }, // standard file + extension format - { route: '/test.:x', path: '/test.a.b', returns: false }, // extensions only capture a single dot - { route: '/test.:x', path: '/test.a', returns: { x: 'a' } }, - { route: '/:x?.y', path: '/test.y', returns: { x: 'test' } }, - { route: '/api(/v1)?/foo', path: '/api/v1/foo' }, // switching support preserved - { route: '/api(/v1)?/foo', path: '/api/foo' }, // switching support preserved - { route: '(/api)?/v1/:x', path: '/api/v1/foo', returns: { x: 'foo' } }, // switching support preserved - { route: '(/api)?/v1/:x', path: '/v1/foo', returns: { x: 'foo' } }, // switching support preserved - ]) - }) - - describe('trailing/leading slashes', () => { - testRoutes([ - { route: '/foo/bar', path: '/foo/bar' }, - { route: '/foo/bar', path: '/foo/bar/' }, - { route: '/foo/bar/', path: '/foo/bar/' }, - { route: '/foo/bar/', path: '/foo/bar' }, - { route: '/', path: '/' }, - { route: '', path: '/' }, - ]) - }) - - describe('wildcards', () => { - testRoutes([ - { route: '*', path: '/something/foo' }, - { route: '/*/foo', path: '/something/foo' }, - { route: '/*/foo', path: '/something/else/foo' }, - { route: '/foo/*/bar', path: '/foo/a/b/c/bar' }, - ]) - }) -}) diff --git a/src/Router.spec.ts b/src/Router.spec.ts new file mode 100644 index 00000000..33b96ba3 --- /dev/null +++ b/src/Router.spec.ts @@ -0,0 +1,86 @@ +import { describe, expect, it, vi } from 'vitest' +import { createTestRunner, extract, toReq } from '../test' +import { Router } from './Router' + +describe(`SPECIFIC TESTS: Router`, () => { + + + it('supports both router.handle and router.fetch', () => { + const router = Router() + expect(router.fetch).toBe(router.handle) + }) + + it('allows populating a before stage', async () => { + const handler = vi.fn(r => typeof r.date) + const router = Router({ + before: [ + (r) => { r.date = Date.now() }, + ], + }).get('*', handler) + + await router.fetch(toReq('/')) + expect(handler).toHaveReturnedWith('number') + }) + + it('before stage terminates on first response', async () => { + const handler1 = vi.fn(() => {}) + const handler2 = vi.fn(() => true) + const handler3 = vi.fn(() => {}) + const router = Router({ + before: [ + handler1, + handler2, + handler3, + ], + }).get('*', () => {}) + + const response = await router.fetch(toReq('/')) + expect(handler1).toHaveBeenCalled() + expect(handler3).not.toHaveBeenCalled() + expect(response).toBe(true) + }) + + it('allows catching errors with an onError stage', async () => { + const handler = vi.fn(r => r instanceof Error) + const noop = vi.fn(r => {}) + const router1 = Router({ onError: [ + noop, + handler, + ] }).get('/', a => a.b.c) + const router2 = Router().get('/', a => a.b.c) + + const response = await router1.fetch(toReq('/')) + expect(noop).toHaveBeenCalled() + expect(handler).toHaveReturnedWith(true) + expect(response).toBe(true) + expect(router2.fetch(toReq('/'))).rejects.toThrow() + }) + + it('allows modifying responses in an after stage', async () => { + const router = Router({ + after: [r => Number(r) || 0], + }).get('/:id?', r => r.params.id) + + const response1 = await router.fetch(toReq('/13')) + const response2 = await router.fetch(toReq('/')) + + expect(response1).toBe(13) + expect(response2).toBe(0) + }) + + it('after stages that return nothing will not modify response', async () => { + const handler = vi.fn(r => {}) + const router = Router({ + after: [ + handler, + r => Number(r) || 0, + ], + }).get('/:id?', r => r.params.id) + + const response = await router.fetch(toReq('/13')) + + expect(response).toBe(13) + expect(handler).toHaveBeenCalled() + }) +}) + diff --git a/src/Router.ts b/src/Router.ts index ab338074..610470a5 100644 --- a/src/Router.ts +++ b/src/Router.ts @@ -9,7 +9,7 @@ import { UniversalRoute, } from './IttyRouter' -// export type ErrorHandler = (input: Input) => void +export type ErrorHandler = (input: Input) => void export type RouterOptions = { before?: Function[] @@ -53,13 +53,13 @@ export const Router = < url = new URL(request.url), query: Record = request.query = { __proto__: null } - try { - // 1. parse query params - for (let [k, v] of url.searchParams) - query[k] = query[k] ? ([] as string[]).concat(query[k], v) : v + // 1. parse query params + for (let [k, v] of url.searchParams) + query[k] = query[k] ? ([] as string[]).concat(query[k], v) : v + t: try { for (let handler of other.before || []) - if ((response = await handler(request.proxy ?? request, ...args)) != null) break + if ((response = await handler(request.proxy ?? request, ...args)) != null) break t // 2. then test routes outer: for (let [method, regex, handlers, path] of routes) @@ -73,7 +73,7 @@ export const Router = < } catch (err) { if (!other.onError) throw err - for (let handler of other.onError || []) + for (let handler of other.onError) response = await handler(response ?? err) ?? response } diff --git a/src/SharedRouter.spec.ts b/src/SharedRouter.spec.ts new file mode 100644 index 00000000..9c9f6eed --- /dev/null +++ b/src/SharedRouter.spec.ts @@ -0,0 +1,712 @@ +import { describe, expect, it, vi } from 'vitest' +import { createTestRunner, extract, toReq } from '../test' +import { IttyRouter } from './IttyRouter' +import { Router as FlowRouter } from './Router' + +const ERROR_MESSAGE = 'Error Message' + +const RoutersToTest = [ + { routerName: 'IttyRouter', Router: IttyRouter }, + { routerName: 'Router', Router: FlowRouter }, +] + +describe('Common Router Spec', () => { + for (const { routerName, Router } of RoutersToTest) { + describe(`ROUTER = ${routerName}`, () => { + const router = Router() + const testRoutes = createTestRunner(Router) + const routes = [ + { path: '/', callback: vi.fn(extract), method: 'get' }, + { path: '/foo/first', callback: vi.fn(extract), method: 'get' }, + { path: '/foo/:id', callback: vi.fn(extract), method: 'get' }, + { path: '/foo', callback: vi.fn(extract), method: 'post' }, + { + path: '/passthrough', + callback: vi.fn(({ method, name }) => ({ method, name })), + method: 'get', + }, + ] + + const applyRoutes = (router, routes) => { + for (const route of routes) { + router[route.method](route.path, route.callback) + } + + return router + } + + applyRoutes(router, routes) + + it('allows introspection', () => { + const r = [] + const config = { routes: r } + const router = Router(config) + + router + .get('/foo', () => {}) + .patch('/bar', () => {}) + .post('/baz', () => {}) + + expect(r.length).toBe(3) // can pass in the routes directly through "r" + expect(config.routes.length).toBe(3) // or just look at the mututated config + expect(router.routes.length).toBe(3) // accessible off the main router + }) + + it('can serialize router without throwing', () => { + const router = Router().get('/', () => 'foo') + + expect(() => router.toString()).not.toThrow() + }) + + it('allows preloading advanced routes', async () => { + const basicHandler = vi.fn((req) => req.params) + const customHandler = vi.fn((req) => req.params) + + const router = Router({ + routes: [ + ['GET', /^\/test\.(?[^/]+)\/*$/, [basicHandler], '/test'], + ['GET', /^\/custom-(?\d{2,4})$/, [customHandler], '/custom'], + ], + }) + + await router.fetch(toReq('/test.a.b')) + expect(basicHandler).toHaveReturnedWith({ x: 'a.b' }) + + await router.fetch(toReq('/custom-12345')) + expect(customHandler).not.toHaveBeenCalled() // custom route mismatch + + await router.fetch(toReq('/custom-123')) + expect(customHandler).toHaveReturnedWith({ custom: '123' }) // custom route hit + }) + + it('allows loading advanced routes after config', async () => { + const handler = vi.fn((req) => req.params) + + const router = Router() + + // allows manual loading (after config) + router.routes.push(['GET', /^\/custom2-(?\w\d{3})$/, [handler], '/custom']) + + await router.fetch(toReq('/custom2-a456')) + expect(handler).toHaveReturnedWith({ custom: 'a456' }) // custom route hit + }) + + describe('.{method}(route: string, handler1: function, ..., handlerN: function)', () => { + it('can accept multiple handlers (each mutates request)', async () => { + const r = Router() + const handler1 = vi.fn((req) => { + req.a = 1 + }) + const handler2 = vi.fn((req) => { + req.b = 2 + + return req + }) + const handler3 = vi.fn((req) => ({ c: 3, ...req })) + r.get('/multi/:id', handler1, handler2, handler3) + + await r.fetch(toReq('/multi/foo')) + + expect(handler2).toHaveBeenCalled() + expect(handler3).not.toHaveBeenCalled() + }) + }) + + describe(`.fetch({ method = 'GET', url })`, () => { + it('always returns a Promise', () => { + const syncRouter = Router() + syncRouter.get('/foo', () => 3) + + const response = syncRouter.fetch(toReq('/foo')) + + expect(typeof response?.then).toBe('function') + expect(typeof response?.catch).toBe('function') + }) + + it('returns { path, query } from match', async () => { + const route = routes.find((r) => r.path === '/foo/:id') + await router.fetch(toReq('/foo/13?foo=bar&cat=dog')) + + expect(route?.callback).toHaveReturnedWith({ + params: { id: '13' }, + query: { foo: 'bar', cat: 'dog' }, + }) + }) + + it('BUG: avoids toString prototype bug', async () => { + const route = routes.find((r) => r.path === '/foo/:id') + await router.fetch(toReq('/foo/13?toString=value')) + + expect(route?.callback).toHaveReturnedWith({ + params: { id: '13' }, + query: { toString: 'value' }, + }) + }) + + it('requires exact route match', async () => { + const route = routes.find((r) => r.path === '/') + + await router.fetch(toReq('/foo')) + + expect(route?.callback).not.toHaveBeenCalled() + }) + + it('returns { method, route } from matched route', async () => { + const route1 = '/foo/bar/:baz+' + const route2 = '/items' + const handler = vi.fn(({ method, route }) => ({ method, route })) + + const router = Router() + router.get(route1, handler).post(route2, handler) + + await router.fetch(toReq(route1)) + expect(handler).toHaveReturnedWith({ method: 'GET', route: route1 }) + + await router.fetch(toReq(`POST ${route2}`)) + expect(handler).toHaveReturnedWith({ method: 'POST', route: route2 }) + }) + + it('match earliest routes that match', async () => { + const router = Router() + const handler1 = vi.fn(() => 1) + const handler2 = vi.fn(() => 1) + router.get('/foo/static', handler1) + router.get('/foo/:id', handler2) + + await router.fetch(toReq('/foo/static')) + expect(handler1).toHaveBeenCalled() + expect(handler2).not.toHaveBeenCalled() + + await router.fetch(toReq('/foo/3')) + expect(handler1).toHaveBeenCalledTimes(1) + expect(handler2).toHaveBeenCalled() + }) + + it('honors correct method (e.g. GET, POST, etc)', async () => { + const route = routes.find((r) => r.path === '/foo' && r.method === 'post') + await router.fetch(toReq('POST /foo')) + + expect(route?.callback).toHaveBeenCalled() + }) + + it('passes the entire original request through to the handler', async () => { + const route = routes.find((r) => r.path === '/passthrough') + await router.fetch({ ...toReq('/passthrough'), name: 'miffles' }) + + expect(route?.callback).toHaveReturnedWith({ + method: 'GET', + name: 'miffles', + }) + }) + + it('allows missing handler later in flow with "all" channel', async () => { + const missingHandler = vi.fn() + const matchHandler = vi.fn() + + const router1 = Router() + const router2 = Router({ base: '/nested' }) + + router2.get('/foo', matchHandler) + router1.all('/nested/*', router2.fetch).all('*', missingHandler) + + await router1.fetch(toReq('/foo')) + expect(missingHandler).toHaveBeenCalled() + + await router1.fetch(toReq('/nested/foo')) + expect(matchHandler).toHaveBeenCalled() + }) + + it(`won't throw on unknown method`, () => { + expect(() => + router.fetch({ method: 'CUSTOM', url: 'https://example.com/foo' }) + ).not.toThrow() + }) + + it('can match multiple routes if earlier handlers do not return (as middleware)', async () => { + const r = Router() + + const middleware = (req) => { + req.user = { id: 13 } + } + + const handler = vi.fn((req) => req.user.id) + + r.get('/middleware/*', middleware) + r.get('/middleware/:id', handler) + + await r.fetch(toReq('/middleware/foo')) + + expect(handler).toHaveBeenCalled() + expect(handler).toHaveReturnedWith(13) + }) + + it('can accept a basepath for routes', async () => { + const router = Router({ base: '/api' }) + const handler = vi.fn() + router.get('/foo/:id?', handler) + + await router.fetch(toReq('/api/foo')) + expect(handler).toHaveBeenCalled() + + await router.fetch(toReq('/api/foo/13')) + expect(handler).toHaveBeenCalledTimes(2) + }) + + it('basepath works with "/"', async () => { + const router = Router({ base: '/' }) + const handler = vi.fn() + router.get('/foo/:id?', handler) + + await router.fetch(toReq('/foo')) + expect(handler).toHaveBeenCalled() + }) + + it('can pull route params from the basepath as well', async () => { + const router = Router({ base: '/:collection' }) + const handler = vi.fn((req) => req.params) + router.get('/:id', handler) + + await router.fetch(toReq('/todos/13')) + expect(handler).toHaveBeenCalled() + expect(handler).toHaveReturnedWith({ collection: 'todos', id: '13' }) + }) + + it('allows any method to match an "all" route', async () => { + const router = Router() + const handler = vi.fn() + router.all('/crud/*', handler) + + await router.fetch(toReq('/crud/foo')) + expect(handler).toHaveBeenCalled() + + await router.fetch(toReq('POST /crud/bar')) + expect(handler).toHaveBeenCalledTimes(2) + }) + + it('stops at a handler that throws', async () => { + const router = Router() + const handler1 = vi.fn() + const handler2 = vi.fn(() => { + throw new Error() + }) + const handler3 = vi.fn() + router.get('/foo', handler1, handler2, handler3) + + const escape = (err) => err + + await router.fetch(toReq('/foo')).catch(escape) + + expect(handler1).toHaveBeenCalled() + expect(handler2).toHaveBeenCalled() + expect(handler3).not.toHaveBeenCalled() + }) + + it('can throw an error and still handle if using catch', async () => { + const router = Router() + const handlerWithError = vi.fn(() => { + throw new Error(ERROR_MESSAGE) + }) + const errorHandler = vi.fn((err) => err.message) + + router.get('/foo', handlerWithError) + + await router.fetch(toReq('/foo')).catch(errorHandler) + + expect(handlerWithError).toHaveBeenCalled() + expect(errorHandler).toHaveBeenCalled() + expect(errorHandler).toHaveReturnedWith(ERROR_MESSAGE) + }) + + it('can throw method not allowed error', async () => { + const router = Router() + const okText = 'OK' + const errorResponse = new Response(JSON.stringify({ foo: 'bar' }), { + headers: { 'content-type': 'application/json;charset=UTF-8' }, + status: 405, + statusText: 'Method not allowed', + }) + const handler = vi.fn(() => new Response(okText)) + const middleware = vi.fn() + const errorHandler = vi.fn(() => errorResponse) + + router.post('*', middleware, handler).all('*', errorHandler) + + // creates a request (with passed method) with JSON body + const createRequest = (method) => + new Request('https://foo.com/foo', { + method, + headers: { + 'content-type': 'application/json', + }, + body: JSON.stringify({ foo: 'bar' }), + }) + + // test POST with JSON body (catch by post handler) + let response = await router.fetch(createRequest('post')) + + expect(handler).toHaveBeenCalled() + expect(middleware).toHaveBeenCalled() + expect(errorHandler).not.toHaveBeenCalled() + expect(await response.text()).toBe(okText) + + // test PUT with json body (will flow to all/errorHandler) + response = await router.fetch(createRequest('put')) + + expect(handler).toHaveBeenCalledTimes(1) + expect(errorHandler).toHaveBeenCalled() + expect(await response.json()).toEqual({ foo: 'bar' }) + }) + + it('allows chaining', () => { + const router = Router() + + expect(() => { + router.get('/foo', vi.fn()).get('/foo', vi.fn()) + }).not.toThrow() + }) + }) + + describe(`.fetch({ method = 'GET', url }, ...args)`, () => { + it('passes extra args to each handler', async () => { + const r = Router() + const h = (req, a, b) => { + req.a = a + req.b = b + } + const originalA = 'A' + const originalB = {} + r.get('*', h) + const req: any = toReq('/foo') + + await r.fetch(req, originalA, originalB) + + expect(req.a).toBe(originalA) + expect(req.b).toBe(originalB) + }) + + it('will pass request.proxy instead of request if found', async () => { + const router = Router() + const handler = vi.fn((req) => req) + let proxy + + const withProxy = (request) => { + request.proxy = proxy = new Proxy(request, {}) + } + + router.get('/foo', withProxy, handler) + + await router.fetch(toReq('/foo')) + + expect(handler).toHaveReturnedWith(proxy) + }) + + it('can handle POST body even if not used', async () => { + const router = Router() + const handler = vi.fn((req) => req.json()) + const errorHandler = vi.fn() + + router.post('/foo', handler).all('*', errorHandler) + + const createRequest = (method) => + new Request('https://foo.com/foo', { + method, + headers: { + 'content-type': 'application/json', + }, + body: JSON.stringify({ foo: 'bar' }), + }) + + await router.fetch(createRequest('put')) + expect(errorHandler).toHaveBeenCalled() + + const response = await router.fetch(createRequest('post')) + expect(handler).toHaveBeenCalled() + expect(await response).toEqual({ foo: 'bar' }) + }) + }) + + it('can get query params', async () => { + const router = Router() + const handler = vi.fn((req) => req.query) + + router.get('/foo', handler) + + const request = new Request( + 'https://foo.com/foo?cat=dog&foo=bar&foo=baz&missing=' + ) + + await router.fetch(request) + expect(handler).toHaveReturnedWith({ + cat: 'dog', + foo: ['bar', 'baz'], + missing: '', + }) + }) + + it('can still get query params with POST or non-GET HTTP methods', async () => { + const router = Router() + const handler = vi.fn((req) => req.query) + + router.post('/foo', handler) + + const request = new Request('https://foo.com/foo?cat=dog&foo=bar&foo=baz', { + method: 'POST', + headers: { + 'content-type': 'application/json', + }, + body: JSON.stringify({ success: true }), + }) + + await router.fetch(request) + expect(handler).toHaveReturnedWith({ cat: 'dog', foo: ['bar', 'baz'] }) + }) + + // CUSTOM ROUTERS + + describe('CUSTOM ROUTERS/PROPS', () => { + it('allows overloading custom properties via options', () => { + const router = Router({ port: 3001 }) + + expect(router.port).toBe(3001) + }) + + it('allows overloading custom properties via direct access', () => { + const router = Router() + router.port = 3001 + + expect(router.port).toBe(3001) + }) + + it('allows overloading custom methods with access to "this"', () => { + const router = Router({ + getMethods: function() { return Array.from(this.routes.reduce((acc, [method]) => acc.add(method), new Set())) } + }).get('/', () => {}) + .post('/', () => {}) + + expect(router.getMethods()).toEqual(['GET', 'POST']) + }) + + it('allows easy custom Router creation', async () => { + const logger = vi.fn() // vitest spy function + + // create a CustomRouter that creates a Router with some predefined options + const CustomRouter = (options = {}) => Router({ + ...options, // we still want to pass in any real options + + // but let's add one to + getMethods: function() { return Array.from(this.routes.reduce((acc, [method]) => acc.add(method), new Set())) }, + + // and a chaining function to "rewire" and intercept fetch requests + addLogging: function(logger = () => {}) { + const ogFetch = this.fetch + this.fetch = (...args) => { + logger(...args) + return ogFetch(...args) + } + + return this // this let's us chain + } + }) + + // implement the CustomRouter + const router = CustomRouter() + .get('/', () => 'foo') + .post('/', () => {}) + .addLogging(logger) // we added this! + + const response = await router.fetch(toReq('/')) + + expect(router.getMethods()).toEqual(['GET', 'POST']) + expect(response).toBe('foo') + expect(logger).toHaveBeenCalled() + }) + }) + + describe('NESTING', () => { + it('can handle legacy nested routers (with explicit base path)', async () => { + const router1 = Router() + const router2 = Router({ base: '/nested' }) + const handler1 = vi.fn() + const handler2 = vi.fn() + const handler3 = vi.fn() + router1.get('/pet', handler1) + router1.get('/nested/*', router2.fetch) + router2.get('/', handler3) + router2.get('/bar/:id?', handler2) + + await router1.fetch(toReq('/pet')) + expect(handler1).toHaveBeenCalled() + + await router1.fetch(toReq('/nested/bar')) + expect(handler2).toHaveBeenCalled() + + await router1.fetch(toReq('/nested')) + expect(handler3).toHaveBeenCalled() + }) + + it('can nest with route params on the nested route if given router.fetch and base path', async () => { + const child = Router({ base: '/child/:bar' }).get('/', () => 'child') + const parent = Router() + .get('/', () => 'parent') + .all('/child/:bar/*', child.fetch) + + expect(await parent.fetch(toReq('/'))).toBe('parent') + expect(await parent.fetch(toReq('/child/kitten'))).toBe('child') + }) + }) + + describe('MIDDLEWARE', () => { + it('calls any handler until a return', async () => { + const router = Router() + const h1 = vi.fn() + const h2 = vi.fn() + const h3 = vi.fn(() => true) + + router.get('*', h1, h2, h3) + + const results = await router.fetch(toReq('/')) + expect(h1).toHaveBeenCalled() + expect(h2).toHaveBeenCalled() + expect(h3).toHaveBeenCalled() + expect(results).toBe(true) + }) + }) + + describe('ROUTE MATCHING', () => { + describe('allowed characters', () => { + const chars = `/foo/-.abc!@%&_=:;',~|/bar` + testRoutes([{ route: chars, path: chars }]) + }) + + describe('dots', () => { + testRoutes([ + { route: '/foo.json', path: '/foo.json' }, + { route: '/foo.json', path: '/fooXjson', returns: false }, + ]) + }) + + describe('greedy params', () => { + testRoutes([ + { route: '/foo/:id+', path: '/foo/14', returns: { id: '14' } }, + { route: '/foo/:id+', path: '/foo/bar/baz', returns: { id: 'bar/baz' } }, + { + route: '/foo/:id+', + path: '/foo/https://foo.bar', + returns: { id: 'https://foo.bar' }, + }, + ]) + }) + + describe('formats/extensions', () => { + testRoutes([ + { route: '/:id.:format', path: '/foo', returns: false }, + { + route: '/:id.:format', + path: '/foo.jpg', + returns: { id: 'foo', format: 'jpg' }, + }, + { + route: '/:id.:format', + path: '/foo.bar.jpg', + returns: { id: 'foo.bar', format: 'jpg' }, + }, + { route: '/:id.:format?', path: '/foo', returns: { id: 'foo' } }, + { + route: '/:id.:format?', + path: '/foo.bar.jpg', + returns: { id: 'foo.bar', format: 'jpg' }, + }, + { + route: '/:id.:format?', + path: '/foo.jpg', + returns: { id: 'foo', format: 'jpg' }, + }, + { route: '/:id.:format?', path: '/foo', returns: { id: 'foo' } }, + { route: '/:id.:format.:compress', path: '/foo.gz', returns: false }, + { + route: '/:id.:format.:compress', + path: '/foo.txt.gz', + returns: { id: 'foo', format: 'txt', compress: 'gz' }, + }, + { + route: '/:id.:format.:compress?', + path: '/foo.txt', + returns: { id: 'foo', format: 'txt' }, + }, + { + route: '/:id.:format?.:compress', + path: '/foo.gz', + returns: { id: 'foo', compress: 'gz' }, + }, + ]) + }) + + describe('optional params', () => { + testRoutes([ + { route: '/foo/abc:id?', path: '/foo/abcbar', returns: { id: 'bar' } }, + { route: '/foo/:id?', path: '/foo' }, + { route: '/foo/:id?', path: '/foo/' }, + { route: '/foo/:id?', path: '/foo/bar', returns: { id: 'bar' } }, + ]) + }) + + describe('regex', () => { + testRoutes([ + { route: '/foo|bar/baz', path: '/foo/baz' }, + { route: '/foo|bar/baz', path: '/bar/baz' }, + { route: '/foo(bar|baz)', path: '/foobar' }, + { route: '/foo(bar|baz)', path: '/foobaz' }, + { route: '/foo(bar|baz)', path: '/foo', returns: false }, + { route: '/foo:bar?', path: '/foXbar', returns: false }, + { route: '/foo+', path: '/foo' }, + { route: '/foo+', path: '/fooooooo' }, + { route: '/foo?', path: '/foo' }, + { route: '/foo?', path: '/fo' }, + { route: '/foo?', path: '/fooo', returns: false }, + { route: '/.', path: '/', returns: false }, + { route: '/x|y', path: '/y', returns: true }, + { route: '/x|y', path: '/x', returns: true }, + { route: '/x/y|z', path: '/z', returns: true }, // should require second path as y or z + { route: '/x/y|z', path: '/x/y', returns: true }, // shouldn't allow the weird pipe + { route: '/x/y|z', path: '/x/z', returns: true }, // shouldn't allow the weird pipe + { route: '/xy*', path: '/x', returns: false }, + { route: '/xy*', path: '/xyz', returns: true }, + { route: '/:x.y', path: '/a.x.y', returns: { x: 'a.x' } }, + { route: '/x.y', path: '/xay', returns: false }, // dots are enforced as dots, not any character (e.g. extensions) + { route: '/xy{2}', path: '/xyxy', returns: false }, // no regex repeating supported + { route: '/xy{2}', path: '/xy/xy', returns: false }, // no regex repeating supported + { route: '/:x.:y', path: '/a.b.c', returns: { x: 'a.b', y: 'c' } }, // standard file + extension format + { route: '/test.:x', path: '/test.a.b', returns: false }, // extensions only capture a single dot + { route: '/test.:x', path: '/test.a', returns: { x: 'a' } }, + { route: '/:x?.y', path: '/test.y', returns: { x: 'test' } }, + { route: '/api(/v1)?/foo', path: '/api/v1/foo' }, // switching support preserved + { route: '/api(/v1)?/foo', path: '/api/foo' }, // switching support preserved + { route: '(/api)?/v1/:x', path: '/api/v1/foo', returns: { x: 'foo' } }, // switching support preserved + { route: '(/api)?/v1/:x', path: '/v1/foo', returns: { x: 'foo' } }, // switching support preserved + ]) + }) + + describe('trailing/leading slashes', () => { + testRoutes([ + { route: '/foo/bar', path: '/foo/bar' }, + { route: '/foo/bar', path: '/foo/bar/' }, + { route: '/foo/bar/', path: '/foo/bar/' }, + { route: '/foo/bar/', path: '/foo/bar' }, + { route: '/', path: '/' }, + { route: '', path: '/' }, + ]) + }) + + describe('wildcards', () => { + testRoutes([ + { route: '*', path: '/something/foo' }, + { route: '/*/foo', path: '/something/foo' }, + { route: '/*/foo', path: '/something/else/foo' }, + { route: '/foo/*/bar', path: '/foo/a/b/c/bar' }, + ]) + }) + }) + }) + } +}) + diff --git a/test/index.ts b/test/index.ts index d378aa86..d3212474 100644 --- a/test/index.ts +++ b/test/index.ts @@ -36,7 +36,7 @@ const testRoute = async ( path, }) - await router.handle(toReq(`${method.toUpperCase()} ${path}`)) + await router.fetch(toReq(`${method.toUpperCase()} ${path}`)) if (!returns) { expect(handler).not.toHaveBeenCalled() From 502ff7b4417f5ffc363fa4116bceb4ff11653815 Mon Sep 17 00:00:00 2001 From: Kevin Whitley Date: Thu, 14 Mar 2024 16:44:13 -0500 Subject: [PATCH 04/16] take that, linter! --- .eslintrc | 1 + src/AutoRouter.ts | 2 +- src/Router.spec.ts | 8 +++----- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/.eslintrc b/.eslintrc index d2f152d3..1f177dcd 100644 --- a/.eslintrc +++ b/.eslintrc @@ -18,6 +18,7 @@ "rules": { "@typescript-eslint/no-empty-function": "off", "@typescript-eslint/no-explicit-any": "off", + "@typescript-eslint/ban-types": "off", "linebreak-style": ["error", "unix"], "prefer-const": "off", "quotes": ["error", "single", { "allowTemplateLiterals": true }], diff --git a/src/AutoRouter.ts b/src/AutoRouter.ts index f9aec3ce..26c89245 100644 --- a/src/AutoRouter.ts +++ b/src/AutoRouter.ts @@ -17,7 +17,7 @@ export const AutoRouter = ({ ], onError: [error], after: [ - // @ts-ignore + // @ts-expect-error because TS (r: any, ...args) => r ?? missing(r, ...args), format, ...after, diff --git a/src/Router.spec.ts b/src/Router.spec.ts index 33b96ba3..bcd5e8fa 100644 --- a/src/Router.spec.ts +++ b/src/Router.spec.ts @@ -1,10 +1,8 @@ import { describe, expect, it, vi } from 'vitest' -import { createTestRunner, extract, toReq } from '../test' +import { toReq } from '../test' import { Router } from './Router' describe(`SPECIFIC TESTS: Router`, () => { - - it('supports both router.handle and router.fetch', () => { const router = Router() expect(router.fetch).toBe(router.handle) @@ -42,7 +40,7 @@ describe(`SPECIFIC TESTS: Router`, () => { it('allows catching errors with an onError stage', async () => { const handler = vi.fn(r => r instanceof Error) - const noop = vi.fn(r => {}) + const noop = vi.fn(() => {}) const router1 = Router({ onError: [ noop, handler, @@ -69,7 +67,7 @@ describe(`SPECIFIC TESTS: Router`, () => { }) it('after stages that return nothing will not modify response', async () => { - const handler = vi.fn(r => {}) + const handler = vi.fn(() => {}) const router = Router({ after: [ handler, From a3b471b4d2abc459b747a069cbffa569cd681eba Mon Sep 17 00:00:00 2001 From: Kevin Whitley Date: Thu, 14 Mar 2024 16:46:45 -0500 Subject: [PATCH 05/16] cleaned up example --- example/bun-autorouter-advanced.ts | 35 ++++++++++++++++++++++++++++++ example/bun-autorouter.ts | 25 +-------------------- 2 files changed, 36 insertions(+), 24 deletions(-) create mode 100644 example/bun-autorouter-advanced.ts diff --git a/example/bun-autorouter-advanced.ts b/example/bun-autorouter-advanced.ts new file mode 100644 index 00000000..138283c1 --- /dev/null +++ b/example/bun-autorouter-advanced.ts @@ -0,0 +1,35 @@ +import { text } from 'text' +import { json } from 'json' +import { AutoRouter } from '../src/AutoRouter' +import { error } from 'error' +import { IRequest } from 'IttyRouter' +import { withParams } from 'withParams' + +const router = AutoRouter({ + port: 3001, + missing: () => error(404, 'Are you sure about that?'), + format: () => {}, + before: [ + (r: any) => { r.date = new Date }, + ], + after: [ + (r: Response, request: IRequest) => + console.log(r.status, request.method, request.url, 'delivered in', Date.now() - request.date, 'ms from', request.date.toLocaleString()), + ] +}) + +const childRouter = AutoRouter({ + base: '/child', + missing: () => {}, +}) + .get('/:id', ({ id }) => [ Number(id), Number(id) / 2 ]) + +router + .get('/basic', () => new Response('Success!')) + .get('/text', () => 'Success!') + .get('/params/:foo', ({ foo }) => foo) + .get('/json', () => ({ foo: 'bar' })) + .get('/throw', (a) => a.b.c) + .get('/child/*', childRouter.fetch) + +export default router diff --git a/example/bun-autorouter.ts b/example/bun-autorouter.ts index 138283c1..45f389c4 100644 --- a/example/bun-autorouter.ts +++ b/example/bun-autorouter.ts @@ -1,28 +1,6 @@ -import { text } from 'text' -import { json } from 'json' import { AutoRouter } from '../src/AutoRouter' -import { error } from 'error' -import { IRequest } from 'IttyRouter' -import { withParams } from 'withParams' -const router = AutoRouter({ - port: 3001, - missing: () => error(404, 'Are you sure about that?'), - format: () => {}, - before: [ - (r: any) => { r.date = new Date }, - ], - after: [ - (r: Response, request: IRequest) => - console.log(r.status, request.method, request.url, 'delivered in', Date.now() - request.date, 'ms from', request.date.toLocaleString()), - ] -}) - -const childRouter = AutoRouter({ - base: '/child', - missing: () => {}, -}) - .get('/:id', ({ id }) => [ Number(id), Number(id) / 2 ]) +const router = AutoRouter({ port: 3001 }) router .get('/basic', () => new Response('Success!')) @@ -30,6 +8,5 @@ router .get('/params/:foo', ({ foo }) => foo) .get('/json', () => ({ foo: 'bar' })) .get('/throw', (a) => a.b.c) - .get('/child/*', childRouter.fetch) export default router From edeebfa8d16695516b30eee9d2064df1556f82e7 Mon Sep 17 00:00:00 2001 From: Kevin Whitley Date: Fri, 15 Mar 2024 00:26:34 -0500 Subject: [PATCH 06/16] added AutoRouter test coverage --- src/AutoRouter.spec.ts | 98 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 src/AutoRouter.spec.ts diff --git a/src/AutoRouter.spec.ts b/src/AutoRouter.spec.ts new file mode 100644 index 00000000..af0eab88 --- /dev/null +++ b/src/AutoRouter.spec.ts @@ -0,0 +1,98 @@ +import { describe, expect, it, vi } from 'vitest' +import { toReq } from '../test' +import { AutoRouter } from './AutoRouter' +import { text } from './text' +import { error } from './error' + +describe(`SPECIFIC TESTS: AutoRouter`, () => { + const jsonData = [1,2,3] + + describe('BEHAVIORS', () => { + describe('DEFAULT', () => { + it('returns a generic 404 on route miss', async () => { + const router = AutoRouter() + + const response = await router.fetch(toReq('/')) + expect(response.status).toBe(404) + }) + + it('formats unformated responses as JSON', async () => { + const router = AutoRouter().get('/', () => jsonData) + + const response = await router.fetch(toReq('/')) + const parsed = await response.json() + expect(parsed).toEqual(jsonData) + }) + + it('includes withParams', async () => { + const handler = vi.fn(({ id }) => id) + const router = AutoRouter().get('/:id', handler) + + await router.fetch(toReq('/foo')) + expect(handler).toHaveReturnedWith('foo') + }) + + it('catches errors by default', async () => { + const router = AutoRouter().get('/', a => a.b.c) + + const response = await router.fetch(toReq('/')) + expect(response.status).toBe(500) + }) + }) + + describe('OPTIONS', () => { + it('format: FormatterFunction - replaces default JSON formatting', async () => { + const router = AutoRouter({ format: text }).get('/', () => 'foo') + + const response = await router.fetch(toReq('/')) + expect(response.headers.get('content-type').includes('text')).toBe(true) + }) + + it('missing: RouteHandler - replaces default missing error', async () => { + const router = AutoRouter({ missing: () => error(418) }) + + const response = await router.fetch(toReq('/')) + expect(response.status).toBe(418) + }) + + it('before: RouteHandler - adds upstream middleware', async () => { + const handler = vi.fn(r => typeof r.date) + const router = AutoRouter({ + before: [ + r => { r.date = Date.now() } + ] + }).get('*', handler) + + await router.fetch(toReq('/')) + expect(handler).toHaveReturnedWith('number') + }) + + describe('after: (response: Response, request: IRequest, ...args) - ResponseHandler', async () => { + it('modifies the response if returning non-null value', async () => { + const router = AutoRouter({ + after: [ r => true ] + }).get('*', () => 314) + + const response = await router.fetch(toReq('/')) + expect(response).toBe(true) + }) + + it('does not modify the response if returning null values', async () => { + const router = AutoRouter({ + after: [ + () => {}, + () => undefined, + () => null, + ] + }).get('*', () => 314) + + const response = await router.fetch(toReq('/')) + const parsed = await response.json() + expect(response.status).toBe(200) + expect(parsed).toBe(314) + }) + }) + }) + }) +}) + From 9d02b2725e5646cf8f347a363030a36f29346320 Mon Sep 17 00:00:00 2001 From: Kevin Whitley Date: Fri, 15 Mar 2024 01:26:08 -0500 Subject: [PATCH 07/16] lint fix --- src/AutoRouter.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/AutoRouter.spec.ts b/src/AutoRouter.spec.ts index af0eab88..df0e1055 100644 --- a/src/AutoRouter.spec.ts +++ b/src/AutoRouter.spec.ts @@ -70,7 +70,7 @@ describe(`SPECIFIC TESTS: AutoRouter`, () => { describe('after: (response: Response, request: IRequest, ...args) - ResponseHandler', async () => { it('modifies the response if returning non-null value', async () => { const router = AutoRouter({ - after: [ r => true ] + after: [ () => true ] }).get('*', () => 314) const response = await router.fetch(toReq('/')) From b31ddf444bbeee9baeb1d4b9166f410269120800 Mon Sep 17 00:00:00 2001 From: Kevin Whitley Date: Fri, 15 Mar 2024 13:24:54 -0500 Subject: [PATCH 08/16] types for before/after/onError --- src/AutoRouter.ts | 10 +++++++-- src/IttyRouter.ts | 8 +++---- src/Router.spec.ts | 34 +++++++++++++++++++++++++++++ src/Router.ts | 53 +++++++++++++++++++++++++++++++++++++++------- 4 files changed, 91 insertions(+), 14 deletions(-) diff --git a/src/AutoRouter.ts b/src/AutoRouter.ts index 26c89245..fadf6126 100644 --- a/src/AutoRouter.ts +++ b/src/AutoRouter.ts @@ -2,6 +2,13 @@ import { error } from './error' import { json } from './json' import { withParams } from './withParams' import { Router, RouterOptions} from './Router' +import { RouteHandler } from 'IttyRouter' +import { ResponseFormatter } from './createResponse' + +type AutoRouterOptions = { + missing?: RouteHandler + format?: ResponseFormatter +} & RouterOptions // MORE FINE-GRAINED/SIMPLIFIED CONTROL, BUT CANNOT FULLY REPLACE BEFORE/AFTER STAGES export const AutoRouter = ({ @@ -9,7 +16,7 @@ export const AutoRouter = ({ missing = () => error(404), after = [], before = [], - ...options }: RouterOptions = {} + ...options }: AutoRouterOptions = {} ) => Router({ before: [ withParams, @@ -17,7 +24,6 @@ export const AutoRouter = ({ ], onError: [error], after: [ - // @ts-expect-error because TS (r: any, ...args) => r ?? missing(r, ...args), format, ...after, diff --git a/src/IttyRouter.ts b/src/IttyRouter.ts index 536203a0..bef7cbe4 100644 --- a/src/IttyRouter.ts +++ b/src/IttyRouter.ts @@ -49,7 +49,7 @@ export type Route = = ( path: string, ...handlers: RouteHandler[] -) => RouterType, Args> +) => IttyRouterType, Args> // helper function to detect equality in types (used to detect custom Request on router) export type Equal = (() => T extends X ? 1 : 2) extends (() => T extends Y ? 1 : 2) ? true : false; @@ -58,8 +58,8 @@ export type CustomRoutes = { [key: string]: R, } -export type RouterType = { - __proto__: RouterType, +export type IttyRouterType = { + __proto__: IttyRouterType, routes: RouteEntry[], fetch: (request: RequestLike, ...extra: Equal extends true ? A : Args) => Promise all: R, @@ -76,7 +76,7 @@ export const IttyRouter = < RequestType = IRequest, Args extends any[] = any[], RouteType = Equal extends true ? Route : UniversalRoute ->({ base = '', routes = [], ...other }: IttyRouterOptions = {}): RouterType => +>({ base = '', routes = [], ...other }: IttyRouterOptions = {}): IttyRouterType => // @ts-expect-error TypeScript doesn't know that Proxy makes this work ({ __proto__: new Proxy({}, { diff --git a/src/Router.spec.ts b/src/Router.spec.ts index bcd5e8fa..f74ecb42 100644 --- a/src/Router.spec.ts +++ b/src/Router.spec.ts @@ -54,6 +54,23 @@ describe(`SPECIFIC TESTS: Router`, () => { expect(router2.fetch(toReq('/'))).rejects.toThrow() }) + it('onError and after stages have access to request and args', async () => { + const request = toReq('/') + const arg1 = { foo: 'bar' } + + const errorHandler = vi.fn((a,b,c) => [b.url, c]) + const afterHandler = vi.fn((a,b,c) => [a, b.url, c]) + const router = Router({ + onError: [ errorHandler ], + after: [ afterHandler ], + }) + .get('/', a => a.b.c) + + await router.fetch(toReq('/'), arg1) + expect(errorHandler).toHaveReturnedWith([request.url, arg1]) + expect(afterHandler).toHaveReturnedWith([[request.url, arg1], request.url, arg1]) + }) + it('allows modifying responses in an after stage', async () => { const router = Router({ after: [r => Number(r) || 0], @@ -80,5 +97,22 @@ describe(`SPECIFIC TESTS: Router`, () => { expect(response).toBe(13) expect(handler).toHaveBeenCalled() }) + + it('can introspect/modify before/after/onError stages after initialization', async () => { + const handler1 = vi.fn(() => {}) + const handler2 = vi.fn(() => {}) + const router = Router({ + before: [ handler1, handler2 ], + after: [ handler1, handler2 ], + }) + + // manipulate + router.after.push(() => true) + + const response = await router.fetch(toReq('/')) + expect(router.before.length).toBe(2) + expect(router.after.length).toBe(3) + expect(response).toBe(true) + }) }) diff --git a/src/Router.ts b/src/Router.ts index 610470a5..ba3ad11a 100644 --- a/src/Router.ts +++ b/src/Router.ts @@ -1,20 +1,30 @@ import { Equal, IRequest, + IttyRouterOptions, + IttyRouterType, RequestLike, Route, RouteHandler, - IttyRouterOptions, - RouterType, - UniversalRoute, + UniversalRoute } from './IttyRouter' -export type ErrorHandler = (input: Input) => void +export type ResponseHandler = + (response: ResponseType, request: RequestType, ...args: Args) => any + +export type ErrorHandler = + (response: ErrorOrResponse, request: RequestType, ...args: Args) => any + +export type RouterType = { + before?: RouteHandler[] + onError?: ErrorHandler[] + after?: ResponseHandler[] +} & IttyRouterType export type RouterOptions = { - before?: Function[] - onError?: Function[] - after?: Function[] + before?: RouteHandler[] + onError?: ErrorHandler[] + after?: ResponseHandler[] } & IttyRouterOptions export const Router = < @@ -74,7 +84,7 @@ export const Router = < if (!other.onError) throw err for (let handler of other.onError) - response = await handler(response ?? err) ?? response + response = await handler(response ?? err, request.proxy ?? request, ...args) ?? response } for (let handler of other.after || []) @@ -83,3 +93,30 @@ export const Router = < return response }, }) + +// const afterHandler: RouteHandler = (response) => { response.headers } +// const errorHandler: ErrorHandler = (err) => { err.message } + +// const router = Router({ +// after: [ +// afterHandler, +// (response: Response) => { response.headers }, +// ], +// onError: [ +// // errorHandler, +// // (err: Error) => { err.message }, +// // () +// ] +// }) + +// type CustomRequest = { +// foo: string +// } & IRequest + +// router.before = [ +// (r: CustomRequest | IRequest) => { r.foo } +// ] + +// router.get('/', (r) => r.foo) + + From 3ac4cdc2f7e6ebebc36764e09ec88efb0ce90969 Mon Sep 17 00:00:00 2001 From: Kevin Whitley Date: Sun, 17 Mar 2024 17:01:43 -0500 Subject: [PATCH 09/16] released v4.3.0-next.0 - releasing v4.3 as next to determine sizes --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 02995860..552cbbc8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "itty-router", - "version": "4.2.1", + "version": "4.3.0-next.0", "description": "A tiny, zero-dependency router, designed to make beautiful APIs in any environment.", "main": "./index.js", "module": "./index.mjs", From 2db8606e98444b0e83708f193dc0f32d816a5765 Mon Sep 17 00:00:00 2001 From: "Kevin R. Whitley" Date: Tue, 19 Mar 2024 00:48:48 -0500 Subject: [PATCH 10/16] Update README.md --- README.md | 172 +++++++----------------------------------------------- 1 file changed, 22 insertions(+), 150 deletions(-) diff --git a/README.md b/README.md index 6c0697e5..883e4203 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@

- Itty Router + Itty Router

- -

v4.x Documentation @ itty.dev + +

Documentation @ itty.dev

@@ -48,164 +48,35 @@ --- -Itty is arguably the smallest (~460 bytes) feature-rich JavaScript router available, while enabling dead-simple API code. - -Designed originally for [Cloudflare Workers](https://itty.dev/itty-router/runtimes#Cloudflare%20Workers), itty can be used in browsers, service workers, edge functions, or runtimes like [Node](https://itty.dev/itty-router/runtimes#Node), [Bun](https://itty.dev/itty-router/runtimes#Bun), etc.! +An ultra-tiny API microrouter, for use when [size matters](https://github.com/TigersWay/cloudflare-playground) (e.g. [Cloudflare Workers](https://developers.cloudflare.com/workers/)). ## Features -- Tiny. [~460](https://deno.bundlejs.com/?q=itty-router/Router) bytes for the Router itself, or [~1.6k](https://bundlephobia.com/package/itty-router) for the entire library (>100x smaller than [express.js](https://www.npmjs.com/package/express)). -- [Fully-Typed](https://itty.dev/itty-router/typescript). -- Shorter, simpler route code than most modern routers. -- Dead-simple [middleware](https://itty.dev/itty-router/middleware) - use ours or write your own. -- Supports [nested APIs](https://itty.dev/itty-router/nesting). -- Platform agnostic (based on [Fetch API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API)) - use it [anywhere, in any environment](https://itty.dev/itty-router/runtimes). -- Parses [route params](https://itty.dev/itty-router/route-patterns#params), - [optional params](https://itty.dev/itty-router/route-patterns#optional), - [wildcards](https://itty.dev/itty-router/route-patterns#wildcards), - [greedy params](https://itty.dev/itty-router/route-patterns#greedy), - [file formats](https://itty.dev/itty-router/route-patterns#file-formats) - and [query strings](https://itty.dev/itty-router/route-patterns#query). -- Extremely extendable/flexible. We leave you in complete control. - -## [Full Documentation](https://itty.dev/itty-router) - -Complete API documentation is available at [itty.dev/itty-router](https://itty.dev/itty-router), or join our [Discord](https://discord.gg/53vyrZAu9u) channel to chat with community members for quick help! +- Tiny. We have routers from [~450 bytes](https://itty.dev/itty-router/routers/ittyrouter) to a [~1kB bytes](https://itty.dev/itty-router/routers/autorouter) batteries-included version. For comparison, [express.js](https://www.npmjs.com/package/express) is over 200x as large. +- Web Standards - Use it [anywhere, in any environment](https://itty.dev/itty-router/runtimes). +- No assumptions. Return anything you like, pass in any arguments you like. +- Future-proof. HTTP methods not-yet-invented already work with it. +- [Route-parsing](https://itty.dev/itty-router/route-patterns) & [query parsing](https://itty.dev/itty-router/route-patterns#query). +- [Middleware](https://itty.dev/itty-router/middleware) - use ours or write your own. +- [Nesting](https://itty.dev/itty-router/nesting). -## Installation - -``` -npm install itty-router -``` - -## Example +## Example (Cloudflare Worker or Bun) ```js -import { - error, // creates error responses - json, // creates JSON responses - Router, // the ~440 byte router itself - withParams, // middleware: puts params directly on the Request -} from 'itty-router' -import { todos } from './external/todos' - -// create a new Router -const router = Router() - -router - // add some middleware upstream on all routes - .all('*', withParams) - - // GET list of todos - .get('/todos', () => todos) - - // GET single todo, by ID - .get( - '/todos/:id', - ({ id }) => todos.getById(id) || error(404, 'That todo was not found') - ) - - // 404 for everything else - .all('*', () => error(404)) - -// Example: Cloudflare Worker module syntax -export default { - fetch: (request, ...args) => - router - .handle(request, ...args) - .then(json) // send as JSON - .catch(error), // catch errors -} -``` - -# What's different about itty? -Itty does a few things very differently from other routers. This allows itty route code to be shorter and more intuitive than most! - -### 1. Simpler handler/middleware flow. -In itty, you simply return (anything) to exit the flow. If any handler ever returns a thing, that's what the `router.handle` returns. If it doesn't, it's considered middleware, and the next handler is called. - -That's it! - -```ts -// not middleware: any handler that returns (anything at all) -(request) => [1, 4, 5, 1] +import { AutoRouter } from 'itty-router' // ~1kB -// middleware: simply doesn't return -const withUser = (request) => { - request.user = 'Halsey' -} +export default AutoRouter() + .get('/text', () => 'Hey there!') + .get('/json', () => [1,2,3]) + .get('/hello/:name', ({ name = 'World' }) => `Hello, ${name}!`) + .get('/promises', () => Promise.resolve('foo')) -// a middleware that *might* return -const onlyHalsey = (request) => { - if (request.user !== 'Halsey') { - return error(403, 'Only Halsey is allowed to see this!') - } -} - -// uses middleware, then returns something -route.get('/secure', withUser, onlyHalsey, - ({ user }) => `Hey, ${user} - welcome back!` -) +// that's it ^ ``` -### 2. You don't have to build a response in each route handler. -We've been stuck in this pattern for over a decade. Almost every router still expects you to build and return a [Response](https://developer.mozilla.org/en-US/docs/Web/API/Response)... in every single route. - -We think you should be able to do that once, at the end. In most modern APIs for instance, we're serving JSON in the majority of our routes. So why handle that more than once? -```ts -router - // we can still do it the manual way - .get('/traditional', (request) => json([1, 2, 3])) - - // or defer to later - .get('/easy-mode', (request) => [1, 2, 3]) - -// later, when handling a request -router - .handle(request) - .then(json) // we can turn any non-Response into valid JSON. -``` - -### 3. It's all Promises. -itty `await`s every handler, looking for a return value. If it gets one, it breaks the flow and returns the value. If it doesn't, it continues processing handlers/routes until it does. This means that every handler can either be synchronous or async - it's all the same. - -When paired with the fact that we can simply return raw data and transform it later, this is AWESOME for working with async APIs, database layers, etc. We don't need to transform anything at the route, we can simply return the Promise (to data) itself! - -Check this out: -```ts -import { myDatabase } from './somewhere' - -router - // assumes getItems() returns a Promise to some data - .get('/items', () => myDatabase.getItems()) - -// later, when handling a request -router - .handle(request) - .then(json) // we can turn any non-Response into valid JSON. -``` +## [Full Documentation](https://itty.dev/itty-router) -### 4. Only one required argument. The rest is up to you. -itty only requires one argument - a Request-like object with the following shape: `{ url, method }` (usually a native [Request](https://developer.mozilla.org/en-US/docs/Web/API/Request)). Because itty is not opinionated about [Response](https://developer.mozilla.org/en-US/docs/Web/API/Response) creation, there is not "response" argument built in. Every other argument you pass to `route.handle` is given to each handler, in the same order. - -> ### This makes itty one of the most platform-agnostic routers, *period*, as it's able to match up to any platform's signature. - -Here's an example using [Cloudflare Worker](https://workers.cloudflare.com/) arguments: -```ts -router - .get('/my-route', (request, environment, context) => { - // we can access anything here that was passed to `router.handle`. - }) - -// Cloudflare gives us 3 arguments: request, environment, and context. -// Passing them to `route.handle` gives every route handler (above) access to each. -export default { - fetch: (request, env, ctx) => router - .handle(request, env, ctx) - .then(json) - .catch(error) -} -``` +Complete API documentation is available at [itty.dev/itty-router](https://itty.dev/itty-router), or join our [Discord](https://discord.gg/53vyrZAu9u) channel to chat with community members for quick help! ## Join the Discussion! @@ -244,6 +115,7 @@ These folks are the real heroes, making open source the powerhouse that it is! H - [@technoyes](https://github.com/technoyes) - three kind-of-a-big-deal errors fixed. Imagine the look on my face... thanks man!! :) - [@roojay520](https://github.com/roojay520) - TS interface fixes - [@jahands](https://github.com/jahands) - v4.x TS fixes +- and many, many others #### Documentation From bc7c7e6fa66eeb5bde9ee49eb0cdee3043d8f8f2 Mon Sep 17 00:00:00 2001 From: "Kevin R. Whitley" Date: Tue, 19 Mar 2024 00:58:45 -0500 Subject: [PATCH 11/16] Update README.md --- README.md | 25 ++++--------------------- 1 file changed, 4 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 883e4203..8f352859 100644 --- a/README.md +++ b/README.md @@ -3,9 +3,6 @@ Itty Router

- -

Documentation @ itty.dev -

@@ -26,9 +23,6 @@ open issues - - -

@@ -66,34 +60,23 @@ An ultra-tiny API microrouter, for use when [size matters](https://github.com/Ti import { AutoRouter } from 'itty-router' // ~1kB export default AutoRouter() - .get('/text', () => 'Hey there!') + .get('/hello/:name', ({ name }) => `Hello, ${name}!`) .get('/json', () => [1,2,3]) - .get('/hello/:name', ({ name = 'World' }) => `Hello, ${name}!`) .get('/promises', () => Promise.resolve('foo')) -// that's it ^ +// that's it ^-^ ``` -## [Full Documentation](https://itty.dev/itty-router) +## [Full Documentation](https://itty.dev/itty-router) @ [itty.dev](https://itty.dev) Complete API documentation is available at [itty.dev/itty-router](https://itty.dev/itty-router), or join our [Discord](https://discord.gg/53vyrZAu9u) channel to chat with community members for quick help! ## Join the Discussion! -Have a question? Suggestion? Complaint? Want to send a gift basket? +Have a question? Suggestion? Idea? Complaint? Want to send a gift basket? Join us on [Discord](https://discord.gg/53vyrZAu9u)! -## Testing and Contributing - -1. Fork repo -1. Install dev dependencies via `yarn` -1. Start test runner/dev mode `yarn dev` -1. Add your code and tests if needed - do NOT remove/alter existing tests -1. Commit files -1. Submit PR (and fill out the template) -1. I'll add you to the credits! :) - ## Special Thanks: Contributors These folks are the real heroes, making open source the powerhouse that it is! Help out and get your name added to this list! <3 From 9be0a6af65517d40679e2476a942cd349efd9b2d Mon Sep 17 00:00:00 2001 From: "Kevin R. Whitley" Date: Tue, 19 Mar 2024 01:09:03 -0500 Subject: [PATCH 12/16] Update README.md --- README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 8f352859..8d2493a0 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,7 @@ Itty Router +

@@ -23,9 +24,9 @@ open issues -

-

+
+ join us on discord From 28804508ee9778eb7fe315d082d26244d40ef9b9 Mon Sep 17 00:00:00 2001 From: "Kevin R. Whitley" Date: Tue, 19 Mar 2024 14:01:39 -0500 Subject: [PATCH 13/16] Update README.md --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 8d2493a0..70460818 100644 --- a/README.md +++ b/README.md @@ -33,8 +33,8 @@ repo stars - - follow the author + + follow ittydev From 65f4ad1764e0d060504efa94ae3d730f0d7ee885 Mon Sep 17 00:00:00 2001 From: "Kevin R. Whitley" Date: Tue, 19 Mar 2024 14:09:32 -0500 Subject: [PATCH 14/16] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 70460818..b2e9240c 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,7 @@ export default AutoRouter() // that's it ^-^ ``` -## [Full Documentation](https://itty.dev/itty-router) @ [itty.dev](https://itty.dev) +# [Full Documentation](https://itty.dev/itty-router) @ [itty.dev](https://itty.dev) Complete API documentation is available at [itty.dev/itty-router](https://itty.dev/itty-router), or join our [Discord](https://discord.gg/53vyrZAu9u) channel to chat with community members for quick help! From 9a70da35b007e90244ea5f2ae4838ff263dbf969 Mon Sep 17 00:00:00 2001 From: Kevin Whitley Date: Tue, 19 Mar 2024 15:13:09 -0500 Subject: [PATCH 15/16] catch is a single handler --- example/{bun-flowrouter.ts => bun-router.ts} | 11 +++++-- src/AutoRouter.ts | 2 +- src/Router.spec.ts | 31 +++++++++++++------- src/Router.ts | 27 ++++++++--------- 4 files changed, 43 insertions(+), 28 deletions(-) rename example/{bun-flowrouter.ts => bun-router.ts} (64%) diff --git a/example/bun-flowrouter.ts b/example/bun-router.ts similarity index 64% rename from example/bun-flowrouter.ts rename to example/bun-router.ts index 573da7bc..d1ad4169 100644 --- a/example/bun-flowrouter.ts +++ b/example/bun-router.ts @@ -1,13 +1,18 @@ +import { IRequest } from 'IttyRouter' import { Router } from '../src/Router' import { error } from '../src/error' import { json } from '../src/json' import { withParams } from '../src/withParams' +const logger = (response: Response, request: IRequest) => { + console.log(response.status, request.url, '@', new Date().toLocaleString()) +} + const router = Router({ port: 3001, before: [withParams], - onError: [error], - after: [json], + after: [json, logger], + catch: error, }) router @@ -15,7 +20,7 @@ router .get('/text', () => 'Success!') .get('/params/:foo', ({ foo }) => foo) .get('/json', () => ({ foo: 'bar' })) - .get('/throw', (a) => a.b.c) + .get('/throw', a => a.b.c) .all('*', () => error(404)) export default router diff --git a/src/AutoRouter.ts b/src/AutoRouter.ts index fadf6126..9a6692ba 100644 --- a/src/AutoRouter.ts +++ b/src/AutoRouter.ts @@ -22,7 +22,7 @@ export const AutoRouter = ({ withParams, ...before ], - onError: [error], + catch: error, after: [ (r: any, ...args) => r ?? missing(r, ...args), format, diff --git a/src/Router.spec.ts b/src/Router.spec.ts index f74ecb42..721f3f50 100644 --- a/src/Router.spec.ts +++ b/src/Router.spec.ts @@ -38,30 +38,41 @@ describe(`SPECIFIC TESTS: Router`, () => { expect(response).toBe(true) }) - it('allows catching errors with an onError stage', async () => { + it('allows catching errors with a catch handler', async () => { const handler = vi.fn(r => r instanceof Error) - const noop = vi.fn(() => {}) - const router1 = Router({ onError: [ - noop, - handler, - ] }).get('/', a => a.b.c) + const router1 = Router({ catch: handler }).get('/', a => a.b.c) const router2 = Router().get('/', a => a.b.c) const response = await router1.fetch(toReq('/')) - expect(noop).toHaveBeenCalled() expect(handler).toHaveReturnedWith(true) expect(response).toBe(true) expect(router2.fetch(toReq('/'))).rejects.toThrow() }) - it('onError and after stages have access to request and args', async () => { + it('an error in the after stage will still be caught with a catch handler', async () => { + const handler = vi.fn(r => r instanceof Error) + const router1 = Router({ + after: [a => a.b.c], + catch: handler + }).get('/', () => 'hey!') + const router2 = Router({ + after: [a => a.b.c], + }).get('/', () => 'hey!') + + const response1 = await router1.fetch(toReq('/')) + expect(handler).toHaveReturnedWith(true) + expect(response1).toBe(true) + expect(router2.fetch(toReq('/'))).rejects.toThrow() + }) + + it('catch and after stages have access to request and args', async () => { const request = toReq('/') const arg1 = { foo: 'bar' } const errorHandler = vi.fn((a,b,c) => [b.url, c]) const afterHandler = vi.fn((a,b,c) => [a, b.url, c]) const router = Router({ - onError: [ errorHandler ], + catch: errorHandler, after: [ afterHandler ], }) .get('/', a => a.b.c) @@ -98,7 +109,7 @@ describe(`SPECIFIC TESTS: Router`, () => { expect(handler).toHaveBeenCalled() }) - it('can introspect/modify before/after/onError stages after initialization', async () => { + it('can introspect/modify before/after/catch stages after initialization', async () => { const handler1 = vi.fn(() => {}) const handler2 = vi.fn(() => {}) const router = Router({ diff --git a/src/Router.ts b/src/Router.ts index ba3ad11a..817000b0 100644 --- a/src/Router.ts +++ b/src/Router.ts @@ -17,13 +17,13 @@ export type ErrorHandler = { before?: RouteHandler[] - onError?: ErrorHandler[] + catch?: ErrorHandler after?: ResponseHandler[] } & IttyRouterType export type RouterOptions = { before?: RouteHandler[] - onError?: ErrorHandler[] + catch?: ErrorHandler after?: ResponseHandler[] } & IttyRouterOptions @@ -80,15 +80,18 @@ export const Router = < for (let handler of handlers) if ((response = await handler(request.proxy ?? request, ...args)) != null) break outer } - } catch (err) { - if (!other.onError) throw err - - for (let handler of other.onError) - response = await handler(response ?? err, request.proxy ?? request, ...args) ?? response + } catch (err: any) { + if (!other.catch) throw err + response = await other.catch(err, request.proxy ?? request, ...args) } - for (let handler of other.after || []) - response = await handler(response, request.proxy ?? request, ...args) ?? response + try { + for (let handler of other.after || []) + response = await handler(response, request.proxy ?? request, ...args) ?? response + } catch(err: any) { + if (!other.catch) throw err + response = await other.catch(err, request.proxy ?? request, ...args) + } return response }, @@ -102,11 +105,7 @@ export const Router = < // afterHandler, // (response: Response) => { response.headers }, // ], -// onError: [ -// // errorHandler, -// // (err: Error) => { err.message }, -// // () -// ] +// catch: errorHandler, // }) // type CustomRequest = { From 201cdce856c0306555a3081541d37c7159fe5c25 Mon Sep 17 00:00:00 2001 From: "Kevin R. Whitley" Date: Wed, 20 Mar 2024 17:35:39 +0100 Subject: [PATCH 16/16] v4.3 DRAFT: rename after stage to finally (#225) * replaced after stage with finally * fixed potential bug in createResponse to prevent pollution if passed a Request as second param, like in v4.3 stages * createResponse simplified and safe against using as ResponseHandler --- example/bun-autorouter-advanced.ts | 2 +- example/bun-router.ts | 2 +- src/AutoRouter.spec.ts | 6 ++-- src/AutoRouter.ts | 19 +++++------ src/IttyRouter.ts | 2 +- src/Router.spec.ts | 55 ++++++++++++++++++++++-------- src/Router.ts | 16 ++++----- src/SharedRouter.spec.ts | 10 +++--- src/createResponse.spec.ts | 15 ++++++-- src/createResponse.ts | 23 +++++-------- test/index.ts | 5 +-- tsconfig.json | 4 +-- 12 files changed, 91 insertions(+), 68 deletions(-) diff --git a/example/bun-autorouter-advanced.ts b/example/bun-autorouter-advanced.ts index 138283c1..f26ddc4d 100644 --- a/example/bun-autorouter-advanced.ts +++ b/example/bun-autorouter-advanced.ts @@ -12,7 +12,7 @@ const router = AutoRouter({ before: [ (r: any) => { r.date = new Date }, ], - after: [ + finally: [ (r: Response, request: IRequest) => console.log(r.status, request.method, request.url, 'delivered in', Date.now() - request.date, 'ms from', request.date.toLocaleString()), ] diff --git a/example/bun-router.ts b/example/bun-router.ts index d1ad4169..5d2087d3 100644 --- a/example/bun-router.ts +++ b/example/bun-router.ts @@ -11,7 +11,7 @@ const logger = (response: Response, request: IRequest) => { const router = Router({ port: 3001, before: [withParams], - after: [json, logger], + finally: [json, logger], catch: error, }) diff --git a/src/AutoRouter.spec.ts b/src/AutoRouter.spec.ts index df0e1055..636871f3 100644 --- a/src/AutoRouter.spec.ts +++ b/src/AutoRouter.spec.ts @@ -67,10 +67,10 @@ describe(`SPECIFIC TESTS: AutoRouter`, () => { expect(handler).toHaveReturnedWith('number') }) - describe('after: (response: Response, request: IRequest, ...args) - ResponseHandler', async () => { + describe('finally: (response: Response, request: IRequest, ...args) - ResponseHandler', async () => { it('modifies the response if returning non-null value', async () => { const router = AutoRouter({ - after: [ () => true ] + finally: [ () => true ] }).get('*', () => 314) const response = await router.fetch(toReq('/')) @@ -79,7 +79,7 @@ describe(`SPECIFIC TESTS: AutoRouter`, () => { it('does not modify the response if returning null values', async () => { const router = AutoRouter({ - after: [ + finally: [ () => {}, () => undefined, () => null, diff --git a/src/AutoRouter.ts b/src/AutoRouter.ts index 9a6692ba..5d61b671 100644 --- a/src/AutoRouter.ts +++ b/src/AutoRouter.ts @@ -1,20 +1,19 @@ +import { RouteHandler } from 'IttyRouter' +import { ResponseHandler, Router, RouterOptions } from './Router' import { error } from './error' import { json } from './json' import { withParams } from './withParams' -import { Router, RouterOptions} from './Router' -import { RouteHandler } from 'IttyRouter' -import { ResponseFormatter } from './createResponse' type AutoRouterOptions = { missing?: RouteHandler - format?: ResponseFormatter + format?: ResponseHandler } & RouterOptions -// MORE FINE-GRAINED/SIMPLIFIED CONTROL, BUT CANNOT FULLY REPLACE BEFORE/AFTER STAGES +// MORE FINE-GRAINED/SIMPLIFIED CONTROL, BUT CANNOT FULLY REPLACE BEFORE/FINALLY STAGES export const AutoRouter = ({ format = json, missing = () => error(404), - after = [], + finally: f = [], before = [], ...options }: AutoRouterOptions = {} ) => Router({ @@ -23,19 +22,19 @@ export const AutoRouter = ({ ...before ], catch: error, - after: [ + finally: [ (r: any, ...args) => r ?? missing(r, ...args), format, - ...after, + ...f, ], ...options, }) -// LESS FINE-GRAINED CONTROL, BUT CAN COMPLETELY REPLACE BEFORE/AFTER STAGES +// LESS FINE-GRAINED CONTROL, BUT CAN COMPLETELY REPLACE BEFORE/FINALLY STAGES // export const AutoRouter2 = ({ ...options }: RouterOptions = {}) => Router({ // before: [withParams], // onError: [error], -// after: [ +// finally: [ // (r: any) => r ?? error(404), // json, // ], diff --git a/src/IttyRouter.ts b/src/IttyRouter.ts index bef7cbe4..91fe8c63 100644 --- a/src/IttyRouter.ts +++ b/src/IttyRouter.ts @@ -39,7 +39,7 @@ export type RouteEntry = [ ] // this is the generic "Route", which allows per-route overrides -export type Route = ( +export type Route = ( path: string, ...handlers: RouteHandler[] ) => RT diff --git a/src/Router.spec.ts b/src/Router.spec.ts index 721f3f50..a277510b 100644 --- a/src/Router.spec.ts +++ b/src/Router.spec.ts @@ -1,6 +1,8 @@ import { describe, expect, it, vi } from 'vitest' import { toReq } from '../test' import { Router } from './Router' +import { json } from './json' +import { error } from './error' describe(`SPECIFIC TESTS: Router`, () => { it('supports both router.handle and router.fetch', () => { @@ -49,14 +51,14 @@ describe(`SPECIFIC TESTS: Router`, () => { expect(router2.fetch(toReq('/'))).rejects.toThrow() }) - it('an error in the after stage will still be caught with a catch handler', async () => { + it('an error in the finally stage will still be caught with a catch handler', async () => { const handler = vi.fn(r => r instanceof Error) const router1 = Router({ - after: [a => a.b.c], + finally: [a => a.b.c], catch: handler }).get('/', () => 'hey!') const router2 = Router({ - after: [a => a.b.c], + finally: [a => a.b.c], }).get('/', () => 'hey!') const response1 = await router1.fetch(toReq('/')) @@ -65,26 +67,26 @@ describe(`SPECIFIC TESTS: Router`, () => { expect(router2.fetch(toReq('/'))).rejects.toThrow() }) - it('catch and after stages have access to request and args', async () => { + it('catch and finally stages have access to request and args', async () => { const request = toReq('/') const arg1 = { foo: 'bar' } const errorHandler = vi.fn((a,b,c) => [b.url, c]) - const afterHandler = vi.fn((a,b,c) => [a, b.url, c]) + const finallyHandler = vi.fn((a,b,c) => [a, b.url, c]) const router = Router({ catch: errorHandler, - after: [ afterHandler ], + finally: [ finallyHandler ], }) .get('/', a => a.b.c) await router.fetch(toReq('/'), arg1) expect(errorHandler).toHaveReturnedWith([request.url, arg1]) - expect(afterHandler).toHaveReturnedWith([[request.url, arg1], request.url, arg1]) + expect(finallyHandler).toHaveReturnedWith([[request.url, arg1], request.url, arg1]) }) - it('allows modifying responses in an after stage', async () => { + it('allows modifying responses in an finally stage', async () => { const router = Router({ - after: [r => Number(r) || 0], + finally: [r => Number(r) || 0], }).get('/:id?', r => r.params.id) const response1 = await router.fetch(toReq('/13')) @@ -94,10 +96,10 @@ describe(`SPECIFIC TESTS: Router`, () => { expect(response2).toBe(0) }) - it('after stages that return nothing will not modify response', async () => { + it('finally stages that return nothing will not modify response', async () => { const handler = vi.fn(() => {}) const router = Router({ - after: [ + finally: [ handler, r => Number(r) || 0, ], @@ -109,21 +111,44 @@ describe(`SPECIFIC TESTS: Router`, () => { expect(handler).toHaveBeenCalled() }) - it('can introspect/modify before/after/catch stages after initialization', async () => { + it('can introspect/modify before/finally/catch stages finally initialization', async () => { const handler1 = vi.fn(() => {}) const handler2 = vi.fn(() => {}) const router = Router({ before: [ handler1, handler2 ], - after: [ handler1, handler2 ], + finally: [ handler1, handler2 ], }) // manipulate - router.after.push(() => true) + router.finally.push(() => true) const response = await router.fetch(toReq('/')) expect(router.before.length).toBe(2) - expect(router.after.length).toBe(3) + expect(router.finally.length).toBe(3) expect(response).toBe(true) }) + + it('response-handler pollution tests - (createResponse)', async () => { + const router = Router({ + finally: [json] + }).get('/', () => [1,2,3]) + const request = toReq('/') + request.headers.append('foo', 'bar') + + const response = await router.fetch(request) + const body = await response.json() + expect(response.headers.get('foo')).toBe(null) + expect(body).toEqual([1,2,3]) + }) + + it('response-handler pollution tests - (createResponse)', async () => { + const router = Router({ catch: error }).get('/', (a) => a.b.c) + const request = toReq('/') + request.headers.append('foo', 'bar') + + const response = await router.fetch(request) + expect(response.headers.get('foo')).toBe(null) + expect(response.status).toBe(500) + }) }) diff --git a/src/Router.ts b/src/Router.ts index 817000b0..19c01b16 100644 --- a/src/Router.ts +++ b/src/Router.ts @@ -12,19 +12,19 @@ import { export type ResponseHandler = (response: ResponseType, request: RequestType, ...args: Args) => any -export type ErrorHandler = - (response: ErrorOrResponse, request: RequestType, ...args: Args) => any +export type ErrorHandler = + (response: ErrorType, request: RequestType, ...args: Args) => any export type RouterType = { before?: RouteHandler[] catch?: ErrorHandler - after?: ResponseHandler[] + finally?: ResponseHandler[] } & IttyRouterType export type RouterOptions = { before?: RouteHandler[] catch?: ErrorHandler - after?: ResponseHandler[] + finally?: ResponseHandler[] } & IttyRouterOptions export const Router = < @@ -86,7 +86,7 @@ export const Router = < } try { - for (let handler of other.after || []) + for (let handler of other.finally || []) response = await handler(response, request.proxy ?? request, ...args) ?? response } catch(err: any) { if (!other.catch) throw err @@ -97,12 +97,12 @@ export const Router = < }, }) -// const afterHandler: RouteHandler = (response) => { response.headers } +// const finallyHandler: RouteHandler = (response) => { response.headers } // const errorHandler: ErrorHandler = (err) => { err.message } // const router = Router({ -// after: [ -// afterHandler, +// finally: [ +// finallyHandler, // (response: Response) => { response.headers }, // ], // catch: errorHandler, diff --git a/src/SharedRouter.spec.ts b/src/SharedRouter.spec.ts index 9c9f6eed..d719fbb2 100644 --- a/src/SharedRouter.spec.ts +++ b/src/SharedRouter.spec.ts @@ -22,7 +22,7 @@ describe('Common Router Spec', () => { { path: '/foo', callback: vi.fn(extract), method: 'post' }, { path: '/passthrough', - callback: vi.fn(({ method, name }) => ({ method, name })), + callback: vi.fn(r => r), method: 'get', }, ] @@ -190,13 +190,11 @@ describe('Common Router Spec', () => { }) it('passes the entire original request through to the handler', async () => { + const request = toReq('/passthrough') const route = routes.find((r) => r.path === '/passthrough') - await router.fetch({ ...toReq('/passthrough'), name: 'miffles' }) + await router.fetch(request) - expect(route?.callback).toHaveReturnedWith({ - method: 'GET', - name: 'miffles', - }) + expect(route?.callback).toHaveReturnedWith(request) }) it('allows missing handler later in flow with "all" channel', async () => { diff --git a/src/createResponse.spec.ts b/src/createResponse.spec.ts index 5a53d59c..757500d0 100644 --- a/src/createResponse.spec.ts +++ b/src/createResponse.spec.ts @@ -59,11 +59,12 @@ describe('createResponse(mimeType: string, transform?: Function)', () => { expect(body).toBe('***') }) - it('will ignore a Response, to allow downstream use', async () => { + it('will ignore a Response, to allow downstream use (will not modify headers)', async () => { const r1 = json({ foo: 'bar' }) - const r2 = json(r1) + const r2 = text(r1) expect(r2).toBe(r1) + expect(r2.headers.get('content-type')?.includes('text')).toBe(false) }) it('will ignore an undefined body', async () => { @@ -74,6 +75,16 @@ describe('createResponse(mimeType: string, transform?: Function)', () => { expect(r2).toBeUndefined() }) + it('will not apply a Request as 2nd options argument (using Request.url check method)', async () => { + const request = new Request('http://foo.bar', { headers: { foo: 'bar' }}) + const response = json(1, request) + // const { ...restCheck } = request + + // expect(restCheck.url).toBe('http://foo.bar/') + // expect(request.url).toBe('http://foo.bar/') + expect(response.headers.get('foo')).toBe(null) + }) + describe('format helpers', () => { const formats = [ { name: 'json', fn: json, mime: 'application/json; charset=utf-8' }, diff --git a/src/createResponse.ts b/src/createResponse.ts index 74b55668..864fde78 100644 --- a/src/createResponse.ts +++ b/src/createResponse.ts @@ -6,22 +6,15 @@ export interface BodyTransformer { (body: any): string } -export const createResponse = + export const createResponse = ( format = 'text/plain; charset=utf-8', transform?: BodyTransformer ): ResponseFormatter => - (body, { headers = {}, ...rest } = {}) => - body === undefined || body?.constructor.name === 'Response' - ? body - : new Response(transform ? transform(body) : body, { - headers: { - 'content-type': format, - ...(headers.entries - // @ts-expect-error - foul - ? Object.fromEntries(headers) - : headers - ), - }, - ...rest - }) + (body, { ...options } = {}) => { + if (body === undefined || body instanceof Response) return body + + const response = new Response(transform?.(body) ?? body, options) + response.headers.set('content-type', format) + return response + } diff --git a/test/index.ts b/test/index.ts index d3212474..10749ecd 100644 --- a/test/index.ts +++ b/test/index.ts @@ -11,10 +11,7 @@ export const toReq = (methodAndPath: string) => { method = 'GET' } - return { - method, - url: `https://example.com${path}` - } + return new Request(`https://example.com${path}`, { method }) } export const extract = ({ params, query }) => ({ params, query }) diff --git a/tsconfig.json b/tsconfig.json index dde7b198..f8ca21c8 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,10 +10,10 @@ "lib": ["esnext", "dom", "dom.iterable"], "listEmittedFiles": false, "listFiles": false, - "moduleResolution": "nodeNext", "noFallthroughCasesInSwitch": true, "pretty": true, - "resolveJsonModule": true, + // "moduleResolution": "nodeNext", // disabled to be compatible with module: "esnext" + // "resolveJsonModule": true, // disabled to be compatible with module: "esnext" "rootDir": "src", "skipLibCheck": true, "strict": true,