You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
lib/src/http/lifecycle/Response.ts

306 lines
9.3 KiB

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<Response> = new BehaviorSubject<Response>()
/**
* Behavior subject fired right after the response content is written.
*/
public readonly sent$: BehaviorSubject<Response> = new BehaviorSubject<Response>()
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<void> {
this.logging.verbose(`Writing headers & data to response... (destroyed? ${!this.serverResponse || this.serverResponse.destroyed})`)
return new Promise<void>((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<void> {
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?
}