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/README.md b/README.md index 6c0697e5..b2e9240c 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,10 @@

- Itty Router + Itty Router +

-

v4.x Documentation @ itty.dev -

-

npm version @@ -26,20 +24,17 @@ open issues - - - -

-

+
+ join us on discord repo stars - - follow the author + + follow ittydev @@ -48,181 +43,41 @@ --- -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) +- 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). -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! - -## 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. +import { AutoRouter } from 'itty-router' // ~1kB -That's it! +export default AutoRouter() + .get('/hello/:name', ({ name }) => `Hello, ${name}!`) + .get('/json', () => [1,2,3]) + .get('/promises', () => Promise.resolve('foo')) -```ts -// not middleware: any handler that returns (anything at all) -(request) => [1, 4, 5, 1] - -// middleware: simply doesn't return -const withUser = (request) => { - request.user = 'Halsey' -} - -// 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! +# [Full Documentation](https://itty.dev/itty-router) @ [itty.dev](https://itty.dev) -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. -``` - -### 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! -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 @@ -244,6 +99,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 diff --git a/example/bun-autorouter-advanced.ts b/example/bun-autorouter-advanced.ts new file mode 100644 index 00000000..f26ddc4d --- /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 }, + ], + finally: [ + (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 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-router.ts b/example/bun-router.ts new file mode 100644 index 00000000..5d2087d3 --- /dev/null +++ b/example/bun-router.ts @@ -0,0 +1,26 @@ +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], + finally: [json, logger], + catch: error, +}) + +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)) + +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 a7dcbb2b..552cbbc8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "itty-router", - "version": "4.2.2", + "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", @@ -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", @@ -31,6 +36,11 @@ "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.spec.ts b/src/AutoRouter.spec.ts new file mode 100644 index 00000000..636871f3 --- /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('finally: (response: Response, request: IRequest, ...args) - ResponseHandler', async () => { + it('modifies the response if returning non-null value', async () => { + const router = AutoRouter({ + finally: [ () => 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({ + finally: [ + () => {}, + () => undefined, + () => null, + ] + }).get('*', () => 314) + + const response = await router.fetch(toReq('/')) + const parsed = await response.json() + expect(response.status).toBe(200) + expect(parsed).toBe(314) + }) + }) + }) + }) +}) + diff --git a/src/AutoRouter.ts b/src/AutoRouter.ts new file mode 100644 index 00000000..5d61b671 --- /dev/null +++ b/src/AutoRouter.ts @@ -0,0 +1,42 @@ +import { RouteHandler } from 'IttyRouter' +import { ResponseHandler, Router, RouterOptions } from './Router' +import { error } from './error' +import { json } from './json' +import { withParams } from './withParams' + +type AutoRouterOptions = { + missing?: RouteHandler + format?: ResponseHandler +} & RouterOptions + +// MORE FINE-GRAINED/SIMPLIFIED CONTROL, BUT CANNOT FULLY REPLACE BEFORE/FINALLY STAGES +export const AutoRouter = ({ + format = json, + missing = () => error(404), + finally: f = [], + before = [], + ...options }: AutoRouterOptions = {} +) => Router({ + before: [ + withParams, + ...before + ], + catch: error, + finally: [ + (r: any, ...args) => r ?? missing(r, ...args), + format, + ...f, + ], + ...options, +}) + +// LESS FINE-GRAINED CONTROL, BUT CAN COMPLETELY REPLACE BEFORE/FINALLY STAGES +// export const AutoRouter2 = ({ ...options }: RouterOptions = {}) => Router({ +// before: [withParams], +// onError: [error], +// finally: [ +// (r: any) => r ?? error(404), +// json, +// ], +// ...options, +// }) diff --git a/src/IttyRouter.ts b/src/IttyRouter.ts new file mode 100644 index 00000000..91fe8c63 --- /dev/null +++ b/src/IttyRouter.ts @@ -0,0 +1,123 @@ +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[] +) => 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; + +export type CustomRoutes = { + [key: string]: R, +} + +export type IttyRouterType = { + __proto__: IttyRouterType, + routes: RouteEntry[], + fetch: (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 = {}): IttyRouterType => + // @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) => + // @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.spec.ts b/src/Router.spec.ts index 010e6247..a277510b 100644 --- a/src/Router.spec.ts +++ b/src/Router.spec.ts @@ -1,709 +1,154 @@ import { describe, expect, it, vi } from 'vitest' -import { createTestRunner, extract, toReq } from '../test' +import { toReq } from '../test' import { Router } from './Router' +import { json } from './json' +import { error } from './error' -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)', () => { +describe(`SPECIFIC TESTS: Router`, () => { + it('supports both router.handle and router.fetch', () => { + const router = Router() 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) - + it('allows populating a before stage', async () => { + const handler = vi.fn(r => typeof r.date) const router = Router({ - routes: [ - ['GET', /^\/test\.(?[^/]+)\/*$/, [basicHandler], '/test'], - ['GET', /^\/custom-(?\d{2,4})$/, [customHandler], '/custom'], + before: [ + (r) => { r.date = Date.now() }, ], - }) - - await router.handle(toReq('/test.a.b')) - expect(basicHandler).toHaveReturnedWith({ x: 'a.b' }) + }).get('*', handler) - 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 + await router.fetch(toReq('/')) + expect(handler).toHaveReturnedWith('number') }) - 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']) + 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('*', () => {}) - await router.handle(toReq('/custom2-a456')) - expect(handler).toHaveReturnedWith({ custom: 'a456' }) // custom route hit + const response = await router.fetch(toReq('/')) + expect(handler1).toHaveBeenCalled() + expect(handler3).not.toHaveBeenCalled() + expect(response).toBe(true) }) - 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 + it('allows catching errors with a catch handler', async () => { + const handler = vi.fn(r => r instanceof Error) + const router1 = Router({ catch: handler }).get('/', a => a.b.c) + const router2 = Router().get('/', a => a.b.c) - 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() - }) + const response = await router1.fetch(toReq('/')) + expect(handler).toHaveReturnedWith(true) + expect(response).toBe(true) + expect(router2.fetch(toReq('/'))).rejects.toThrow() }) - 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('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({ + finally: [a => a.b.c], + catch: handler + }).get('/', () => 'hey!') + const router2 = Router({ + finally: [a => a.b.c], + }).get('/', () => 'hey!') - 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() - }) + const response1 = await router1.fetch(toReq('/')) + expect(handler).toHaveReturnedWith(true) + expect(response1).toBe(true) + expect(router2.fetch(toReq('/'))).rejects.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) + it('catch and finally stages have access to request and args', async () => { + const request = toReq('/') + const arg1 = { foo: 'bar' } - 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' }) + const errorHandler = vi.fn((a,b,c) => [b.url, c]) + const finallyHandler = vi.fn((a,b,c) => [a, b.url, c]) + const router = Router({ + catch: errorHandler, + finally: [ finallyHandler ], }) - }) - - it('can get query params', async () => { - const router = Router() - const handler = vi.fn((req) => req.query) + .get('/', a => a.b.c) - 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: '', - }) + await router.fetch(toReq('/'), arg1) + expect(errorHandler).toHaveReturnedWith([request.url, arg1]) + expect(finallyHandler).toHaveReturnedWith([[request.url, arg1], request.url, arg1]) }) - 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) + it('allows modifying responses in an finally stage', async () => { + const router = Router({ + finally: [r => Number(r) || 0], + }).get('/:id?', r => r.params.id) - 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 }), - }) + const response1 = await router.fetch(toReq('/13')) + const response2 = await router.fetch(toReq('/')) - await router.handle(request) - expect(handler).toHaveReturnedWith({ cat: 'dog', foo: ['bar', 'baz'] }) + expect(response1).toBe(13) + expect(response2).toBe(0) }) -}) -describe('CUSTOM ROUTERS/PROPS', () => { - it('allows overloading custom properties via options', () => { - const router = Router({ port: 3001 }) - - expect(router.port).toBe(3001) - }) + it('finally stages that return nothing will not modify response', async () => { + const handler = vi.fn(() => {}) + const router = Router({ + finally: [ + handler, + r => Number(r) || 0, + ], + }).get('/:id?', r => r.params.id) - it('allows overloading custom properties via direct access', () => { - const router = Router() - router.port = 3001 + const response = await router.fetch(toReq('/13')) - expect(router.port).toBe(3001) + expect(response).toBe(13) + expect(handler).toHaveBeenCalled() }) - it('allows overloading custom methods with access to "this"', () => { + it('can introspect/modify before/finally/catch stages finally initialization', async () => { + const handler1 = vi.fn(() => {}) + const handler2 = vi.fn(() => {}) 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 - } + before: [ handler1, handler2 ], + finally: [ handler1, handler2 ], }) - // implement the CustomRouter - const router = CustomRouter() - .get('/', () => 'foo') - .post('/', () => {}) - .addLogging(logger) // we added this! + // manipulate + router.finally.push(() => true) const response = await router.fetch(toReq('/')) - - expect(router.getMethods()).toEqual(['GET', 'POST']) - expect(response).toBe('foo') - expect(logger).toHaveBeenCalled() + expect(router.before.length).toBe(2) + expect(router.finally.length).toBe(3) + expect(response).toBe(true) }) -}) -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) + it('response-handler pollution tests - (createResponse)', async () => { + const router = Router({ + finally: [json] + }).get('/', () => [1,2,3]) + const request = toReq('/') + request.headers.append('foo', 'bar') - expect(await parent.handle(toReq('/'))).toBe('parent') - expect(await parent.handle(toReq('/child/kitten'))).toBe('child') + const response = await router.fetch(request) + const body = await response.json() + expect(response.headers.get('foo')).toBe(null) + expect(body).toEqual([1,2,3]) }) -}) -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) + 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 results = await router.handle(toReq('/')) - expect(h1).toHaveBeenCalled() - expect(h2).toHaveBeenCalled() - expect(h3).toHaveBeenCalled() - expect(results).toBe(true) + const response = await router.fetch(request) + expect(response.headers.get('foo')).toBe(null) + expect(response.status).toBe(500) }) }) -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.ts b/src/Router.ts index ac97b7fc..19c01b16 100644 --- a/src/Router.ts +++ b/src/Router.ts @@ -1,77 +1,31 @@ -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 +import { + Equal, + IRequest, + IttyRouterOptions, + IttyRouterType, + RequestLike, + Route, + RouteHandler, + UniversalRoute +} from './IttyRouter' -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 ResponseHandler = + (response: ResponseType, request: RequestType, ...args: Args) => any + +export type ErrorHandler = + (response: ErrorType, request: RequestType, ...args: Args) => any 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?: RouteHandler[] + catch?: ErrorHandler + finally?: ResponseHandler[] +} & IttyRouterType + +export type RouterOptions = { + before?: RouteHandler[] + catch?: ErrorHandler + finally?: ResponseHandler[] +} & IttyRouterOptions export const Router = < RequestType = IRequest, @@ -84,39 +38,84 @@ export const Router = < // @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 + // @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 } // 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 - } + t: try { + for (let handler of other.before || []) + if ((response = await handler(request.proxy ?? request, ...args)) != null) break t + + // 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 + } + } catch (err: any) { + if (!other.catch) throw err + response = await other.catch(err, request.proxy ?? request, ...args) + } + + try { + for (let handler of other.finally || []) + 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 }, }) + +// const finallyHandler: RouteHandler = (response) => { response.headers } +// const errorHandler: ErrorHandler = (err) => { err.message } + +// const router = Router({ +// finally: [ +// finallyHandler, +// (response: Response) => { response.headers }, +// ], +// catch: errorHandler, +// }) + +// type CustomRequest = { +// foo: string +// } & IRequest + +// router.before = [ +// (r: CustomRequest | IRequest) => { r.foo } +// ] + +// router.get('/', (r) => r.foo) + + diff --git a/src/SharedRouter.spec.ts b/src/SharedRouter.spec.ts new file mode 100644 index 00000000..d719fbb2 --- /dev/null +++ b/src/SharedRouter.spec.ts @@ -0,0 +1,710 @@ +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(r => r), + 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 request = toReq('/passthrough') + const route = routes.find((r) => r.path === '/passthrough') + await router.fetch(request) + + expect(route?.callback).toHaveReturnedWith(request) + }) + + 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/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/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/src/index.ts b/src/index.ts index e9770245..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' diff --git a/src/withContent.ts b/src/withContent.ts index b481dc27..24bb9051 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, { diff --git a/test/index.ts b/test/index.ts index d378aa86..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 }) @@ -36,7 +33,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() 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,