diff --git a/.eslintrc.yml b/.eslintrc.yml index 70bc9a6e7e..ade7c0faa6 100644 --- a/.eslintrc.yml +++ b/.eslintrc.yml @@ -2,9 +2,16 @@ root: true env: es2022: true node: true +plugins: + - "@typescript-eslint" rules: eol-last: error eqeqeq: [error, allow-null] indent: [error, 2, { MemberExpression: "off", SwitchCase: 1 }] no-trailing-spaces: error no-unused-vars: [error, { vars: all, args: none, ignoreRestSiblings: true }] +overrides: + - files: ["*.ts"] + parser: "@typescript-eslint/parser" + rules: + no-unused-vars: off diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0421d56242..a110ff19fa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -85,6 +85,9 @@ jobs: path: ./coverage/lcov.info retention-days: 1 + - name: Test types + run: npm run test-types + coverage: needs: test runs-on: ubuntu-latest diff --git a/.github/workflows/legacy.yml b/.github/workflows/legacy.yml index 81ffb94d74..7bdc6cedb8 100644 --- a/.github/workflows/legacy.yml +++ b/.github/workflows/legacy.yml @@ -69,6 +69,9 @@ jobs: path: ./coverage/lcov.info retention-days: 1 + - name: Test types + run: npm run test-types + coverage: needs: test runs-on: ubuntu-latest diff --git a/core.d.ts b/core.d.ts new file mode 100644 index 0000000000..6c7535544b --- /dev/null +++ b/core.d.ts @@ -0,0 +1,1236 @@ +// This extracts the core definitions from express to prevent a circular dependency between express and serve-static +/// + +import { SendOptions } from "send"; + +declare global { + namespace Express { + // These open interfaces may be extended in an application-specific manner via declaration merging. + // See for example method-override.d.ts (https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/method-override/index.d.ts) + interface Request {} + interface Response {} + interface Locals {} + interface Application {} + } +} + +import { EventEmitter } from "events"; +import * as http from "http"; +import { ParsedQs } from "qs"; +import { Options as RangeParserOptions, Ranges as RangeParserRanges, Result as RangeParserResult } from "range-parser"; + +export {}; + +export type Query = ParsedQs; + +export interface NextFunction { + (err?: any): void; + /** + * "Break-out" of a router by calling {next('router')}; + * @see {https://expressjs.com/en/guide/using-middleware.html#middleware.router} + */ + (deferToNext: "router"): void; + /** + * "Break-out" of a route by calling {next('route')}; + * @see {https://expressjs.com/en/guide/using-middleware.html#middleware.application} + */ + (deferToNext: "route"): void; +} + +export interface Dictionary { + [key: string]: T; +} + +export interface ParamsDictionary { + [key: string]: string; +} +export type ParamsArray = string[]; +export type Params = ParamsDictionary | ParamsArray; + +export interface Locals extends Express.Locals {} + +export interface RequestHandler< + P = ParamsDictionary, + ResBody = any, + ReqBody = any, + ReqQuery = ParsedQs, + LocalsObj extends Record = Record, +> { + ( + req: Request, + res: Response, + next: NextFunction, + ): void | Promise; +} + +export type ErrorRequestHandler< + P = ParamsDictionary, + ResBody = any, + ReqBody = any, + ReqQuery = ParsedQs, + LocalsObj extends Record = Record, +> = ( + err: any, + req: Request, + res: Response, + next: NextFunction, +) => void | Promise; + +export type PathParams = string | RegExp | Array; + +export type RequestHandlerParams< + P = ParamsDictionary, + ResBody = any, + ReqBody = any, + ReqQuery = ParsedQs, + LocalsObj extends Record = Record, +> = + | RequestHandler + | ErrorRequestHandler + | Array | ErrorRequestHandler

