@softvisio/core
Version:
Softisio core
903 lines (756 loc) • 23.7 kB
JavaScript
import path from "node:path";
import Cookie from "#lib/http/cookie";
const SET_COOKIE_ATTRIBUTES = {
"domain": [ "domain", false ],
"path": [ "encodedPath", false ],
"expires": [ "expires", false ],
"max-age": [ "maxAge", false ],
"secure": [ "secure", true ],
"httponly": [ "httpOnly", true ],
"partitioned": [ "partitioned", true ],
"samesite": [ "sameSite", false ],
};
const REPLACE_VALUE = new Set( [
//
"age",
"authorization",
"content-length",
"content-type",
"etag",
"expires",
"from",
"host",
"if-modified-since",
"if-unmodified-since",
"last-modified",
"location",
"max-forwards",
"proxy-authorization",
"referer",
"retry-after",
"server",
"user-agent",
] );
const ORIGINAL_NAMES = Object.fromEntries( [
//
"A-IM",
"Accept",
"Accept-Additions",
"Accept-CH",
"Accept-CH-Lifetime",
"Accept-Charset",
"Accept-Datetime",
"Accept-Encoding",
"Accept-Features",
"Accept-Language",
"Accept-Patch",
"Accept-Post",
"Accept-Ranges",
"Access-Control",
"Access-Control-Allow-Credentials",
"Access-Control-Allow-Headers",
"Access-Control-Allow-Methods",
"Access-Control-Allow-Origin",
"Access-Control-Expose-Headers",
"Access-Control-Max-Age",
"Access-Control-Request-Headers",
"Access-Control-Request-Method",
"Age",
"Allow",
"ALPN",
"Alt-Svc",
"Alt-Used",
"Alternates",
"AMP-Cache-Transform",
"Apply-To-Redirect-Ref",
"Authentication-Control",
"Authentication-Info",
"Authorization",
"C-Ext",
"C-Man",
"C-Opt",
"C-PEP",
"C-PEP-Info",
"Cache-Control",
"Cache-Status",
"Cal-Managed-ID",
"CalDAV-Timezones",
"Capsule-Protocol",
"CDN-Cache-Control",
"CDN-Loop",
"Cert-Not-After",
"Cert-Not-Before",
"Clear-Site-Data",
"Close",
"Configuration-Context",
"Connection",
"Content-Base",
"Content-Disposition",
"Content-DPR",
"Content-Encoding",
"Content-ID",
"Content-Language",
"Content-Length",
"Content-Location",
"Content-MD5",
"Content-Range",
"Content-Script-Type",
"Content-Security-Policy",
"Content-Security-Policy-Report-Only",
"Content-Style-Type",
"Content-Type",
"Content-Version",
"Cookie",
"Cookie2",
"Cross-Origin-Embedder-Policy",
"Cross-Origin-Embedder-Policy-Report-Only",
"Cross-Origin-Opener-Policy",
"Cross-Origin-Opener-Policy-Report-Only",
"Cross-Origin-Resource-Policy",
"DASL",
"Date",
"DAV",
"Default-Style",
"Delta-Base",
"Depth",
"Derived-From",
"Destination",
"Device-Memory",
"Differential-ID",
"Digest",
"DNT",
"Downlink",
"DPR",
"Early-Data",
"ECT",
"EDIINT-Features",
"ETag",
"Expect",
"Expect-CT",
"Expires",
"Ext",
"Feature-Policy",
"Forwarded",
"From",
"Front-End-Https",
"GetProfile",
"Hobareg",
"Host",
"HTTP2-Settings",
"If",
"If-Match",
"If-Modified-Since",
"If-None-Match",
"If-Range",
"If-Schedule-Tag-Match",
"If-Unmodified-Since",
"IM",
"Include-Referred-Token-Binding-ID",
"Isolation",
"Keep-Alive",
"Label",
"Large-Allocation",
"Last-Event-ID",
"Last-Modified",
"Link",
"Location",
"Lock-Token",
"Man",
"Max-Forwards",
"Memento-Datetime",
"Meter",
"Method-Check",
"Method-Check-Expires",
"MIME-Version",
"Negotiate",
"NEL",
"OData-EntityId",
"OData-Isolation",
"OData-MaxVersion",
"OData-Version",
"Opt",
"Optional-WWW-Authenticate",
"Ordering-Type",
"Origin",
"Origin-Agent-Cluster",
"OSCORE",
"OSLC-Core-Version",
"Overwrite",
"P3P",
"PEP",
"Pep-Info",
"Permissions-Policy",
"PICS-Label",
"Ping-From",
"Ping-To",
"Position",
"Pragma",
"Prefer",
"Preference-Applied",
"Priority",
"ProfileObject",
"Protocol",
"Protocol-Info",
"Protocol-Query",
"Protocol-Request",
"Proxy-Authenticate",
"Proxy-Authentication-Info",
"Proxy-Authorization",
"Proxy-Connection",
"Proxy-Features",
"Proxy-Instruction",
"Proxy-Status",
"Public",
"Public-Key-Pins",
"Public-Key-Pins-Report-Only",
"Range",
"Redirect-Ref",
"Referer",
"Referer [sic]",
"Referer-Root",
"Referrer-Policy",
"Refresh",
"Repeatability-Client-ID",
"Repeatability-First-Sent",
"Repeatability-Request-ID",
"Repeatability-Result",
"Replay-Nonce",
"Report-To",
"Retry-After",
"RTT",
"Safe",
"Save-Data",
"Schedule-Reply",
"Schedule-Tag",
"Sec-CH-UA",
"Sec-CH-UA-Arch",
"Sec-CH-UA-Bitness",
"Sec-CH-UA-Full-Version",
"Sec-CH-UA-Full-Version-List",
"Sec-CH-UA-Mobile",
"Sec-CH-UA-Model",
"Sec-CH-UA-Platform",
"Sec-CH-UA-Platform-Version",
"Sec-Fetch-Dest",
"Sec-Fetch-Mode",
"Sec-Fetch-Site",
"Sec-Fetch-User",
"Sec-GPC",
"Sec-Token-Binding",
"Sec-WebSocket-Accept",
"Sec-WebSocket-Extensions",
"Sec-WebSocket-Key",
"Sec-WebSocket-Protocol",
"Sec-WebSocket-Version",
"Security-Scheme",
"Server",
"Server-Timing",
"Service-Worker-Navigation-Preload",
"Set-Cookie",
"Set-Cookie2",
"SetProfile",
"SLUG",
"SoapAction",
"SourceMap",
"Status",
"Status-URI",
"Strict-Transport-Security",
"Sunset",
"Surrogate-Capability",
"Surrogate-Control",
"TCN",
"TE",
"Timeout",
"Timing-Allow-Origin",
"Tk",
"Topic",
"Traceparent",
"Tracestate",
"Trailer",
"Transfer-Encoding",
"TTL",
"Upgrade",
"Upgrade-Insecure-Requests",
"Urgency",
"URI",
"User-Agent",
"Variant-Vary",
"Vary",
"Via",
"Viewport-Width",
"Want-Digest",
"Warning",
"Width",
"WWW-Authenticate",
"X-ATT-DeviceId",
"X-Content-Duration",
"X-Content-Security-Policy",
"X-Content-Type-Options",
"X-Correlation-ID",
"X-Csrf-Token",
"X-DNS-Prefetch-Control",
"X-Forwarded-For",
"X-Forwarded-Host",
"X-Forwarded-Proto",
"X-Frame-Options",
"X-Http-Method-Override",
"X-Powered-By",
"X-Real-IP",
"X-Redirect-By",
"X-Request-ID",
"X-Requested-With",
"X-UA-Compatible",
"X-UIDH",
"X-Wap-Profile",
"X-WebKit-CSP",
"X-XSS-Protection",
].map( header => [ header.toLowerCase(), header ] ) );
export default class Headers {
#headers = {};
#parsed = {};
#originalNames = {};
constructor ( headers ) {
this.add( headers );
}
// static
static parse ( buffer ) {
const headers = new this();
if ( Buffer.isBuffer( buffer ) ) buffer = buffer.toString( "latin1" );
for ( const header of buffer.split( "\r\n" ) ) {
const idx = header.indexOf( ":" );
const name = header.slice( 0, idx ).trim();
const value = header.slice( idx + 1 ).trim();
headers.add( name, value );
}
return headers;
}
static parseSetCookie ( setCookieHeader ) {
if ( !setCookieHeader ) return [];
if ( !Array.isArray( setCookieHeader ) ) {
setCookieHeader = [ setCookieHeader ];
}
const cookies = [];
for ( const header of setCookieHeader ) {
let cookie;
for ( const attrubute of header.split( ";" ) ) {
const idx = attrubute.indexOf( "=" );
let name, value;
if ( idx > 0 ) {
name = attrubute.slice( 0, idx ).trim();
value = attrubute.slice( idx + 1 ).trim();
}
else {
name = attrubute.slice( idx + 1 ).trim();
}
if ( !name && !value ) continue;
// name / value
if ( !cookie ) {
if ( value == null ) {
cookie = {
"encodedValue": name,
};
}
else {
cookie = {
"encodedName": name,
"encodedValue": value,
};
}
continue;
}
name = name.toLowerCase();
const spec = SET_COOKIE_ATTRIBUTES[ name ];
if ( spec ) {
cookie[ spec[ 0 ] ] = spec[ 1 ] || value;
}
}
cookies.push( new Cookie( cookie ) );
}
return cookies;
}
// properties
get acceptEncoding () {
ERROR: if ( this.#parsed[ "accept-encoding" ] === undefined ) {
this.#parsed[ "accept-encoding" ] = null;
const header = this.#headers[ "accept-encoding" ];
if ( !header ) break ERROR;
const encodings = {};
// deflate, gzip;q=1.0, *;q=0.5
for ( const match of header.matchAll( /([*a-z]+)\s*(?:;\s*q=([\d.]+))?,?/gi ) ) {
encodings[ match[ 1 ] ] = +( match[ 2 ] || 1 );
}
this.#parsed[ "accept-encoding" ] = Object.keys( encodings ).sort( ( a, b ) => encodings[ b ] - encodings[ a ] );
}
return this.#parsed[ "accept-encoding" ];
}
get cookie () {
ERROR: if ( this.#parsed[ "cookie" ] === undefined ) {
this.#parsed[ "cookie" ] = {};
const header = this.#headers[ "cookie" ];
if ( !header ) break ERROR;
for ( const cookie of header.split( ";" ) ) {
const idx = cookie.indexOf( "=" );
let encodedName, encodedValue;
if ( idx > 0 ) {
encodedName = cookie.slice( 0, idx ).trim();
encodedValue = cookie.slice( idx + 1 ).trim();
}
else {
encodedValue = cookie.slice( idx + 1 ).trim();
}
if ( !encodedName && !encodedValue ) continue;
const cookie1 = new Cookie( {
encodedName,
encodedValue,
} );
this.#parsed[ "cookie" ][ cookie1.name ] = cookie1;
}
}
return this.#parsed[ "cookie" ];
}
get contentDisposition () {
ERROR: if ( this.#parsed[ "content-disposition" ] === undefined ) {
this.#parsed[ "content-disposition" ] = null;
const header = this.#headers[ "content-disposition" ];
if ( !header ) break ERROR;
// parse type
var idx = header.indexOf( ";" );
if ( idx === -1 ) break ERROR;
this.#parsed[ "content-disposition" ] = {};
this.#parsed[ "content-disposition" ].type = header.slice( 0, idx ).toLowerCase().trim();
// allowed fields: name, filename
for ( const match of header.matchAll( /((?:file)?name)\s*=\s*(?:"([^"]+)"|([^;]+));?/g ) ) {
const name = match[ 1 ];
// encode latin1 -> utf8, decode `"`
let value = Buffer.from( match[ 2 ] ?? match[ 3 ].trim(), "latin1" )
.toString()
.replaceAll( "%22", `"` );
// truncate filename to basename
if ( name === "filename" ) value = path.basename( value );
this.#parsed[ "content-disposition" ][ name ] = value;
}
}
return this.#parsed[ "content-disposition" ];
}
get contentType () {
ERROR: if ( this.#parsed[ "content-type" ] === undefined ) {
this.#parsed[ "content-type" ] = null;
const header = this.#headers[ "content-type" ];
if ( !header ) break ERROR;
// parse type
var idx = header.indexOf( ";" );
this.#parsed[ "content-type" ] = {};
if ( idx === -1 ) {
this.#parsed[ "content-type" ].type = header.toLowerCase().trim();
}
else {
this.#parsed[ "content-type" ].type = header.slice( 0, idx ).toLowerCase().trim();
// allowed fields: charset, boundary, media-type
for ( const match of header.matchAll( /(charset|boundary|media-type)\s*=\s*(?:"([^"]+)"|([^;]+));?/g ) ) {
const name = match[ 1 ];
const value = match[ 2 ] ?? match[ 3 ].trim();
this.#parsed[ "content-type" ][ name ] = value;
}
}
}
return this.#parsed[ "content-type" ];
}
get contentLength () {
if ( this.#parsed[ "content-length" ] === undefined ) {
this.#parsed[ "content-length" ] = Number.parseInt( this.#headers[ "content-length" ] );
if ( Number.isNaN( this.#parsed[ "content-length" ] ) ) this.#parsed[ "content-length" ] = null;
}
return this.#parsed[ "content-length" ];
}
get setCookie () {
if ( this.#parsed[ "set-cookie" ] === undefined ) {
this.#parsed[ "set-cookie" ] = this.constructor.parseSetCookie( this.#headers[ "set-cookie" ] );
}
return this.#parsed[ "set-cookie" ];
}
get range () {
ERROR: if ( this.#parsed[ "range" ] === undefined ) {
this.#parsed[ "range" ] = null;
const header = this.#headers[ "range" ];
if ( !header ) break ERROR;
const match = header.match( /^\s*(bytes)=(.+)/ );
if ( !match ) break ERROR;
const unit = match[ 1 ],
ranges = [];
for ( const rangeString of match[ 2 ].split( "," ) ) {
const rangeMatch = rangeString.match( /^\s*(?:(\d+)-(\d+)?|(-\d+))\s*$/ );
if ( !rangeMatch ) break ERROR;
let start, end;
if ( rangeMatch[ 1 ] ) {
start = +rangeMatch[ 1 ];
if ( rangeMatch[ 2 ] ) {
end = +rangeMatch[ 2 ];
if ( start > end ) break ERROR;
}
}
else {
start = +rangeMatch[ 3 ];
}
ranges.push( {
start,
end,
} );
}
if ( !ranges.length ) break ERROR;
this.#parsed[ "range" ] = {
unit,
"isMultiple": ranges.length > 1,
ranges,
};
}
return this.#parsed[ "range" ];
}
get wwwAuthenticate () {
ERROR: if ( this.#parsed[ "www-authenticate" ] === undefined ) {
this.#parsed[ "www-authenticate" ] = null;
const header = this.#headers[ "www-authenticate" ];
if ( !header ) break ERROR;
// parse scheme
var idx = header.indexOf( " " );
if ( idx === -1 ) break ERROR;
this.#parsed[ "www-authenticate" ] = {};
this.#parsed[ "www-authenticate" ].scheme = header.slice( 0, idx ).toLowerCase();
for ( const match of header.matchAll( /([a-z-]+)\s*=\s*(?:"([^"]+)"|([^,]+)),?/gi ) ) {
this.#parsed[ "www-authenticate" ][ match[ 1 ].toLowerCase() ] = match[ 2 ] ?? match[ 3 ].trim();
}
}
return this.#parsed[ "www-authenticate" ];
}
// public
has ( name ) {
return name.toLowerCase() in this.#headers;
}
get ( name ) {
return this.#headers[ name.toLowerCase() ];
}
set ( name, value ) {
if ( name != null ) {
if ( typeof name === "object" ) {
if ( Array.isArray( name ) ) {
for ( let n = 0; n < name.length; n += 2 ) {
this.#set( name[ n ], name[ n + 1 ] );
}
}
else if ( typeof name.entries === "function" ) {
for ( const [ key, value ] of name.entries() ) this.#set( key, value );
}
else {
for ( const [ key, value ] of Object.entries( name ) ) this.#set( key, value );
}
}
else {
this.#set( name, value );
}
}
return this;
}
add ( name, value ) {
if ( name != null ) {
if ( typeof name === "object" ) {
if ( Array.isArray( name ) ) {
for ( let n = 0; n < name.length; n += 2 ) {
this.#add( name[ n ], name[ n + 1 ] );
}
}
else if ( typeof name.entries === "function" ) {
for ( const [ key, value ] of name.entries() ) this.#add( key, value );
}
else {
for ( const [ key, value ] of Object.entries( name ) ) this.#add( key, value );
}
}
else {
this.#add( name, value );
}
}
return this;
}
delete ( name ) {
if ( name != null ) {
if ( typeof name === "object" ) {
if ( Array.isArray( name ) ) {
for ( const key of name ) this.#delete( key );
}
else if ( typeof name.keys === "function" ) {
for ( const key of name.keys() ) this.#delete( key );
}
else {
for ( const key of Object.keys( name ) ) this.#delete( key );
}
}
else {
this.#delete( name );
}
}
return this;
}
toString () {
const headers = [];
for ( const [ name, value ] of Object.entries( this.#headers ) ) {
if ( Array.isArray( value ) ) {
for ( const data of value ) {
headers.push( `${ this.getOriginalName( name ) }: ${ data }\r\n` );
}
}
else {
headers.push( `${ this.getOriginalName( name ) }: ${ value }\r\n` );
}
}
return headers.join( "" );
}
toJSON () {
const headers = {};
for ( const [ name, value ] of Object.entries( this.#headers ) ) {
headers[ this.getOriginalName( name ) ] = value;
}
return headers;
}
[ Symbol.for( "nodejs.util.inspect.custom" ) ] ( depth, options, inspect ) {
return "Headers: " + inspect( this.toJSON() );
}
keys () {
return Object.keys( this.#headers ).values();
}
entries () {
return Object.entries( this.#headers ).values();
}
forEach ( callback, thisArg ) {
for ( const name of this.keys() ) {
Reflect.apply( callback, thisArg, [ this.#headers[ name ], name, this ] );
}
}
[ Symbol.iterator ] () {
return this.entries();
}
getOriginalName ( name ) {
const lowerName = name.toLowerCase();
return ORIGINAL_NAMES[ lowerName ] || this.#originalNames[ lowerName ] || name;
}
setContentDisposition ( { name, filename } = {} ) {
var value = [];
if ( name ) {
value.push( "form-data", `name="${ name.replaceAll( `"`, "%22" ) }"` );
}
else {
value.push( "attachment" );
}
if ( filename ) {
value.push( `filename="${ filename.replaceAll( `"`, "%22" ) }"` );
}
this.#set( "content-disposition", value.join( "; " ) );
}
createContentRange ( { start, end }, size, { unit = "bytes" } = {} ) {
const maxEnd = size
? size - 1
: 0;
if ( start < 0 ) start = size + start;
if ( start < 0 || start > maxEnd ) return;
end ??= maxEnd;
if ( start > end || end > maxEnd ) return;
return {
start,
"end": end,
"size": end - start + 1,
"contentRange": `bytes ${ start }-${ end }/${ size }`,
};
}
// private
#set ( name, value ) {
if ( value == null ) return;
if ( Array.isArray( value ) ) {
for ( const data of value ) {
this.#set( name, data );
}
}
else {
const lowerName = name.toLowerCase();
this.#originalNames[ lowerName ] = name;
// set-cookie
if ( lowerName === "set-cookie" ) {
if ( typeof value !== "string" ) {
value = Cookie.new( value ).toSetCookieHeader();
}
this.#headers[ "set-cookie" ] = [ value ];
}
// other
else {
if ( value instanceof Date ) {
value = value.toUTCString();
}
else if ( typeof value !== "string" ) {
value = String( value );
}
this.#headers[ lowerName ] = value;
}
// drop cache
delete this.#parsed[ lowerName ];
}
}
#add ( name, value ) {
if ( value == null ) return;
if ( Array.isArray( value ) ) {
for ( const data of value ) {
this.#add( name, data );
}
}
else {
const lowerName = name.toLowerCase();
this.#originalNames[ lowerName ] = name;
// set-cookie
if ( lowerName === "set-cookie" ) {
if ( typeof value !== "string" ) {
value = Cookie.new( value ).toSetCookieHeader();
}
if ( this.#headers[ "set-cookie" ] == null ) {
this.#headers[ "set-cookie" ] = [ value ];
}
else {
this.#headers[ "set-cookie" ].push( value );
}
}
else {
if ( value instanceof Date ) {
value = value.toUTCString();
}
else if ( typeof value !== "string" ) {
value = String( value );
}
// replace value
if ( REPLACE_VALUE.has( lowerName ) ) {
this.#headers[ lowerName ] = value;
}
// cookie
else if ( lowerName === "cookie" ) {
if ( this.#headers.cookie == null ) {
this.#headers.cookie = value;
}
else {
this.#headers.cookie += "; " + value;
}
}
// other
else {
if ( this.#headers[ lowerName ] == null ) {
this.#headers[ lowerName ] = value;
}
else {
this.#headers[ lowerName ] += ", " + value;
}
}
}
// drop cache
delete this.#parsed[ lowerName ];
}
}
#delete ( name ) {
name = name.toLowerCase();
delete this.#headers[ name ];
// drop cache
delete this.#parsed[ name ];
}
}