unrouted
unrouted copied to clipboard
Unrouted is a minimal, composable router built for speed, portability and DX
Status: Public Preview - In Development 🔨 Made possible by my Sponsor Program 💖 Follow me @harlan_zw 🐦 |
Features
- 🤝 Portable Powered by h3, supporting Serverless, Workers, and Node.js workers.
- 🌳 Fast Param Routing radix3 named params (
/user/:id
,/user/{id}
and/user/**
) - 🧩 Composable Design Utility functions for defining your api, handling requests and serving responses
- ✅ Built to Test Testing utility package provided:
@unrouted/test-kit
using supertest - 🐱 Pluggable hookable hooks, preset and plugin system.
- 🎮 Controller Support Create complex API architectures using controller pattern
Node Preset
- 🇹 Fetch Payload Types Automatic type definitions for your routes
API Preset
- 🏖️ Easy Prototyping cors enabled by default, easy debugging with consola and composable utility for sirv
Getting Started
- Add the dependency.
# NPM
npm install unrouted
# or Yarn
yarn add unrouted
# or PNPM
pnpm add unrouted
- Create the Unrouted instance.
import { createUnrouted } from 'unrouted'
// ...
async function createApi() {
const { setup, handle } = await createUnrouted({
// options
})
}
Creating unrouted will return the Unrouted Context. To get your API setup, you need to make use of two functions: setup and handle.
- Create your routes using composable functions, within setup (setup optional).
import { createUnrouted, get } from 'unrouted'
// ...
async function createApi() {
const { setup, app } = await createUnrouted({
// options
})
await setup(() => {
get('/', 'hello world')
})
}
Note: The setup
function ensures the unrouted context is used by the utility functions and lets us perform
hooks on the final routes provided by your API, such as generating types.
- Tell your server to handle the request using
handle
.
import { createUnrouted, get } from 'unrouted'
// ...
async function createApi() {
const { setup, app } = await createUnrouted({
// options
})
await setup(() => {
get('/', 'hello world')
})
// app could be h3, koa, connect, express servers
app.use(app.nodeHandler)
}
Setup Examples
Using listhen and h3.
import { createUnrouted, get } from 'unrouted'
import { createApp } from 'h3'
import { listen } from 'listhen'
async function createApi() {
// ctx is the unrouted context
const { setup, app } = await createUnrouted({
// options
})
await setup(() => {
get('/', 'hello world')
})
return app
}
async function boot() {
const app = createApp()
app.use(await createApi())
listen(app)
}
boot().then(() => {
console.log('Ready!')
})
Using connect.
import { createUnrouted, get } from 'unrouted'
import createConnectApp from 'connect'
async function createApi() {
// ctx is the unrouted context
const { setup, app } = await createUnrouted({
// options
})
await setup(() => {
get('/', 'hello world')
})
return app.nodeHandler
}
async function boot() {
const app = createConnectApp()
app.use(await createApi())
}
boot().then(() => {
console.log('Ready!')
})
Using express.
import { createUnrouted, get } from 'unrouted'
import createExpressApp from 'express'
async function createApi() {
// ctx is the unrouted context
const { setup, app } = await createUnrouted({
// options
})
await setup(() => {
get('/hello-world', 'api is working')
post('/contact', () => {
const { email } = useBody<{ email: string }>()
return {
success: true,
email,
}
})
})
return app.nodeHandler
}
async function boot() {
const app = createExpressApp()
app.use(await createApi())
}
boot().then(() => {
console.log('Ready!')
})
Guides
Using Presets
Using Controllers
Writing your API
Composables
Verbs
-
get(path: string, res)
- GET route -
post(path: string, res)
- POST route -
put(path: string, res)
- PUT route -
del(path: string, res)
- DELETE route -
head(path: string, res)
- HEAD route -
options(path: string, res)
- OPTIONS route -
any(path: string, res)
- Matches any HTTP method -
match(method: string, path: string, res)
- Matches a specific HTTP method, useful for dynamic method matching
Response Utils
-
permanentRedirect(path: string, toPath: string)
- Performs a permanent redirect -
redirect(path: string, toPath: string, statusCode: number = 302)
- Performs a temproary redirect by default, you can change the status code
Grouping utils
-
group(prefix: string, () => void)
- Allows you to group composables under a specific prefix -
middleware(prefix: string, () => void)
- Allows you to group composables under a specific prefix -
prefix(prefix: string, () => void)
- Allows you to group composables under a specific prefix
Node only
-
serve(path: string, dirname: string, sirvOptions: Options = {})
- Serve static content using sirv
res
is a function similar to standard middleware.
get('/', (request: IncomingMessage, res: ServerResponse) => {
return 'hello world'
})
Since Unrouted is composable, you may not need to use these arguments.
get('/', 'hello world')
You can return the following as a primitive or as an async / sync function which returns a primitive:
-
string|boolean
- Will be assumed an HTML response and set the content-type to text/html -
number
- Will be assumed a status code -
object
- Will be assumed a JSON response and set the content-type to application/json -
void
- You can modify theServerResponse
directly and return nothing
// text/html -> 'api is working' - 200
get('/hello-world', 'api is working')
// application/json -> { success: true, time: 1245456789 } - 200
post('/time', () => {
return {
success: true,
time: new Date().toTimeString(),
}
})
get('/secret-zone', async (req, res) => {
const authenticated = await authenticate()
// Example where we use the response directly
if (!authenticated) {
res.statusCode = 401
res.end()
// we can return void here
return
}
// using the request directly
if (!authenticated && req.headers['x-secret-token'] !== 'secret') {
// can simply return an integer as the status code response
return 401
}
return {
success: true,
message: 'Welcome to the secret zone!',
}
})
API Examples
Setup
Use of the setup
function is optional.
By defining all of your routes in a predictable way unrouted is able
to provide runtime enhancements through the hooks' system, such as generating types.
For example plugins can make use of the defined routes as:
const { hooks } = useUnrouted()
hooks.hook('setup:after', (ctx) => {
// ctx.routes contains all of the routes defined in the setup function
})
Handling requests and responses
The two main functions you'll use are useBody
and useParams
, both are provided as composables with generics.
Body and Params example
interface User {
name: string
age: number
}
post('/user/:name', () => {
const { name } = useParams<{ name: string }>()
const { age } = useBody<User>()
// ...
return {
success: true,
user: {
name,
age
}
}
})
const { name } = useBody<{ name: string }>()
// ts works, name is a string
console.log(name.toUpperCase())
Note: Unrouted does not come with validation.
Most functions provided by h3 are exposed on unrouted
as composable utilities.
See the h3 docs for more details.
Request Utils
-
useRequest()
- Returns the request object -
useRawBody(encoding?: string)
- Reads the raw body of the request -
useQuery<T>()
- Reads the query string of the request, has generics support -
useMethod(defaultMethod?: string)
- Reads the HTTP method of the request -
isMethod(method: string)
- Checks if the request method is the same as the provided method -
assertMethod(method: string)
- Asserts that the request method is the same as the provided method -
useCookies()
- Reads the cookies of the request -
useCookies(name: string)
- Reads a specific cookie of the request
Response Utils
-
useResponse()
- Returns the response object -
setCookie(name: string, value: string, serializeOptions?: any)
- Sets cookie on the response -
sendRedirect(path: string, statusCode?: number)
- Performs a redirect -
setStatusCode(statusCode: number)
- Sets the status code of the response -
sendError(error: Error | H3Error)
- Sends an error response -
appendHeader(name: string, value: string)
- Appends a header to the response
Extending composables
If you'd like to create your own composable utility functions,
you can use the low-level registerRoute
or use the existing composable functions.
Examples
Using registerRoute
we create a new composable function to deny certain paths.
export function deny(route: string) {
registerRoute('*', route, () => {
setStatusCode(400)
return {
success: false,
error: 'you\'re not allowed here'
}
})
}
// ...
deny('/private-zone/**')
We can build on top of existing composable functions to create more complex utilities.
export function resource(route: string, factory) {
get(route, factory.getAll)
group(`${route}/:id`, () => {
get('/', factory.getResource)
post('/', factory.saveResource)
del('/', factory.deleteResource)
})
}
// ...
resource('/posts', factory)
Using test-kit with auto-completion
Unrouted comes with package called @unrouted/test-kit
which provides a simple way to write tests that make use of
generated types.
- Add the dependency
npm install -D @unrouted/test-kit
- Have Unrouted generate types
import { createUnrouted } from 'unrouted'
await createUnrouted({
// dev should be dynamic, must be on to generate types
dev: true,
generateTypes: true,
// Optional: if you want to change the output directory of the routes
root: join(__dirname, '__routes__')
})
Now when your code next runs the setup function, the route definitions will be generated.
- Use the test-kit to write tests
Here we bootstrap Unrouted on our server (such as connect) and create a request
instance which we'll use to test.
import { test } from '@unrouted/test-kit'
// this should point to your routes
import { RequestPathSchema } from '../../routes.d.ts'
// createApi is a function which builds the api and returns the handle function
const api = await createApi({ debug: true })
// tell our server to use the api
app.use(api)
// create a test request instance
const request = testKit<RequestPathSchema>(app)
Now you can start testing. See supertest documentation for further testing instructions.
// /hello-world is autocompleted
request.get('/hello-world')
Unrouted functions
-
createUnrouted
- Create the unrouted instance -
defineConfig
- Define unrouted config -
defineUnroutedPlugin
- Define an unrouted plugin -
defineUnroutedPreset
- Define an unrouted preset -
useUnrouted
- Use the global unrouted instance
Hooks
-
setup:before: (ctx: UnroutedContext) => HookResult;
Called before the setup()
function starts. No routes are available yet.
-
setup:after: (ctx: UnroutedContext) => HookResult
Called after the setup()
function is finished. At this point, routes are normalised and registered.
-
setup:routes: (routes: Route[]) => HookResult
Called when hooks are normalised, can be used to transform the hooks before they are registered to the router.
-
request:payload: (ctx: PayloadCtx) => HookResult
When the payload is resolved from your routes.
-
request:lookup:before
: (requestPath: string) => HookResult;
Before the radix3 router is used to look up the route path.
-
request:error:404
: (requestPath: string, req: IncomingMessage) => HookResult;
By default, unrouted, does not handle 404s; this lets you handle it.
Example
import { useUnrouted } from 'unrouted'
const { hooks } = useUnrouted()
hooks.hook('setup:before', () => {
console.log('before setup')
})
Configuration
You can provide configuration to the createUnrouted
function directly, provide a unrouted.config.ts
file or link
a configuration file using configFile
.
prefix
-
Type:
string
-
Default:
/
All routes will be served from this prefix.
name
-
Type:
string
- Default: ``
Setting a name for the unrouted context will allow you to generate contextual types and have custom scoped debugging logs.
If you only plan to have a single instance of Unrouted, this will likely not be needed.
debug
-
Type:
boolean
-
Default:
false
Displays debug logs on the bootstrapping and request life cycles.
dev
-
Type:
boolean
-
Default:
false
Setting the dev
mode to true allows unrouted to generate types.
root
-
Type:
string
-
Default:
process.cwd()
Specify the root where we're running things. This is used for type generation and config loading.
configFile
-
Type:
string
-
Default:
unrouted.config.js
Specify the location of a config file.
presets
-
Type:
ResolvedPlugin[]
-
Default:
[]
plugins
-
Type:
ResolvedPlugin[]
-
Default:
[]
middleware
-
Type:
Middleware[]|Handle[]
-
Default:
[]
hooks
-
Type:
UnroutedHooks
-
Default:
{}
Types
Unrouted Context
export interface UnroutedContext {
/**
* Runtime configuration for the current prefix path.
*/
prefix: string
/**
* Resolved configuration.
*/
config: ResolvedConfig
/**
* Function used to handle a request for the Unrouted instance.
* This should be passed to a server such as h3, connect, express, koa, etc.
*/
handle: HandleFn
/**
* A flat copy of the normalised routes being used.
*/
routes: Route[]
/**
* The routes grouped by method, this is internally used by the handle function for quicker lookups.
*/
methodStack: Record<HttpMethod, (RadixRouter<Route> | null)>
/**
* The logger instance. Will be Consola if available, otherwise console.
*/
logger: Consola | Console
/**
* The hookable instance, allows hooking into core functionality.
*/
hooks: UnroutedHookable
/**
* Composable setup function for declaring routes.
* @param fn
*/
setup: (fn: () => void) => Promise<void>
}
Sponsors
License
MIT License © 2022 Harlan Wilton