diff --git a/README.md b/README.md index 14577dc5..ff0139ce 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,10 @@ npm install itty-router - [x] bonus query parsing (e.g. `?page=3`) - [x] adds params & query to request: `{ params: { foo: 'bar' }, query: { page: '3' }}` - [x] chainable route declarations (why not?) -- [x] multiple (sync or async) handlers per route for passthrough logic, auth, errors, etc +- [x] multiple (sync or async) [middleware handlers](#multiple-route-handlers-as-middleware) per route for passthrough logic, auth, errors, etc +- [x] handler functions "stop" at the first handler to return [anything] +- [x] supports [nested routers](#nested-routers) +- [x] supports [base path](#base-path) option to prefix all routes - [ ] have pretty code (yeah right...) # Examples @@ -40,7 +43,7 @@ const router = Router() // basic GET route router.get('/todos/:id', console.log) - + // first match always wins, so be careful with order of registering routes router .get('/todos/oops', () => console.log('you will never see this, thanks to upstream /todos/:id')) @@ -64,7 +67,7 @@ router.handle({ method: 'GET', url: 'https://foo.com/todos/13?foo=bar' }) // } ``` -# Usage +# Usage ### 1. Create a Router ```js import { Router } from 'itty-router' @@ -80,7 +83,7 @@ The "instantiated" router translates any attribute (e.g. `.get`, `.post`, `.patc router.get('/todos/:user/:item?', (req) => { let { params, query, url } = req let { user, item } = params - + console.log('GET TODOS from', url, { user, item }) }) ``` @@ -154,49 +157,75 @@ router router.handle({ url: 'https://example.com/user' }) // --> STATUS 200: { name: 'Mittens', age: 3 } ``` +### Nested Routers +```js + const parentRouter = Router() + const todosRouter = Router() + + todosRouter.get('/todos/:id?', ({ params }) => console.log({ id: params.id })) + + parentRouter.get('/todos/*', todosRouter.handle) // all /todos/* routes will route through the todosRouter +``` + +### Base Path +```js + const router = Router({ base: '/api/v0' }) + + router.get('/todos', indexHandler) + router.get('/todos/:id', itemHandler) + + router.handle({ url: 'https://example.com/api/v0/todos' }) // --> fires indexHandler +``` + + ## Testing & Contributing 1. fork repo 2. add code -3. run tests (and add your own) `yarn test` -4. submit PR -5. profit +3. run tests (add your own if needed) `yarn dev` +4. verify tests run once minified `yarn verify` +5. commit files (do not manually modify version numbers) +6. submit PR +7. we'll add you to the credits :) ## Entire Router Code (latest...) ```js -const Router = () => +const Router = (o = {}) => new Proxy({}, { - get: (o, k) => k === 'handle' + get: (t, k) => k === 'handle' ? async (q) => { - for ([p, hs] of o[(q.method || 'GET').toLowerCase()] || []) { - if (m = (u = new URL(q.url)).pathname.match(p)) { - q.params = m.groups - q.query = Object.fromEntries(u.searchParams.entries()) - - for (h of hs) { - if ((s = await h(q)) !== undefined) return s + for ([p, hs] of t[(q.method || 'GET').toLowerCase()] || []) { + if (m = (u = new URL(q.url)).pathname.match(p)) { + q.params = m.groups + q.query = Object.fromEntries(u.searchParams.entries()) + + for (h of hs) { + if ((s = await h(q)) !== undefined) return s + } } } } - } - : (p, ...hs) => (o[k] = o[k] || []).push([ - `^${p - .replace('*', '.*') - .replace(/(\/:([^\/\?]+)(\?)?)/gi, '/$3(?<$2>[^/]+)$3')}$`, - hs - ]) && o + : (p, ...hs) => + (t[k] = t[k] || []).push([ + `^${(o.base || '')+p + .replace(/(\/?)\*/g, '($1.*)?') + .replace(/(\/:([^\/\?]+)(\?)?)/gi, '/$3(?<$2>[^/]+)$3') + }$`, + hs + ]) && t }) ``` ## Special Thanks -This repo goes out to my past and present colleagues at Arundo - who have brought me such inspiration, fun, -and drive over the last couple years. In particular, the absurd brevity of this code is thanks to a -clever [abuse] of `Proxy`, courtesy of the brilliant [@mvasigh](https://github.com/mvasigh). -This trick allows methods (e.g. "get", "post") to by defined dynamically by the router as they are requested, +This repo goes out to my past and present colleagues at Arundo - who have brought me such inspiration, fun, +and drive over the last couple years. In particular, the absurd brevity of this code is thanks to a +clever [abuse] of `Proxy`, courtesy of the brilliant [@mvasigh](https://github.com/mvasigh). +This trick allows methods (e.g. "get", "post") to by defined dynamically by the router as they are requested, **drastically** reducing boilerplate. ## Changelog Until this library makes it to a production release of v1.x, **minor versions may contain breaking changes to the API**. After v1.x, semantic versioning will be honored, and breaking changes will only occur under the umbrella of a major version bump. +- **v1.1.0** - feature: added single option `{ base: '/some/path' }` to `Router` for route prefixing, fix: trailing wildcard issue (e.g. `/foo/*` should match `/foo`) - **v1.0.0** - production release, stamped into gold from x0.9.7 - **v0.9.0** - added support for multiple handlers (middleware) - **v0.8.0** - deep minification pass and build steps for final module diff --git a/package.json b/package.json index 055fe54f..b1df8dac 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ ], "scripts": { "test": "jest --verbose --coverage", + "verify": "yarn build && yarn test", "dev": "yarn test - --watch", "coveralls": "cat ./coverage/lcov.info | node node_modules/.bin/coveralls", "prerelease": "echo releasing...", diff --git a/prebuild.js b/prebuild.js index 149b1716..fb0a26a6 100644 --- a/prebuild.js +++ b/prebuild.js @@ -1,16 +1,18 @@ const { readFileSync, writeFileSync } = require('fs-extra') const base = readFileSync('./src/itty-router.js', { encoding: 'utf-8' }) -const minifiedBase = base.replace(/\bhandlers\b/g, 'hs') - .replace(/\bhandler\b/g, 'h') - .replace(/([^\.])obj\b/g, '$1o') - .replace(/([^\.])attr\b/g, '$1a') - .replace(/([^\.])route\b/g, '$1p') - .replace(/([^\.])request\b/g, '$1q') - .replace(/([^\.])response\b/g, '$1s') - .replace(/([^\.])match\b/g, '$1m') - .replace(/([^\.])prop\b/g, '$1k') - .replace(/([^\.])url\b/g, '$1u') +const minifiedBase = base + .replace(/\bhandlers\b/g, 'hs') // Handler(S) + .replace(/\bhandler\b/g, 'h') // Handler + .replace(/([^\.])obj\b/g, '$1t') // Target + .replace(/([^\.])options\b/g, '$1o') // Options + .replace(/([^\.])attr\b/g, '$1a') // Attr + .replace(/([^\.])route\b/g, '$1p') // Path + .replace(/([^\.])request\b/g, '$1q') // reQuest + .replace(/([^\.])response\b/g, '$1s') // reSponse + .replace(/([^\.])match\b/g, '$1m') // Match + .replace(/([^\.])prop\b/g, '$1k') // Key + .replace(/([^\.])url\b/g, '$1u') // Url writeFileSync('./dist/itty-router.js', minifiedBase) console.log('minifying variables --> dist/itty-router.js') diff --git a/src/itty-router.js b/src/itty-router.js index 54df3f57..e84c6ff7 100644 --- a/src/itty-router.js +++ b/src/itty-router.js @@ -1,26 +1,26 @@ -const Router = () => +const Router = (options = {}) => new Proxy({}, { - get: (obj, prop) => prop === 'handle' + get: (obj, prop) => prop === 'handle' ? async (request) => { - for ([route, handlers] of obj[(request.method || 'GET').toLowerCase()] || []) { - if (match = (url = new URL(request.url)).pathname.match(route)) { // route matched - request.params = match.groups - request.query = Object.fromEntries(url.searchParams.entries()) + for ([route, handlers] of obj[(request.method || 'GET').toLowerCase()] || []) { + if (match = (url = new URL(request.url)).pathname.match(route)) { + request.params = match.groups + request.query = Object.fromEntries(url.searchParams.entries()) - for (handler of handlers) { - if ((response = await handler(request)) !== undefined) return response + for (handler of handlers) { + if ((response = await handler(request)) !== undefined) return response + } } } } - } - : (route, ...handlers) => - (obj[prop] = obj[prop] || []).push([ - `^${route - .replace('*', '.*') - .replace(/(\/:([^\/\?]+)(\?)?)/gi, '/$3(?<$2>[^/]+)$3')}$`, - handlers - ]) && obj - } - ) + : (route, ...handlers) => + (obj[prop] = obj[prop] || []).push([ + `^${(options.base || '')+route + .replace(/(\/?)\*/g, '($1.*)?') + .replace(/(\/:([^\/\?]+)(\?)?)/gi, '/$3(?<$2>[^/]+)$3') + }$`, + handlers + ]) && obj + }) module.exports = { Router } diff --git a/src/itty-router.spec.js b/src/itty-router.spec.js index d9d8eacb..b28bae85 100644 --- a/src/itty-router.spec.js +++ b/src/itty-router.spec.js @@ -29,10 +29,16 @@ describe('Router', () => { { path: '*', callback: jest.fn(), method: 'get' }, ] - for (var route of routes) { - router[route.method](route.path, route.callback) + const applyRoutes = (router, routes) => { + for (var 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') }) @@ -144,7 +150,7 @@ describe('Router', () => { } const handler = jest.fn((req) => req.user.id) - + r.get('/middleware/*', middleware) r.get('/middleware/:id', handler) @@ -153,5 +159,57 @@ describe('Router', () => { expect(handler).toHaveBeenCalled() expect(handler).toHaveReturnedWith(13) }) + + it('can accept a basepath for routes', async () => { + const router = Router({ base: '/api' }) + const handler = jest.fn() + router.get('/foo/:id?', handler) + + router.handle(buildRequest({ path: '/api/foo' })) + expect(handler).toHaveBeenCalled() + + router.handle(buildRequest({ path: '/api/foo/13' })) + expect(handler).toHaveBeenCalledTimes(2) + }) + + it('gracefully handles trailing slashes', async () => { + const r = Router() + + const middleware = req => { + req.user = { id: 13 } + } + + const handler = jest.fn((req) => req.user.id) + + r.get('/middleware/*', middleware) + r.get('/middleware', handler) + + await r.handle(buildRequest({ path: '/middleware' })) + + expect(handler).toHaveBeenCalled() + expect(handler).toHaveReturnedWith(13) + }) + + it('allow wildcards in the middle of paths', async () => { + const r = Router() + const handler = jest.fn() + + r.get('/foo/*/end', handler) + + await r.handle(buildRequest({ path: '/foo/bar/baz/13/end' })) + + expect(handler).toHaveBeenCalled() + }) + + it('can handle nested routers', async () => { + const router1 = Router({ base: '/api' }) + const router2 = Router({ base: '/api/foo' }) + const handler = jest.fn() + router1.get('/foo/*', router2.handle) + router2.get('/bar/:id?', handler) + + router1.handle(buildRequest({ path: '/api/foo/bar' })) + expect(handler).toHaveBeenCalled() + }) }) })