UNPKG

@nasriya/hypercloud

Version:

Nasriya HyperCloud is a lightweight Node.js HTTP2 framework.

992 lines 79.8 kB
import path from 'path'; import helpers from '../../../utils/helpers.js'; import Renderer from '../../renderer/renderer.js'; import Cookies from './cookies.js'; import fs from 'fs'; import ms from 'ms'; const _dirname = import.meta.dirname; const mimes = helpers.loadJSON(path.resolve(_dirname, '../../../data/mimes.json')); const extensions = helpers.loadJSON(path.resolve(_dirname, '../../../data/extensions.json')); /** * TODO: Change all the server examples to use my own server class */ /**This class is used internally, not by the user */ export class HyperCloudResponse { #_server; #_req; #_res; #_cookies; #_preservedHeaders = ['x-server', 'x-request-id']; #_encodings = Object.freeze([ "ascii", "utf8", "utf-8", "utf16le", "utf-16le", "ucs2", "ucs-2", "base64", "base64url", "latin1", "binary", "hex" ]); #_status = Object.seal({ closed: false }); #_next = undefined; constructor(server, req, res) { this.#_server = server; this.#_req = req; this.#_res = res; this.#_cookies = new Cookies(this); } get pages() { return Object.freeze({ /** * Return a not found `404` response. * * By default, **HyperCloud** returns its own `404` page. To return your * own page use the {@link HyperCloudServer.setHandler} method. * @example * // Use the default 404 page * response.pages.notFound({ * locals: { * title: '404 - Not Found', * subtitle: 'This page cannot be found', * home: 'Home' * } * }); * * // All options are "optional" and can be omitted * response.pages.notFound(); // Renders the default 404 page * @example * // Setting your own handler * server.handlers.notFound((request, response, next) => { * // Decide what to do here * }) * @param {NotFoundResponseOptions} [options] Rendering options */ notFound: async (options) => { try { if (typeof this.#_server._handlers.notFound === 'function') { try { // Run the user defined handler for not-found resources this.#_server._handlers.notFound(this.#_req, this, this._next); } catch (error) { this.pages.serverError({ error: error }); } } else { const viewName = 'hypercloud_404'; const page = this.server.rendering.pages.storage[viewName]; const locals = page.locals.get(this.req.language); const renderOptions = { locals: { title: helpers.is.validString(options?.locals?.title) ? options?.locals?.title : locals.title, subtitle: helpers.is.validString(options?.locals?.subtitle) ? options?.locals?.subtitle : locals.subtitle, homeBtnLabel: helpers.is.validString(options?.locals?.homeBtnLabel) ? options?.locals?.homeBtnLabel : locals.homeBtnLabel }, httpOptions: { cacheControl: false, statusCode: 404, } }; return this.render(viewName, renderOptions); } } catch (error) { console.error(error); return this.pages.serverError(); } }, /** * Return an unauthorized `401` response. * * By default, **HyperCloud** returns its own `401` page. To return your * own page use the {@link HyperCloudServer.setHandler} method. * @example * // Use the default 401 page * response.pages.unauthorized({ * locals: { * title: '401 - Unauthorized', * commands: { * code: 'ERROR CODE', * description: 'ERROR DESCRIPTION', * cause: 'ERROR POSSIBLY CAUSED BY', * allowed: 'SOME PAGES ON THIS SERVER THAT YOU DO HAVE PERMISSION TO ACCESS', * regards: 'HAVE A NICE DAY :-)' * }, * content: { * code: 'HTTP 401 Unauthorized', * description: 'Access Denied. You Do Not Have The Permission To Access This Page', * cause: 'execute access unauthorized, read access unauthorized, write access unauthorized', * allowed: [{ label: 'Home', link: '/' }, { label: 'About Us', link: '/about' }, { label: 'Contact Us', link: '/support/contact' }], * } * } * }); * * // All options are "optional" and can be omitted * response.pages.unauthorized(); // Renders the default 401 page * @example * // Setting your own handler * server.handlers.unauthorized((request, response, next) => { * // Decide what to do here * }) * @param {ForbiddenAndUnauthorizedOptions} [options] */ unauthorized: async (options) => { try { if (typeof this.#_server._handlers.unauthorized === 'function') { try { // Run the user defined handler for not-found resources this.#_server._handlers.unauthorized(this.#_req, this, this._next); } catch (error) { this.pages.serverError({ error: error }); } } else { const viewName = 'hypercloud_401'; const page = this.server.rendering.pages.storage[viewName]; const locals = page.locals.get(this.req.language); const renderOptions = { locals: { title: helpers.is.validString(options?.locals?.title) ? options?.locals?.title : locals.title, code: locals.code, description: locals.description, commands: { code: helpers.is.validString(options?.locals?.commands?.code) ? options?.locals?.commands?.code : locals.commands.code, description: helpers.is.validString(options?.locals?.commands?.description) ? options?.locals?.commands?.description : locals.commands.description, cause: helpers.is.validString(options?.locals?.commands?.cause) ? options?.locals?.commands?.cause : locals.commands.cause, allowed: helpers.is.validString(options?.locals?.commands?.allowed) ? options?.locals?.commands?.allowed : locals.commands.allowed, regards: helpers.is.validString(options?.locals?.commands?.regards) ? options?.locals?.commands?.regards : locals.commands.regards, }, content: { code: helpers.is.validString(options?.locals?.content?.code) ? options?.locals?.content?.code : locals.content.code, description: helpers.is.validString(options?.locals?.content?.description) ? options?.locals?.content?.description : locals.content.description, cause: helpers.is.validString(options?.locals?.content?.cause) ? options?.locals?.content?.cause : locals.content.cause, allowed: Array.isArray(options?.locals?.content?.allowed) ? options?.locals?.commands?.allowed : locals.commands.allowed, } }, httpOptions: { cacheControl: false, statusCode: 401, } }; return this.render(viewName, renderOptions); } } catch (error) { console.error(error); return this.pages.serverError(); } }, /** * Return a forbidden `403` response. * * By default, **HyperCloud** returns its own `403` page. To return your * own page use the {@link HyperCloudServer.setHandler} method. * @example * // Use the default 403 page * response.pages.forbidden({ * locals: { * title: '403 - Forbidden', * commands: { * code: 'ERROR CODE', * description: 'ERROR DESCRIPTION', * cause: 'ERROR POSSIBLY CAUSED BY', * allowed: 'SOME PAGES ON THIS SERVER THAT YOU DO HAVE PERMISSION TO ACCESS', * regards: 'HAVE A NICE DAY :-)' * }, * content: { * code: 'HTTP 403 Forbidden', * description: 'Access Denied. You Do Not Have The Permission To Access This Page', * cause: 'execute access forbidden, read access forbidden, write access forbidden', * allowed: [{ label: 'Home', link: '/' }, { label: 'About Us', link: '/about' }, { label: 'Contact Us', link: '/support/contact' }], * } * } * }); * * // All options are "optional" and can be omitted * response.pages.forbidden(); // Renders the default 403 page * @example * // Setting your own handler * server.handlers.forbidden((request, response, next) => { * // Decide what to do here * }) * @param {ForbiddenAndUnauthorizedOptions} options */ forbidden: async (options) => { try { if (typeof this.#_server._handlers.forbidden === 'function') { try { // Run the user defined handler for not-found resources this.#_server._handlers.forbidden(this.#_req, this, this._next); } catch (error) { this.pages.serverError({ error: error }); } } else { const viewName = 'hypercloud_403'; const page = this.server.rendering.pages.storage[viewName]; const locals = page.locals.get(this.req.language); const renderOptions = { locals: { title: helpers.is.validString(options?.locals?.title) ? options?.locals?.title : locals.title, code: locals.code, description: locals.description, commands: { code: helpers.is.validString(options?.locals?.commands?.code) ? options?.locals?.commands?.code : locals.commands.code, description: helpers.is.validString(options?.locals?.commands?.description) ? options?.locals?.commands?.description : locals.commands.description, cause: helpers.is.validString(options?.locals?.commands?.cause) ? options?.locals?.commands?.cause : locals.commands.cause, allowed: helpers.is.validString(options?.locals?.commands?.allowed) ? options?.locals?.commands?.allowed : locals.commands.allowed, regards: helpers.is.validString(options?.locals?.commands?.regards) ? options?.locals?.commands?.regards : locals.commands.regards, }, content: { code: helpers.is.validString(options?.locals?.content?.code) ? options?.locals?.content?.code : locals.content.code, description: helpers.is.validString(options?.locals?.content?.description) ? options?.locals?.content?.description : locals.content.description, cause: helpers.is.validString(options?.locals?.content?.cause) ? options?.locals?.content?.cause : locals.content.cause, allowed: Array.isArray(options?.locals?.content?.allowed) ? options?.locals?.commands?.allowed : locals.commands.allowed, } }, httpOptions: { cacheControl: false, statusCode: 403, } }; return this.render(viewName, renderOptions); } } catch (error) { console.error(error); return this.pages.serverError(); } }, /** * Return a server error `500` response. * * By default, **HyperCloud** returns its own `500` page. To return your * own page use the {@link HyperCloudServer.setHandler} method. * @example * // Use the default 500 page * response.pages.serverError({ * locals: { * title: '500 - Server Error', * subtitle: 'Internal <code>Server error<span>!</span></code>', * message: '<p> We\'re sorry, but something went wrong on our end. </p>' * }, * error: new Error('Something went wrong') * }); * * // All options are "optional" and can be omitted * response.pages.serverError(); // Renders the default 500 page * @example * // Setting your own handler * server.handlers.serverError((request, response, next) => { * // Decide what to do here * }) * @param {ServerErrorOptions} options */ serverError: async (options) => { try { if (options && 'error' in options) { const dashLine = '#'.repeat(50); const diver = `${dashLine}\n${dashLine}`; helpers.printConsole(diver); console.error(`A server error has occurred`); helpers.printConsole(`${new Date().toUTCString()} - Page Load Error - Request ID: ${this.#_req.id}`); helpers.printConsole(`Request:\n${this.#_req._toString()}`); helpers.printConsole(options.error); helpers.printConsole(diver); } if (typeof this.#_server._handlers.serverError === 'function' && options?.bypassHandler !== true) { try { // Run the user defined handler for not-found resources this.#_server._handlers.serverError(this.#_req, this, this._next); } catch (error) { this.pages.serverError({ bypassHandler: true }); } } else { const viewName = 'hypercloud_500'; const page = this.server.rendering.pages.storage[viewName]; const locals = page.locals.get(this.req.language); const renderOptions = { locals: { title: helpers.is.validString(options?.locals?.title) ? options?.locals?.title : locals.title, subtitle: helpers.is.validString(options?.locals?.subtitle) ? options?.locals?.subtitle : locals.subtitle, message: helpers.is.validString(options?.locals?.message) ? options?.locals?.message : locals.message, }, httpOptions: { cacheControl: false, statusCode: 500, } }; return this.render(viewName, renderOptions); } } catch (error) { console.error(error); return this.status(500).json({ message: 'A serious server error has occurred. Please report this issue to the framework repo.' }); } } }); } /** * HyperCloud's next() function * @private */ get _next() { return this.#_next; } set _next(value) { if (typeof value === 'function') { this.#_next = value; } } /** * Redirect the client to a new location * @param {string} url A relative or full path URL. * @param {RedirectCode} [code] A redirect code. Default `307`. Learn more about [redirections in HTTP](https://developer.mozilla.org/en-US/docs/Web/HTTP/Redirections). */ redirect(url, code = 307) { try { if (typeof url !== 'string') { throw new TypeError(`The redirect URL should be a string, but instead got ${typeof url}`); } if (typeof code === 'number' || typeof code === 'string') { if (typeof code === 'string') { try { code = Number.parseInt(code); } catch (error) { throw new TypeError(`The redirect code should be a number, instead got ${typeof code}`); } } const codes = [300, 301, 302, 303, 304, 307, 308]; if (!codes.includes(code)) { throw new RangeError(`Invalid redirect code: ${code}. Learn more about redirections at: https://developer.mozilla.org/en-US/docs/Web/HTTP/Redirections`); } this.status(code).setHeader('Location', url); return this.end(); } else { throw new TypeError(`The redirect code should be a number, instead got ${typeof code}`); } } catch (error) { if (typeof error === 'string') { error = `Unable to redirect: ${error}`; } if (error instanceof Error) { error.message = `Unable to redirect: ${error.message}`; } throw error; } } /** * Render a page template with the provided options. * @param {string} name A defined `Page` name * @param {PageRenderingOptions} options * @returns {HyperCloudResponse} */ async render(name, options) { try { const renderer = new Renderer(this.#_req, name); const html = await renderer.render(options); this.setHeader('Content-Type', 'text/html'); if (options && 'httpOptions' in options) { if (options && options.httpOptions && helpers.is.realObject(options.httpOptions)) { if ('cacheControl' in options.httpOptions) { if (typeof options.httpOptions.cacheControl !== 'boolean') { throw new TypeError(`The "cacheControl" option in response.render expected a boolean value but instead got ${typeof options.httpOptions.cacheControl}`); } if (options.httpOptions.cacheControl === true) { const ONEYEAR = 31_536_000_000; // in ms const cache = { maxAge: 0, immutable: false }; if (!('maxAge' in options.httpOptions)) { throw new SyntaxError('The render cache-control was enabled without providing the maxAge'); } if (!(typeof options.httpOptions.maxAge === 'number' || typeof options.httpOptions.maxAge === 'string')) { throw new TypeError(`The maxAge property should be either a number or string, but instead got ${typeof options.httpOptions.maxAge}`); } if (typeof options.httpOptions.maxAge === 'number') { cache.maxAge = options.httpOptions.maxAge; } if (typeof options.httpOptions.maxAge === 'string') { const maxAgeStr = options.httpOptions.maxAge.trim(); if (maxAgeStr.length === 0) { throw new SyntaxError(`The maxAge string value cannot be empty`); } const value = ms(maxAgeStr); if (typeof value !== 'number') { throw new SyntaxError(`${options.httpOptions.maxAge} is not a valid maxAge value`); } cache.maxAge = value; } if (options.httpOptions.maxAge < 0) { throw new RangeError(`The maxAge cannot be a negative value`); } if ((options.httpOptions.maxAge) > ONEYEAR) { throw new RangeError(`The maxAge value should not be more than one year`); } if ('immutable' in options.httpOptions) { if (typeof options.httpOptions.immutable !== 'boolean') { throw new TypeError(`The immutable property only accepts boolean values, but instead got ${typeof options.httpOptions.immutable}`); } cache.immutable = true; } const expiryDate = new Date(Date.now() + cache.maxAge).toUTCString(); this.setHeader('Cache-Control', `public, max-age=${cache.maxAge}${cache.immutable ? ', immutable' : ''}`); this.setHeader('Expires', expiryDate); } else { this.setHeader('Cache-Control', 'no-cache'); } } else { this.setHeader('Cache-Control', 'no-cache'); } if ('statusCode' in options.httpOptions) { if (typeof options.httpOptions.statusCode !== 'number') { throw new TypeError(`The "statusCode" option in response.render expected a number value but instead got ${typeof options.httpOptions.statusCode}`); } this.status(options.httpOptions.statusCode); } if ('eTag' in options.httpOptions && options.httpOptions.eTag) { if (typeof options.httpOptions.eTag !== 'string') { throw new TypeError(`The "eTag" option in response.render expected a string value but got ${typeof options.httpOptions.eTag}`); } this.setHeader('etag', options.httpOptions.eTag); } } } this.write({ chunk: html, encoding: 'utf-8' }); return this.end(); } catch (error) { if (typeof error === 'string') { error = `Unable to render page: ${error}`; } if (error instanceof Error) { error.message = `Unable to render page: ${error.message}`; } throw error; } } /** * Download a file using the `response.downloadFile` method. * @param {string} filePath The file path (relative/absolute). When providing a relative path, you must specify the `root` in the `options` argument * @param {DownloadFileOptions} options Options for sending the file * @returns {http2.Http2ServerResponse|undefined} */ downloadFile(filePath, options) { const sendOptions = helpers.is.realObject(options) ? { ...options, download: true } : { download: true }; return this.sendFile(filePath, sendOptions); } /** * Send a file back to the client * @param {string} filePath The file path (relative/absolute). When providing a relative path, you must specify the `root` in the `options` argument * @param {SendFileOptions} [options] Options for sending the file * @returns {http2.Http2ServerResponse|undefined} */ sendFile(filePath, options) { const root = process.cwd(); try { // Basic filePath validations if (typeof filePath !== 'string') { throw new TypeError(`The sendFile expected a file path to be passed as its first argument, but instead got ${typeof filePath}`); } if (filePath.length === 0) { throw new SyntaxError('The file path cannot be an empty string'); } // Validating the root path if provided if (options && 'root' in options) { if (typeof options.root !== 'string') { throw new TypeError(`The root path of the file should be of type string, but instead got ${typeof options.root}`); } if (options.root.length === 0) { throw new SyntaxError(`The root path cannot be an empty string`); } const rootAvail = helpers.checkPathAccessibility(options.root); if (!rootAvail.valid) { if (rootAvail.errors.doesntExist) { throw new Error(`The provided root path (${options.root}) doesn't exist`); } if (rootAvail.errors.doesntExist) { throw new Error(`You don't have enough permissions to access the root path: ${options.root}`); } } filePath = path.resolve(options.root, filePath); if (!filePath.startsWith(options.root)) { throw new RangeError(`When providing a relative filePath, the relative path must not escape the provided root directory`); } } // Validating the file path const fileAvail = helpers.checkPathAccessibility(filePath); if (!fileAvail.valid) { if (fileAvail.errors.doesntExist) { throw new Error(`The provided filePath (${filePath}) doesn't exist`); } if (fileAvail.errors.notAccessible) { throw new Error(`You don't have enough permissions to access the file path: ${filePath}`); } } const fileName = (() => { if (options && 'fileName' in options) { if (helpers.isNot.validString(options.fileName)) { throw new TypeError(`The procided filename is not a string but a ${typeof options.fileName}`); } return options.fileName; } else { const paths = filePath.split('\\'); return paths[paths.length - 1]; } })(); // Handling dotFiles if (fileName.startsWith('.')) { if (options && 'dotfiles' in options) { const allowed = ['allow', 'deny', 'ignore']; if (!allowed.includes(options.dotfiles || '')) { throw new TypeError(`The dotfiles property was provided with an unsupported value. Only "allow", "deny", and "ignore" are supported`); } const choice = options.dotfiles; if (choice === 'ignore') { if ('notFoundFile' in options) { const notFoundAvail = helpers.checkPathAccessibility(options.notFoundFile); if (!notFoundAvail.valid) { if (notFoundAvail.errors.notString) { throw new Error(`The notFoundFile path should be a string, instead got ${typeof options.notFoundFile}`); } if (notFoundAvail.errors.doesntExist) { throw new Error(`The notFoundFile path (${options.notFoundFile}) doesn't exist`); } if (notFoundAvail.errors.notAccessible) { throw new Error(`You don't have enough permissions to access the notFoundFile path: ${options.notFoundFile}`); } } if (!options.notFoundFile?.toLowerCase().startsWith(root.toLowerCase())) { throw new RangeError(`The not 404 file (${options.notFoundFile}) is not in your root directory.`); } this.setHeader('Content-Type', 'text/html'); this.write({ chunk: fs.readFileSync(options.notFoundFile) }); this.end(); return; } else { this.status(404).json({ message: 'File not found', code: 404 }); return; } } if (choice === 'deny') { if ('unauthorizedFile' in options) { const unAuthAvail = helpers.checkPathAccessibility(options.unauthorizedFile); if (!unAuthAvail.valid) { if (unAuthAvail.errors.notString) { throw new Error(`The unauthorizedFile path should be a string, instead got ${typeof options.unauthorizedFile}`); } if (unAuthAvail.errors.doesntExist) { throw new Error(`The unauthorizedFile path (${options.unauthorizedFile}) doesn't exist`); } if (unAuthAvail.errors.notAccessible) { throw new Error(`You don't have enough permissions to access the unauthorizedFile path: ${options.unauthorizedFile}`); } } if (!options.unauthorizedFile?.toLowerCase().startsWith(root.toLowerCase())) { throw new RangeError(`The not 401 file (${options.unauthorizedFile}) is not in your root directory.`); } this.setHeader('Content-Type', 'text/html'); this.write({ chunk: fs.readFileSync(options.unauthorizedFile) }); this.end(); return; } else { this.status(401).json({ message: 'Unauthorized', code: 401 }); return; } } } } // Validate the modification property (if provided) if (options && 'lastModified' in options) { if (typeof options.lastModified !== 'boolean') { throw new TypeError(`The lastModified option can only be a boolean type, but instead got ${typeof options.lastModified}`); } } // Get the file stats const stats = fs.statSync(filePath); // Set the modification time if (!options || options?.lastModified !== false) { this.setHeader('Last-Modified', stats.mtime.toUTCString()); } // Checking the cache-control if (options && 'cacheControl' in options) { if (typeof options.cacheControl !== 'boolean') { throw new TypeError(`The cacheControl option can only be a boolean type, but instead got ${typeof options.cacheControl}`); } if (options.cacheControl) { const ONEYEAR = 31_536_000_000; // in ms const cache = { maxAge: 0, immutable: false }; if (!('maxAge' in options)) { throw new SyntaxError('The sendFile cache-control was enabled without providing the maxAge'); } if (!(typeof options.maxAge === 'number' || typeof options.maxAge === 'string')) { throw new TypeError(`The maxAge property should be either a number or string, but instead got ${typeof options.maxAge}`); } if (typeof options.maxAge === 'number') { cache.maxAge = options.maxAge; } if (typeof options.maxAge === 'string') { const maxAge = options.maxAge.trim(); if (maxAge.length === 0) { throw new SyntaxError(`The maxAge string value cannot be empty`); } const value = ms(maxAge); if (typeof value !== 'number') { throw new SyntaxError(`${options.maxAge} is not a valid maxAge value`); } cache.maxAge = value; } if (options.maxAge < 0) { throw new RangeError(`The maxAge cannot be a negative value`); } if (options.maxAge > ONEYEAR) { throw new RangeError(`The maxAge value should not be more than one year`); } if ('immutable' in options) { if (typeof options.immutable !== 'boolean') { throw new TypeError(`The immutable property only accepts boolean values, but instead got ${typeof options.immutable}`); } cache.immutable = true; } const expiryDate = new Date(Date.now() + cache.maxAge).toUTCString(); this.setHeader('Cache-Control', `public, max-age=${cache.maxAge}${cache.immutable ? ', immutable' : ''}`); this.setHeader('Expires', expiryDate); } } // Applying the headers if (options && 'headers' in options) { const headers = Object.keys(options.headers || {}); if (typeof options.headers === 'object' && headers.length > 0) { const preserved = [...this.#_preservedHeaders, 'last-modified', 'cache-control', 'expires']; const headersUsed = [...preserved]; for (const headerInput of headers) { const headerName = headerInput.toLowerCase(); if (!headersUsed.includes(headerName)) { headersUsed.push(headerName); this.setHeader(headerName, options.headers[headerInput]); } } } } if (options && 'eTag' in options && options.eTag) { if (typeof options.eTag !== 'string') { throw new TypeError(`The "eTag" option in response.sendFile expected a string value but got ${typeof options.eTag}`); } this.setHeader('etag', options.eTag); } // Preparing the mime-type const exts = fileName.split('.').filter(i => i.length > 0); const extension = `.${exts[exts.length - 1]}`; const mime = extensions.find(i => i.extension.includes(extension))?.mime; // Check if the download option is triggered or not if (options && 'download' in options) { if (typeof options.download !== 'boolean') { throw new TypeError(`The download property should be a boolean value, but instead got ${typeof options.download}`); } if (options.download === true) { this.setHeader('Content-Type', 'application/octet-stream'); this.setHeader('Content-Disposition', `attachment; filename="${fileName}"`); } else { this.setHeader('Content-Type', mime); } } else { this.setHeader('Content-Type', mime); } // Checking the range settings if (options && 'acceptRanges' in options) { if (typeof options.acceptRanges !== 'boolean') { throw new TypeError(`The acceptRanges option only accepts boolean values, but instead got ${typeof options.acceptRanges}`); } const range = this.req.headers.range; // Check if the request has ranges if (range) { // Function to parse the Range header const parseRangeHeader = (range, size) => { const [start, end] = range.replace(/bytes=/, '').split('-'); const parsedStart = parseInt(start, 10); const parsedEnd = parseInt(end, 10); const validStart = isNaN(parsedStart) ? 0 : Math.max(0, parsedStart); const validEnd = isNaN(parsedEnd) ? size - 1 : Math.min(size - 1, parsedEnd); return [validStart, validEnd]; }; const totalSize = stats.size; const [start, end] = parseRangeHeader(range, totalSize); const chunkSize = (end - start) + 1; this.status(206); this.setHeader('Content-Range', `bytes ${start}-${end}/${totalSize}`); this.setHeader('Accept-Ranges', 'bytes'); this.setHeader('Content-Length', chunkSize); const fileStream = fs.createReadStream(filePath, { start, end }); return fileStream.pipe(this.#_res); } } this.status(200).setHeader('Content-Length', stats.size); const fileStream = fs.createReadStream(filePath); return fileStream.pipe(this.#_res); } catch (error) { if (options && 'serverErrorFile' in options) { const errValidity = helpers.checkPathAccessibility(options.serverErrorFile); if (!errValidity.valid) { if (errValidity.errors.notString) { throw new Error(`The serverErrorFile path should be a string, instead got ${typeof options.serverErrorFile}`); } if (errValidity.errors.doesntExist) { throw new Error(`The serverErrorFile path (${options.serverErrorFile}) doesn't exist`); } if (errValidity.errors.notAccessible) { throw new Error(`You don't have enough permissions to access the errValidity path: ${options.serverErrorFile}`); } } if (!options.serverErrorFile?.toLowerCase().startsWith(root.toLowerCase())) { throw new RangeError(`The not 500 file (${options.serverErrorFile}) is not in your root directory.`); } this.setHeader('Content-Type', 'text/html'); this.write({ chunk: fs.readFileSync(options.serverErrorFile) }); console.error(error); this.status(500).end(); return; } if (error instanceof Error) { error.message = `Unable to send file: ${error.message}`; } throw error; } } /** * Send a response. * * Examples: * @example * // Send buffer * response.send(Buffer.from('wahoo')); * // Send JSON * response.send({ some: 'json' }); * //Send HTML content * response.send('<p>some html</p>'); * // Sending plain text * response.status(404).send('Sorry, cant find that'); * // Sending a file * const fs = require('fs'); * response.status(200).send(fs.readFileSync('./style.css', { encoding: 'utf8' }), 'text/css'); * @param {string|object|Buffer} data The data to be sent * @param {MimeType} [contentType] Specify the type of content */ send(data, contentType) { let type = null; if (typeof data === 'string') { if (typeof contentType === 'string' && mimes.includes(contentType.toLowerCase())) { type = contentType; } else if (helpers.is.html(data)) { type = 'text/html'; } else { type = 'text/plain'; } } else if (Buffer.isBuffer(data)) { if (typeof contentType === 'string' && mimes.includes(contentType.toLowerCase())) { type = contentType; } else { type = 'application/octet-stream'; } } else if (Array.isArray(data) || (typeof data === 'object' && data !== null)) { data = JSON.stringify(data); if (typeof contentType === 'string' && mimes.includes(contentType.toLowerCase())) { type = contentType; } else { type = 'application/json'; } } else { throw new TypeError(`${typeof data} is not a valid data type. Expected an Object, String, or Buffer, but instead got ${typeof data}`); } this.setHeader('Content-Type', type); this.write({ chunk: data }); return this.end(); } /** * Send JSON response. * *Examples:* * @example * response.json(null); * response.json({ user: 'tj' }); * response.status(500).json('oh noes!'); * response.status(404).json('I dont have that'); * @param data */ json(data) { const chunk = Array.isArray(data) || helpers.is.realObject(data) ? JSON.stringify(data) : String(data); this.setHeader('Content-Type', 'application/json'); this.write({ chunk }); return this.end(); } /** * When using implicit headers (not calling `response.writeHead()` explicitly), * this method controls the status code that will be sent to the client when * the headers get flushed. * * ```js * response.status(404); * ``` * @param {number} statusCode The status code of the request * @returns {this} */ status(statusCode) { try { this.statusCode = statusCode; } catch (error) { throw error; } return this; } /** * Add an event handler * @param {EventConfig} config */ addListener(config) { const events = ['close', 'drain', 'error', 'finish', 'pipe', 'unpipe']; if (events.includes(config?.event)) { throw `${config.event} is not a valid response event`; } if (typeof config?.listener !== 'function') { throw 'The event listener must be a function'; } this.#_res.addListener(config.event, config.listener); return this; } /** * Returns a copy of the array of listeners for the event named eventName. * * ```js * server.on('connection', (stream) => { * console.log('someone connected!'); * }); * * console.log(util.inspect(server.listeners('connection'))); * // Prints: [ [Function] ] * ``` * @param {string|symbol} eventName The event name */ listeners(eventName) { return this.#_res.listeners(eventName); } /** * This method adds HTTP trailing headers (a header but at the end of the message) to the response. * * Attempting to set a header field name or value that contains invalid characters will result in a ```TypeError``` being thrown. * @param {http2.OutgoingHttpHeaders} trailers */ addTrailers(trailers) { this.#_res.addTrailers(trailers); } /** * This method signals to the server that all of * the response headers and body have been sent; * that server should consider this message complete. * The method, ```response.end()```, MUST be called on each response. * * If data is specified, it is equivalent to calling * ```response.write(data, encoding)``` followed by ```response.end(callback)```. * * If ```callback``` is specified, it will be called when the response stream is finished. * @param {ResponseEndOptions} [options] End stream options * @returns {this} */ end(options) { if (helpers.is.undefined(options) || helpers.isNot.realObject(options)) { this.#_res.end(); return this; } const params = { data: options && 'data' in options && options.data ? options.data : null, callback: options && 'callback' in options && typeof options.callback === 'function' ? options.callback : null, encoding: options && 'encoding' in options && typeof options.encoding === 'string' && this.#_encodings.includes(options.encoding) ? options.encoding : null }; if (params.data) { if (params.encoding && params.callback) { this.#_res.end(params.data, params.encoding, params.callback); } else if (params.callback) { this.#_res.end(params.data, params.callback); } else { this.#_res.end(params.data); } } else if (params.callback) { this.#_res.end(params.callback); } else { this.#_res.end(); } return this; } /** * Reads out a header that has already been queued but not sent to the client. The name is case-insensitive. * * ```js * const contentType = response.getHeader('content-type'); * ``` * @param {string} name The header name * @returns {string} The header value */ getHeader(name) { return this.#_res.getHeader(name); } /** * Returns an array containing the unique names of the current outgoing headers. All header names are lowercase. * * ```js * response.setHeader('Foo', 'bar'); * response.setHeader('Set-Cookie', ['foo=bar', 'bar=baz']); * * const headerNames = response.getHeaderNames(); * // headerNames === ['foo', 'set-cookie'] * ``` * @returns {string[]} The names of the provided headers */ getHeaderNames() { return this.#_res.getHeaderNames(); } /** * Returns a shallow copy of the current outgoing headers. * Since a shallow copy is used, array values may be mutated * without additional calls to various header-related http * module methods. The keys of the returned object are the * header names and the values are the respective header values. * All header names are lowercase. * * The object returned by the ```response.getHeaders()``` method *does * not* prototypically inherit from the JavaScript ```Object```. This means * that typicalObject methods such as ```obj.toString()```, ```obj.hasOwnProperty()```, * and others are not defined and *will not work*. * @returns {http2.OutgoingHttpHeaders} */ getHeaders() { return this.#_res.getHeaders(); } /** * Returns ```true``` if the header identified by name is currently * set in the outgoing hea