@softvisio/core
Version:
Softisio core
777 lines (596 loc) • 22.8 kB
JavaScript
import { createBrotliCompress, createDeflate, createGzip } from "node:zlib";
import Events from "#lib/events";
import File from "#lib/file";
import Headers from "#lib/http/headers";
import HttpResponse from "#lib/http/response";
import IpAddress from "#lib/ip/address";
import subnets from "#lib/ip/subnets";
import mime from "#lib/mime";
import stream, { pipeline, Readable } from "#lib/stream";
import StreamFormData from "#lib/stream/form-data";
import StreamMultipart from "#lib/stream/multipart";
import { objectIsPlain } from "#lib/utils";
const COMPRESSORS = {
"gzip": createGzip,
"br": createBrotliCompress,
"deflate": createDeflate,
};
const localAddress = new IpAddress( "127.0.0.1" );
export default class Request extends Events {
#server;
#res;
#socketContext;
#isAborted;
#isEnded = false;
#method;
#clientRemoteAddress;
#remoteAddress;
#headers;
#url;
#path;
#hasBody;
#bodyUsed = false;
#body;
#formData;
#endEventSent;
#abortController = new AbortController();
constructor ( server, req, res, socketContext ) {
super();
this.#server = server;
this.#res = res;
this.#socketContext = socketContext;
this.#method = req.getMethod();
// headers
this.#headers = new Headers();
req.forEach( ( key, value ) => this.#headers.add( key, value ) );
// has body
this.#hasBody = !!( this.headers.contentLength || this.headers.get( "transfer-encoding" )?.toLowerCase().includes( "chunked" ) );
// remote address
this.#clientRemoteAddress = this.#res.getProxiedRemoteAddressAsText();
if ( !this.#clientRemoteAddress.byteLength ) this.#clientRemoteAddress = this.#res.getRemoteAddressAsText();
this.#clientRemoteAddress = this.#clientRemoteAddress.byteLength
? new IpAddress( Buffer.from( this.#clientRemoteAddress ).toString() )
: localAddress;
// url
var url = "http://" + ( this.headers.get( "host" ) || this.#clientRemoteAddress.toString() ) + req.getUrl() + ( req.getQuery()
? "?" + req.getQuery()
: "" );
try {
this.#url = new URL( url );
}
catch {
this.#url = null;
}
this.#res.onAborted( this.#onAborted.bind( this ) );
}
// properties
get isAborted () {
return this.#isAborted;
}
get isEnded () {
return this.#isEnded;
}
get abortSignal () {
return this.#abortController.signal;
}
get clientRemoteAddress () {
return this.#clientRemoteAddress;
}
get remoteAddress () {
if ( !this.#remoteAddress ) {
this.#remoteAddress = this.clientRemoteAddress;
if ( this.#server.realIpHeader && this.#server.setRealIpFrom && this.#isIpAddressTrusted( this.clientRemoteAddress ) ) {
const addresses = this.headers.get( this.#server.realIpHeader )?.split( "," );
if ( addresses ) {
while ( addresses.length ) {
try {
this.#remoteAddress = new IpAddress( addresses.pop().trim() );
if ( !this.#isIpAddressTrusted( this.#remoteAddress ) ) break;
}
catch {
break;
}
}
}
}
}
return this.#remoteAddress;
}
get method () {
return this.#method;
}
get headers () {
return this.#headers;
}
get url () {
return this.#url;
}
get path () {
if ( this.#path === undefined ) {
if ( this.url ) {
this.#path = decodeURI( this.url.pathname );
}
else {
this.#path = null;
}
}
return this.#path;
}
// XXX https://github.com/uNetworking/uWebSockets.js/issues/1095
get hasBody () {
return this.#hasBody;
}
get bodyUsed () {
return this.#bodyUsed;
}
get body () {
if ( !this.#body ) {
this.#body = new Readable( { read () {} } );
this.#body.setType( this.headers.get( "content-type" ) );
this.#body.setSize( this.headers.contentLength );
const abortHandler = () => this.#body.destroy( "HTTP request aborted" );
this.once( "abort", abortHandler );
this.#res.onData( ( arrayBuffer, isLast ) => {
// make a copy of array buffer
this.#body.push( Buffer.concat( [ Buffer.from( arrayBuffer ) ] ) );
// eof
if ( isLast ) {
this.#bodyUsed = true;
this.off( "abort", abortHandler );
this.#body.push( null );
}
} );
}
return this.#body;
}
get formData () {
if ( !this.#formData ) {
this.#formData = new StreamFormData( this.headers.contentType?.boundary );
stream.pipeline( this.body, this.#formData, () => {} );
}
return this.#formData;
}
// public
async end ( options ) {
if ( this.#isAborted || this.#isEnded ) return;
this.#isEnded = true;
await this.#end( options );
this.#onEnd();
}
// also calls abort callbacks
close ( status ) {
if ( this.#isAborted ) return;
if ( status ) {
if ( typeof status !== "number" ) throw new Error( "Status must be a number" );
status = result.getHttpStatus( status );
status += " " + result.getStatusText( status );
this.#res.cork( () => {
// write status
this.#res.writeStatus( status );
// write body buffer
this.#res.endWithoutBody( 0, true );
} );
this.#onEnd();
}
else {
this.#res.close();
}
}
upgrade ( { data, key, protocol, extensions } = {} ) {
if ( this.#isAborted || this.#isEnded ) return;
this.#isEnded = true;
key ??= this.headers.get( "sec-websocket-key" );
protocol ??= this.headers.get( "sec-websocket-protocol" );
extensions ??= this.headers.get( "sec-websocket-extensions" );
this.#res.cork( () => {
this.#res.upgrade(
{
"remoteAddress": this.remoteAddress,
data,
},
key,
protocol,
extensions,
this.#socketContext
);
} );
this.#onEnd();
}
// body methods
async buffer ( { maxLength } = {} ) {
return this.body.buffer( { maxLength } );
}
async json ( { maxLength } = {} ) {
return this.body.json( { maxLength } );
}
async text ( { maxLength, encoding } = {} ) {
return this.body.text( { maxLength, encoding } );
}
async arrayBuffer ( { maxLength } = {} ) {
return this.body.arrayBuffer( { maxLength } );
}
async blob ( { maxLength, type } = {} ) {
return this.body.blob( { maxLength, "type": type || this.headers.get( "content-type" ) } );
}
async tmpFile ( options ) {
return this.body.tmpFile( { "type": this.headers.get( "content-type" ), ...options } );
}
// private
#onAborted () {
if ( this.#isAborted ) return;
this.#isAborted = true;
this.#isEnded = true;
this.#abortController.abort();
this.emit( "abort" );
this.#onEnd();
}
#isIpAddressTrusted ( address ) {
for ( const subnet of this.#server.setRealIpFrom ) {
if ( subnets.get( subnet )?.includes( address ) ) return true;
}
}
async #end ( options ) {
var status, headers, body, compress, zlibOptions;
// parse options
{
if ( !options ) {
status = 200;
}
// options is status number
else if ( typeof options === "number" ) {
status = options;
}
// options is plain object
else if ( objectIsPlain( options ) ) {
( { status, headers, body, compress, zlibOptions } = options );
}
// options is http response
else if ( options instanceof HttpResponse ) {
status = options.status;
headers = options.headers;
body = options.body;
}
// options is result
else if ( options instanceof result.Result ) {
status = options.status;
if ( objectIsPlain( options.data ) ) {
( { headers, body, compress, zlibOptions } = options.data );
}
else {
body = options.data;
}
}
// options is body
else {
body = options;
}
}
var contentType, contentLength;
const methodIsHead = this.method === "head";
compress ??= this.#server.compress;
// prepate status
if ( status ) {
if ( typeof status !== "number" ) throw new Error( "Status must be a number" );
}
else {
status = 200;
}
// prepare headers
if ( headers ) {
if ( !( headers instanceof Headers ) ) headers = new Headers( headers );
contentLength = headers.contentLength;
headers.delete( "content-length" );
headers.delete( "transfer-encoding" );
}
else {
headers = new Headers();
}
// cache
CACHE: {
let lastModified = headers.get( "last-modified" );
if ( lastModified ) {
lastModified = new Date( lastModified );
if ( Number.isNaN( lastModified.getTime() ) ) {
lastModified = null;
headers.delete( "last-modified" );
}
}
else if ( body instanceof File ) {
lastModified = await body.getLastModifiedDate();
if ( lastModified ) headers.set( "last-modified", lastModified.toUTCString() );
}
if ( status === 304 ) break CACHE;
// etag
const etag = headers.get( "etag" );
ETAG: if ( etag ) {
// etag is weak
if ( etag.startsWith( "W/" ) ) break ETAG;
if ( etag === this.headers.get( "if-none-match" ) ) {
status = 304;
body = this.#closeBody( body );
break CACHE;
}
else if ( this.headers.has( "if-match" ) && etag !== this.headers.get( "if-match" ) ) {
status = 412; // Precondition Failed
body = this.#closeBody( body );
break CACHE;
}
}
// if-modified since
if ( lastModified ) {
let ifModifiedSince = this.headers.get( "if-modified-since" );
if ( ifModifiedSince ) {
ifModifiedSince = new Date( ifModifiedSince );
if ( Number.isNaN( ifModifiedSince.getTime() ) ) break CACHE;
if ( lastModified <= ifModifiedSince ) {
status = 304;
body = this.#closeBody( body );
break CACHE;
}
}
}
}
// prepare body
BODY: {
if ( body ) {
let rangeSupported;
// body is function
if ( typeof body === "function" ) {
const res = await body();
status = res.status;
let resHeaders = res.data?.headers;
if ( resHeaders ) {
if ( !( resHeaders instanceof Headers ) ) resHeaders = new Headers( resHeaders );
for ( const [ header, value ] of resHeaders.entries() ) {
if ( header === "content-length" ) {
contentLength = resHeaders.contentLength;
}
else if ( header === "content-type" ) {
contentType = value;
}
else {
headers.set( header, value );
}
}
}
body = res.data?.body;
if ( !body ) break BODY;
}
if ( typeof body === "string" ) {
rangeSupported = true;
contentLength = Buffer.byteLength( body );
}
else if ( Buffer.isBuffer( body ) ) {
rangeSupported = true;
contentLength = body.length;
}
else if ( body instanceof File ) {
rangeSupported = true;
contentLength = await body.getSize();
contentType ||= body.type;
// file not exists
if ( contentLength == null ) {
status = 404;
body = this.#closeBody( body );
break BODY;
}
}
else if ( body instanceof Blob ) {
rangeSupported = true;
contentLength = body.size;
contentType ||= body.type;
}
else if ( body instanceof StreamMultipart ) {
contentLength = body.length;
contentType = body.type;
}
else if ( body instanceof stream.Readable ) {
contentLength = body.size;
contentType ||= body.type;
}
let range;
RANGE: {
// range already applied
if ( status === 206 || headers.has( "content-range" ) ) break RANGE;
if ( !headers.has( "accept-ranges" ) ) break RANGE;
if ( !rangeSupported ) {
headers.delete( "accept-ranges" );
break RANGE;
}
range = this.headers.range;
if ( !range ) break RANGE;
// multiple ranges are not supported
if ( range?.isMultiple ) range = null;
// check range
range = headers.createContentRange( range.ranges[ 0 ], contentLength );
// range is invalid
if ( !range ) {
status = 416; // Range Not Satisfiable
body = this.#closeBody( body );
break BODY;
}
// range is ok
status = 206; // Partial Content
contentLength = range.size;
headers.set( "content-range", range.contentRange );
}
if ( methodIsHead ) break BODY;
if ( body instanceof File ) {
if ( range ) {
body = body.stream( { "start": range.start, "end": range.end + 1 } );
}
else {
body = body.stream();
}
}
else if ( body instanceof Blob ) {
if ( range ) {
body = await body.slice( range.start, range.end + 1 ).srrayBuffer();
}
else {
body = await body.arrayBuffer();
}
}
else if ( Buffer.isBuffer( body ) ) {
if ( range ) {
body = body.subarray( range.start, range.end + 1 );
}
}
else if ( typeof body === "string" ) {
if ( range ) {
body = Buffer.from( body ).subarray( range.start, range.end + 1 );
}
}
}
}
// prepare status string
status = result.getHttpStatus( status );
status += " " + result.getStatusText( status );
// add content type
if ( contentType ) headers.set( "content-type", contentType );
// compress
COMPRESS: {
if ( compress && body && !headers.get( "content-encoding" ) ) {
if ( typeof compress !== "boolean" && contentLength < compress ) break COMPRESS;
const mimeType = mime.get( headers.get( "content-type" ) );
if ( !mimeType?.compressible ) break COMPRESS;
const acceptEncoding = this.headers.acceptEncoding;
if ( !acceptEncoding ) break COMPRESS;
for ( const encoding of acceptEncoding ) {
const compressor = COMPRESSORS[ encoding ];
if ( compressor ) {
// prepare compressed body stream
if ( !methodIsHead ) {
// convert body to stream
if ( !( body instanceof stream.Readable ) ) {
body = stream.Readable.from( body, { "objectMode": false } );
}
// pipe body to zlib compressor
body = pipeline( body, compressor( zlibOptions ), e => {} );
}
contentLength = null;
headers.set( "content-encoding", encoding );
headers.add( "vary", "accept-encoding" );
break;
}
}
}
}
// add content-length header
if ( body ) {
// only for HEAD method
if ( methodIsHead ) {
// chunked transfer
if ( contentLength == null ) {
headers.set( "transfer-encoding", "chunked" );
}
// know content length
else {
headers.set( "content-length", contentLength );
}
}
}
else {
headers.set( "content-length", 0 );
}
if ( methodIsHead ) {
body = this.#closeBody( body );
}
// write head
this.#res.cork( () => {
// write status
this.#res.writeStatus( status );
// write headers
for ( const [ header, value ] of headers.entries() ) {
if ( Array.isArray( value ) ) {
for ( const data of value ) {
this.#res.writeHeader( headers.getOriginalName( header ), data );
}
}
else {
this.#res.writeHeader( headers.getOriginalName( header ), value );
}
}
// write body buffer
if ( !body ) {
this.#res.endWithoutBody();
}
// write body buffer
else if ( !( body instanceof stream.Readable ) ) {
this.#res.end( body );
}
} );
// write body stream
if ( body instanceof stream.Readable ) await this.#writeStream( body, contentLength );
}
async #writeStream ( stream, contentLength ) {
this.once( "abort", () => stream.destroy() );
var ok, done, chunk, lastOffset;
return new Promise( resolve => {
stream.once( "close", resolve );
stream.once( "error", () => this.close() );
stream.once( "end", () => {
// end request, if chunked transfer was used
if ( !this.#isAborted && !contentLength ) {
this.#res.cork( () => {
this.#res.endWithoutBody();
} );
}
} );
stream.on( "data", buffer => {
chunk = buffer;
// first try
if ( contentLength ) {
lastOffset = this.#res.getWriteOffset();
this.#res.cork( () => {
[ ok, done ] = this.#res.tryEnd( chunk, contentLength );
} );
}
else {
this.#res.cork( () => {
ok = this.#res.write( chunk );
} );
}
// all data sent to client
if ( done ) {
stream.destroy();
}
// backpressure
else if ( !ok ) {
// pause because backpressure
stream.pause();
this.#res.onWritable( offset => {
if ( !contentLength ) {
stream.resume();
return true;
}
else {
// only buffers are supported
this.#res.cork( () => {
[ ok, done ] = this.#res.tryEnd( chunk.subarray( offset - lastOffset ), contentLength );
} );
// all data sent to client
if ( done ) {
stream.destroy();
}
// no backpressure
else if ( ok ) {
stream.resume();
}
return ok;
}
} );
}
} );
} );
}
#onEnd () {
if ( this.#endEventSent ) return;
this.#endEventSent = true;
this.emit( "end" );
}
#closeBody ( body ) {
if ( body instanceof stream.Readable ) body.destroy();
return null;
}
}