>; + +type RemoveTail = S extends `${infer P}${Tail}` ? P : S; +type GetRouteParameter = RemoveTail< + RemoveTail, `-${string}`>, + `.${string}` +>; + +export type RouteParameters = Route extends `${infer Required}{${infer Optional}}${infer Next}` + ? ParseRouteParameters & Partial> & RouteParameters + : ParseRouteParameters; + +type ParseRouteParameters = string extends Route ? ParamsDictionary + : Route extends `${string}(${string}` ? ParamsDictionary // TODO: handling for regex parameters + : Route extends `${string}:${infer Rest}` ? + & ( + GetRouteParameter extends never ? ParamsDictionary + : GetRouteParameter extends `${infer ParamName}?` ? { [P in ParamName]?: string } // TODO: Remove old `?` handling when Express 5 is promoted to "latest" + : { [P in GetRouteParameter]: string } + ) + & (Rest extends `${GetRouteParameter}${infer Next}` ? RouteParameters : unknown) + : {}; + +export interface IRouterMatcher< + T, + Method extends "all" | "get" | "post" | "put" | "delete" | "patch" | "options" | "head" = any, +> { + < + Route extends string, + P = RouteParameters, + ResBody = any, + ReqBody = any, + ReqQuery = ParsedQs, + LocalsObj extends Record = Record, + >( + // (it's used as the default type parameter for P) + path: Route, + // (This generic is meant to be passed explicitly.) + ...handlers: Array> + ): T; + < + Path extends string, + P = RouteParameters, + ResBody = any, + ReqBody = any, + ReqQuery = ParsedQs, + LocalsObj extends Record = Record, + >( + // (it's used as the default type parameter for P) + path: Path, + // (This generic is meant to be passed explicitly.) + ...handlers: Array> + ): T; + < + P = ParamsDictionary, + ResBody = any, + ReqBody = any, + ReqQuery = ParsedQs, + LocalsObj extends Record = Record, + >( + path: PathParams, + // (This generic is meant to be passed explicitly.) + ...handlers: Array> + ): T; + < + P = ParamsDictionary, + ResBody = any, + ReqBody = any, + ReqQuery = ParsedQs, + LocalsObj extends Record = Record, + >( + path: PathParams, + // (This generic is meant to be passed explicitly.) + ...handlers: Array> + ): T; + (path: PathParams, subApplication: Application): T; +} + +export interface IRouterHandler { + (...handlers: Array>>): T; + (...handlers: Array>>): T; + < + P = RouteParameters, + ResBody = any, + ReqBody = any, + ReqQuery = ParsedQs, + LocalsObj extends Record = Record, + >( + // (This generic is meant to be passed explicitly.) + ...handlers: Array> + ): T; + < + P = RouteParameters, + ResBody = any, + ReqBody = any, + ReqQuery = ParsedQs, + LocalsObj extends Record = Record, + >( + // (This generic is meant to be passed explicitly.) + ...handlers: Array> + ): T; + < + P = ParamsDictionary, + ResBody = any, + ReqBody = any, + ReqQuery = ParsedQs, + LocalsObj extends Record = Record, + >( + // (This generic is meant to be passed explicitly.) + ...handlers: Array> + ): T; + < + P = ParamsDictionary, + ResBody = any, + ReqBody = any, + ReqQuery = ParsedQs, + LocalsObj extends Record = Record, + >( + // (This generic is meant to be passed explicitly.) + ...handlers: Array> + ): T; +} + +export interface IRouter extends RequestHandler { + /** + * Map the given param placeholder `name`(s) to the given callback(s). + * + * Parameter mapping is used to provide pre-conditions to routes + * which use normalized placeholders. For example a _:user_id_ parameter + * could automatically load a user's information from the database without + * any additional code, + * + * The callback uses the samesignature as middleware, the only differencing + * being that the value of the placeholder is passed, in this case the _id_ + * of the user. Once the `next()` function is invoked, just like middleware + * it will continue on to execute the route, or subsequent parameter functions. + * + * app.param('user_id', function(req, res, next, id){ + * User.find(id, function(err, user){ + * if (err) { + * next(err); + * } else if (user) { + * req.user = user; + * next(); + * } else { + * next(new Error('failed to load user')); + * } + * }); + * }); + */ + param(name: string, handler: RequestParamHandler): this; + + /** + * Special-cased "all" method, applying the given route `path`, + * middleware, and callback to _every_ HTTP method. + */ + all: IRouterMatcher; + get: IRouterMatcher; + post: IRouterMatcher; + put: IRouterMatcher; + delete: IRouterMatcher; + patch: IRouterMatcher; + options: IRouterMatcher; + head: IRouterMatcher; + + checkout: IRouterMatcher; + connect: IRouterMatcher; + copy: IRouterMatcher; + lock: IRouterMatcher; + merge: IRouterMatcher; + mkactivity: IRouterMatcher; + mkcol: IRouterMatcher; + move: IRouterMatcher; + "m-search": IRouterMatcher; + notify: IRouterMatcher; + propfind: IRouterMatcher; + proppatch: IRouterMatcher; + purge: IRouterMatcher; + report: IRouterMatcher; + search: IRouterMatcher; + subscribe: IRouterMatcher; + trace: IRouterMatcher; + unlock: IRouterMatcher; + unsubscribe: IRouterMatcher; + link: IRouterMatcher; + unlink: IRouterMatcher; + + use: IRouterHandler & IRouterMatcher; + + route(prefix: T): IRoute; + route(prefix: PathParams): IRoute; + /** + * Stack of configured routes + */ + stack: ILayer[]; +} + +export interface ILayer { + route?: IRoute; + name: string | ""; + params?: Record; + keys: string[]; + path?: string; + method: string; + regexp: RegExp; + handle: (req: Request, res: Response, next: NextFunction) => any; +} + +export interface IRoute { + path: string; + stack: ILayer[]; + all: IRouterHandler; + get: IRouterHandler; + post: IRouterHandler; + put: IRouterHandler; + delete: IRouterHandler; + patch: IRouterHandler; + options: IRouterHandler; + head: IRouterHandler; + + checkout: IRouterHandler; + copy: IRouterHandler; + lock: IRouterHandler; + merge: IRouterHandler; + mkactivity: IRouterHandler; + mkcol: IRouterHandler; + move: IRouterHandler; + "m-search": IRouterHandler; + notify: IRouterHandler; + purge: IRouterHandler; + report: IRouterHandler; + search: IRouterHandler; + subscribe: IRouterHandler; + trace: IRouterHandler; + unlock: IRouterHandler; + unsubscribe: IRouterHandler; +} + +export interface Router extends IRouter {} + +/** + * Options passed down into `res.cookie` + * @link https://expressjs.com/en/api.html#res.cookie + */ +export interface CookieOptions { + /** Convenient option for setting the expiry time relative to the current time in **milliseconds**. */ + maxAge?: number | undefined; + /** Indicates if the cookie should be signed. */ + signed?: boolean | undefined; + /** Expiry date of the cookie in GMT. If not specified (undefined), creates a session cookie. */ + expires?: Date | undefined; + /** Flags the cookie to be accessible only by the web server. */ + httpOnly?: boolean | undefined; + /** Path for the cookie. Defaults to “/”. */ + path?: string | undefined; + /** Domain name for the cookie. Defaults to the domain name of the app. */ + domain?: string | undefined; + /** Marks the cookie to be used with HTTPS only. */ + secure?: boolean | undefined; + /** A synchronous function used for cookie value encoding. Defaults to encodeURIComponent. */ + encode?: ((val: string) => string) | undefined; + /** + * Value of the “SameSite” Set-Cookie attribute. + * @link https://tools.ietf.org/html/draft-ietf-httpbis-cookie-same-site-00#section-4.1.1. + */ + sameSite?: boolean | "lax" | "strict" | "none" | undefined; + /** + * Value of the “Priority” Set-Cookie attribute. + * @link https://datatracker.ietf.org/doc/html/draft-west-cookie-priority-00#section-4.3 + */ + priority?: "low" | "medium" | "high"; + /** Marks the cookie to use partioned storage. */ + partitioned?: boolean | undefined; +} + +export interface ByteRange { + start: number; + end: number; +} + +export interface RequestRanges extends RangeParserRanges {} + +export type Errback = (err: Error) => void; + +/** + * @param P For most requests, this should be `ParamsDictionary`, but if you're + * using this in a route handler for a route that uses a `RegExp` or a wildcard + * `string` path (e.g. `'/user/*'`), then `req.params` will be an array, in + * which case you should use `ParamsArray` instead. + * + * @see https://expressjs.com/en/api.html#req.params + * + * @example + * app.get('/user/:id', (req, res) => res.send(req.params.id)); // implicitly `ParamsDictionary` + * app.get(/user\/(.*)/, (req, res) => res.send(req.params[0])); + * app.get('/user/*', (req, res) => res.send(req.params[0])); + */ +export interface Request< + P = ParamsDictionary, + ResBody = any, + ReqBody = any, + ReqQuery = ParsedQs, + LocalsObj extends Record = Record, +> extends http.IncomingMessage, Express.Request { + /** + * Return request header. + * + * The `Referrer` header field is special-cased, + * both `Referrer` and `Referer` are interchangeable. + * + * Examples: + * + * req.get('Content-Type'); + * // => "text/plain" + * + * req.get('content-type'); + * // => "text/plain" + * + * req.get('Something'); + * // => undefined + * + * Aliased as `req.header()`. + */ + get(name: "set-cookie"): string[] | undefined; + get(name: string): string | undefined; + + header(name: "set-cookie"): string[] | undefined; + header(name: string): string | undefined; + + /** + * Check if the given `type(s)` is acceptable, returning + * the best match when true, otherwise `undefined`, in which + * case you should respond with 406 "Not Acceptable". + * + * The `type` value may be a single mime type string + * such as "application/json", the extension name + * such as "json", a comma-delimted list such as "json, html, text/plain", + * or an array `["json", "html", "text/plain"]`. When a list + * or array is given the _best_ match, if any is returned. + * + * Examples: + * + * // Accept: text/html + * req.accepts('html'); + * // => "html" + * + * // Accept: text/*, application/json + * req.accepts('html'); + * // => "html" + * req.accepts('text/html'); + * // => "text/html" + * req.accepts('json, text'); + * // => "json" + * req.accepts('application/json'); + * // => "application/json" + * + * // Accept: text/*, application/json + * req.accepts('image/png'); + * req.accepts('png'); + * // => false + * + * // Accept: text/*;q=.5, application/json + * req.accepts(['html', 'json']); + * req.accepts('html, json'); + * // => "json" + */ + accepts(): string[]; + accepts(type: string): string | false; + accepts(type: string[]): string | false; + accepts(...type: string[]): string | false; + + /** + * Returns the first accepted charset of the specified character sets, + * based on the request's Accept-Charset HTTP header field. + * If none of the specified charsets is accepted, returns false. + * + * For more information, or if you have issues or concerns, see accepts. + */ + acceptsCharsets(): string[]; + acceptsCharsets(charset: string): string | false; + acceptsCharsets(charset: string[]): string | false; + acceptsCharsets(...charset: string[]): string | false; + + /** + * Returns the first accepted encoding of the specified encodings, + * based on the request's Accept-Encoding HTTP header field. + * If none of the specified encodings is accepted, returns false. + * + * For more information, or if you have issues or concerns, see accepts. + */ + acceptsEncodings(): string[]; + acceptsEncodings(encoding: string): string | false; + acceptsEncodings(encoding: string[]): string | false; + acceptsEncodings(...encoding: string[]): string | false; + + /** + * Returns the first accepted language of the specified languages, + * based on the request's Accept-Language HTTP header field. + * If none of the specified languages is accepted, returns false. + * + * For more information, or if you have issues or concerns, see accepts. + */ + acceptsLanguages(): string[]; + acceptsLanguages(lang: string): string | false; + acceptsLanguages(lang: string[]): string | false; + acceptsLanguages(...lang: string[]): string | false; + + /** + * Parse Range header field, capping to the given `size`. + * + * Unspecified ranges such as "0-" require knowledge of your resource length. In + * the case of a byte range this is of course the total number of bytes. + * If the Range header field is not given `undefined` is returned. + * If the Range header field is given, return value is a result of range-parser. + * @see https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/range-parser/index.d.ts + * + * NOTE: remember that ranges are inclusive, so for example "Range: users=0-3" + * should respond with 4 users when available, not 3. + */ + range(size: number, options?: RangeParserOptions): RangeParserRanges | RangeParserResult | undefined; + + /** + * Return an array of Accepted media types + * ordered from highest quality to lowest. + */ + accepted: MediaType[]; + + /** + * Check if the incoming request contains the "Content-Type" + * header field, and it contains the give mime `type`. + * + * Examples: + * + * // With Content-Type: text/html; charset=utf-8 + * req.is('html'); + * req.is('text/html'); + * req.is('text/*'); + * // => true + * + * // When Content-Type is application/json + * req.is('json'); + * req.is('application/json'); + * req.is('application/*'); + * // => true + * + * req.is('html'); + * // => false + */ + is(type: string | string[]): string | false | null; + + /** + * Return the protocol string "http" or "https" + * when requested with TLS. When the "trust proxy" + * setting is enabled the "X-Forwarded-Proto" header + * field will be trusted. If you're running behind + * a reverse proxy that supplies https for you this + * may be enabled. + */ + readonly protocol: string; + + /** + * Short-hand for: + * + * req.protocol == 'https' + */ + readonly secure: boolean; + + /** + * Return the remote address, or when + * "trust proxy" is `true` return + * the upstream addr. + * + * Value may be undefined if the `req.socket` is destroyed + * (for example, if the client disconnected). + */ + readonly ip: string | undefined; + + /** + * When "trust proxy" is `true`, parse + * the "X-Forwarded-For" ip address list. + * + * For example if the value were "client, proxy1, proxy2" + * you would receive the array `["client", "proxy1", "proxy2"]` + * where "proxy2" is the furthest down-stream. + */ + readonly ips: string[]; + + /** + * Return subdomains as an array. + * + * Subdomains are the dot-separated parts of the host before the main domain of + * the app. By default, the domain of the app is assumed to be the last two + * parts of the host. This can be changed by setting "subdomain offset". + * + * For example, if the domain is "tobi.ferrets.example.com": + * If "subdomain offset" is not set, req.subdomains is `["ferrets", "tobi"]`. + * If "subdomain offset" is 3, req.subdomains is `["tobi"]`. + */ + readonly subdomains: string[]; + + /** + * Short-hand for `url.parse(req.url).pathname`. + */ + readonly path: string; + + /** + * Contains the hostname derived from the `Host` HTTP header. + */ + readonly hostname: string; + + /** + * Contains the host derived from the `Host` HTTP header. + */ + readonly host: string; + + /** + * Check if the request is fresh, aka + * Last-Modified and/or the ETag + * still match. + */ + readonly fresh: boolean; + + /** + * Check if the request is stale, aka + * "Last-Modified" and / or the "ETag" for the + * resource has changed. + */ + readonly stale: boolean; + + /** + * Check if the request was an _XMLHttpRequest_. + */ + readonly xhr: boolean; + + // body: { username: string; password: string; remember: boolean; title: string; }; + body: ReqBody; + + // cookies: { string; remember: boolean; }; + cookies: any; + + method: string; + + params: P; + + query: ReqQuery; + + route: any; + + signedCookies: any; + + originalUrl: string; + + url: string; + + baseUrl: string; + + app: Application; + + /** + * After middleware.init executed, Request will contain res and next properties + * See: express/lib/middleware/init.js + */ + res?: Response | undefined; + next?: NextFunction | undefined; +} + +export interface MediaType { + value: string; + quality: number; + type: string; + subtype: string; +} + +export type Send> = (body?: ResBody) => T; + +export interface SendFileOptions extends SendOptions { + /** Object containing HTTP headers to serve with the file. */ + headers?: Record; +} + +export interface DownloadOptions extends SendOptions { + /** Object containing HTTP headers to serve with the file. The header `Content-Disposition` will be overridden by the filename argument. */ + headers?: Record; +} + +export interface Response< + ResBody = any, + LocalsObj extends Record = Record, + StatusCode extends number = number, +> extends http.ServerResponse, Express.Response { + /** + * Set status `code`. + */ + status(code: StatusCode): this; + + /** + * Set the response HTTP status code to `statusCode` and send its string representation as the response body. + * @link http://expressjs.com/4x/api.html#res.sendStatus + * + * Examples: + * + * res.sendStatus(200); // equivalent to res.status(200).send('OK') + * res.sendStatus(403); // equivalent to res.status(403).send('Forbidden') + * res.sendStatus(404); // equivalent to res.status(404).send('Not Found') + * res.sendStatus(500); // equivalent to res.status(500).send('Internal Server Error') + */ + sendStatus(code: StatusCode): this; + + /** + * Set Link header field with the given `links`. + * + * Examples: + * + * res.links({ + * next: 'http://api.example.com/users?page=2', + * last: 'http://api.example.com/users?page=5' + * }); + */ + links(links: any): this; + + /** + * Send a response. + * + * Examples: + * + * res.send(new Buffer('wahoo')); + * res.send({ some: 'json' }); + * res.send('

