Ecosyste.ms: Awesome

An open API service indexing awesome lists of open source software.

Awesome Lists | Featured Topics | Projects

https://github.com/jrf0110/8track

A service worker router with async middleware and neato type-inference inspired by Koa
https://github.com/jrf0110/8track

Last synced: 3 days ago
JSON representation

A service worker router with async middleware and neato type-inference inspired by Koa

Awesome Lists containing this project

README

        

# 8Track - A Service Worker Router


Wanted: a better logo

> A service worker router with async middleware and neato type-inference inspired by Koa

![./doc/img/screen-1.png](./doc/img/screen-1.png)

#### Installation

```
npm install -S 8track
-- Or yarn
yarn add 8track
```

##### TypeScript

This library is written in TypeScript, so typings are bundled.

#### Basic usage

```typescript
import { Router, handle } from '8track'

const router = new Router()

router.all`(.*)`.use(async (ctx, next) => {
console.log(`Handling ${ctx.event.request.method} - ${ctx.url.pathname}`)
await next()
console.log(`${ctx.event.request.method} - ${ctx.url.pathname}`)
})

router.get`/`.handle((ctx) => ctx.html('Hello, world!'))

router.all`(.*)`.handle((ctx) => ctx.end('Not found', { status: 404 }))

addEventListener('fetch', (event) => handle({ event, router }))
```

## Examples

#### Add CORS headers

```typescript
import { Router } from '8track'

const router = new Router()

router.all`(.*)`.use(async (ctx, next) => {
const allowedOrigins = ['https://www.myorigin.com']
const allowedHeaders = ['Content-type', 'X-My-Custom-Header']
const allowedMethods = ['GET', 'HEAD', 'PUT', 'POST', 'DELETE', 'PATCH']

ctx.response.headers.append('Vary', 'Origin')
ctx.response.headers.append('Access-Control-Allow-Origin', allowedOrigins.join(','))
ctx.response.headers.append('Access-Control-Allow-Headers', allowedHeaders.join(','))
ctx.response.headers.append('Access-Control-Allow-Methods', allowedMethods.join(','))
ctx.response.headers.append('Access-Control-Allow-Credentials', 'true')

if (ctx.req.method === 'OPTIONS') {
return ctx.end('', { status: 204 })
}

await next()
})
```

#### Catch all errors and display error page

![./doc/img/screen-3.png](./doc/img/screen-3.png)

```typescript
import { Router, getErrorPageHTML } from '8track'

const router = new Router()

addEventListener('fetch', (e) => {
const res = router.getResponseForEvent(e).catch(
(error) =>
new Response(getErrorPageHTML(e.request, error), {
status: 500,
headers: {
'Content-Type': 'text/html',
},
}),
)

e.respondWith(res as any)
})
```

#### Attach new properties to each request

Each Middleware and route handler receives a new copy of the ctx object, but a special object under the `data` property is mutable and should be used to share data between handlers

```typescript
interface User {
id: string
name: string
}

// Pretend this is a function that looks up a user by ID
async function getUserById(id: string): Promise {
return null as any
}

// The describes the shape of the shared data each middleware will use
interface RouteData {
user?: User
}

// This middleware attaches the user associated to the route to the request
const getUserMiddleware: Middleware = async (ctx, next) => {
ctx.data.user = await getUserById(ctx.params.userId)
await next()
}

const router = new Router()

// For all user requests, attach the user
router.all`/users/${'userId'}`.use(getUserMiddleware)

router.get`/users/${'userId'}`.handle((ctx) => {
if (!ctx.data.user) return ctx.end('Not found', { status: 404 })
ctx.json(JSON.stringify(ctx.data.user))
})
```

#### Sub-router mounting

```typescript
const apiRouter = new Router()
const usersRouter = new Router()
const userBooksRouter = new Router()

usersRouter.get`/`.handle((ctx) => ctx.end('users-list'))
usersRouter.get`/${'id'}`.handle((ctx) => ctx.end(`user: ${ctx.params.id}`))
userBooksRouter.get`/`.handle((ctx) => ctx.end('books-list'))
userBooksRouter.get`/${'id'}`.handle((ctx) => ctx.end(`book: ${ctx.params.id}`))

usersRouter.all`/${'id'}/books`.use(userBooksRouter)
apiRouter.all`/api/users`.use(usersRouter)
```

