diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dffb14f..08d4e17 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,6 +38,9 @@ jobs: - name: Lint code run: npm run lint + - name: Check types + run: npm run test-types + - name: Upload code coverage uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: diff --git a/.gitignore b/.gitignore index f15b98e..282eaee 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ coverage/ node_modules/ npm-debug.log package-lock.json +*.lock diff --git a/index.d.ts b/index.d.ts new file mode 100644 index 0000000..b01689b --- /dev/null +++ b/index.d.ts @@ -0,0 +1,55 @@ +import type { IncomingMessage, ServerResponse } from 'node:http' +import type { + Handler, + NextFunction, + RequestHandler, + Route as IRoute +} from './lib/route' +import Route = require('./lib/route') + +export type RouterMethod = (path: Path, handler: F) => T + +export type Path = string | RegExp | Array + +export interface RouterOptions { + caseSensitive?: boolean + mergeParams?: boolean + strict?: boolean +} + +type MethodHandlers = { [M in Method | 'all']: RouterMethod } + +export interface Router extends RequestHandler, MethodHandlers { + params: Record + stack: any[] + caseSensitive: boolean + mergeParams: boolean + strict: boolean + + handle(req: IncomingMessage, res: ServerResponse, callback: NextFunction): void + param(name: string, fn: (req: any, res: any, next: NextFunction, value: any, name: string) => void): this + route(path: Path): IRoute + + use: { + (path: Path, handler: HandlerArg, ...handlers: Array>): Router + (handler: HandlerArg, ...handlers: Array>): Router + } + + all: RouterMethod + get: RouterMethod + post: RouterMethod + put: RouterMethod + delete: RouterMethod + patch: RouterMethod + options: RouterMethod + head: RouterMethod +} + +interface RouterConstructor { + (options?: RouterOptions): Router + new (options?: RouterOptions): Router + Route: typeof Route +} + +declare const router: RouterConstructor +export = router diff --git a/lib/route.d.ts b/lib/route.d.ts new file mode 100644 index 0000000..56f6275 --- /dev/null +++ b/lib/route.d.ts @@ -0,0 +1,40 @@ +import type { IncomingMessage, ServerResponse } from 'node:http' + +export type NextFunction = (err?: any) => void + +export type RequestHandler = (req: IncomingMessage, res: ServerResponse, next: NextFunction) => void +export type ErrorRequestHandler = (err: Error, req: IncomingMessage, res: ServerResponse, next: NextFunction) => void + +export type Handler = RequestHandler | ErrorRequestHandler + +export type HandlerArg = F | ReadonlyArray> + +export type RouteMethod = (handler: HandlerArg, ...handlers: Array>) => T + + +export type Method = + | 'acl' | 'bind' | 'checkout' | 'connect' | 'copy' | 'delete' | 'get' + | 'head' | 'link' | 'lock' | 'm-search' | 'merge' | 'mkactivity' + | 'mkcalendar' | 'mkcol' | 'move' | 'notify' | 'options' | 'patch' + | 'post' | 'propfind' | 'proppatch' | 'purge' | 'put' | 'query' + | 'rebind' | 'report' | 'search' | 'source' | 'subscribe' | 'trace' + | 'unbind' | 'unlink' | 'unlock' | 'unsubscribe' + +export type MethodHandlers = { [M in Method | 'all']: RouteMethod } + +export interface Route extends MethodHandlers { + path: string + stack: any[] + methods: Record + + _handlesMethod(method: string): boolean + _methods(): string[] + dispatch(req: IncomingMessage, res: ServerResponse, done: NextFunction): void +} + +interface RouteConstructor { + new (path: Path): Route +} + +declare const Route: RouteConstructor +export = Route diff --git a/package.json b/package.json index e6be557..a75c80b 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,9 @@ ], "license": "MIT", "repository": "pillarjs/router", + "type": "commonjs", + "main": "index.js", + "types": "index.d.ts", "funding": { "type": "opencollective", "url": "https://opencollective.com/express" @@ -20,7 +23,11 @@ "path-to-regexp": "^8.0.0" }, "devDependencies": { + "@arethetypeswrong/cli": "0.18.5", + "@tsconfig/node18": "18.2.6", + "@types/node": "18.19.130", "finalhandler": "^2.1.0", + "expect-type": "1.4.0", "mocha": "10.2.0", "nyc": "15.1.0", "run-series": "^1.1.9", @@ -32,7 +39,8 @@ "LICENSE", "HISTORY.md", "README.md", - "index.js" + "index.js", + "index.d.ts" ], "engines": { "node": ">= 18" @@ -43,6 +51,7 @@ "test:debug": "mocha --reporter spec --check-leaks test/ --inspect --inspect-brk", "test-ci": "nyc --reporter=lcov --reporter=text npm test", "test-cov": "nyc --reporter=text npm test", - "version": "node scripts/version-history.js && git add HISTORY.md" + "version": "node scripts/version-history.js && git add HISTORY.md", + "test-types": "tsc --noEmit && attw --pack" } } diff --git a/test/types.ts b/test/types.ts new file mode 100644 index 0000000..9eb3c3b --- /dev/null +++ b/test/types.ts @@ -0,0 +1,118 @@ +import { IncomingMessage, ServerResponse } from "node:http"; +import { expectTypeOf } from "expect-type"; +import Router, { ErrorRequestHandler, Method, NextFunction, Path, RequestHandler } from ".."; + +const router = Router(); + +const handler: RequestHandler = (req, res, next) => next(); +const other: RequestHandler = (req, res, next) => next(); + +// handler parameters are inferred +router.get("/", function (req, res) { + expectTypeOf(req).toEqualTypeOf(); + expectTypeOf(res).toEqualTypeOf(); +}); + +router.get("/", function (req, res, next) { + expectTypeOf(req).toEqualTypeOf(); + expectTypeOf(res).toEqualTypeOf(); + expectTypeOf(next).toBeFunction(); +}); + +// typescript has no way to infer the types automatically +router.get("/", function (err, req, res, next) { + expectTypeOf(err).toEqualTypeOf(); + expectTypeOf(req).toEqualTypeOf(); + expectTypeOf(res).toEqualTypeOf(); + expectTypeOf(next).toBeFunction(); +}); + +// methods accept multiple handlers and nested arrays (flattened at runtime) +router.get("/", handler, other); +router.get("/", [handler, other]); +router.get("/", [handler, [other]], handler); + +// at least one handler is required (runtime throws TypeError) +// @ts-expect-error +router.get("/"); +// handlers must be functions or arrays of them +// @ts-expect-error +router.get("/", "not-a-handler"); + +// paths may be a string, RegExp, or array of either +router.get(/^\/x/, handler); +router.get(["/a", /^\/b/], handler); +// @ts-expect-error +router.get(42, handler); + +// use: with or without path, multiple handlers, arrays +router.use(handler); +router.use(handler, other); +router.use([handler, [other]]); +router.use("/a", handler, [other], handler); +router.use(["/a", "/b"], handler); +router.use(/^\/c/, handler); +// @ts-expect-error path without any handler (runtime throws TypeError) +router.use("/x"); +// @ts-expect-error +router.use(); + +// verb methods and use return the router for chaining +expectTypeOf(router.get("/", handler)).toEqualTypeOf(router); +router.use(handler).use("/a", other).get("/", handler); + +// one method per node:http METHODS entry, not just the classic 8 +router.trace("/", handler); +router.search("/", handler); +router.propfind("/", handler); +router["m-search"]("/", handler); +// @ts-expect-error not an HTTP method +router.foo("/", handler); + +// error handlers are distinguished by the explicit generic +router.use(function (err, req, res, next) { + expectTypeOf(err).toEqualTypeOf(); + next(err); +}); + +// param callbacks receive typed req/res +router.param("id", function (req, res, next, value, name) { + expectTypeOf(req).toEqualTypeOf(); + expectTypeOf(res).toEqualTypeOf(); + expectTypeOf(next).toEqualTypeOf(); + expectTypeOf(name).toBeString(); +}); + +// router is itself a request handler (mountable in a server or another router) +expectTypeOf(router).toExtend(); +router.use(Router()); + +// the instance, options, and helper types are nameable by consumers +const mounted: Router.Router = Router({ strict: true, caseSensitive: true, mergeParams: true }); +mounted.use(handler); +const opts: Router.RouterOptions = { strict: true }; +Router(opts); +new Router(opts); +// @ts-expect-error unknown options are rejected +Router({ nope: true }); +expectTypeOf().toExtend(); +expectTypeOf().toEqualTypeOf>(); + +// routes: all verbs, multiple handlers, chaining +const route = router.route("/things/:id"); +expectTypeOf(route.path).toBeString(); +route.all(handler).get(handler, other).post([handler, [other]]); +route.checkout(handler); +route["m-search"](handler); +// @ts-expect-error at least one handler is required +route.put(); + +// Route must be constructed with new, and accepts any Path +new Router.Route("/x"); +new Router.Route(/^\/x/); +new Router.Route(["/a", /^\/b/]); +// @ts-expect-error calling without new does not construct (strict mode) +Router.Route("/x"); +// @ts-expect-error +new Router.Route(42); + diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..369ea4e --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "@tsconfig/node18/tsconfig.json", + "include": ["index.d.ts", "lib/route.d.ts", "test/types.ts"], + "compilerOptions": { + "types": ["node"] + } +}