306 lines
9.3 KiB
TypeScript
306 lines
9.3 KiB
TypeScript
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?
|
|
}
|