├── .eslintignore ├── src ├── index.ts ├── types.ts └── pico.ts ├── .gitignore ├── tsconfig.build.json ├── examples ├── tsconfig.json └── worker.ts ├── .prettierrc ├── .vscode └── settings.json ├── jest.config.cjs ├── tsconfig.json ├── LICENSE ├── .eslintrc.js ├── package.json ├── README.md └── test └── pico.test.ts /.eslintignore: -------------------------------------------------------------------------------- 1 | dist -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { Pico } from './pico' 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | *.tgz 4 | yarn-error.log 5 | sandbox -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": [ 4 | "src/**/*.ts", 5 | "src/**/*.d.ts", 6 | ], 7 | } -------------------------------------------------------------------------------- /examples/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "types": [ 4 | "@cloudflare/workers-types", 5 | ], 6 | }, 7 | "include": [ 8 | "*.ts", 9 | ], 10 | } -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "trailingComma": "es5", 4 | "tabWidth": 2, 5 | "semi": false, 6 | "singleQuote": true, 7 | "jsxSingleQuote": true, 8 | "endOfLine": "lf" 9 | } 10 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "eslint.validate": [ 3 | "javascript", 4 | "javascriptreact", 5 | "typescript", 6 | "typescriptreact" 7 | ], 8 | "editor.codeActionsOnSave": { 9 | "source.fixAll.eslint": true 10 | } 11 | } -------------------------------------------------------------------------------- /jest.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | testMatch: ['**/test/**/*.+(ts|tsx)', '**/src/**/(*.)+(spec|test).+(ts|tsx)'], 3 | transform: { 4 | '^.+\\.(ts|tsx)$': 'esbuild-jest', 5 | }, 6 | testPathIgnorePatterns: ['./examples'], 7 | testEnvironment: 'miniflare', 8 | } 9 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "declaration": true, 5 | "moduleResolution": "Node", 6 | "module": "ESNext", 7 | "lib": [ 8 | "ESNext" 9 | ], 10 | "types": [ 11 | "@cloudflare/workers-types", 12 | "jest" 13 | ], 14 | "rootDir": "./src/", 15 | "outDir": "./dist/", 16 | }, 17 | "include": [ 18 | "src/**/*.ts", 19 | "src/**/*.d.ts", 20 | "src/**/*.test.ts" 21 | ], 22 | } -------------------------------------------------------------------------------- /examples/worker.ts: -------------------------------------------------------------------------------- 1 | import { Pico } from '../src/index' 2 | 3 | const router = Pico() 4 | 5 | router.get('/', () => new Response('Hello Pico!')) 6 | router.get('/entry/:id', ({ result }) => { 7 | const { id } = result.pathname.groups 8 | return Response.json({ 'your id is': id }) 9 | }) 10 | router.get('/money', () => new Response('Payment required', { status: 402 })) 11 | 12 | router.all('*', () => new Response('Not Found', { status: 404 })) 13 | 14 | export default router 15 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | export type Handler = (c: C) => Response | Promise 2 | 3 | export type C = { 4 | req: Request 5 | env: {} 6 | executionContext: ExecutionContext 7 | result: URLPatternURLPatternResult 8 | } 9 | 10 | export type Route = { 11 | p: URLPattern 12 | m: string 13 | h: Handler 14 | } 15 | 16 | export type Fetch = ( 17 | req: Request, 18 | env?: {}, 19 | executionContext?: ExecutionContext 20 | ) => Response | Promise 21 | 22 | export type PicoType = { 23 | routes: Route[] 24 | fetch: Fetch 25 | on: (method: string, path: string, handler: Handler) => void 26 | } & Methods 27 | 28 | type MethodHandler = (path: string, handler: Handler) => PicoType 29 | 30 | type Methods = { 31 | all: MethodHandler 32 | get: MethodHandler 33 | put: MethodHandler 34 | post: MethodHandler 35 | delete: MethodHandler 36 | head: MethodHandler 37 | patch: MethodHandler 38 | option: MethodHandler 39 | } 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 - present Yusuke Wada 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/pico.ts: -------------------------------------------------------------------------------- 1 | import type { PicoType, Route, Fetch, Handler } from './types' 2 | 3 | export const Pico = (): PicoType => { 4 | const routes: Route[] = [] 5 | const f: { 6 | fetch: Fetch 7 | on: (method: string, path: string, handler: Handler) => void 8 | } = { 9 | fetch: (req, env, executionContext) => { 10 | const m = req.method 11 | for (let i = 0, len = routes.length; i < len; i++) { 12 | const route = routes[i] 13 | const result = route.p.exec(req.url) 14 | if ((result && route.m === 'ALL') || (result && route.m === m)) 15 | return route.h({ 16 | req, 17 | env, 18 | executionContext, 19 | result, 20 | }) 21 | } 22 | }, 23 | on: (method, path, handler) => { 24 | routes.push({ 25 | p: new URLPattern({ 26 | pathname: path, 27 | }), 28 | m: method.toUpperCase(), 29 | h: handler, 30 | }) 31 | }, 32 | } 33 | const p = new Proxy({} as PicoType, { 34 | get: 35 | (_, prop: string, receiver) => 36 | (...args: unknown[]) => { 37 | if (prop === 'fetch' || prop === 'on') { 38 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 39 | // @ts-ignore 40 | return f[prop](...args) 41 | } 42 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 43 | // @ts-ignore 44 | f['on'](prop, ...args) 45 | return receiver 46 | }, 47 | }) 48 | 49 | return p as PicoType 50 | } 51 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | const { defineConfig } = require('eslint-define-config') 2 | 3 | module.exports = defineConfig({ 4 | root: true, 5 | extends: [ 6 | 'eslint:recommended', 7 | 'plugin:node/recommended', 8 | 'plugin:@typescript-eslint/recommended', 9 | 'prettier', 10 | ], 11 | parser: '@typescript-eslint/parser', 12 | parserOptions: { 13 | sourceType: 'module', 14 | ecmaVersion: 2021, 15 | }, 16 | plugins: ['@typescript-eslint', 'import'], 17 | globals: { 18 | fetch: false, 19 | Response: false, 20 | Request: false, 21 | addEventListener: false, 22 | }, 23 | rules: { 24 | quotes: ['error', 'single'], 25 | semi: ['error', 'never'], 26 | 'no-debugger': ['error'], 27 | 'no-empty': ['warn', { allowEmptyCatch: true }], 28 | 'no-process-exit': 'off', 29 | 'no-useless-escape': 'off', 30 | 'prefer-const': [ 31 | 'warn', 32 | { 33 | destructuring: 'all', 34 | }, 35 | ], 36 | '@typescript-eslint/ban-types': [ 37 | 'error', 38 | { 39 | types: { 40 | Function: false, 41 | '{}': false, 42 | }, 43 | }, 44 | ], 45 | 'sort-imports': 0, 46 | 'import/order': [2, { alphabetize: { order: 'asc' } }], 47 | 48 | 'node/no-missing-import': 'off', 49 | 'node/no-missing-require': 'off', 50 | 'node/no-deprecated-api': 'off', 51 | 'node/no-unpublished-import': 'off', 52 | 'node/no-unpublished-require': 'off', 53 | 'node/no-unsupported-features/es-syntax': 'off', 54 | 55 | '@typescript-eslint/no-empty-function': ['error', { allow: ['arrowFunctions'] }], 56 | '@typescript-eslint/no-empty-interface': 'off', 57 | '@typescript-eslint/no-inferrable-types': 'off', 58 | '@typescript-eslint/no-var-requires': 'off', 59 | '@typescript-eslint/consistent-type-imports': ['error', { prefer: 'type-imports' }], 60 | }, 61 | }) 62 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@picojs/pico", 3 | "description": "Ultra-tiny router for Cloudflare Workers and Deno", 4 | "version": "0.3.2", 5 | "types": "dist/index.d.ts", 6 | "module": "dist/index.js", 7 | "type": "module", 8 | "files": [ 9 | "dist" 10 | ], 11 | "scripts": { 12 | "test": "jest", 13 | "esbuild": "esbuild --bundle --minify ./src/index.ts --outdir=./dist --format=esm --tsconfig=tsconfig.build.json", 14 | "build": "rimraf dist && yarn esbuild && yarn tsc", 15 | "tsc": "tsc --emitDeclarationOnly --declaration", 16 | "lint": "eslint -c .eslintrc.js src/**.ts", 17 | "lint:fix": "eslint -c .eslintrc.js src/**.ts --fix", 18 | "prerelease": "yarn build", 19 | "release": "np" 20 | }, 21 | "author": "Yusuke Wada (https://github.com/yusukebe)", 22 | "license": "MIT", 23 | "repository": { 24 | "type": "git", 25 | "url": "https://github.com/yusukebe/pico.git" 26 | }, 27 | "publishConfig": { 28 | "registry": "https://registry.npmjs.org", 29 | "access": "public" 30 | }, 31 | "homepage": "https://github.com/yusukebe/pico", 32 | "keywords": [ 33 | "web", 34 | "framework", 35 | "router", 36 | "cloudflare", 37 | "workers", 38 | "deno" 39 | ], 40 | "devDependencies": { 41 | "@cloudflare/workers-types": "^4.20230419.0", 42 | "@types/jest": "^29.2.3", 43 | "@types/node": "^18.11.9", 44 | "@typescript-eslint/eslint-plugin": "^5.44.0", 45 | "@typescript-eslint/parser": "^5.44.0", 46 | "esbuild": "^0.15.15", 47 | "esbuild-jest": "^0.5.0", 48 | "eslint": "^8.28.0", 49 | "eslint-config-prettier": "^8.5.0", 50 | "eslint-define-config": "^1.12.0", 51 | "eslint-plugin-import": "^2.26.0", 52 | "eslint-plugin-node": "^11.1.0", 53 | "jest": "^29.3.1", 54 | "jest-environment-miniflare": "^2.11.0", 55 | "np": "^7.7.0", 56 | "prettier": "^2.8.0", 57 | "rimraf": "^3.0.2", 58 | "typescript": "^4.9.3" 59 | }, 60 | "engines": { 61 | "node": ">=16.0.0" 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Pico 2 | 3 | [![Version](https://img.shields.io/npm/v/@picojs/pico.svg)](https://npmjs.com/package/@picojs/pico) 4 | [![Bundle Size](https://img.shields.io/bundlephobia/min/@picojs/pico)](https://bundlephobia.com/result?p=@picojs/pico) 5 | [![Bundle Size](https://img.shields.io/bundlephobia/minzip/@picojs/pico)](https://bundlephobia.com/result?p=@picojs/pico) 6 | 7 | Pico is an ultra-tiny ([~400 bytes](https://bundlephobia.com/package/@picojs/pico) compressed) router using `URLPattern`. 8 | Pico works on Cloudflare Workers and Deno. 9 | 10 | **This project is still experimental. The API might be changed.** 11 | 12 | ## Install 13 | 14 | ``` 15 | npm i @picojs/pico 16 | // Or 17 | yarn add @picojs/pico 18 | ``` 19 | 20 | Install `@cloudflare/workers-types` for supporting the types. 21 | 22 | ``` 23 | npm i -D @cloudflare/workers-types 24 | // Or 25 | yarn add -D @cloudflare/workers-types 26 | ``` 27 | 28 | ## Example 29 | 30 | ```ts 31 | // index.ts 32 | import { Pico } from '@picojs/pico' 33 | 34 | // create a router object, `new` is not needed 35 | const router = Pico() 36 | 37 | // handle a GET request and return a TEXT response 38 | router.get('/', (c) => new Response('Hello Pico!')) 39 | 40 | // capture path parameters and return a JSON response 41 | router.post('/entry/:id', ({ result }) => { 42 | const { id } = result.pathname.groups 43 | return Response.json({ 44 | 'your id is': id, 45 | }) 46 | }) 47 | 48 | // return a primitive Response object 49 | router.get('/money', () => new Response('Payment required', { status: 402 })) 50 | 51 | // capture path parameters with RegExp 52 | router.get('/post/:date(\\d+)/:title([a-z]+)', ({ result }) => { 53 | const { date, title } = result.pathname.groups 54 | return Response.json({ post: { date, title } }) 55 | }) 56 | 57 | // get query parameters 58 | router.get('/search', ({ result }) => { 59 | const query = new URLSearchParams(result.search.input).get('q') 60 | return new Response(`Your query is ${query}`) 61 | }) 62 | 63 | // handle a PURGE method and return a Redirect response 64 | router.on('PURGE', '/cache', () => { 65 | return new Response(null, { 66 | status: 302, 67 | headers: { 68 | Location: '/', 69 | }, 70 | }) 71 | }) 72 | 73 | // get environment variables for Cloudflare Workers 74 | router.get('/secret', ({ env }) => { 75 | console.log(env.TOKEN) 76 | return new Response('Welcome!') 77 | }) 78 | 79 | // use an executionContext for Cloudflare Workers 80 | router.get('/log', ({ executionContext, req }) => { 81 | executionContext.waitUntil((async () => console.log(`You access ${req.url.toString()}`))()) 82 | return new Response('log will be shown') 83 | }) 84 | 85 | // return a custom 404 response 86 | router.all('*', () => new Response('Custom 404', { status: 404 })) 87 | 88 | // export the app for Cloudflare Workers 89 | export default router 90 | ``` 91 | 92 | ## Develop with Wrangler 93 | 94 | ``` 95 | wrangler dev index.ts 96 | ``` 97 | 98 | ## Deploy to Cloudflare Workers 99 | 100 | ``` 101 | wrangler publish index.ts 102 | ``` 103 | 104 | ## Deno 105 | 106 | ```ts 107 | import { serve } from 'https://deno.land/std/http/server.ts' 108 | import { Pico } from 'https://esm.sh/@picojs/pico' 109 | 110 | const router = Pico() 111 | router.get('/', () => new Response('Hi Deno!')) 112 | 113 | //... 114 | 115 | serve(router.fetch) 116 | ``` 117 | 118 | ``` 119 | deno run --allow-net pico.ts 120 | ``` 121 | 122 | ## Related projects 123 | 124 | - Hono 125 | - itty-router 126 | 127 | ## Author 128 | 129 | Yusuke Wada 130 | 131 | ## License 132 | 133 | MIT 134 | -------------------------------------------------------------------------------- /test/pico.test.ts: -------------------------------------------------------------------------------- 1 | import { Pico } from '../src/pico' 2 | 3 | const json = (data: unknown) => 4 | new Response(JSON.stringify(data), { 5 | headers: { 6 | 'Content-Type': 'application/json', 7 | }, 8 | }) 9 | 10 | describe('Basic', () => { 11 | const router = Pico() 12 | router.get('/', () => new Response('Hi')) 13 | router.get('/json', () => 14 | json({ 15 | message: 'hello', 16 | }) 17 | ) 18 | router.get('*', () => { 19 | return new Response('Custom Not Found', { 20 | status: 404, 21 | }) 22 | }) 23 | 24 | it('Should return 200 text response', async () => { 25 | const req = new Request('http://localhost') 26 | const res = await router.fetch(req) 27 | expect(res).not.toBeUndefined() 28 | expect(res.status).toBe(200) 29 | expect(res.headers.get('Content-Type')).toMatch(/^text\/plain/) 30 | expect(await res.text()).toBe('Hi') 31 | }) 32 | 33 | it('Should return 200 JSON response', async () => { 34 | const req = new Request('http://localhost/json') 35 | const res = await router.fetch(req) 36 | expect(res).not.toBeUndefined() 37 | expect(res.status).toBe(200) 38 | expect(res.headers.get('Content-Type')).toBe('application/json') 39 | expect(await res.json()).toEqual({ message: 'hello' }) 40 | }) 41 | 42 | it('Should return 404 response', async () => { 43 | const req = new Request('http://localhost/not-found') 44 | const res = await router.fetch(req) 45 | expect(res.status).toBe(404) 46 | expect(await res.text()).toBe('Custom Not Found') 47 | }) 48 | }) 49 | 50 | describe('RegExp', () => { 51 | const router = Pico() 52 | router.get('/post/:date(\\d+)/:title([a-z]+)', ({ result }) => { 53 | const { date, title } = result.pathname.groups 54 | return json({ 55 | post: { 56 | date, 57 | title, 58 | }, 59 | }) 60 | }) 61 | 62 | router.get('/assets/:filename(.*.png)', (c) => { 63 | const filename = c.result.pathname.groups['filename'] 64 | return json({ filename }) 65 | }) 66 | 67 | it('Should capture regexp path parameters', async () => { 68 | const req = new Request('http://localhost/post/20221124/hello') 69 | const res = await router.fetch(req) 70 | expect(res).not.toBeUndefined() 71 | expect(res.status).toBe(200) 72 | expect(await res.json()).toEqual({ post: { date: '20221124', title: 'hello' } }) 73 | }) 74 | 75 | it('Should return nothing', async () => { 76 | const req = new Request('http://localhost/post/onetwothree/hello') 77 | const res = await router.fetch(req) 78 | expect(res).toBeUndefined() 79 | }) 80 | 81 | it('Should capture the path parameter with the wildcard', async () => { 82 | const req = new Request('http://localhost/assets/animal.png') 83 | const res = await router.fetch(req) 84 | expect(res).not.toBeUndefined() 85 | expect(res.status).toBe(200) 86 | expect(await res.json()).toEqual({ filename: 'animal.png' }) 87 | }) 88 | }) 89 | 90 | describe('Query', () => { 91 | const app = Pico() 92 | app.get('/search', ({ result }) => { 93 | const query = new URLSearchParams(result.search.input).get('q') 94 | return new Response(query) 95 | }) 96 | 97 | it('Should get query parameters', async () => { 98 | const req = new Request('http://localhost/search?q=foo') 99 | const res = await app.fetch(req) 100 | expect(res.status).toBe(200) 101 | expect(await res.text()).toBe('foo') 102 | }) 103 | }) 104 | 105 | describe('All', () => { 106 | const router = Pico() 107 | router.all('/abc', () => new Response('Hi')) 108 | 109 | it('Should return 200 response with GET request', async () => { 110 | const req = new Request('http://localhost/abc') 111 | const res = await router.fetch(req) 112 | expect(res).not.toBeUndefined() 113 | expect(res.status).toBe(200) 114 | }) 115 | 116 | it('Should return 200 response with POST request', async () => { 117 | const req = new Request('http://localhost/abc', { method: 'POST' }) 118 | const res = await router.fetch(req) 119 | expect(res).not.toBeUndefined() 120 | expect(res.status).toBe(200) 121 | }) 122 | }) 123 | 124 | describe('on', () => { 125 | const router = Pico() 126 | router.on('PURGE', '/cache', () => new Response('purged')) 127 | 128 | it('Should return 200 response with PURGE method', async () => { 129 | const req = new Request('http://localhost/cache', { method: 'PURGE' }) 130 | const res = await router.fetch(req) 131 | expect(res).not.toBeUndefined() 132 | expect(res.status).toBe(200) 133 | }) 134 | }) 135 | --------------------------------------------------------------------------------