import {Request} from './Request' import {ErrorWithContext, HTTPStatus, BehaviorSubject} from '../../util' import {ServerResponse} from 'http' import {HTTPCookieJar} from '../kernel/HTTPCookieJar' import {Readable} from 'stream' import {Logging} from '../../service/Logging' /** * Error thrown when the server tries to re-send headers after they have been sent once. */ export class HeadersAlreadySentError extends ErrorWithContext { constructor(response: Response, headerName?: string) { super(`Cannot modify or re-send headers for this request as they have already been sent.`) this.context = { headerName } } } /** * Error thrown when the server tries to re-send a response that has already been sent. */ export class ResponseAlreadySentError extends ErrorWithContext { constructor(public readonly response: Response) { super(`Cannot modify or re-send response as it has already ended.`) } } /** * A class representing an HTTP response to a client. */ export class Response { /** Mapping of headers that should be sent back to the client. */ private headers: {[key: string]: string | string[]} = {} /** True if the headers have been sent. */ private sentHeaders = false /** True if the response has been sent and closed. */ private responseEnded = false /** The HTTP status code that should be sent to the client. */ private status: HTTPStatus = HTTPStatus.OK /** * If this is true, then some module in the kernel has flagged the response * as being interrupted and handled. Subsequent modules should NOT overwrite * the response. * @private */ private isBlockingWriteback = false /** The body contents that should be written to the response. */ public body: string | Buffer | Uint8Array | Readable = '' /** * Behavior subject fired right before the response content is written. */ public readonly sending$: BehaviorSubject = new BehaviorSubject() /** * Behavior subject fired right after the response content is written. */ public readonly sent$: BehaviorSubject = new BehaviorSubject() constructor( /** The associated request object. */ public readonly request: Request, /** The native Node.js ServerResponse. */ protected readonly serverResponse?: ServerResponse, ) { } protected get logging(): Logging { return this.request.make(Logging) } /** Get the currently set response status. */ public getStatus(): HTTPStatus { return this.status } /** Set a new response status. */ public setStatus(status: HTTPStatus): this { if ( this.sentHeaders ) { throw new HeadersAlreadySentError(this, 'status') } this.status = status return this } /** Get the HTTPCookieJar for the client. */ public get cookies(): HTTPCookieJar { return this.request.cookies } /** Get the value of the response header, if it exists. */ public getHeader(name: string): string | string[] | undefined { return this.headers[name] } /** Set the value of the response header. */ public setHeader(name: string, value: string | string[]): this { this.logging.verbose(`Will set header on response: ${name}`) if ( this.sentHeaders ) { throw new HeadersAlreadySentError(this, name) } this.headers[name] = value return this } /** * Remove a header from the response by name. * @param name */ public unsetHeader(name: string): this { this.logging.verbose(`Will unset header on response: ${name}`) if ( this.sentHeaders ) { throw new HeadersAlreadySentError(this, name) } delete this.headers[name] return this } /** * Bulk set the specified headers in the response. * @param data */ public setHeaders(data: {[name: string]: string | string[]}): this { this.logging.verbose(`Will set headers on response: ${Object.keys(data).join(', ')}`) if ( this.sentHeaders ) { throw new HeadersAlreadySentError(this) } this.headers = {...this.headers, ...data} return this } /** * Add the given value as a header, appending it to an existing header if one exists. * @param name * @param value */ public appendHeader(name: string, value: string | string[]): this { this.logging.verbose(`Will append header: ${name}`) if ( this.sentHeaders ) { throw new HeadersAlreadySentError(this, name) } if ( !Array.isArray(value) ) { value = [value] } let existing = this.headers[name] ?? [] if ( !Array.isArray(existing) ) { existing = [existing] } existing = [...existing, ...value] if ( existing.length === 1 ) { existing = existing[0] } this.headers[name] = existing return this } /** * Write the headers to the client. */ public sendHeaders(): this { this.logging.verbose(`Sending headers...`) if ( !this.serverResponse ) { throw new ErrorWithContext('Unable to send headers: Response has no underlying connection.', { suggestion: 'This usually means the Request was created by an alternative server, like WebsocketServer. You should use that server to handle the request.', }) } const headers = {} as any const setCookieHeaders = this.cookies.getSetCookieHeaders() if ( setCookieHeaders.length ) { headers['Set-Cookie'] = setCookieHeaders } for ( const key in this.headers ) { if ( !Object.prototype.hasOwnProperty.call(this.headers, key) ) { continue } headers[key] = this.headers[key] } this.serverResponse.writeHead(this.status, headers) this.sentHeaders = true return this } /** Returns true if the headers have been sent. */ public hasSentHeaders(): boolean { return this.sentHeaders } /** Returns true if a body has been set in the response. */ public hasBody(): boolean { return Boolean(this.body) } /** * Get or set the flag for whether the writeback should be blocked. * @param set - if this is specified, the value will be set. */ public blockingWriteback(set?: boolean): boolean { if ( typeof set !== 'undefined' ) { this.isBlockingWriteback = set } return this.isBlockingWriteback } /** * Write the headers and specified data to the client. * @param data */ public async write(data: string | Buffer | Uint8Array | Readable): Promise { this.logging.verbose(`Writing headers & data to response... (destroyed? ${!this.serverResponse || this.serverResponse.destroyed})`) return new Promise((res, rej) => { if ( !this.serverResponse ) { throw new ErrorWithContext('Unable to write response: Response has no underlying connection.', { suggestion: 'This usually means the Request was created by an alternative server, like WebsocketServer. You should use that server to handle the request.', }) } if ( this.responseEnded || this.serverResponse.destroyed ) { throw new ErrorWithContext('Tried to write to Response after lifecycle ended.') } if ( !this.sentHeaders ) { this.sendHeaders() } if ( data instanceof Readable ) { data.pipe(this.serverResponse) .on('finish', () => { res() }) .on('error', error => { rej(error) }) } else { this.serverResponse.write(data, error => { if ( error ) { rej(error) } else { res() } }) } }) } /** * Send the response to the client, writing the headers and configured body. */ public async send(): Promise { await this.sending$.next(this) if ( !(this.body instanceof Readable) ) { this.setHeader('Content-Length', String(Buffer.from(this.body ?? '').length)) } this.setHeader('Date', (new Date()).toUTCString()) this.setHeader('Permissions-Policy', 'interest-cohort=()') await this.write(this.body ?? '') this.end() await this.sent$.next(this) } /** * Returns true if the response can still be sent. False if it has been sent * or the connection has been destroyed. */ public canSend(): boolean { return !(this.responseEnded || !this.serverResponse || this.serverResponse.destroyed) } /** * Mark the response as ended and close the socket. */ public end(): this { if ( this.responseEnded ) { throw new ResponseAlreadySentError(this) } this.sentHeaders = true this.serverResponse?.end() return this } // location? }