x2node-ws
Version:
RESTful web services framework.
1,621 lines (1,373 loc) • 43.8 kB
JavaScript
'use strict';
const http = require('http');
const stream = require('stream');
const EventEmitter = require('events');
const common = require('x2node-common');
const ServiceCall = require('./service-call.js');
const ServiceResponse = require('./service-response.js');
const PatternMap = require('./pattern-map.js');
/**
* Application shutdown initiation event. Fired when the application has stopped
* accepting new connections but has not completed the shutdown yet (active HTTP
* connections may still be open).
*
* @event module:x2node-ws~Application#shuttingdown
* @type {string}
*/
/**
* Application shutdown event. Fired after all active HTTP connections have
* completed.
*
* @event module:x2node-ws~Application#shutdown
* @type {string}
*/
/**
* Cacheable HTTP response status codes.
*
* @private
* @constant {Set.<number>}
*/
const CACHEABLE_STATUS_CODES = new Set([
200, 203, 204, 206, 300, 301, 304, 308, 404, 405, 410, 414, 501
]);
/**
* "Simple" HTTP response headers.
*
* @private
* @constant {Set.<string>}
*/
const SIMPLE_RESPONSE_HEADERS = new Set([
'cache-control',
'content-language',
'content-type',
'expires',
'last-modified',
'pragma'
]);
/**
* Multipart HTTP response boundary.
*
* @private
* @constant {string}
*/
const BOUNDARY = 'x2node_boundary_gc0p4Jq0M2Yt08j34c0p';
const BOUNDARY_MID = Buffer.from(`--${BOUNDARY}\r\n`, 'ascii');
const BOUNDARY_END = Buffer.from(`--${BOUNDARY}--`, 'ascii');
const CRLF = Buffer.from('\r\n', 'ascii');
/**
* Default connection idle timeout.
*
* @private
* @constant {number}
*/
const DEFAULT_CONN_IDLE_TIMEOUT = 30000;
const DEFAULT_MAX_REQUEST_SIZE = 2048;
const DEFAULT_CORS_PREFLIGHT_MAX_AGE = 20 * 24 * 3600;
/**
* Used to store list of supported methods on a handler.
*
* @private
* @constant {Symbol}
*/
const METHODS = Symbol('METHODS');
/**
* Used to store connection id on sockets.
*
* @private
* @constant {Symbol}
*/
const CONNECTION_ID = Symbol('CONNECTION_ID');
/**
* Used to store next API call id on sockets.
*
* @private
* @constant {Symbol}
*/
const NEXT_CALL_ID = Symbol('NEXT_CALL_ID');
/**
* Function to be added to sockets to get next API call id for the connection.
*
* @private
* @returns {string} The call id.
*/
function nextCallId() {
return `${this[CONNECTION_ID]}-${this[NEXT_CALL_ID]++}`;
}
/**
* Used to store idle/active status on sockets.
*
* @private
* @constant {Symbol}
*/
const IDLE = Symbol('IDLE');
/**
* Used to store the list of service calls associated with a socket.
*
* @private
* @constant {Symbol}
*/
const CALLS = Symbol('CALLS');
/**
* Known HTTP methods.
*
* @private
* @constant {Set.<string>}
*/
const KNOWN_METHODS = new Set(http.METHODS);
KNOWN_METHODS.delete('OPTIONS'); // handled in a special way
/**
* Known normalized header names that are not trivially capitalized.
*
* @private
* @constant {Object.<string,string>}
*/
const NORMAL_HEADER_NAMES = {
'www-authenticate': 'WWW-Authenticate',
'etag': 'ETag'
};
/**
* Pattern used to parse media type ranges in the "Accept" header.
*
* @private
* @constant {RegExp}
*/
const ACCEPT_HEADER_RANGE_PATTERN = new RegExp(
'^((?:\\*|[a-z0-9][a-z0-9!#$&^_.+-]*)/(?:\\*|[a-z0-9][a-z0-9!#$&^_.+-]*))' +
'\\s*(?:.*;\\s*q=(0(?:\\.\\d*)?|1(?:\\.0*)?)\\s*.*)?$'
);
/**
* Callback for the socket timeout before response has started to be sent.
*
* @private
* @param {net.external:Socket} socket The connection socket.
*/
function onBeforeResponseTimeout(socket) {
if (socket)
socket.end(
'HTTP/1.1 408 ' + http.STATUS_CODES[408] + '\r\n' +
'Date: ' + (new Date()).toUTCString() + '\r\n' +
'Connection: close\r\n' +
'\r\n');
}
/**
* JSON marshaller implementation.
*
* @private
* @constant {module:x2node-ws.Marshaller}
*/
const JSON_MARSHALLER = {
serialize(obj) {
return Buffer.from(JSON.stringify(obj));
},
deserialize(data) {
let record;
try {
record = JSON.parse(data.toString());
} catch (err) {
if (err instanceof SyntaxError)
throw new common.X2DataError(`Invalid JSON: ${err.message}`);
throw err;
}
if (((typeof record) !== 'object') || (record === null))
throw new common.X2DataError(
'Invalid record data: expected an object.');
return record;
}
};
/**
* Represents the web service application.
*
* @memberof module:x2node-ws
* @inner
* @extends external:EventEmitter
* @fires module:x2node-ws~Application#shutdown
*/
class Application extends EventEmitter {
/**
* <strong>Note:</strong> The constructor is not accessible from the client
* code. Instances are created using module's
* [createApplication()]{@link module:x2node-ws.createApplication} function.
*
* @protected
* @param {module:x2node-ws~ApplicationOptions} options Application
* configuration options.
*/
constructor(options) {
super();
// save options
this._options = options;
// application API version
if (options.apiVersion === undefined) {
if (process.env.NODE_ENV === 'development') {
this._apiVersion = `dev-${Date.now()}`;
} else {
this._apiVersion =
require.main.require('./package.json').version;
}
} else {
this._apiVersion = String(options.apiVersion);
}
// allowed CORS origins
if (Array.isArray(options.allowedOrigins) &&
(options.allowedOrigins.length > 0))
this._allowedOrigins = new Set(
options.allowedOrigins.map(o => String(o).toLowerCase()));
else if (((typeof options.allowedOrigins) === 'string') &&
(options.allowedOrigins.trim().length > 0))
this._allowedOrigins = new Set(
options.allowedOrigins.toLowerCase().trim().split(/\s*,\s*/));
// the debug log
this._log = common.getDebugLogger('X2_APP');
// marshallers, authenticators, authorizers and endpoints (later maps)
this._marshallers = new Array();
this._authenticators = new Array();
this._authorizers = new Array();
this._endpoints = new Array();
// current URIs prefix
this._prefix = '';
// application running state
this._connections = new Map();
this._nextConnectionId = 1;
this._running = false;
this._shuttingDown = false;
}
/**
* Set URI prefix for the subsequent <code>addAuthenticator()</code>,
* <code>addAuthorizer()</code> and <code>addEndpoint()</code> calls.
*
* @param {string} prefix Prefix to add to the URI patterns.
* @returns {module:x2node-ws~Application} This application.
*/
setPrefix(prefix) {
this._prefix = (prefix || '');
return this;
}
/**
* Add marshaller for the content type. When looking up marshaller for a
* content type, the content type patterns are matched in the order the
* marshallers were added to the application. After all custom marshallers
* are added, the application automatically adds default JSON marshaller
* implementation for patterns "application/json" and ".+\+json".
*
* @param {string} contentTypePattern Content type regular expression
* pattern. The content type is matched against the pattern as a whole, so no
* starting <code>^</code> and ending <code>$</code> are necessary. It is
* matched without any parameters (such as charset, etc.). Also, the match is
* case-insensitive.
* @param {module:x2node-ws.Marshaller} marshaller The marshaller
* implementation.
* @returns {module:x2node-ws~Application} This application.
*/
addMarshaller(contentTypePattern, marshaller) {
if (this._running)
throw new common.X2UsageError('Application is already running.');
this._marshallers.push({
pattern: contentTypePattern,
value: marshaller
});
return this;
}
/**
* Associate an authenticator with the specified URI pattern. When looking up
* authenticator for a URI, the URI patterns are matched in the order the
* authenticators were added to the application.
*
* @param {string} uriPattern URI regular expression pattern. The URI is
* matched against the pattern as a whole, so no starting <code>^</code> and
* ending <code>$</code> are necessary. The match is case-sensitive.
* @param {module:x2node-ws.Authenticator} authenticator The authenticator.
* @returns {module:x2node-ws~Application} This application.
*/
addAuthenticator(uriPattern, authenticator) {
if (this._running)
throw new common.X2UsageError('Application is already running.');
this._authenticators.push(
this._toMappingDesc(uriPattern, authenticator));
return this;
}
/**
* Associate an authorizer with the specified URI pattern. When looking up
* authorizer for a URI, the URI patterns are matched in the order the
* authorizers were added to the application.
*
* @param {string} uriPattern URI regular expression pattern. The URI is
* matched against the pattern as a whole, so no starting <code>^</code> and
* ending <code>$</code> are necessary. The match is case-sensitive.
* @param {(module:x2node-ws.Authorizer|function)} authorizer The authorizer.
* If function, the function is used as the authorizer's
* <code>isAllowed()</code> method.
* @returns {module:x2node-ws~Application} This application.
*/
addAuthorizer(uriPattern, authorizer) {
if (this._running)
throw new common.X2UsageError('Application is already running.');
this._authorizers.push(
this._toMappingDesc(uriPattern, (
(typeof authorizer) === 'function' ?
{ isAllowed: authorizer } : authorizer
)));
return this;
}
/**
* Add web service endpoint. When looking up endpoint handler for a URI, the
* URI patterns are matched in the order the handlers were added to the
* application.
*
* @param {(string|Array.<string>)} uriPattern Endpoint URI regular
* expression pattern. URI parameters are groups in the pattern. The URI is
* matched against the pattern as a whole, so no starting <code>^</code> and
* ending <code>$</code> are necessary. The match is case-sensitive. If
* array, the first array element is the pattern and the rest are names for
* the positional URI parameters.
* @param {module:x2node-ws.Handler} handler The handler for the endpoint.
* @returns {module:x2node-ws~Application} This application.
*/
addEndpoint(uriPattern, handler) {
if (this._running)
throw new common.X2UsageError('Application is already running.');
this._endpoints.push(
this._toMappingDesc(uriPattern, handler));
return this;
}
/**
* Create mapping descriptor.
*
* @private
* @param {(string|Array.<string>)} uriPattern URI pattern.
* @param {*} value The mapping value.
* @returns {module:x2node-ws~PatternMap~MappingDesc} Mapping descriptor.
*/
_toMappingDesc(uriPattern, value) {
return {
pattern: (
this._prefix.length > 0 ? (
Array.isArray(uriPattern) ?
[ this._prefix + uriPattern[0] ].concat(
uriPattern.slice(1))
: this._prefix + uriPattern
) : uriPattern
),
value: value
};
}
/**
* Create HTTP server and run the application on it.
*
* @param {number} port Port, on which to listen for incoming HTTP requests.
* @returns {http.external:Server} The HTTP server.
*/
run(port) {
// check if already running
if (this._running)
throw new common.X2UsageError('Application is already running.');
this._running = true;
// the debug log
const log = this._log;
log('starting up');
// add default marshallers
this._marshallers.push({
pattern: 'application/json',
value: JSON_MARSHALLER
});
this._marshallers.push({
pattern: '.+\\+json',
value: JSON_MARSHALLER
});
// compile pattern maps
this._marshallers = new PatternMap(this._marshallers);
this._authenticators = new PatternMap(this._authenticators);
this._authorizers = new PatternMap(this._authorizers);
this._endpoints = new PatternMap(this._endpoints);
// create HTTP server
const server = http.createServer();
// set initial connection idle timeout
server.setTimeout(
this._options.connectionIdleTimeout || DEFAULT_CONN_IDLE_TIMEOUT,
onBeforeResponseTimeout
);
// set maximum allowed number of HTTP request headers
server.maxHeadersCount = (this._options.maxRequestHeadersCount || 50);
// set open connections registry maintenance handlers
server.on('connection', socket => {
const connectionId = `#${this._nextConnectionId++}`;
this._log(`connection ${connectionId}: opened`);
socket[CONNECTION_ID] = connectionId;
socket[NEXT_CALL_ID] = 1;
socket.x2NextCallId = nextCallId.bind(socket);
socket[IDLE] = true;
socket[CALLS] = [];
this._connections.set(CONNECTION_ID, socket);
socket.on('close', () => {
this._log(`connection ${connectionId}: closed`);
this._connections.delete(CONNECTION_ID);
for (let call of socket[CALLS])
call.connectionClosed = true;
delete socket[CALLS];
});
});
// set shutdown handler
server.on('close', () => {
log('all connections closed, firing shutdown event');
this.emit('shutdown');
});
// set request processing handlers
server.on('checkContinue', this._respond.bind(this));
server.on('request', this._respond.bind(this));
// setup signals
const terminate = (singalNum) => {
if (this._shuttingDown) {
log('already shutting down');
} else {
log('shutting down');
// mark application as shutting down
this._shuttingDown = true;
// stop accepting new connections
server.close(() => {
process.exit(128 + singalNum);
});
// severe idle keep-alive connections
for (let connection of this._connections.values())
if (connection[IDLE])
this._destroyConnection(connection);
// fire shutting down event
this.emit('shuttingdown');
}
};
process.on('SIGHUP', () => { terminate(1); });
process.on('SIGINT', () => { terminate(2); });
process.on('SIGTERM', () => { terminate(15); });
process.on('SIGBREAK', () => { terminate(21); });
// terminate on server error
server.on('error', err => {
common.error('could not start the application', err);
terminate(-127);
});
// start listening for incoming requests
server.listen(port, () => {
log(
`ready for requests on ${port}, ` +
`API version ${this._apiVersion}`);
});
// return the HTTP server
return server;
}
/**
* Respond to an HTTP request.
*
* @private
* @param {http.external:IncomingMessage} httpRequest HTTP request.
* @param {http.external:ServerResponse} httpResponse HTTP response.
*/
_respond(httpRequest, httpResponse) {
// mark connection as active
httpRequest.socket[IDLE] = false;
// create the service call object
const call = new ServiceCall(
this._apiVersion, httpRequest, this._options);
// add call to the calls associated with the connection
httpRequest.socket[CALLS].push(call);
// process the call
try {
// log the call
this._log(
`call ${call.id}: received ${httpRequest.method}` +
` ${call.requestUrl.pathname}`);
// remove the initial connection idle timeout
httpRequest.socket.setTimeout(0, onBeforeResponseTimeout);
// lookup the handler
const hasHandler = this._endpoints.lookup(
call.requestUrl.pathname,
(handler, uriParams) => { call.setHandler(handler, uriParams); }
);
if (!hasHandler)
return this._sendResponse(
httpResponse, call, (new ServiceResponse(404).setEntity({
errorCode: 'X2-404-1',
errorMessage: 'No service endpoint at this URI.'
})));
// lookup the authenticator (OPTIONS responder needs it)
this._authenticators.lookup(
call.requestUrl.pathname,
authenticator => { call.setAuthenticator(authenticator); }
);
// get handler methods
const handlerMethods = this._getHandlerMethods(call.handler);
// get requested method
const method = (
httpRequest.method === 'HEAD' ? 'GET' : httpRequest.method);
// respond to an OPTIONS request
if (method === 'OPTIONS')
return this._sendOptionsResponse(
httpResponse, call, handlerMethods);
// check if the method is supported by the handler
if (!handlerMethods.has(method)) {
const response = new ServiceResponse(405);
this._setAllowedMethods(response, handlerMethods);
response.setEntity({
errorCode: 'X2-405-1',
errorMessage: 'Method not supported by the service endpoint.'
});
return this._sendResponse(httpResponse, call, response);
}
// lookup the authorizer
this._authorizers.lookupMultiReverse(
call.requestUrl.pathname,
authorizer => { call.addAuthorizer(authorizer); }
);
// build the processing chain
this._authenticateCall(
call
).then(
call => (
this._log(
`call ${call.id}: authed actor` +
` ${call.actor && call.actor.stamp}`),
this._authorizeCall(call)
)
).then(
call => this._chooseRepresentation(call)
).then(
call => this._readRequestPayload(call, httpResponse)
).then(
call => Promise.resolve(call.handler[method](call))
).then(
result => {
let response;
if ((result === null) || (result === undefined)) {
response = new ServiceResponse(204);
} else if (result instanceof ServiceResponse) {
response = result;
} else if ((typeof result) === 'object') {
response = new ServiceResponse(200);
response.setEntity(result);
} else {
response = new ServiceResponse(200);
response.setEntity(
Buffer.from(String(result), 'utf8'),
'text/plain; charset=UTF-8'
);
}
this._sendResponse(httpResponse, call, response);
}
).catch(
err => {
if (err instanceof ServiceResponse)
this._sendResponse(httpResponse, call, err);
else if (err)
this._sendInternalServerErrorResponse(
httpResponse, call, err);
}
);
} catch (err) {
this._sendInternalServerErrorResponse(httpResponse, call, err);
}
}
/**
* Perform service call authentication, set the actor on the call and check
* if allowed to proceed.
*
* @private
* @param {module:x2node-ws~ServiceCall} call The call.
* @returns {Promise.<module:x2node-ws~ServiceCall>} Promise of the
* authenticated call.
*/
_authenticateCall(call) {
// check if no authenticator
if (!call.authenticator)
return Promise.resolve(call);
// call the authenticator
return Promise.resolve(call.authenticator.authenticate(call)).then(
actor => {
// check if connection closed while authenticating
if (call.connectionClosed)
return Promise.reject(null);
// set actor on the call
if (actor)
call.actor = actor;
// proceed with the call
return call;
}
);
}
/**
* Perform service call authorization.
*
* @private
* @param {module:x2node-ws~ServiceCall} call The call.
* @returns {Promise.<module:x2node-ws~ServiceCall>} Promise of the
* authorized call.
*/
_authorizeCall(call) {
// pre-authorize the call
call.authorized = true;
// check if no authorizers
const authorizers = call.authorizers;
if (!authorizers || (authorizers.length === 0))
return call;
// queue up the authorizers
let promiseChain = Promise.resolve(call);
for (let authorizer of authorizers) {
promiseChain = promiseChain.then(
call => {
// check if connection closed while authorizing
if (call.connectionClosed)
return Promise.reject(null);
// check if unauthorized by previous authorizer
if (!call.authorized)
return call;
// call the authorizer
return Promise.resolve(authorizer.isAllowed(call)).then(
authorized => {
// check if connection closed while authorizing
if (call.connectionClosed)
return Promise.reject(null);
// check if unauthorized
if (!authorized)
call.authorized = false;
// proceed with the call
return call;
}
);
}
);
}
// queue up the authorization check and return the result
return promiseChain.then(
call => {
// check if failed to authorize
if (!call.authorized)
return Promise.reject(
call.actor ?
(new ServiceResponse(403)).setEntity({
errorCode: 'X2-403-1',
errorMessage: 'Insufficient permissions.'
}) :
(new ServiceResponse(401)).setEntity({
errorCode: 'X2-401-1',
errorMessage: 'Authentication required.'
})
);
// authorized, proceed with the call
return call;
}
);
}
/**
* Analyze "Accept" request header, if any, and choose response content type.
*
* @private
* @param {module:x2node-ws~ServiceCall} call The call.
* @returns {Promise.<module:x2node-ws~ServiceCall>} Promise of the call with
* the requested representation set.
*/
_chooseRepresentation(call) {
// get list of supported representations from the handler
const handler = call.handler;
const representations = (
(typeof handler.getRepresentations) === 'function' ?
handler.getRepresentations(call)
: [ 'application/json' ]
);
// check if has "Accept" header
const acceptHeader = call.httpRequest.headers['accept'];
// set default representation if no "Accept" header
if (!acceptHeader) {
call.requestedRepresentation = representations[0];
return call;
}
// parse "Accept" header
let invalid = false;
const acceptedRepresentations = acceptHeader.trim().toLowerCase().split(
/\s*,\s*/
).map(rangeDef => {
const m = ACCEPT_HEADER_RANGE_PATTERN.exec(rangeDef);
const range = (m && m[1]);
if (!range || /^\*\/[^*]/.test(range)) {
invalid = true;
return {};
}
const precision = (
range === '*/*' ? 2 : (
range.endsWith('/*') ? 1 : 0
)
);
let testFunc;
switch (precision) {
case 0:
testFunc = v => (v === range);
break;
case 1:
testFunc = v => v.startsWith(
range.substring(0, range.length - 1));
break;
case 2:
testFunc = () => true;
}
return {
test: testFunc,
precision: precision,
weight: (m[2] !== undefined ? Number(m[2]) : 1)
};
}).sort((a, b) => (
a.precision > b.precision ? -1 : (
a.precision < b.precision ? 1 : (
a.weight > b.weight ? -1 : (
a.weight < b.weight ? 1 : 0
)
)
)
));
if (invalid)
return Promise.reject((new ServiceResponse(400)).setEntity({
errorCode: 'X2-400-2',
errorMessage: 'Malformed Accept request header.'
}));
// select representation
const numAcceptedReps = acceptedRepresentations.length;
let selectedRepresentation, selectedWeight = numAcceptedReps;
for (let representation of representations) {
for (let weight = 0; weight < numAcceptedReps; weight++) {
const range = acceptedRepresentations[weight];
if (range.test(representation) && (weight < selectedWeight)) {
selectedRepresentation = representation;
selectedWeight = weight;
}
}
}
// got matching representation?
if (!selectedRepresentation)
return Promise.reject((new ServiceResponse(406)).setEntity({
errorCode: 'X2-406',
errorMessage: 'Unable to provide acceptable representation.'
}));
// set it in the call
call.requestedRepresentation = selectedRepresentation;
// continue call processing
return call;
}
/**
* Load request payload, if any, and add it to the service call.
*
* @private
* @param {module:x2node-ws~ServiceCall} call The call.
* @param {http.external:ServerResponse} httpResponse The HTTP response.
* @returns {Promise.<module:x2node-ws~ServiceCall>} Promise of the call with
* payload added to it.
*/
_readRequestPayload(call, httpResponse) {
// get request headers
const requestHeaders = call.httpRequest.headers;
// check if there is payload
const contentLength = Number(requestHeaders['content-length']);
if (!(contentLength > 0))
return call;
// check if not too large
const maxRequestSize = (
this._options.maxRequestSize || DEFAULT_MAX_REQUEST_SIZE);
if (contentLength > maxRequestSize)
return Promise.reject(
(new ServiceResponse(413))
.setHeader('Connection', 'close')
.setEntity({
errorCode: 'X2-413',
errorMessage: 'The request entity is too large.'
})
);
// get content type
const contentType = (
requestHeaders['content-type'] || 'application/octet-stream');
// restore connection idle timeout
const connection = call.httpRequest.socket;
connection.setTimeout(
this._options.connectionIdleTimeout || DEFAULT_CONN_IDLE_TIMEOUT,
onBeforeResponseTimeout
);
// check if multipart
if (/^multipart\//i.test(contentType)) {
// TODO: implement
return Promise.reject((new ServiceResponse(415)).setEntity({
errorCode: 'X2-415',
errorMessage: 'Multipart requests are not supported yet.'
}));
} else { // not multipart
// find marshaller
const entityContentType = contentType.split(';')[0].toLowerCase();
let marshaller;
if (call.handler.requestEntityParsers) {
const deserializer = call.handler.requestEntityParsers[
entityContentType];
if ((typeof deserializer) === 'function')
marshaller = { deserialize: deserializer };
}
if (!marshaller)
marshaller = this._marshallers.lookup(entityContentType);
if (!marshaller)
return Promise.reject(
(new ServiceResponse(415)).setEntity({
errorCode: 'X2-415',
errorMessage: 'Unsupported request entity content type.'
})
);
// respond with 100 if expecting continue
this._sendContinue(httpResponse);
// read the data
return this._readEntity(
call, call.httpRequest, marshaller, contentType).then(
entity => {
// remove connection idle timeout
connection.setTimeout(0, onBeforeResponseTimeout);
// set entity on the call
call.entity = entity;
call.entityContentType = entityContentType;
// proceed with the call
return call;
},
err => {
// remove connection idle timeout
connection.setTimeout(0, onBeforeResponseTimeout);
// abort the call
return Promise.reject(err);
}
);
}
}
/**
* Read and parse entity from input stream.
*
* @private
* @param {module:x2node-ws~ServiceCall} call The call.
* @param {stream.external:Readable} input Input stream.
* @param {module:x2node-ws.Marshaller} marshaller Marshaller to use to parse
* the entity.
* @param {string} contentType Content type request header value.
* @returns {Promise.<Object>} Promise of the parsed entity object.
*/
_readEntity(call, input, marshaller, contentType) {
return (new Promise((resolve, reject) => {
const dataBufs = new Array();
let done = false;
input
.on('data', chunk => {
// check if response resolved in another event handler
if (done)
return;
// check if connection closed
if (call.connectionClosed) {
done = true;
return Promise.reject(null);
}
// add data chunk to the read data buffer
dataBufs.push(chunk);
})
.on('error', err => {
// check if response resolved in another event handler
if (done)
return;
// mark as done
done = true;
// reject with the error
reject(err);
})
.on('end', () => {
// check if response resolved in another event handler
if (done)
return;
// mark as done
done = true;
// check if connection closed
if (call.connectionClosed)
return Promise.reject(null);
// parse the request entity
try {
resolve(marshaller.deserialize((
dataBufs.length === 1 ?
dataBufs[0] : Buffer.concat(dataBufs)
), contentType));
} catch (err) {
this._log(
`call ${call.id}: error parsing request entity:` +
` ${err.message}`);
if (err instanceof common.X2DataError) {
reject(
(new ServiceResponse(400)).setEntity({
errorCode: 'X2-400-1',
errorMessage:
'Could not parse request entity.'
})
);
} else {
reject(err);
}
}
});
})).then( // let all I/O events play out
entity => new Promise(resolve => {
setTimeout(() => { resolve(entity); }, 1);
}),
err => new Promise((_, reject) => {
setTimeout(() => { reject(err); }, 1);
})
);
}
/**
* Send 100 (Continue) HTTP response, if needs to.
*
* @private
* @param {http.external:ServerResponse} httpResponse The HTTP response.
*/
_sendContinue(httpResponse) {
// KLUDGE: response properties used below are undocumented
if (httpResponse._expect_continue && !httpResponse._sent100)
httpResponse.writeContinue();
/*if (httpRequest.headers['expect'] === '100-continue')
httpResponse.writeContinue();*/
}
/**
* Send response to an OPTIONS request.
*
* @private
* @param {http.external:ServerResponse} httpResponse HTTP response.
* @param {module:x2node-ws~ServiceCall} call The call.
* @param {Set.<string>} allowedMethods Allowed HTTP methods.
*/
_sendOptionsResponse(httpResponse, call, allowedMethods) {
// create the response object
const response = new ServiceResponse(200);
// add allowed methods
this._setAllowedMethods(response, allowedMethods);
// add zero content length header
response.setHeader('Content-Length', 0);
// response always varies depending on the "Origin" header
response.setHeader('Vary', 'Origin');
// process CORS preflight request
const requestHeaders = call.httpRequest.headers;
const requestedMethod = requestHeaders['access-control-request-method'];
if (requestedMethod && this._addCORS(call, response)) {
// preflight response caching
response.setHeader(
'Access-Control-Max-Age', (
this._options.corsPreflightMaxAge ||
DEFAULT_CORS_PREFLIGHT_MAX_AGE));
// allowed methods
response.setHeader(
'Access-Control-Allow-Methods', response.headers['allow']);
// allowed request headers
const requestedHeaders =
requestHeaders['access-control-request-headers'];
if (requestedHeaders)
response.setHeader(
'Access-Control-Allow-Headers', requestedHeaders);
}
// custom handler logic
if ((typeof call.handler.OPTIONS) === 'function')
call.handler.OPTIONS(call, response);
// send the response
this._sendResponse(httpResponse, call, response);
}
/**
* Send HTTP 500 (Internal Server Error) response as a reaction to an
* unexpected error.
*
* @private
* @param {http.external:ServerResponse} httpResponse HTTP response.
* @param {module:x2node-ws~ServiceCall} call The call.
* @param {external:Error} err The error that caused the 500 response.
*/
_sendInternalServerErrorResponse(httpResponse, call, err) {
common.error(`call ${call.id}: internal server error`, err);
this._sendResponse(
httpResponse, call, (new ServiceResponse(500))
.setHeader('Connection', 'close')
.setEntity({
errorCode: 'X2-500-1',
errorMessage: 'Internal server error.'
})
);
}
/**
* Send web-service response.
*
* @private
* @param {http.external:ServerResponse} httpResponse HTTP response.
* @param {module:x2node-ws~ServiceCall} call The call.
* @param {module:x2node-ws~ServiceResponse} response The response.
* @param {boolean} [noDelay] If <code>true</code>, response delay logic is
* disabled.
*/
_sendResponse(httpResponse, call, response, noDelay) {
// check if delayed
if (!noDelay && Number.isInteger(this._options.delay)) {
this._log(
`call ${call.id}: delaying response by` +
` ${this._options.delay}ms`);
return setTimeout(() => {
this._sendResponse(httpResponse, call, response, true);
}, this._options.delay);
}
// remove call from the calls associated with the connection
const connectionCalls = httpResponse.socket[CALLS];
if (connectionCalls)
connectionCalls.splice(connectionCalls.indexOf(call), 1);
// check if connection closed
if (call.connectionClosed) {
this._log(
`call ${call.id}: not sending response because` +
` connection was closed`);
return;
}
try {
// restore idle timeout on the connection
httpResponse.setTimeout(
this._options.connectionIdleTimeout || DEFAULT_CONN_IDLE_TIMEOUT,
socket => {
if (!call.complete)
common.error(
`call ${call.id}: connection timed out before` +
' completing the response');
this._destroyConnection(socket);
}
);
// get the request method for quick access
const method = call.httpRequest.method;
// response always varies depending on the "Origin" header
response.addToHeadersListHeader('Vary', 'Origin');
// let authenticator to add its response headers
if (call.authenticator && call.authenticator.addResponseHeaders)
call.authenticator.addResponseHeaders(call, response);
// default response cache control if none in the service response
if (!response.hasHeader('Cache-Control') && (
(method === 'GET') || (method === 'HEAD') ||
response.hasHeader('Content-Location')) &&
CACHEABLE_STATUS_CODES.has(response.statusCode)) {
response
.setHeader('Cache-Control', 'no-cache')
.setHeader('Expires', '0')
.setHeader('Pragma', 'no-cache');
}
// check if cross-origin request
if (this._addCORS(call, response)) {
// add exposed headers
const exposedHeaders = Object.keys(response.headers).filter(
h => !SIMPLE_RESPONSE_HEADERS.has(h)).join(', ');
if (exposedHeaders.length > 0)
response.setHeader(
'Access-Control-Expose-Headers', exposedHeaders);
}
// don't keep connection alive if shutting down
if (this._shuttingDown)
response.setHeader('Connection', 'close');
// completion handler
httpResponse.on('finish', () => {
call.complete = true;
const connection = call.httpRequest.socket;
connection[IDLE] = true;
if (this._shuttingDown)
process.nextTick(() => {
this._destroyConnection(connection);
});
this._log(
`call ${call.id}: completed in ` +
`${Date.now() - call.timestamp}ms`);
});
// add response entities
const entities = response.entities;
if (entities.length > 0) {
// set up response content type
if (entities.length > 1) {
response.setHeader(
'Content-Type', `multipart/mixed; boundary=${BOUNDARY}`);
} else { // single entity
const entity = entities[0];
for (let h of Object.keys(entity.headers))
response.setHeader(h, entity.headers[h]);
}
// send entities using different methods
if (method === 'HEAD') {
this._completeResponseNoEntities(
httpResponse, call, response);
} else {
this._completeResponseWithEntities(
httpResponse, call, response, entities);
}
} else { // no entities
this._completeResponseNoEntities(httpResponse, call, response);
}
} catch (err) {
if (call.responseHeaderWritten) {
common.error(
`call ${call.id}: internal error after response header has` +
' been written, quitely closing the connection', err);
this._destroyConnection(httpResponse.socket);
} else {
common.error(
`call ${call.id}: internal error preparing response,` +
' sending 500 response', err);
try {
httpResponse.socket.end(
'HTTP/1.1 500 ' + http.STATUS_CODES[500] + '\r\n' +
'Date: ' + (new Date()).toUTCString() + '\r\n' +
'Connection: close\r\n' +
'\r\n');
} catch (errorResponseErr) {
common.error(
`call ${call.id}: internal error sending 500 response,` +
' quitely closing the connection', errorResponseErr);
this._destroyConnection(httpResponse.socket);
}
}
}
}
/**
* Forcibly severe connection.
*
* @private
* @param {net.external:Socket} socket The connection socket.
*/
_destroyConnection(socket) {
if (socket && !socket.destroyed) {
const connectionId = socket[CONNECTION_ID];
this._log(`connection ${connectionId}: severing`);
this._connections.delete(connectionId);
socket.destroy();
}
}
/**
* Check origin of a cross-origin request and if allowed, add CORS response
* headers common for simple and preflight requests.
*
* @private
* @param {module:x2node-ws~ServiceCall} call The call.
* @param {module:x2node-ws~ServiceResponse} response The response.
* @returns {boolean} <code>true</code> if CORS headers were added (allowed
* cross-origin request or not a cross-origin request).
*/
_addCORS(call, response) {
const origin = call.httpRequest.headers['origin'];
if (!origin)
return false;
// check if the service allows only specific origins
let allowed = false;
if (this._allowedOrigins) {
// check if allowed
allowed = this._allowedOrigins.has(origin.toLowerCase());
if (allowed) {
// allow the specific origin
response.setHeader('Access-Control-Allow-Origin', origin);
// check if the endpoint supports credentialed requests
if (call.authenticator)
response.setHeader(
'Access-Control-Allow-Credentials', 'true');
}
} else { // service is open to all origins
// allow it
allowed = true;
// check if the endpoint supports credentialed requests
if (call.authenticator) {
response.setHeader('Access-Control-Allow-Origin', origin);
response.setHeader('Access-Control-Allow-Credentials', 'true');
} else { // public endpoint
response.setHeader('Access-Control-Allow-Origin', '*');
}
}
// if allowed, headers were added
return allowed;
}
/**
* Complete sending response with no entities.
*
* @private
* @param {http.external:ServerResponse} httpResponse HTTP response.
* @param {module:x2node-ws~ServiceCall} call The call.
* @param {module:x2node-ws~ServiceResponse} response The response.
*/
_completeResponseNoEntities(httpResponse, call, response) {
httpResponse.writeHead(
response.statusCode, this._capitalizeHeaders(response.headers));
call.responseHeaderWritten = true;
httpResponse.end();
}
/**
* Complete sending response with entities.
*
* @private
* @param {http.external:ServerResponse} httpResponse HTTP response.
* @param {module:x2node-ws~ServiceCall} call The call.
* @param {module:x2node-ws~ServiceResponse} response The response.
* @param {Array.<module:x2node-ws~ServiceResponse~Entity>} entities Entities
* to send.
*/
_completeResponseWithEntities(httpResponse, call, response, entities) {
// create sequence of buffers and streams to send in the response body
let bufs = new Array(), chunked = false;
if (entities.length === 1) {
const entity = entities[0];
if (entity.data instanceof stream.Readable) {
bufs.push(entity.data);
chunked = true;
} else {
bufs.push(this._getResponseEntityDataBuffer(entity));
}
} else { // multipart
// add payload parts
for (let i = 0, len = entities.length; i < len; i++) {
const entity = entities[i];
// part boundary
bufs.push(BOUNDARY_MID);
// part headers
bufs.push(Buffer.from(
Object.keys(entity.headers).reduce((res, h) => {
return res + this._capitalizeHeaderName(h) + ': ' +
entity.headers[h] + '\r\n';
}, '') + '\r\n', 'ascii'));
// part body
if (entity.data instanceof stream.Readable) {
bufs.push(entity.data);
chunked = true;
} else {
bufs.push(this._getResponseEntityDataBuffer(entity));
}
// part end
bufs.push(CRLF);
}
// end boundary of the multipart payload
bufs.push(BOUNDARY_END);
}
// set response content length
if (!chunked)
response.setHeader(
'Content-Length',
bufs.reduce((tot, buf) => (tot + buf.length), 0)
);
// write response head
httpResponse.writeHead(
response.statusCode, this._capitalizeHeaders(response.headers));
call.responseHeaderWritten = true;
// setup error listener
let error = false;
httpResponse.on('error', err => {
this._log(
`call ${call.id}: error writing the response: ${err.message}`);
error = true;
});
// write response body buffers and streams
const numBufs = bufs.length;
let curBufInd = 0;
function writeHttpResponse() {
// give up if error or connection closed
if (error || call.connectionClosed) {
this._log(`call ${call.id}: aborting sending the response`);
return;
}
// write the buffers to the stream
while (curBufInd < numBufs) {
const data = bufs[curBufInd++];
// buffer or stream?
if (Buffer.isBuffer(data)) {
// write the buffer, wait for "drain" if necessary
if (!httpResponse.write(data)) {
// continue to the next buffer or stream upon "drain"
httpResponse.once('drain', writeHttpResponse);
// exit until "drain" is received
return;
}
} else { // stream
// pipe the stream into the response
data.pipe(httpResponse, { end: false });
// continue to the next buffer or stream upon "end"
data.on('end', writeHttpResponse);
// exit until "end" is received
return;
}
}
// all buffers written, end the response
httpResponse.end();
// remove call from the socket
const socketCalls = httpResponse.socket[CALLS];
const callInd = socketCalls.indexOf(call);
if (callInd >= 0)
socketCalls.splice(callInd, 1);
}
// initiate the write
writeHttpResponse();
}
/**
* Get data buffer for the specified response entity invoking appropriate
* marshaller if necessary.
*
* @private
* @param {module:x2node-ws~ServiceResponse~Entity} entity Response entity.
* @returns {external:Buffer} Buffer with the response entity data.
*/
_getResponseEntityDataBuffer(entity) {
if (Buffer.isBuffer(entity.data))
return entity.data;
const contentType = entity.headers['content-type'];
const marshaller = this._marshallers.lookup(contentType.toLowerCase());
if (!marshaller)
throw new common.X2UsageError(
`No marshaller for content type ${contentType}.`);
return marshaller.serialize(entity.data, contentType);
}
/**
* Capitalize header names.
*
* @private
* @param {Object.<string,string>} headers Headers to capitalize.
* @returns {Object.<string,string>} Capitalized headers.
*/
_capitalizeHeaders(headers) {
return Object.keys(headers).reduce((res, h) => {
res[this._capitalizeHeaderName(h)] = headers[h];
return res;
}, new Object());
}
/**
* Capitalize header name.
*
* @private
* @param {string} headerName Header name to capitalize.
* @returns {string} Capitalized header name.
*/
_capitalizeHeaderName(headerName) {
const headerNameLC = headerName.toLowerCase();
const normalizedHeaderName = NORMAL_HEADER_NAMES[headerNameLC];
if (normalizedHeaderName)
return normalizedHeaderName;
return headerNameLC.replace(/\b[a-z]/g, m => m.toUpperCase());
}
/**
* Get HTTP methods supported by the handler.
*
* @private
* @param {module:x2node-ws.Handler} handler The handler.
* @returns {Set.<string>} The supported methods.
*/
_getHandlerMethods(handler) {
let methods = handler[METHODS];
if (!methods) {
handler[METHODS] = methods = new Set();
for (let o = handler; o; o = Object.getPrototypeOf(o))
for (let m of Object.getOwnPropertyNames(o))
if (((typeof handler[m]) === 'function') &&
KNOWN_METHODS.has(m))
methods.add(m);
}
return methods;
}
/**
* Set "Allow" header on the response.
*
* @private
* @param {module:x2node-ws~ServiceResponse} response The response.
* @param {Set.<string>} handlerMethods Methods supported by the handler.
*/
_setAllowedMethods(response, handlerMethods) {
const methodsArray = Array.from(handlerMethods);
methodsArray.push('OPTIONS');
if (handlerMethods.has('GET'))
methodsArray.push('HEAD');
response.addToMethodsListHeader('Allow', methodsArray);
}
}
// export the class
module.exports = Application;