some html

'); + * res.status(404).send('Sorry, cant find that'); + */ + send: Send; + + /** + * Send JSON response. + * + * Examples: + * + * res.json(null); + * res.json({ user: 'tj' }); + * res.status(500).json('oh noes!'); + * res.status(404).json('I dont have that'); + */ + json: Send; + + /** + * Send JSON response with JSONP callback support. + * + * Examples: + * + * res.jsonp(null); + * res.jsonp({ user: 'tj' }); + * res.status(500).jsonp('oh noes!'); + * res.status(404).jsonp('I dont have that'); + */ + jsonp: Send; + + /** + * Transfer the file at the given `path`. + * + * Automatically sets the _Content-Type_ response header field. + * The callback `fn(err)` is invoked when the transfer is complete + * or when an error occurs. Be sure to check `res.headersSent` + * if you wish to attempt responding, as the header and some data + * may have already been transferred. + * + * Options: + * + * - `maxAge` defaulting to 0 (can be string converted by `ms`) + * - `root` root directory for relative filenames + * - `headers` object of headers to serve with file + * - `dotfiles` serve dotfiles, defaulting to false; can be `"allow"` to send them + * + * Other options are passed along to `send`. + * + * Examples: + * + * The following example illustrates how `res.sendFile()` may + * be used as an alternative for the `static()` middleware for + * dynamic situations. The code backing `res.sendFile()` is actually + * the same code, so HTTP cache support etc is identical. + * + * app.get('/user/:uid/photos/:file', function(req, res){ + * var uid = req.params.uid + * , file = req.params.file; + * + * req.user.mayViewFilesFrom(uid, function(yes){ + * if (yes) { + * res.sendFile('/uploads/' + uid + '/' + file); + * } else { + * res.send(403, 'Sorry! you cant see that.'); + * } + * }); + * }); + * + * @api public + */ + sendFile(path: string, fn?: Errback): void; + sendFile(path: string, options: SendFileOptions, fn?: Errback): void; + + /** + * Transfer the file at the given `path` as an attachment. + * + * Optionally providing an alternate attachment `filename`, + * and optional callback `fn(err)`. The callback is invoked + * when the data transfer is complete, or when an error has + * ocurred. Be sure to check `res.headersSent` if you plan to respond. + * + * The optional options argument passes through to the underlying + * res.sendFile() call, and takes the exact same parameters. + * + * This method uses `res.sendFile()`. + */ + download(path: string, fn?: Errback): void; + download(path: string, filename: string, fn?: Errback): void; + download(path: string, filename: string, options: DownloadOptions, fn?: Errback): void; + + /** + * Set _Content-Type_ response header with `type` through `mime.lookup()` + * when it does not contain "/", or set the Content-Type to `type` otherwise. + * + * Examples: + * + * res.type('.html'); + * res.type('html'); + * res.type('json'); + * res.type('application/json'); + * res.type('png'); + */ + contentType(type: string): this; + + /** + * Set _Content-Type_ response header with `type` through `mime.lookup()` + * when it does not contain "/", or set the Content-Type to `type` otherwise. + * + * Examples: + * + * res.type('.html'); + * res.type('html'); + * res.type('json'); + * res.type('application/json'); + * res.type('png'); + */ + type(type: string): this; + + /** + * Respond to the Acceptable formats using an `obj` + * of mime-type callbacks. + * + * This method uses `req.accepted`, an array of + * acceptable types ordered by their quality values. + * When "Accept" is not present the _first_ callback + * is invoked, otherwise the first match is used. When + * no match is performed the server responds with + * 406 "Not Acceptable". + * + * Content-Type is set for you, however if you choose + * you may alter this within the callback using `res.type()` + * or `res.set('Content-Type', ...)`. + * + * res.format({ + * 'text/plain': function(){ + * res.send('hey'); + * }, + * + * 'text/html': function(){ + * res.send('