## API

### Router

Instantiate a new router

```typescript
const router = new Router<{ logger: typeof console.log }>()
```

#### .getResponseForEvent(request: FetchEvent): Promise | undefined

Given an event, run the matching middleware chain and return the response returned by the chain.

#### Router handlers and middleware

The primary way to interact with the router is to add routes via method tags:

```typescript
router.post`/api/users`.handle((ctx) => ctx.json({ id: 123 }))
```

In the above example, the `post` tag returns a [RouteMatchResult](#routematchresult) object.

##### Method Matchers

Each of these methods returns a [RouteMatchResult](#routematchresult) object.

- .all\`pattern\`
- .get\`pattern\`
- .post\`pattern\`
- .put\`pattern\`
- .patch\`pattern\`
- .delete\`pattern\`
- .head\`pattern\`
- .options\`pattern\`

### RouteMatchResult

When you use a template tag on the router, you create a RouteMatchResult.

```typescript
router.patch`/api/users/${'id'}` // RouteMatchResult
```

The RouteMatchResult object allows you to mount a route handler or a middleware that only runs when the pattern is matched.

```typescript
router.patch`/api/users/${'id'}`.use(async (ctx, next) => {
console.log('Before: User ID', ctx.params.id)
await next()
console.log('After: User ID', ctx.params.id)
})
```

#### .handle((ctx: Context) => any)

Mount a route handler that should return an instance of Response

#### .use((ctx: Context, next: () => Promise) => any)

Mount a route middleware that can optionally terminate the chain early and handle the request.

```typescript
router.patch`/api/users/${'id'}`.use(async (ctx, next) => {
console.log('Before: User ID', ctx.params.id)

if (ctx.params.id === '123') {
return ctx.end(Response.redirect(302))
}

await next()

console.log('After: User ID', ctx.params.id)
})
```

### Context

Each route handler and middleware receives an instance of `Context`.

#### Context Properties

- `readonly event: FetchEvent`
- `readonly params: Params`
- `response: Response`
- `data: Data`
- `url: URL`

#### Context Methods

- `end(body: string | ReadableStream | Response | null, responseInit: ResponseInit = {})`
- `html(body: string | ReadableStream, responseInit: ResponseInit = {})`
- `json(body: any, responseInit: ResponseInit = {})`

##### What's up with that weird syntax?

8track uses a JavaScript feature called [tagged templates](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Template_literals#Tagged_templates) in order to extract parameter names from url patterns. TypeScript is able extract types from tagged template literals:

```typescript
const bar = 123
const baz = new Date()

// Extracted type here is a tuple [number, Date]
foo`testing: ${bar} - ${baz} cool`

// But things get interesting when using literal types
// Extracted type here is a tuple ['bar', 'baz']
foo`testing: ${'bar'} - ${'baz'} cool`
```

Since the template literal is able to extract a tuple whose types are the _literal values_ passed in, we can utilize generics to describe the shape of the route parameters:

![./doc/img/screen-2.png](./doc/img/screen-2.png)

## Built-in Middleware

### KV Static

Serves files from Cloudflare KV.

```typescript
import { Router, kvStatic } from '8track'

const router = new Router()

router.all`(.*)`.use(kvStatic({ kv: myKvNamespaceVar, maxAge: 24 * 60 * 60 * 30 }))
```

## Deploying your worker

8track comes with a CLI to upload your worker and sync your kv files. In order to use 8track's kv [static file middleware](#kv-static), you must upload your files using this CLI.

Add a deploy script to your package.json:

```javascript
{
"scripts": {
"deploy": "8track deploy --worker dist/worker.js --kv-files dist/client.js,dist/client.css"
}
}
```

**Note**: This does not support globs yet!

You'll need the following environment variables set:

```bash
# Your Cloudflare API Token
CF_KEY
# Your Cloudflare account email
CF_EMAIL
# Your Cloudflare account ID
CF_ID
# The ID of the namespace
KV_NAMESPACE_ID
# The name of the KV namespace you want to use
KV_NAMESPACE
# The variable name your KV namespace is bound to
KV_VAR_NAME
```