Skip to main content
Module

x/opine/src/response.ts

Fast, minimalist web framework for Deno ported from ExpressJS.
Very Popular
Go to Latest
File
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932
// deno-lint-ignore-file no-explicit-any no-this-aliasimport { contentDisposition } from "./utils/contentDisposition.ts";import { stringify } from "./utils/stringify.ts";import { normalizeType, normalizeTypes } from "./utils/normalizeType.ts";import { hasCookieNameProperty, hasCookieRequiredProperties,} from "./utils/cookies.ts";import { send, sendError } from "./utils/send.ts";import { contentType, Cookie, encodeUrl, escapeHtml, extname, fromFileUrl, isAbsolute, resolve, setCookie, Status, STATUS_TEXT, vary,} from "../deps.ts";import type { Application, CookieOptions, CookieWithOptionalValue, DenoResponseBody, NextFunction, OpineRequest, OpineResponse, ResponseBody,} from "../src/types.ts";
/** * OpineResponse class. * * @public */export class Response implements OpineResponse { status: Status = 200; headers: Headers = new Headers(); written = false; body!: DenoResponseBody; app!: Application; req!: OpineRequest; locals!: any;
#resources: number[] = [];
/** * Add a resource ID to the list of resources to be * closed after the .end() method has been called. * * @param {number} rid Resource ID * @public */ addResource(rid: number): void { this.#resources.push(rid); }
/** * Append additional header `field` with value `val`. * Value can be either a `string` or an array of `string`. * * Example: * * res.append('Set-Cookie', 'foo=bar; Path=/; HttpOnly'); * res.append('Warning', '199 Miscellaneous warning'); * res.append("cache-control", ["public", "max-age=604800", "immutable"]); * * @param {string} field * @param {string|string[]} value * @return {OpineResponse} for chaining * @public */ append(field: string, value: string | string[]): this { if (Array.isArray(value)) { for (let i = 0, len = value.length; i < len; i++) { this.headers.append(field, value[i]); } } else { this.headers.append(field, value); }
return this; }
/** * Set _Content-Disposition_ header to _attachment_ with optional `filename`. * * @param {string} filename * @return {OpineResponse} for chaining * @public */ attachment(filename: string): this { if (filename) { this.type(extname(filename)); }
this.set("Content-Disposition", contentDisposition("attachment", filename));
return this; }
/** * Set a cookie. Sets the cookie path to "/" if not defined. * * Examples: * * // "Remember Me" for 15 minutes * res.cookie({ name: "rememberme", value: "1", expires: new Date(Date.now() + 900000), httpOnly: true }); * * @param {Cookie} cookie * @return {OpineResponse} for chaining * @public */ cookie(name: string, value: string, options: CookieOptions): this; cookie(cookie: Cookie): this; cookie(nameOrCookie: unknown): this { let cookie: Cookie;
if (typeof nameOrCookie === "string") { cookie = { ...arguments[2], name: nameOrCookie, value: arguments[1] ?? "", }; } else if (hasCookieRequiredProperties(nameOrCookie)) { cookie = nameOrCookie; } else { throw new TypeError( "response.cookie, args provided do not match one of the supported signatures: " + Array.prototype.join.call(arguments, ", "), ); }
if (cookie.path == null) { cookie.path = "/"; }
setCookie(this.headers, cookie);
return this; }
/** * Clear a cookie. * * @param {string|CookieWithOptionalValue} cookie * @return {OpineResponse} for chaining * @public */ clearCookie(cookie: CookieWithOptionalValue): this; clearCookie(name: string, options?: CookieOptions): this; clearCookie(nameOrCookie: unknown): this { if (typeof nameOrCookie === "string") { setCookie( this.headers, { path: "/", ...arguments[1], value: "", expires: new Date(0), name: nameOrCookie, }, ); } else if (hasCookieNameProperty(nameOrCookie)) { setCookie(this.headers, { path: "/", ...nameOrCookie, value: "", expires: new Date(0), }); } else { throw new TypeError( "res.clearCookie, args provided do not match one of the supported signatures: " + Array.prototype.join.call(arguments, ", "), ); }
return this; }
/** * Transfer the file at the given `path` as an attachment. * * Optionally providing an alternate attachment `filename`. * * Optionally providing an `options` object to use with `res.sendFile()`. * * This function will set the `Content-Disposition` header, overriding * any existing `Content-Disposition` header in order to set the attachment * and filename. * * This method uses `res.sendFile()`. * * @param {string} path * @param {string} [filename] * @param {object} [options] * @return {Promise<OpineResponse>} * @public */ async download( path: string, filename?: string, options?: any, ): Promise<this | void> { const headers: { [key: string]: string } = { "Content-Disposition": contentDisposition("attachment", filename || path), };
// Merge user-provided headers if (options?.headers) { const keys = Object.keys(options.headers);
for (let i = 0; i < keys.length; i++) { const key = keys[i];
if (key.toLowerCase() !== "content-disposition") { headers[key] = options.headers[key]; } } }
// Merge user-provided options options = { ...options, headers, };
const fullPath = resolve( path.startsWith("file:") ? fromFileUrl(path) : path, );
// Send file return await this.sendFile(fullPath, options); }
/** * Ends the response process. * * @param {DenoResponseBody} body * @return {Promise<void>} * @public */ async end( body?: DenoResponseBody, ): Promise<void> { if (body) { this.body = body; }
this.written = true;
try { await this.req.respond(this); } catch (e) { // Connection might have been already closed if ( !(e instanceof Deno.errors.BadResource || e instanceof Deno.errors.BrokenPipe) ) { throw e; } }
for (const rid of this.#resources) { try { Deno.close(rid); } catch (e) { // Resource might have been already closed if (!(e instanceof Deno.errors.BadResource)) { throw e; } } }
this.#resources = []; }
/** * Sets an ETag header. * * @param {string|Uint8Array|Deno.FileInfo} chunk * @returns {OpineResponse} for chaining * @public */ etag(chunk: string | Uint8Array | Deno.FileInfo): this { const etagFn = this.app.get("etag fn");
if (typeof etagFn === "function" && typeof (chunk as any).length) { const etag = etagFn(chunk);
if (etag) { this.set("ETag", etag); } }
return 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('<p>hey</p>'); * }, * * 'application/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('<p>hey</p>'); * }, * * 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. * * @param {Object} obj * @return {OpineResponse} for chaining * @public */ format(obj: any): this { const req = this.req; const next = req.next as NextFunction;
const { default: fn, ...rest } = obj; const keys = Object.keys(rest); const accepts = keys.length > 0 ? req.accepts(keys) : false;
this.vary("Accept");
if (accepts) { const key = Array.isArray(accepts) ? accepts[0] : accepts; this.set("Content-Type", normalizeType(key).value); obj[key](req, this, next); } else if (fn) { fn(); } else { const err = new Error("Not Acceptable") as any; err.status = err.statusCode = 406; err.types = normalizeTypes(keys).map(function (o) { return o.value; });
next(err); }
return this; }
/** * Get value for header `field`. * * @param {string} field * @return {string} the header * @public */ get(field: string): string { return this.headers.get(field.toLowerCase()) || ""; }
/** * Send JSON response. * * Examples: * * res.json(null); * res.json({ user: 'deno' }); * * @param {ResponseBody} body * @return {OpineResponse} for chaining * @public */ json(body: ResponseBody): this { const app = this.app; const replacer = app.get("json replacer"); const spaces = app.get("json spaces"); const escape = app.get("json escape"); body = stringify(body, replacer, spaces, escape);
if (!this.get("Content-Type")) { this.type("application/json"); }
return this.send(body); }
/** * Send JSON response with JSONP callback support. * * Examples: * * res.jsonp(null); * res.jsonp({ user: 'deno' }); * * @param {ResponseBody} body * @return {OpineResponse} for chaining * @public */ jsonp(body: ResponseBody) { const app = this.app; const replacer = app.get("json replacer"); const spaces = app.get("json spaces"); const escape = app.get("json escape"); body = stringify(body, replacer, spaces, escape);
let callback = this.req.query[app.get("jsonp callback name")];
if (Array.isArray(callback)) { callback = callback[0]; }
if (typeof callback === "string" && callback.length !== 0) { this.set("X-Content-Type-Options", "nosniff"); this.type("text/javascript");
// restrict callback charset callback = callback.replace(/[^\[\]\w$.]/g, "");
// replace chars not allowed in JavaScript that are in JSON body = body .replace(/\u2028/g, "\\u2028") .replace(/\u2029/g, "\\u2029");
// the /**/ is a specific security mitigation for "Rosetta Flash JSONP abuse" // the typeof check is just to reduce client error noise body = `/**/ typeof ${callback} === 'function' && ${callback}(${body});`; } else if (!this.get("Content-Type")) { this.set("X-Content-Type-Options", "nosniff"); this.set("Content-Type", "application/json"); }
return this.send(body); }
/** * 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' * }); * * @param {any} links * @return {OpineResponse} for chaining * @public */ links(links: any) { let currentLink = this.get("Link");
if (currentLink) { currentLink += ", "; }
const link = currentLink + Object.entries(links).map(([rel, field]) => `<${field}>; rel="${rel}"`) .join(", ");
return this.set("Link", link); }
/** * Set the location header to `url`. * * The given `url` can also be "back", which redirects * to the _Referrer_ or _Referer_ headers or "/". * * Examples: * * res.location('/foo/bar').; * res.location('http://example.com'); * res.location('../login'); * * @param {string} url * @return {OpineResponse} for chaining * @public */ location(url: string): this { const loc = url === "back" ? (this.req.get("Referrer") || "/") : url;
// set location return this.set("Location", encodeUrl(loc)); }
/** * Redirect to the given `url` with optional response `status` * defaulting to `302`. * * The resulting `url` is determined by `res.location()`. * * Examples: * * res.redirect('/foo/bar'); * res.redirect('http://example.com'); * res.redirect(301, 'http://example.com'); * res.redirect('../login'); // /blog/post/1 -> /blog/login * * @param {Status} statusCode * @param {string} url * @public */ redirect(url: string): void; redirect(statusCode: Status, url: string): void; redirect() { let address: string; let body = ""; let status: Status;
if (arguments.length === 0) { throw new TypeError("res.redirect: requires a location url"); } else if (arguments.length === 1) { address = arguments[0] + ""; status = 302; } else { if (typeof arguments[0] !== "number" || Number.isNaN(arguments[0])) { throw new TypeError( "res.redirect: expected status code to be a valid number", ); }
address = arguments[1] + ""; status = arguments[0]; }
// Set location header address = this.location(address).get("Location");
// Support text/{plain,html} by default this.format({ text: function _renderRedirectBody() { body = `${STATUS_TEXT.get(status)}. Redirecting to ${address}`; },
html: function _renderRedirectHtmlBody() { const u = escapeHtml(address); body = `<p>${ STATUS_TEXT.get(status) }. Redirecting to <a href="${u}">${u}</a></p>`; },
default: function _renderDefaultRedirectBody() { body = ""; }, });
// Respond this.status = status;
if (this.req.method === "HEAD") { this.end(); } else { this.end(body); } }
/** * Remove a header from the response * * Examples: * * res.removeHeader('Accept'); * @param {string} field * @return {OpineResponse} for chaining * @public */ removeHeader(field: string): this { this.headers.has(field) && this.headers.delete(field); return this; }
/** * 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 * * @public */ render(view: string, options: any = {}, callback?: any) { const app = this.req.app; const req = this.req; const self = this; let done = callback;
// support callback function as second arg if (typeof options === "function") { done = options; options = {}; }
// merge res.locals options._locals = self.locals;
// default callback to respond done = done || function (err: unknown, str: string) { if (err) { return req.next!(err); }
self.send(str); };
// render app.render(view, options, done); }
/** * Send a response. * * Examples: * * res.send({ some: 'json' }); * res.send('<p>some html</p>'); * * @param {ResponseBody} body * @return {OpineResponse} for chaining * @public */ send(body: ResponseBody): this { let chunk: DenoResponseBody; const isUndefined = body === undefined;
if (isUndefined || body === null) { body = ""; }
switch (typeof body) { case "string": chunk = body; break; case "boolean": case "number": return this.json(body); case "object": default: if ( body instanceof Uint8Array || typeof (body as Deno.Reader).read === "function" ) { chunk = body as Uint8Array | Deno.Reader;
if (!this.get("Content-Type")) { this.type("bin"); } } else { return this.json(body); } }
if (typeof chunk === "string" && !this.get("Content-Type")) { this.type("html"); }
if ( !this.get("ETag") && (typeof chunk === "string" || chunk instanceof Uint8Array) && !isUndefined ) { this.etag(chunk); }
if (this.req.fresh) { this.status = 304; }
if (this.status === 204 || this.status === 304) { this.unset("Content-Type"); this.unset("Content-Length"); this.unset("Transfer-Encoding");
chunk = ""; }
if (this.req.method === "HEAD") { this.end(); } else { this.end(chunk); }
return this; }
/** * Transfer the file at the given `path`. * * Automatically sets the _Content-Type_ response header field. * * @param {string} path * @param {object} [options] * @return {Promise<OpineResponse>} * @public */ async sendFile(path: string, options: any = {}): Promise<this | void> { if (!path) { throw new TypeError("path argument is required to res.sendFile"); } else if (typeof path !== "string") { throw new TypeError("path must be a string to res.sendFile"); }
path = path.startsWith("file:") ? fromFileUrl(path) : path;
if (!options.root && !isAbsolute(path)) { throw new TypeError( "path must be absolute or specify root to res.sendFile", ); }
const onDirectory = async () => { let stat: Deno.FileInfo;
try { stat = await Deno.stat(path); } catch (err) { return sendError(this, err); }
if (stat.isDirectory) { return sendError(this); } };
options.onDirectory = onDirectory;
if (options.headers) { const obj = options.headers; const keys = Object.keys(obj);
for (let i = 0; i < keys.length; i++) { const k = keys[i]; this.set(k, obj[k]); } }
return await send(this.req as OpineRequest, this, path, options); }
/** * Send given HTTP status code. * * Sets the response status to `code` and the body of the * response to the standard description from deno's http_status.STATUS_TEXT * or the code number if no description. * * Examples: * * res.sendStatus(200); * * @param {Status} code * @return {OpineResponse} for chaining * @public */ sendStatus(code: Status): this { const body: string = STATUS_TEXT.get(code) || String(code);
this.setStatus(code); this.type("txt");
return this.send(body); }
/** * Set header `field` to `value`, or pass * an object of header fields. * * Examples: * * res.set('Accept', 'application/json'); * res.set({ * 'Accept-Language': 'en-US, en;q=0.5', * 'Accept': 'text/html', * }); * @param {string} field * @param {string} value * @return {OpineResponse} for chaining * @public */ set(field: string, value: string): this; set(obj: Record<string, string>): this; set(field: unknown, value?: unknown): this { if (arguments.length === 2) { const lowerCaseField = (field + "").toLowerCase(); const coercedVal = value + "";
if (lowerCaseField === "content-type") { this.type(coercedVal); } else { this.headers.set(lowerCaseField, coercedVal); } } else if (typeof field === "object" && field) { const entries = Object.entries(field);
for (const [key, val] of entries) { this.set(key, val); } }
return this; }
/** * Set header `field` to `value`, or pass * an object of header fields. * * alias for res.set() * * Examples: * * res.setHeader('Accept', 'application/json'); * res.setHeader({ * 'Accept-Language': "en-US, en;q=0.5", * 'Accept': 'text/html', * }); * @param {string} field * @param {string} value * @return {OpineResponse} for chaining * @public */ setHeader(field: string, value: string): this; setHeader(obj: Record<string, string>): this; setHeader(field: unknown, value?: unknown): this { return this.set(field as any, value as any); }
/** * Set status `code`. * * This method deviates from Express due to the naming clash * with Deno.OpineResponse `status` property. * * @param {Status} code * @return {OpineResponse} for chaining * @public */ setStatus(code: Status): this { this.status = code;
return this; }
/** * Set _Content-Type_ response header with `type`. * * Examples: * * res.type('.html'); * res.type('html'); * res.type('json'); * res.type('application/json'); * res.type('png'); * * @param {string} type * @return {OpineResponse} for chaining * @public */ type(type: string): this { const ct = contentType(type) || "application/octet-stream"; this.headers.set("content-type", ct);
return this; }
/** * Deletes a header. * * @param {string} field * @return {OpineResponse} for chaining * @public */ unset(field: string): this { this.headers.delete(field);
return this; }
/** * Add `field` to Vary. If already present in the Vary set, then * this call is simply ignored. * * @param {Array|String} field * @return {OpineResponse} for chaining * @public */ vary(field: string | string[]): this { vary(this.headers, field);
return this; }}