hey

'); + * }, + * + * 'appliation/json': function(){ + * res.send({ message: 'hey' }); + * } + * }); + * + * In addition to canonicalized MIME types you may + * also use extnames mapped to these types: + * + * res.format({ + * text: function(){ + * res.send('hey'); + * }, + * + * html: function(){ + * res.send('

hey

'); + * }, + * + * json: function(){ + * res.send({ message: 'hey' }); + * } + * }); + * + * By default Express passes an `Error` + * with a `.status` of 406 to `next(err)` + * if a match is not made. If you provide + * a `.default` callback it will be invoked + * instead. + */ + format(obj: any): this; + + /** + * Set _Content-Disposition_ header to _attachment_ with optional `filename`. + */ + attachment(filename?: string): this; + + /** + * Set header `field` to `val`, or pass + * an object of header fields. + * + * Examples: + * + * res.set('Foo', ['bar', 'baz']); + * res.set('Accept', 'application/json'); + * res.set({ Accept: 'text/plain', 'X-API-Key': 'tobi' }); + * + * Aliased as `res.header()`. + */ + set(field: any): this; + set(field: string, value?: string | string[]): this; + + header(field: any): this; + header(field: string, value?: string | string[]): this; + + // Property indicating if HTTP headers has been sent for the response. + headersSent: boolean; + + /** Get value for header `field`. */ + get(field: string): string | undefined; + + /** Clear cookie `name`. */ + clearCookie(name: string, options?: CookieOptions): this; + + /** + * Set cookie `name` to `val`, with the given `options`. + * + * Options: + * + * - `maxAge` max-age in milliseconds, converted to `expires` + * - `signed` sign the cookie + * - `path` defaults to "/" + * + * Examples: + * + * // "Remember Me" for 15 minutes + * res.cookie('rememberme', '1', { expires: new Date(Date.now() + 900000), httpOnly: true }); + * + * // save as above + * res.cookie('rememberme', '1', { maxAge: 900000, httpOnly: true }) + */ + cookie(name: string, val: string, options: CookieOptions): this; + cookie(name: string, val: any, options: CookieOptions): this; + cookie(name: string, val: any): this; + + /** + * Set the location header to `url`. + * + * Examples: + * + * res.location('/foo/bar').; + * res.location('http://example.com'); + * res.location('../login'); // /blog/post/1 -> /blog/login + * + * Mounting: + * + * When an application is mounted and `res.location()` + * is given a path that does _not_ lead with "/" it becomes + * relative to the mount-point. For example if the application + * is mounted at "/blog", the following would become "/blog/login". + * + * res.location('login'); + * + * While the leading slash would result in a location of "/login": + * + * res.location('/login'); + */ + location(url: string): this; + + /** + * Redirect to the given `url` with optional response `status` + * defaulting to 302. + * + * The resulting `url` is determined by `res.location()`, so + * it will play nicely with mounted apps, relative paths, etc. + * + * Examples: + * + * res.redirect('/foo/bar'); + * res.redirect('http://example.com'); + * res.redirect(301, 'http://example.com'); + * res.redirect('../login'); // /blog/post/1 -> /blog/login + */ + redirect(url: string): void; + redirect(status: number, url: string): void; + + /** + * Render `view` with the given `options` and optional callback `fn`. + * When a callback function is given a response will _not_ be made + * automatically, otherwise a response of _200_ and _text/html_ is given. + * + * Options: + * + * - `cache` boolean hinting to the engine it should cache + * - `filename` filename of the view being rendered + */ + render(view: string, options?: object, callback?: (err: Error, html: string) => void): void; + render(view: string, callback?: (err: Error, html: string) => void): void; + + locals: LocalsObj & Locals; + + charset: string; + + /** + * Adds the field to the Vary response header, if it is not there already. + * Examples: + * + * res.vary('User-Agent').render('docs'); + */ + vary(field: string): this; + + app: Application; + + /** + * Appends the specified value to the HTTP response header field. + * If the header is not already set, it creates the header with the specified value. + * The value parameter can be a string or an array. + * + * Note: calling res.set() after res.append() will reset the previously-set header value. + * + * @since 4.11.0 + */ + append(field: string, value?: string[] | string): this; + + /** + * After middleware.init executed, Response will contain req property + * See: express/lib/middleware/init.js + */ + req: Request; +} + +export interface Handler extends RequestHandler {} + +export type RequestParamHandler = (req: Request, res: Response, next: NextFunction, value: any, name: string) => any; + +export type ApplicationRequestHandler = + & IRouterHandler + & IRouterMatcher + & ((...handlers: RequestHandlerParams[]) => T); + +export interface Application< + LocalsObj extends Record = Record, +> extends EventEmitter, IRouter, Express.Application { + /** + * Express instance itself is a request handler, which could be invoked without + * third argument. + */ + (req: Request | http.IncomingMessage, res: Response | http.ServerResponse): any; + + /** + * Initialize the server. + * + * - setup default configuration + * - setup default middleware + * - setup route reflection methods + */ + init(): void; + + /** + * Initialize application configuration. + */ + defaultConfiguration(): void; + + /** + * Register the given template engine callback `fn` + * as `ext`. + * + * By default will `require()` the engine based on the + * file extension. For example if you try to render + * a "foo.jade" file Express will invoke the following internally: + * + * app.engine('jade', require('jade').__express); + * + * For engines that do not provide `.__express` out of the box, + * or if you wish to "map" a different extension to the template engine + * you may use this method. For example mapping the EJS template engine to + * ".html" files: + * + * app.engine('html', require('ejs').renderFile); + * + * In this case EJS provides a `.renderFile()` method with + * the same signature that Express expects: `(path, options, callback)`, + * though note that it aliases this method as `ejs.__express` internally + * so if you're using ".ejs" extensions you dont need to do anything. + * + * Some template engines do not follow this convention, the + * [Consolidate.js](https://github.com/visionmedia/consolidate.js) + * library was created to map all of node's popular template + * engines to follow this convention, thus allowing them to + * work seamlessly within Express. + */ + engine( + ext: string, + fn: (path: string, options: object, callback: (e: any, rendered?: string) => void) => void, + ): this; + + /** + * Assign `setting` to `val`, or return `setting`'s value. + * + * app.set('foo', 'bar'); + * app.get('foo'); + * // => "bar" + * app.set('foo', ['bar', 'baz']); + * app.get('foo'); + * // => ["bar", "baz"] + * + * Mounted servers inherit their parent server's settings. + */ + set(setting: string, val: any): this; + get: ((name: string) => any) & IRouterMatcher; + + param(name: string | string[], handler: RequestParamHandler): this; + + /** + * Return the app's absolute pathname + * based on the parent(s) that have + * mounted it. + * + * For example if the application was + * mounted as "/admin", which itself + * was mounted as "/blog" then the + * return value would be "/blog/admin". + */ + path(): string; + + /** + * Check if `setting` is enabled (truthy). + * + * app.enabled('foo') + * // => false + * + * app.enable('foo') + * app.enabled('foo') + * // => true + */ + enabled(setting: string): boolean; + + /** + * Check if `setting` is disabled. + * + * app.disabled('foo') + * // => true + * + * app.enable('foo') + * app.disabled('foo') + * // => false + */ + disabled(setting: string): boolean; + + /** Enable `setting`. */ + enable(setting: string): this; + + /** Disable `setting`. */ + disable(setting: string): this; + + /** + * Render the given view `name` name with `options` + * and a callback accepting an error and the + * rendered template string. + * + * Example: + * + * app.render('email', { name: 'Tobi' }, function(err, html){ + * // ... + * }) + */ + render(name: string, options?: object, callback?: (err: Error, html: string) => void): void; + render(name: string, callback: (err: Error, html: string) => void): void; + + /** + * Listen for connections. + * + * A node `http.Server` is returned, with this + * application (which is a `Function`) as its + * callback. If you wish to create both an HTTP + * and HTTPS server you may do so with the "http" + * and "https" modules as shown here: + * + * var http = require('http') + * , https = require('https') + * , express = require('express') + * , app = express(); + * + * http.createServer(app).listen(80); + * https.createServer({ ... }, app).listen(443); + */ + listen(port: number, hostname: string, backlog: number, callback?: (error?: Error) => void): http.Server; + listen(port: number, hostname: string, callback?: (error?: Error) => void): http.Server; + listen(port: number, callback?: (error?: Error) => void): http.Server; + listen(callback?: (error?: Error) => void): http.Server; + listen(path: string, callback?: (error?: Error) => void): http.Server; + listen(handle: any, listeningListener?: (error?: Error) => void): http.Server; + + router: Router; + + settings: any; + + resource: any; + + map: any; + + locals: LocalsObj & Locals; + + /** + * The app.routes object houses all of the routes defined mapped by the + * associated HTTP verb. This object may be used for introspection + * capabilities, for example Express uses this internally not only for + * routing but to provide default OPTIONS behaviour unless app.options() + * is used. Your application or framework may also remove routes by + * simply by removing them from this object. + */ + routes: any; + + /** + * Used to get all registered routes in Express Application + */ + _router: any; + + use: ApplicationRequestHandler; + + /** + * The mount event is fired on a sub-app, when it is mounted on a parent app. + * The parent app is passed to the callback function. + * + * NOTE: + * Sub-apps will: + * - Not inherit the value of settings that have a default value. You must set the value in the sub-app. + * - Inherit the value of settings with no default value. + */ + on: (event: string, callback: (parent: Application) => void) => this; + + /** + * The app.mountpath property contains one or more path patterns on which a sub-app was mounted. + */ + mountpath: string | string[]; +} + +export interface Express extends Application { + request: Request; + response: Response; +} diff --git a/index.d.ts b/index.d.ts new file mode 100644 index 0000000000..085c56d68c --- /dev/null +++ b/index.d.ts @@ -0,0 +1,122 @@ +/* =================== USAGE =================== + + import express = require("express"); + var app = express(); + + =============================================== */ + +/// +/// + +import * as bodyParser from "body-parser"; +import * as core from "./core"; +import * as serveStatic from "serve-static"; + +/** + * Creates an Express application. The express() function is a top-level function exported by the express module. + */ +declare function e(): core.Express; + +declare namespace e { + /** + * This is a built-in middleware function in Express. It parses incoming requests with JSON payloads and is based on body-parser. + * @since 4.16.0 + */ + var json: typeof bodyParser.json; + + /** + * This is a built-in middleware function in Express. It parses incoming requests with Buffer payloads and is based on body-parser. + * @since 4.17.0 + */ + var raw: typeof bodyParser.raw; + + /** + * This is a built-in middleware function in Express. It parses incoming requests with text payloads and is based on body-parser. + * @since 4.17.0 + */ + var text: typeof bodyParser.text; + + /** + * These are the exposed prototypes. + */ + var application: Application; + var request: Request; + var response: Response; + + /** + * This is a built-in middleware function in Express. It serves static files and is based on serve-static. + */ + var static: serveStatic.RequestHandlerConstructor; + + /** + * This is a built-in middleware function in Express. It parses incoming requests with urlencoded payloads and is based on body-parser. + * @since 4.16.0 + */ + var urlencoded: typeof bodyParser.urlencoded; + + export function Router(options?: RouterOptions): core.Router; + + interface RouterOptions { + /** + * Enable case sensitivity. + */ + caseSensitive?: boolean | undefined; + + /** + * Preserve the req.params values from the parent router. + * If the parent and the child have conflicting param names, the child’s value take precedence. + * + * @default false + * @since 4.5.0 + */ + mergeParams?: boolean | undefined; + + /** + * Enable strict routing. + */ + strict?: boolean | undefined; + } + + interface Application extends core.Application {} + interface CookieOptions extends core.CookieOptions {} + interface Errback extends core.Errback {} + interface ErrorRequestHandler< + P = core.ParamsDictionary, + ResBody = any, + ReqBody = any, + ReqQuery = core.Query, + Locals extends Record = Record, + > extends core.ErrorRequestHandler {} + interface Express extends core.Express {} + interface Handler extends core.Handler {} + interface IRoute extends core.IRoute {} + interface IRouter extends core.IRouter {} + interface IRouterHandler extends core.IRouterHandler {} + interface IRouterMatcher extends core.IRouterMatcher {} + interface MediaType extends core.MediaType {} + interface NextFunction extends core.NextFunction {} + interface Locals extends core.Locals {} + interface Request< + P = core.ParamsDictionary, + ResBody = any, + ReqBody = any, + ReqQuery = core.Query, + Locals extends Record = Record, + > extends core.Request {} + interface RequestHandler< + P = core.ParamsDictionary, + ResBody = any, + ReqBody = any, + ReqQuery = core.Query, + Locals extends Record = Record, + > extends core.RequestHandler {} + interface RequestParamHandler extends core.RequestParamHandler {} + interface Response< + ResBody = any, + Locals extends Record = Record, + > extends core.Response {} + interface Router extends core.Router {} + interface Send extends core.Send {} +} + +export = e; diff --git a/package.json b/package.json index bdcd25e60f..b87ebfdbea 100644 --- a/package.json +++ b/package.json @@ -61,12 +61,20 @@ "vary": "^1.1.2" }, "devDependencies": { + "@arethetypeswrong/cli": "^0.17.4", + "@types/body-parser": "^1.19.5", + "@types/ejs": "^3.1.5", + "@types/node": "^22.13.10", + "@types/qs": "^6.9.18", + "@types/range-parser": "^1.2.7", + "@types/serve-static": "^1.15.7", "after": "0.8.2", "connect-redis": "^8.0.1", "cookie-parser": "1.4.7", "cookie-session": "2.1.0", "ejs": "^3.1.10", - "eslint": "8.47.0", + "eslint": "8.57.1", + "expect-type": "^1.2.0", "express-session": "^1.18.1", "hbs": "4.2.0", "marked": "^15.0.3", @@ -76,6 +84,8 @@ "nyc": "^17.1.0", "pbkdf2-password": "1.2.1", "supertest": "^6.3.0", + "typescript": "^5.8.2", + "typescript-eslint": "^8.26.0", "vhost": "~3.0.2" }, "engines": { @@ -86,6 +96,8 @@ "History.md", "Readme.md", "index.js", + "index.d.ts", + "core.d.ts", "lib/" ], "scripts": { @@ -93,6 +105,7 @@ "test": "mocha --require test/support/env --reporter spec --check-leaks test/ test/acceptance/", "test-ci": "nyc --exclude examples --exclude test --exclude benchmarks --reporter=lcovonly --reporter=text npm test", "test-cov": "nyc --exclude examples --exclude test --exclude benchmarks --reporter=html --reporter=text npm test", - "test-tap": "mocha --require test/support/env --reporter tap --check-leaks test/ test/acceptance/" + "test-tap": "mocha --require test/support/env --reporter tap --check-leaks test/ test/acceptance/", + "test-types": "tsc && attw --pack" } } diff --git a/test/types.ts b/test/types.ts new file mode 100644 index 0000000000..1b0d014265 --- /dev/null +++ b/test/types.ts @@ -0,0 +1,203 @@ +import * as express from '../index'; +import * as http from 'http'; +import * as ejs from 'ejs'; +import { Request, ParamsArray, ParamsDictionary } from '../core'; +import { expectTypeOf } from 'expect-type'; + +namespace express_tests { + const app = express(); + + expectTypeOf(app.engine).toBeCallableWith('html', ejs.renderFile); + + expectTypeOf(express.static.mime.define).toBeCallableWith({ + 'application/fx': ['fx'] + }); + expectTypeOf(app.use).toBeCallableWith('/static', express.static(__dirname + '/public')); + + // Simple logger + app.use((req, res, next) => { + expectTypeOf(console.log).toBeCallableWith('%s %s', req.method, req.url); + expectTypeOf(next).toBeCallableWith(); + }); + + expectTypeOf(app.use).toBeCallableWith((err: any, req: express.Request, res: express.Response, next: express.NextFunction) => { + expectTypeOf(console.error).toBeCallableWith(err); + expectTypeOf(next).toBeCallableWith(err); + }); + + app.get('/', (req, res) => { + expectTypeOf(res.send).toBeCallableWith('hello world'); + }); + + // Accept json app-wide or on one endpoint. + expectTypeOf(app.use).toBeCallableWith(express.json({ limit: "200kb" })); + app.post('/echo', express.json(), (req, res) => { + expectTypeOf(res.json).toBeCallableWith(req.body); + }); + + // Accept urlencoded app-wide or on one endpoint. + expectTypeOf(app.use).toBeCallableWith(express.urlencoded({ + extended: false, + parameterLimit: 16 + })); + app.post('/search', express.urlencoded(), (req, res) => { + expectTypeOf(res.json).toBeCallableWith(Object.keys(req.body)); + }); + + const router = express.Router({ caseSensitive: true, mergeParams: true, strict: true }); + + expectTypeOf(router.get).toBeCallableWith('test'); + expectTypeOf(router.put).toBeCallableWith('test'); + expectTypeOf(router.post).toBeCallableWith('test'); + expectTypeOf(router.delete).toBeCallableWith('test'); + expectTypeOf(router.get).toBeCallableWith(/test/); + expectTypeOf(router.put).toBeCallableWith(/test/); + expectTypeOf(router.post).toBeCallableWith(/test/); + expectTypeOf(router.delete).toBeCallableWith(/test/); + + + router.use((req, res, next) => { + expectTypeOf(next).toBeCallableWith(); + }); + + router.route('/users') + .get((req, res, next) => { + expectTypeOf(req.accepts()).toEqualTypeOf(); + expectTypeOf(req.accepts(['json', 'text'])).toEqualTypeOf(); + expectTypeOf(req.accepts('json', 'text')).toEqualTypeOf(); + + expectTypeOf(req.acceptsCharsets()).toEqualTypeOf(); + expectTypeOf(req.acceptsCharsets(['utf-8', 'utf-16'])).toEqualTypeOf(); + expectTypeOf(req.acceptsCharsets('utf-8', 'utf-16')).toEqualTypeOf(); + + expectTypeOf(req.acceptsEncodings()).toEqualTypeOf(); + expectTypeOf(req.acceptsEncodings(['gzip', 'deflate'])).toEqualTypeOf(); + expectTypeOf(req.acceptsEncodings('gzip', 'deflate')).toEqualTypeOf(); + + expectTypeOf(req.acceptsLanguages()).toEqualTypeOf(); + expectTypeOf(req.acceptsLanguages(['en', 'ja'])).toEqualTypeOf(); + expectTypeOf(req.acceptsLanguages('en', 'ja')).toEqualTypeOf(); + + expectTypeOf(req.get('set-cookie')).toEqualTypeOf(); + expectTypeOf(req.header('header')).toEqualTypeOf(); + + expectTypeOf(req.headers.existingHeader).toEqualTypeOf(); + + // Since 4.14.0 req.range() has options + expectTypeOf(req.range).toBeCallableWith(2, { combine: true }); + + expectTypeOf(res.send).toBeCallableWith(req.query['token']); + }); + + router.get('/user/:id', (req, res, next) => { + expectTypeOf(next).toBeCallableWith('route'); + expectTypeOf(next).toBeCallableWith('router'); + expectTypeOf(next).toBeCallableWith(); + expectTypeOf(next).toBeCallableWith(new Error('test')); + }, (req, res, next) => { + expectTypeOf(res.render).toBeCallableWith('regular'); + }); + + // Params defaults to dictionary + router.get('/:foo', req => { + expectTypeOf(req.params.foo).toBeString(); + expectTypeOf(req.params).not.toBeArray(); + }); + + // Params can be used as an array + router.get('/*', req => { + expectTypeOf(req.params[0]).toBeString(); + expectTypeOf(req.params.length).toBeNumber(); + }); + + // Params can be used as an array and can be specified via an explicit param type (core) + router.get('/*', (req: Request) => { + expectTypeOf(req.params[0]).toBeString(); + expectTypeOf(req.params.length).toBeNumber(); + }); + + // Params can be used as an array and can be specified via an explicit param type (express) + router.get('/*', (req: express.Request) => { + expectTypeOf(req.params[0]).toBeString(); + expectTypeOf(req.params.length).toBeNumber(); + }); + + // Params can be a custom type that conforms to constraint + router.get<{ foo: string }>('/:foo', req => { + expectTypeOf(req.params.foo).toBeString(); + expectTypeOf(req.params).not.toHaveProperty("bar"); + }); + + // Params can be a custom type that conforms to constraint and can be specified via an explicit param type (core) + router.get('/:foo', (req: Request<{ foo: string }>) => { + expectTypeOf(req.params.foo).toBeString(); + expectTypeOf(req.params).not.toHaveProperty("bar"); + }); + + // Params can be a custom type that conforms to constraint and can be specified via an explicit param type (express) + router.get('/:foo', (req: express.Request<{ foo: string }>) => { + expectTypeOf(req.params.foo).toBeString(); + expectTypeOf(req.params).not.toHaveProperty("bar"); + }); + + // Params cannot be a custom type that does not conform to constraint + expectTypeOf<{ foo: number }>().not.toExtend(); + + // Response will default to any type + router.get("/", (req: Request, res: express.Response) => { + expectTypeOf(res.json).toBeCallableWith({}); + }); + + // Response will be of Type provided + router.get("/", (req: Request, res: express.Response) => { + expectTypeOf(res.json).toBeCallableWith(); + expectTypeOf(res.json).parameter(0).exclude(undefined).toBeString(); + expectTypeOf(res.send).parameter(0).exclude(undefined).toBeString(); + }); + + // Router is a handler + app.use((req, res, next) => { + expectTypeOf(router).toBeCallableWith(req, res, next); + }); + + // Test append function + app.use((req, res, next) => { + expectTypeOf(res.append).toBeCallableWith('Link', ['', '']); + expectTypeOf(res.append).toBeCallableWith('Set-Cookie', 'foo=bar; Path=/; HttpOnly'); + expectTypeOf(res.append).toBeCallableWith('Warning', '199 Miscellaneous warning'); + }); + + expectTypeOf(app.use).toBeCallableWith(router); + + // Test req.res, req.next, res.req should exist after middleware.init + app.use((req, res) => { + expectTypeOf(req).toHaveProperty("res"); + expectTypeOf(req).toHaveProperty("next"); + expectTypeOf(res).toHaveProperty("req"); + }); + + // Test mounting sub-apps + expectTypeOf(app.use).toBeCallableWith('/sub-app', express()); + + // Test on mount event + expectTypeOf(app.on).toBeCallableWith('mount', (parent) => true); + + // Test mount path + expectTypeOf(app.mountpath).toEqualTypeOf(); + + expectTypeOf(app.listen).toBeCallableWith(3000); + + expectTypeOf().toExtend<() => void>(); +} + +/*************************** + * * + * Test with other modules * + * * + ***************************/ + +namespace node_tests { + // http.createServer can take express application + expectTypeOf(express).returns.toExtend(); + expectTypeOf(http.createServer).toBeCallableWith(express()); +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000000..03791a3772 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "module": "commonjs", + "lib": ["es6"], + "noImplicitAny": true, + "noImplicitThis": true, + "strictNullChecks": true, + "strictFunctionTypes": true, + "noEmit": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["./*.d.ts", "./test/types.ts"] +}