const { fileScope } = require('@squeep/log-helper');
const _fileScope = fileScope(__filename);
+/**
+ * @type {DingusOptions}
+ */
const defaultOptions = {
ignoreTrailingSlash: true,
proxyPrefix: '',
const lineBreakRE = /\r\n|\n|\r/;
const proxySplitRE = /\s*,\s*/u;
+/**
+ * @typedef {object} DingusOptions
+ * @property {boolean=} ignoreTrailingSlash requests for '/foo/' will match a '/foo' route (default: true)
+ * @property {string=} proxyPrefix leading part of url path to strip before processing, should not have trailing slash (default: '')
+ * @property {boolean=} strictAccept whether to error on unsupported Accept type (default: true)
+ * @property {string=} selfBaseUrl root url including proxyPrefix, for constructing external links (default: '')
+ * @property {boolean=} staticMetadata serve static headers with static files (default: true)
+ * @property {string=} staticPath local filepath to static files, e.g. path.join(__dirname, 'static')
+ * @property {string=} staticRoute route base path to serve static files (default: '/static)
+ * @property {boolean=} trustProxy trust some header data to be provided by proxy (default: true)
+ * @property {boolean=} intrinsicHeadMethod handle HEAD requests automatically if not specified as a route method for routes with a GET handler (default: true)
+ * @property {boolean=} intrinsicHeadPersistBody include un-sent body on ctx for automatic HEAD requests (default: false)
+ * @property {object=} querystring alternate qs parser to use, must provide .parse() function (default: node:querystring)
+ */
+
class Dingus {
/**
* @param {object} logger console-like object which implements logging methods
- * @param {object} options options
- * @param {boolean} options.ignoreTrailingSlash requests for '/foo/' will match a '/foo' route
- * @param {string} options.proxyPrefix leading part of url path to strip
- * @param {boolean} options.strictAccept whether to error on unsupported Accept type
- * @param {string} options.selfBaseUrl for constructing links
- * @param {boolean} options.staticMetadata serve static headers with static files
- * @param {boolean} options.trustProxy trust some header data to be provided by proxy
- * @param {boolean} options.intrinsicHeadMethod handle HEAD requests automatically if not specified as a route method
- * @param {boolean} options.intrinsicHeadPersistBody include un-sent body on ctx for automatic HEAD requests
- * @param {object} options.querystring alternate qs parser to use
+ * @param {DingusOptions} options options
*/
constructor(logger = console, options = {}) {
common.setOptions(this, defaultOptions, options);
}
+ /**
+ * @typedef {object} StandardStaticRouteOptions
+ * @property {string=} staticRoute url path to static content, without trailing slash (default: '/static')
+ * @property {string=} staticRouteFileName internal reference to name of static file route (default: 'static-file')
+ */
+ /**
+ * Establish some routes for handling static files.
+ * @param {StandardStaticRouteOptions} options options
+ */
+ defineStandardStaticRoutes(options = {}) {
+ const opt = {
+ staticRoute: '/static',
+ staticRouteFileName: 'static-file',
+ ...options,
+ };
+ if (!this.staticPath || !opt.staticRoute) {
+ throw new DingusError('static route definition requested but missing required options');
+ }
+ if (!opt.staticRoute.startsWith('/')) {
+ throw new RangeError('staticRoute parameter should have leading slash');
+ }
+ if (opt.staticRoute.endsWith('/')) {
+ throw new RangeError('staticRoute parameter should not have trailing slash');
+ }
+
+ // /static/:file serves file
+ this.on(['GET'], `${opt.staticRoute}/:file`, opt.staticRouteFileName, this.handlerGetStaticFile.bind(this));
+
+ // /static forwards to /static/
+ this.on(['GET'], opt.staticRoute, this.handlerRedirect.bind(this), `${this.proxyPrefix}${opt.staticRoute}/`);
+
+ // /static/ serves /static/index.html
+ this.on(['GET'], `${opt.staticRoute}/`, this.handlerGetStaticFile.bind(this), 'index.html');
+
+ // /favicon.ico and /robots.txt serve from /static/
+ this.on(['GET'], '/favicon.ico', this.handlerGetStaticFile.bind(this), 'favicon.ico');
+ this.on(['GET'], '/robots.txt', this.handlerGetStaticFile.bind(this), 'robots.txt');
+ }
+
+
/**
* Resolve relative and empty paths in url
* @param {string} p path
* @returns {object} path string and query params
*/
_splitUrl(url) {
- const [ p, qs ] = common.splitFirst(url, '?');
+ const [p, qs] = common.splitFirst(url, '?');
return {
pathPart: this._normalizePath(p),
queryParams: this.querystring.parse(qs),
* @param {any[]} handlerArgs additional arguments for handler
*/
on(methods, urlPath, name, handler, ...handlerArgs) {
- if (typeof name === 'string') {
- // Named path
- this.router.on(methods, urlPath, handler, handlerArgs, name);
- } else {
+ if (typeof name === 'function') {
// Unnamed path, shift parameters: name contains handler, handler contains first handlerArg
this.router.on(methods, urlPath, name, [handler, ...handlerArgs]);
+ } else {
+ // Named path
+ this.router.on(methods, urlPath, handler, handlerArgs, name);
}
}
+ /**
+ * @typedef {import('./router').RoutePath} RoutePath
+ */
+ /**
+ * Replace parameters in route path array.
+ * @param {RoutePath} routePath route path array
+ * @param {object=} params parameter substitutions
+ * @returns {RoutePath} parameterized route path array
+ */
+ static _parameterizePath(routePath, params) {
+ return routePath.map((part) => {
+ if (typeof part === 'string') {
+ return part;
+ }
+ if (part in params) {
+ return params[part]; // eslint-disable-line security/detect-object-injection
+ }
+ throw new DingusError(`missing route parameter '${part}'`);
+ });
+ }
+
+
/**
* Render a named path with any parameters.
* @param {string} name path name
- * @param {object} params parameter substitutions
+ * @param {object=} params parameter substitutions
+ * @param {boolean=} asArray disable joining parts into string
* @returns {string} rendered path
*/
- routePath(name, params) {
- const p = this.router.getNamedPath(name);
- if (!p) {
+ routePath(name, params, asArray = false) {
+ const namedPath = this.router.getNamedPath(name);
+ if (!namedPath) {
throw new DingusError(`undefined route path '${name}'`);
}
- return p
- .map((part) => {
- if (typeof part === 'string') {
- return part;
- }
- if (part in params) {
- return params[part]; // eslint-disable-line security/detect-object-injection
- }
- throw new DingusError(`missing route parameter '${part}'`);
- })
- .join('/');
+
+ const parameterizedPath = this.constructor._parameterizePath(namedPath, params);
+
+ return asArray ? parameterizedPath : parameterizedPath.join('/');
+ }
+
+
+ /**
+ * Render internal path including proxyPrefix.
+ * @param {string} name path name
+ * @param {object=} params parameter substitutions
+ * @returns {string} rendered path
+ */
+ absolutePath(name, params) {
+ return this.proxyPrefix + this.routePath(name, params);
+ }
+
+
+ /**
+ * Render internal path relative to current path.
+ * Exemplorum gratia:
+ * Current_Path_|_Target_Path__|_Result_________
+ * /foo/bar/baz | /foo/bar/zoo | zoo
+ * /foo/bar/baz | /foo/quux | ../quux
+ * /foo/bar/baz | /frink | ../../frink
+ * @param {string} current path name
+ * @param {string} target path name
+ * @param {object=} currentParams parameter substitutions
+ * @param {object=} targetParams parameter substitutions
+ * @returns {string} rendered path
+ */
+ relativePath(current, target, currentParams, targetParams) {
+ const targetPath = this.router.getNamedPath(target);
+ if (!targetPath) {
+ throw new DingusError(`undefined target route path '${target}'`);
+ }
+ const currentPath = this.router.getNamedPath(current);
+ if (!currentPath) {
+ throw new DingusError(`undefined current route path '${current}'`);
+ }
+
+ const targetParameterized = this.constructor._parameterizePath(targetPath, targetParams);
+ const currentParameterized = this.constructor._parameterizePath(currentPath, currentParams);
+
+ let idx = 0;
+ while (currentParameterized[idx] === targetParameterized[idx]) { // eslint-disable-line security/detect-object-injection
+ idx += 1;
+ }
+
+ const upPaths = currentParameterized.slice(idx).length;
+ const diff = targetParameterized.slice(idx);
+ for (let x = 1; x < upPaths; x++) {
+ diff.unshift('..');
+ }
+
+ return diff.join('/');
+ }
+
+
+ /**
+ * Render external URL for path.
+ * @param {string} name path name
+ * @param {object=} params parameter substitutions
+ * @returns {string} rendered url
+ */
+ externalPath(name, params) {
+ return this.selfBaseUrl + this.routePath(name, params);
}
+
/**
* Common header tagging for all requests.
* Add our own identifier, and persist any external transit identifiers.
static ingestCookie(req, res, ctx) {
ctx.cookie = {};
req.getHeader(Enum.Header.Cookie)?.split(cookieSplitRE).forEach((cookie) => {
- const [ name, value ] = common.splitFirst(cookie, '=', null).map((x) => {
+ const [name, value] = common.splitFirst(cookie, '=', null).map((x) => {
try {
return decodeURIComponent(x.trim());
} catch (e) { // eslint-disable-line no-unused-vars
* @param {http.ServerResponse} res response
* @param {string} directory path to file
* @param {string} fileName - already normalized and filtered
- * @returns {boolean} if meta headers were added
+ * @returns {Promise<boolean>} if meta headers were added
*/
async _serveFileMetaHeaders(res, directory, fileName) {
const _scope = _fileScope('_serveFileMetaHeaders');
continue;
}
const encodedFilePath = `${filePath}${suffix}`;
- const [ encodedStat, encodedData ] = await this._readFileInfo(encodedFilePath);
+ const [encodedStat, encodedData] = await this._readFileInfo(encodedFilePath);
if (encodedStat) {
- ([ stat, data ] = [ encodedStat, encodedData ]);
+ ([stat, data] = [encodedStat, encodedData]);
ctx.selectedEncoding = encoding;
Dingus.addEncodingHeader(res, encoding);
res.setHeader(Enum.Header.Vary, Enum.Header.AcceptEncoding);
const _nop = () => undefined;
const _logFn = (process.env['VERBOSE_TESTS'] && console.log) || _nop;
-const noLogger = {
+const logger = {
debug: _logFn,
error: _logFn,
};
-sinon.spy(noLogger, 'debug');
-sinon.spy(noLogger, 'error');
+sinon.spy(logger, 'debug');
+sinon.spy(logger, 'error');
describe('Dingus', function () {
let dingus;
beforeEach(function () {
- dingus = new Dingus(noLogger, {});
+ dingus = new Dingus(logger, {
+ selfBaseUrl: 'https://example.com/foo',
+ proxyPrefix: '/foo',
+ staticPath: '/static',
+ });
});
afterEach(function () {
sinon.restore();
});
}); // constructor
+ describe('defineStandardStaticRoutes', function () {
+ it('requires staticPath option', function () {
+ delete dingus.staticPath;
+ assert.throws(() => dingus.defineStandardStaticRoutes(), DingusError);
+ });
+ it('requires staticRoute option', function () {
+ assert.throws(() => dingus.defineStandardStaticRoutes({ staticRoute: '' }), DingusError);
+ });
+ it('requires staticRoute option be absolute', function () {
+ assert.throws(() => dingus.defineStandardStaticRoutes({ staticRoute: 'static/' }), RangeError);
+ });
+ it('requires staticRoute option not have trailing slash', function () {
+ assert.throws(() => dingus.defineStandardStaticRoutes({ staticRoute: '/static/' }), RangeError);
+ });
+ it('configures static routes', function () {
+ sinon.spy(dingus, 'on');
+ dingus.defineStandardStaticRoutes();
+ assert(dingus.on.called);
+ });
+ }); // defineStandardStaticRoutes
+
describe('_normalizePath', function () {
it('returns normal path', function () {
const p = '/a/b/c';
const r = dingus._normalizePath(p);
assert.strictEqual(r, expected);
});
+ it('covers no proxy prefix', function () {
+
+ });
}); // _normalizePath
describe('_splitUrl', function () {
const result = dingus.routePath('pathName', { b: 'foo' });
assert.strictEqual(result, '/a/foo/c');
});
+ it('renders parameterized path as array', function () {
+ dingus.on('GET', '/a/:b/c', 'pathName', () => {});
+ const result = dingus.routePath('pathName', { b: 'foo' }, true);
+ assert.deepStrictEqual(result, ['', 'a', 'foo', 'c']);
+ });
it('requires parameters', function () {
dingus.on('GET', '/a/:b/c', 'pathName', () => {});
assert.throws(() => dingus.routePath('pathName', { a: 'foo' }), DingusError);
it('requires path exists', function () {
assert.throws(() => dingus.routePath('pathName'), DingusError);
});
- });
+ }); // routePath
+
+ describe('absolutePath', function () {
+ it('renders', function () {
+ dingus.on('GET', '/a/b/c', 'pathName', () => {});
+ const result = dingus.absolutePath('pathName');
+ assert.strictEqual(result, '/foo/a/b/c');
+ });
+ }); // absolutePath
+
+ describe('relativePath', function () {
+ let currentParams, targetParams;
+ beforeEach(function () {
+ dingus.on('GET', '/foo/:id/baz', 'baz', () => {});
+ dingus.on('GET', '/foo/:id/zoo', 'zoo', () => {});
+ dingus.on('GET', '/foo/quux', 'quux', () => {});
+ dingus.on('GET', '/frink', 'frink', () => {});
+ currentParams = { id: '123' };
+ targetParams = { id: '123' };
+ });
+ it('requires current path exist', function () {
+ assert.throws(() => dingus.relativePath('nope', 'baz', currentParams, targetParams), DingusError);
+ });
+ it('requires target path exist', function () {
+ assert.throws(() => dingus.relativePath('frink', 'nope', currentParams, targetParams), DingusError);
+ });
+ it('renders at same depth', function () {
+ const result = dingus.relativePath('baz', 'zoo', currentParams, targetParams);
+ assert.strictEqual(result, 'zoo');
+ });
+ it('renders at depth', function () {
+ const result = dingus.relativePath('baz', 'quux', currentParams, targetParams);
+ assert.strictEqual(result, '../quux');
+ });
+ it('renders at more depth', function () {
+ const result = dingus.relativePath('baz', 'frink', currentParams, targetParams);
+ assert.strictEqual(result, '../../frink');
+ });
+ it('renders', function () {
+ const result = dingus.relativePath('frink', 'baz', currentParams, targetParams);
+ assert.strictEqual(result, 'foo/123/baz');
+ });
+
+ }); // relativePath
+
+ describe('externalPath', function () {
+ it('renders', function () {
+ dingus.on('GET', '/a/b/c', 'pathName', () => {});
+ const result = dingus.externalPath('pathName');
+ assert.strictEqual(result, 'https://example.com/foo/a/b/c');
+ });
+ }); // externalPath
describe('setEndBodyHandler', function () {
let req, res, ctx, handler, origEnd, origWrite;
beforeEach(function () {
pfxDingus = new Dingus(console, { proxyPrefix: pfx });
req = {
+ method: 'GET',
setHeader: sinon.stub(),
getHeader: sinon.stub(),
};
afterEach(function () {
sinon.restore();
});
+ it('covers no prefix', async function () {
+ pfxDingus = new Dingus(console, { proxyPrefix: '' });
+ sinon.stub(pfxDingus, 'handlerMethodNotAllowed');
+ sinon.stub(pfxDingus, 'handlerNotFound');
+ const urlPath = '/:id';
+ req.url = '/abc';
+ pfxDingus.on(req.method, urlPath, stubHandler);
+ await pfxDingus.dispatch(req, res, ctx);
+ assert(stubHandler.called);
+ assert(pfxDingus.handlerMethodNotAllowed.notCalled);
+ assert(pfxDingus.handlerNotFound.notCalled);
+ });
it('handles prefixed route', async function () {
const urlPath = '/:id';
- const method = 'GET';
- pfxDingus.on(method, urlPath, stubHandler);
+ pfxDingus.on(req.method, urlPath, stubHandler);
req.url = pfx + '/abc';
- req.method = method;
await pfxDingus.dispatch(req, res, ctx);
assert(stubHandler.called);
- assert(!pfxDingus.handlerMethodNotAllowed.called);
- assert(!pfxDingus.handlerNotFound.called);
+ assert(pfxDingus.handlerMethodNotAllowed.notCalled);
+ assert(pfxDingus.handlerNotFound.notCalled);
});
it('does not handle prefixed route', async function () {
const urlPath = '/:id';
- const method = 'GET';
- pfxDingus.on(method, urlPath, stubHandler);
+ pfxDingus.on(req.method, urlPath, stubHandler);
req.url = '/wrongpfx/abc';
- req.method = method;
await pfxDingus.dispatch(req, res, ctx);
- assert(!stubHandler.called);
- assert(!pfxDingus.handlerMethodNotAllowed.called);
+ assert(stubHandler.notCalled);
+ assert(pfxDingus.handlerMethodNotAllowed.notCalled);
assert(pfxDingus.handlerNotFound.called);
});
}); // proxyPrefix