Comparing version 1.5.4 to 1.6.0
1430
http-apps.js
@@ -42,656 +42,117 @@ | ||
/* | ||
Multiplexing Routing: | ||
Cap | ||
Branch | ||
Method | ||
Accept | ||
Language | ||
Trial Routing: | ||
FirstFound | ||
Decorators: | ||
Error | ||
Log | ||
CookieSession TODO reevaluate | ||
PathSession TODO reevaluate | ||
Limit TODO | ||
Cache TODO | ||
Time | ||
Decorators | ||
Tap | ||
Trap | ||
Permanent | ||
Date | ||
Adapters: | ||
ParseQuery | ||
ContentRequest | ||
JsonRequest | ||
Json | ||
Inspect | ||
Producers: | ||
Content | ||
File | ||
FileTree | ||
Redirect | ||
PermanentRedirect | ||
TemporaryRedirect | ||
RedirectTree | ||
PermanentRedirectTree | ||
TemporaryRedirectTree | ||
Producer Functions: | ||
ok | ||
badRequest | ||
notFound | ||
methodNotAllowed | ||
notAcceptable | ||
file | ||
directory | ||
redirect | ||
json | ||
Utility: | ||
etag | ||
*/ | ||
require("collections/shim"); | ||
var Q = require("q"); | ||
var NODE_FS = require("fs"); | ||
var HTTP = require("./http"); | ||
var FS = require("./fs"); | ||
var URL = require("url"); | ||
var MIME_PARSE = require("mimeparse"); | ||
var MIME_TYPES = require("mime"); | ||
var URL = require("url"); | ||
var URL = require("url2"); | ||
var inspect = require("util").inspect; | ||
var QS = require("querystring"); | ||
var Cookie = require("./http-cookie"); | ||
//var UUID = require("uuid"); | ||
var UUID = { | ||
generate: function () { | ||
return Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2); | ||
} | ||
}; | ||
exports.Chain = require("./http-apps/chain"); | ||
/** | ||
* Makes a Q-JSGI app that only responds when there is nothing left | ||
* on the path to route. If the there is unprocessed data on the | ||
* path, the returned app either forwards to the `notFound` app or | ||
* returns a `404 Not Found` response. | ||
* | ||
* @param {App} app a Q-JSGI application to | ||
* respond to this end of the routing chain. | ||
* @param {App} notFound (optional) defaults | ||
* to the `notFound` app. | ||
* @returns {App} | ||
*/ | ||
exports.Cap = function (app, notFound) { | ||
notFound = notFound || exports.notFound; | ||
return function (request, response) { | ||
// TODO Distinguish these cases | ||
if (request.pathInfo === "" || request.pathInfo === "/") { | ||
return app(request, response); | ||
} else { | ||
return notFound(request, response); | ||
} | ||
}; | ||
}; | ||
var RouteApps = require("./http-apps/route"); | ||
exports.Cap = RouteApps.Cap; | ||
exports.Tap = RouteApps.Tap; | ||
exports.Trap = RouteApps.Trap; | ||
exports.Branch = RouteApps.Branch; | ||
/** | ||
* Wraps an app with a function that will observe incoming requests | ||
* before giving the app an opportunity to respond. If the "tap" | ||
* function returns a response, it will be used in lieu of forwarding | ||
* the request to the wrapped app. | ||
*/ | ||
exports.Tap = function (app, tap) { | ||
return function (request, response) { | ||
var self = this, args = arguments; | ||
return Q.when(tap.apply(this, arguments), function (response) { | ||
if (response) { | ||
return response; | ||
} else { | ||
return app.apply(self, args); | ||
} | ||
}); | ||
}; | ||
} | ||
var ContentApps = require("./http-apps/content"); | ||
exports.Content = ContentApps.Content; | ||
exports.content = ContentApps.content; | ||
exports.ok = ContentApps.ok; | ||
exports.ContentRequest = ContentApps.ContentRequest; | ||
exports.Inspect = ContentApps.Inspect; | ||
/** | ||
* Wraps an app with a "trap" function that intercepts and may | ||
* alter or replace the response of the wrapped application. | ||
*/ | ||
exports.Trap = function (app, trap) { | ||
return function (request, response) { | ||
return Q.when(app.apply(this, arguments), function (response) { | ||
if (response) { | ||
response.headers = response.headers || {}; | ||
return trap(response, request) || response; | ||
} | ||
}); | ||
}; | ||
} | ||
var FsApps = require("./http-apps/fs"); | ||
exports.File = FsApps.File; | ||
exports.FileTree = FsApps.FileTree; | ||
exports.file = FsApps.file; | ||
exports.directory = FsApps.directory; | ||
exports.etag = FsApps.etag; | ||
exports.Date = function (app, present) { | ||
present = present || function () { | ||
return new Date(); | ||
}; | ||
return exports.Trap(app, function (response, request) { | ||
response.headers["date"] = "" + present(); | ||
}); | ||
}; | ||
exports.ListDirectories = FsApps.ListDirectories; | ||
exports.listDirectory = FsApps.listDirectory; | ||
exports.listDirectoryHtmlFragment = FsApps.listDirectoryHtmlFragment; | ||
exports.listDirectoryText = FsApps.listDirectoryText; | ||
exports.listDirectoryMarkdown = FsApps.listDirectoryMarkdown; | ||
exports.listDirectoryJson = FsApps.listDirectoryJson; | ||
exports.listDirectoryData = FsApps.listDirectoryData; | ||
exports.DirectoryIndex = FsApps.DirectoryIndex; | ||
var farFuture = | ||
1000 * // ms | ||
60 * // s | ||
60 * // m | ||
24 * // h | ||
365 * // d | ||
10; // years | ||
exports.Permanent = function (app, future) { | ||
future = future || function () { | ||
return new Date(new Date().getTime() + farFuture); | ||
}; | ||
app = exports.Tap(app, function (request, response) { | ||
request.permanent = future; | ||
}); | ||
app = exports.Trap(app, function (response, request) { | ||
response.headers["expires"] = "" + future(); | ||
}); | ||
return app; | ||
}; | ||
var HtmlApps = require("./http-apps/html"); | ||
exports.HandleHtmlFragmentResponses = HtmlApps.HandleHtmlFragmentResponses; | ||
exports.handleHtmlFragmentResponse = HtmlApps.handleHtmlFragmentResponse; | ||
exports.escapeHtml = HtmlApps.escapeHtml; | ||
/** | ||
* Makes a Q-JSGI app that branches requests based on the next | ||
* unprocessed path component. | ||
* @param {Object * App} paths a mapping from path components (single | ||
* file or directory names) to Q-JSGI applications for subsequent | ||
* routing. The mapping may be a plain JavaScript `Object` record, | ||
* which must own the mapping properties, or an object that has | ||
* `has(key)` and `get(key)` methods in its prototype chain. | ||
* @param {App} notFound a Q-JSGI application | ||
* that handles requests for which the next file name does not exist | ||
* in paths. | ||
* @returns {App} | ||
*/ | ||
exports.Branch = function (paths, notFound) { | ||
if (!paths) | ||
paths = {}; | ||
if (!notFound) | ||
notFound = exports.notFound; | ||
return function (request, response) { | ||
if (!/^\//.test(request.pathInfo)) { | ||
return notFound(request, response); | ||
} | ||
var path = request.pathInfo.slice(1); | ||
var parts = path.split("/"); | ||
var part = decodeURIComponent(parts.shift()); | ||
if (Object.has(paths, part)) { | ||
request.scriptName = request.scriptName + part + "/"; | ||
request.pathInfo = path.slice(part.length); | ||
return Object.get(paths, part)(request, response); | ||
} | ||
return notFound(request, response); | ||
}; | ||
}; | ||
var JsonApps = require("./http-apps/json"); | ||
exports.HandleJsonResponses = JsonApps.HandleJsonResponses; | ||
exports.handleJsonResponse = JsonApps.handleJsonResponse; | ||
exports.Json = JsonApps.Json; | ||
exports.json = JsonApps.json; | ||
exports.JsonRequest = JsonApps.JsonRequest; | ||
/** | ||
* Makes an app that returns a response with static content | ||
* from memory. | ||
* @param {Body} body a Q-JSGI | ||
* response body | ||
* @param {String} contentType | ||
* @param {Number} status | ||
* @returns {App} a Q-JSGI app | ||
*/ | ||
exports.Content = function (body, contentType, status) { | ||
return function (request, response) { | ||
return { | ||
"status": status || 200, | ||
"headers": { | ||
"content-type": contentType || "text/plain" | ||
}, | ||
"body": body || "" | ||
}; | ||
}; | ||
}; | ||
var RedirectApps = require("./http-apps/redirect"); | ||
exports.PermanentRedirect = RedirectApps.PermanentRedirect; | ||
exports.PermanentRedirectTree = RedirectApps.PermanentRedirectTree; | ||
exports.TemporaryRedirect = RedirectApps.TemporaryRedirect; | ||
exports.TemporaryRedirectTree = RedirectApps.TemporaryRedirectTree; | ||
exports.Redirect = RedirectApps.Redirect; | ||
exports.RedirectTree = RedirectApps.RedirectTree; | ||
exports.permanentRedirect = RedirectApps.permanentRedirect; | ||
exports.permanentRedirectTree = RedirectApps.permanentRedirectTree; | ||
exports.temporaryRedirect = RedirectApps.temporaryRedirect; | ||
exports.temporaryRedirectTree = RedirectApps.temporaryRedirectTree; | ||
exports.redirectTree = RedirectApps.redirectTree; | ||
exports.redirect = RedirectApps.redirect; | ||
exports.redirectText = RedirectApps.redirectText; | ||
exports.redirectHtml = RedirectApps.redirectHtml; | ||
exports.RedirectTrap = RedirectApps.RedirectTrap; | ||
exports.isRedirect = RedirectApps.isRedirect; | ||
/** | ||
* Returns a Q-JSGI response with the given content. | ||
* @param {Body} content (optional) defaults to `[""]` | ||
* @param {String} contentType (optional) defaults to `"text/plain"` | ||
* @param {Number} status (optional) defaults to `200` | ||
* @returns {Response} | ||
*/ | ||
exports.content = | ||
exports.ok = function (content, contentType, status) { | ||
status = status || 200; | ||
content = content || ""; | ||
if (typeof content === "string") { | ||
content = [content]; | ||
} | ||
contentType = contentType || "text/plain"; | ||
return { | ||
"status": status, | ||
"headers": { | ||
"content-type": contentType | ||
}, | ||
"body": content | ||
}; | ||
}; | ||
var ProxyApps = require("./http-apps/proxy"); | ||
exports.Proxy = ProxyApps.Proxy; | ||
exports.ProxyTree = ProxyApps.ProxyTree; | ||
/** | ||
* @param {String} path | ||
* @param {String} contentType | ||
* @returns {App} | ||
*/ | ||
exports.File = function (path, contentType) { | ||
return function (request, response) { | ||
return exports.file(request, String(path), contentType); | ||
}; | ||
}; | ||
var NegotiationApps = require("./http-apps/negotiate"); | ||
exports.negotiate = NegotiationApps.negotiate; | ||
exports.Method = NegotiationApps.Method; | ||
exports.ContentType = NegotiationApps.ContentType; | ||
exports.Language = NegotiationApps.Language; | ||
exports.Charset = NegotiationApps.Charset; | ||
exports.Encoding = NegotiationApps.Encoding; | ||
exports.Host = NegotiationApps.Host; | ||
exports.Select = NegotiationApps.Select; | ||
/** | ||
* @param {String} path | ||
* @param {{ | ||
notFound, | ||
file, | ||
directory, | ||
contentType, | ||
redirectSymbolicLinks:Boolean, | ||
redirect:Function(location), | ||
permanent:Boolean | ||
* }} options | ||
* @returns {App} | ||
*/ | ||
exports.FileTree = function (root, options) { | ||
if (!options) | ||
options = {}; | ||
options.notFound = options.notFound || exports.notFound; | ||
options.file = options.file || exports.file; | ||
options.directory = options.directory || exports.directory; | ||
options.fs = options.fs || FS; | ||
var fs = options.fs; | ||
root = fs.canonical(root); | ||
return function (request, response) { | ||
var redirect = options.redirect || ( | ||
request.permanent || options.permanent ? | ||
exports.permanentRedirect : | ||
exports.temporaryRedirect | ||
); | ||
return Q.when(root, function (root) { | ||
var path = fs.join(root, request.pathInfo.slice(1)); | ||
return Q.when(fs.canonical(path), function (canonical) { | ||
if (!fs.contains(root, canonical)) | ||
return options.notFound(request, response); | ||
if (path !== canonical && options.redirectSymbolicLinks) | ||
return redirect(request, fs.relativeFromFile(path, canonical)); | ||
// TODO: relativeFromFile should be designed for URL’s, not generalized paths. | ||
return Q.when(fs.stat(canonical), function (stat) { | ||
if (stat.isFile()) { | ||
return options.file(request, canonical, options.contentType, fs); | ||
} else if (stat.isDirectory()) { | ||
return options.directory(request, canonical, options.contentType); | ||
} else { | ||
return options.notFound(request, response); | ||
} | ||
}); | ||
}, function (reason) { | ||
return options.notFound(request, response); | ||
}); | ||
}); | ||
}; | ||
}; | ||
var StatusApps = require("./http-apps/status"); | ||
exports.statusCodes = StatusApps.statusCodes; | ||
exports.statusMessages = StatusApps.statusMessages; | ||
exports.statusWithNoEntityBody = StatusApps.statusWithNoEntityBody; | ||
exports.appForStatus = StatusApps.appForStatus; | ||
exports.responseForStatus = StatusApps.responseForStatus; | ||
exports.textResponseForStatus = StatusApps.textResponseForStatus; | ||
exports.htmlResponseForStatus = StatusApps.htmlResponseForStatus; | ||
exports.badRequest = StatusApps.badRequest; | ||
exports.notFound = StatusApps.notFound; | ||
exports.methodNotAllowed = StatusApps.methodNotAllowed; | ||
exports.noLanguage = StatusApps.noLanguage; | ||
exports.notAcceptable = StatusApps.notAcceptable; | ||
/** | ||
* @param {Request} request | ||
* @param {String} path | ||
* @param {String} contentType | ||
* @returns {Response} | ||
*/ | ||
exports.file = function (request, path, contentType, fs) { | ||
fs = fs || FS; | ||
// TODO last-modified header | ||
contentType = contentType || MIME_TYPES.lookup(path); | ||
return Q.when(fs.stat(path), function (stat) { | ||
var etag = exports.etag(stat); | ||
var range; // undefined or {begin, end} | ||
var status = 200; | ||
var headers = { | ||
"content-type": contentType, | ||
"etag": etag | ||
}; | ||
var DecoratorApps = require("./http-apps/decorators"); | ||
exports.Normalize = DecoratorApps.Normalize; | ||
exports.Date = DecoratorApps.Date; | ||
exports.Error = DecoratorApps.Error; | ||
exports.Debug = DecoratorApps.Debug; | ||
exports.Log = DecoratorApps.Log; | ||
exports.Time = DecoratorApps.Time; | ||
exports.Headers = DecoratorApps.Headers; | ||
exports.Decorators = DecoratorApps.Decorators; | ||
// Partial range requests | ||
if ("range" in request.headers) { | ||
// Invalid cache | ||
if ( | ||
"if-range" in request.headers && | ||
etag != request.headers["if-range"] | ||
) { | ||
// Normal 200 for entire, altered content | ||
} else { | ||
// Truncate to the first requested continuous range | ||
range = interpretFirstRange(request.headers["range"]); | ||
// Like Apache, ignore the range header if it is invalid | ||
if (range) { | ||
if (range.end > stat.size) | ||
return exports.responseForStatus(416); // not satisfiable | ||
status = 206; // partial content | ||
headers["content-range"] = ( | ||
"bytes " + | ||
range.begin + "-" + (range.end - 1) + | ||
"/" + stat.size | ||
); | ||
headers["content-length"] = "" + (range.end - range.begin); | ||
} | ||
} | ||
// Full requests | ||
} else { | ||
// Cached | ||
// We do not use date-based caching | ||
// TODO consider if-match? | ||
if (etag == request.headers["if-none-match"]) | ||
return exports.responseForStatus(304); | ||
headers["content-length"] = "" + stat.size; | ||
} | ||
// TODO sendfile | ||
return { | ||
"status": status, | ||
"headers": headers, | ||
"body": fs.open(path, range) | ||
}; | ||
}); | ||
}; | ||
var rangesExpression = /^\s*bytes\s*=\s*(\d*\s*-\s*\d*\s*(?:,\s*\d*\s*-\s*\d*\s*)*)$/; | ||
var rangeExpression = /^\s*(\d*)\s*-\s*(\d*)\s*$/; | ||
var interpretRange = function (text, size) { | ||
var match = rangeExpression.exec(text); | ||
if (!match) | ||
return; | ||
if (match[1] == "" && match[2] == "") | ||
return; | ||
var begin, end; | ||
if (match[1] == "") { | ||
begin = size - match[2]; | ||
end = size; | ||
} else if (match[2] == "") { | ||
begin = +match[1]; | ||
end = size; | ||
} else { | ||
begin = +match[1]; | ||
end = +match[2] + 1; | ||
} | ||
return { | ||
"begin": begin, | ||
"end": end | ||
}; | ||
}; | ||
var interpretFirstRange = exports.interpretFirstRange = function (text, size) { | ||
var match = rangesExpression.exec(text); | ||
if (!match) | ||
return; | ||
var texts = match[1].split(/\s*,\s*/); | ||
var range = interpretRange(texts[0], size); | ||
for (var i = 0, ii = texts.length; i < ii; i++) { | ||
var next = interpretRange(texts[i], size); | ||
if (!next) | ||
break; | ||
if (next.begin <= range.end) { | ||
range.end = next.end; | ||
} else { | ||
break; | ||
} | ||
} | ||
return range; | ||
}; | ||
/** | ||
* @param {Stat} | ||
* @returns {String} | ||
*/ | ||
exports.etag = function (stat) { | ||
return [ | ||
stat.node.ino, | ||
stat.size, | ||
stat.lastModified().getTime() | ||
].join("-"); | ||
}; | ||
/** | ||
* @param {Request} request | ||
* @param {String} path | ||
* @param {Response} | ||
*/ | ||
exports.directory = function (request, path) { | ||
return Q.reject("directory listing not yet implemented"); | ||
}; | ||
/** | ||
* @param {String} path | ||
* @param {Number} status (optional) default is `301` | ||
* @returns {App} | ||
*/ | ||
exports.PermanentRedirect = function (location, status, tree) { | ||
exports.ParseQuery = function (app) { | ||
return function (request, response) { | ||
return exports.permanentRedirect(request, location, status, tree); | ||
request.query = QS.parse(URL.parse(request.url).query || ""); | ||
return app(request, response); | ||
}; | ||
}; | ||
/** | ||
* @param {String} path | ||
* @param {Number} status (optional) default is `301` | ||
* @returns {App} | ||
*/ | ||
exports.PermanentRedirectTree = function (location, status) { | ||
return function (request, response) { | ||
return exports.permanentRedirect(request, location, status, true); | ||
}; | ||
}; | ||
/** | ||
* @param {String} path | ||
* @param {Number} status (optional) default is `307` | ||
* @returns {App} | ||
*/ | ||
exports.TemporaryRedirect = function (location, status, tree) { | ||
return function (request, response) { | ||
return exports.temporaryRedirect(request, location, status, tree); | ||
}; | ||
}; | ||
/** | ||
* @param {String} path | ||
* @param {Number} status (optional) default is `307` | ||
* @returns {App} | ||
*/ | ||
exports.TemporaryRedirectTree = function (location, status) { | ||
return function (request, response) { | ||
return exports.temporaryRedirect(request, location, status, true); | ||
}; | ||
}; | ||
/** | ||
* @param {String} path | ||
* @param {Number} status (optional) default is `307` | ||
* @returns {App} | ||
*/ | ||
exports.Redirect = function (location, status, tree) { | ||
return function (request, response) { | ||
return exports.redirect(request, location, status, tree); | ||
}; | ||
}; | ||
/** | ||
* @param {String} path | ||
* @param {Number} status (optional) default is `307` | ||
* @returns {App} | ||
*/ | ||
exports.RedirectTree = function (location, status) { | ||
return function (request, response) { | ||
return exports.redirect(request, location, status, true); | ||
}; | ||
}; | ||
exports.permanentRedirect = function (request, location, status) { | ||
return exports.redirect(request, location, status || 301); | ||
}; | ||
exports.permanentRedirectTree = function (request, location, status) { | ||
return exports.redirect(request, location, status || 301, true); | ||
}; | ||
exports.temporaryRedirect = function (request, location, status) { | ||
return exports.redirect(request, location, status || 307); | ||
}; | ||
exports.temporaryRedirectTree = function (request, location, status) { | ||
return exports.redirect(request, location, status || 307, true); | ||
}; | ||
exports.redirectTree = function (request, location, status) { | ||
return exports.redirect(request, location, status, true); | ||
}; | ||
/** | ||
* @param {String} location | ||
* @param {Number} status (optional) default is `301` | ||
* @returns {Response} | ||
*/ | ||
exports.redirect = function (request, location, status, tree) { | ||
// request.permanent gets set by Permanent middleware | ||
status = status || (request.permanent ? 301 : 307); | ||
// ascertain that the location is absolute, per spec | ||
location = URL.resolve(request.url, location); | ||
// redirect into a subtree with the remaining unrouted | ||
// portion of the path, if so configured | ||
if (tree) { | ||
location = URL.resolve( | ||
location, | ||
request.pathInfo.replace(/^\//, "") | ||
); | ||
} | ||
return { | ||
"status": status, | ||
"headers": { | ||
"location": location, | ||
"content-type": "text/html" | ||
}, | ||
"body": [ | ||
'Go to <a href="' + location + '">' + // TODO escape | ||
location + | ||
"</a>" | ||
] | ||
}; | ||
}; | ||
exports.Proxy = function (app) { | ||
if (typeof app === "string") { | ||
var location = app; | ||
app = function (request) { | ||
request.url = location; | ||
return request; | ||
}; | ||
} | ||
return function (request, response) { | ||
return Q.when(app.apply(this, arguments), function (request) { | ||
return HTTP.request(request); | ||
}); | ||
}; | ||
}; | ||
exports.ProxyTree = function (url) { | ||
return exports.Proxy(function (request) { | ||
request.url = URL.resolve(url, request.pathInfo.replace(/^\//, "")); | ||
return request; | ||
}); | ||
}; | ||
/// branch on HTTP method | ||
/** | ||
* @param {Object * App} methods | ||
* @param {App} notAllowed (optional) | ||
* @returns {App} | ||
*/ | ||
exports.Method = function (methods, methodNotAllowed) { | ||
var keys = Object.keys(methods); | ||
if (!methodNotAllowed) | ||
methodNotAllowed = exports.methodNotAllowed; | ||
return function (request, response) { | ||
var method = request.method; | ||
if (Object.has(keys, method)) { | ||
return Object.get(methods, method)(request, response); | ||
} else { | ||
return methodNotAllowed(request, response); | ||
} | ||
}; | ||
}; | ||
var Negotiator = function (requestHeader, responseHeader, respond) { | ||
return function (types, notAcceptable) { | ||
var keys = Object.keys(types); | ||
if (!notAcceptable) | ||
notAcceptable = exports.notAcceptable; | ||
return function (request, response) { | ||
var header = requestHeader; | ||
if (typeof header === "function") { | ||
header = requestHeader(request); | ||
} | ||
var accept = request.headers[requestHeader] || "*"; | ||
var type = MIME_PARSE.bestMatch(keys, accept); | ||
request.terms = request.terms || {}; | ||
request.terms[responseHeader] = type; | ||
if (Object.has(keys, type)) { | ||
return Q.when(types[type](request, response), function (response) { | ||
if ( | ||
respond !== null && | ||
response && | ||
response.status === 200 && | ||
response.headers | ||
) { | ||
response.headers[responseHeader] = type; | ||
} | ||
return response; | ||
}); | ||
} else { | ||
return notAcceptable(request, response); | ||
} | ||
}; | ||
}; | ||
}; | ||
/// branch on HTTP content negotiation | ||
/** | ||
* Routes based on content negotiation, between the request's `accept` | ||
* header and the application's list of possible content types. | ||
* | ||
* @param {Object * App} types mapping content types to apps that can | ||
* handle them. | ||
* @param {App} notAcceptable | ||
* @returns {App} | ||
*/ | ||
exports.ContentType = Negotiator("accept", "content-type"); | ||
exports.Language = Negotiator("accept-language", "language"); | ||
exports.Charset = Negotiator("accept-charset", "charset"); | ||
exports.Encoding = Negotiator("accept-encoding", "encoding"); | ||
exports.Host = Negotiator(function (request) { | ||
return (request.headers.host || "*") + ":" + request.port; | ||
}, "host", null); | ||
// Branch on a selector function based on the request | ||
exports.Select = function (select) { | ||
return function (request, response) { | ||
return Q.when(select(request, response), function (app) { | ||
return app(request, response); | ||
}); | ||
}; | ||
}; | ||
// Create an application from the "app" exported by a module | ||
@@ -709,709 +170,4 @@ exports.require = function (id, _require) { | ||
/** | ||
* Decorates a JSGI application such that rejected response promises | ||
* get translated into `500` server error responses with no content. | ||
* | ||
* @param {App} app | ||
* @returns {App} | ||
*/ | ||
exports.Error = function (app, debug) { | ||
return function (request, response) { | ||
return Q.when(app(request, response), null, function (error) { | ||
if (!debug) | ||
error = undefined; | ||
return exports.responseForStatus(500, error && error.stack || error); | ||
}); | ||
}; | ||
}; | ||
var CookieApps = require("./http-apps/cookie"); | ||
exports.CookieJar = CookieApps.CookieJar; | ||
/** | ||
* Decorates a Q-JSGI application such that all requests and responses | ||
* are logged. | ||
* | ||
* @param {App} app | ||
* @returns {App} | ||
*/ | ||
exports.Log = function (app, log, stamp) { | ||
log = log || console.log; | ||
stamp = stamp || function (message) { | ||
return new Date().toISOString() + " " + message; | ||
}; | ||
return function (request, response) { | ||
var remoteHost = | ||
request.remoteHost + ":" + | ||
request.remotePort; | ||
var requestLine = | ||
request.method + " " + | ||
request.path + " " + | ||
"HTTP/" + request.version.join("."); | ||
log(stamp( | ||
remoteHost + " " + | ||
"--> " + | ||
requestLine | ||
)); | ||
return Q.when(app(request, response), function (response) { | ||
if (response) { | ||
log(stamp( | ||
remoteHost + " " + | ||
"<== " + | ||
response.status + " " + | ||
requestLine + " " + | ||
(response.headers["content-length"] || "-") | ||
)); | ||
} else { | ||
log(stamp( | ||
remoteHost + " " + | ||
"... " + | ||
"... " + | ||
requestLine + " (response undefined / presumed streaming)" | ||
)); | ||
} | ||
return response; | ||
}, function (reason) { | ||
log(stamp( | ||
remoteHost + " " + | ||
"!!! " + | ||
requestLine + " " + | ||
(reason && reason.message || reason) | ||
)); | ||
return Q.reject(reason); | ||
}); | ||
}; | ||
}; | ||
/** | ||
* Decorates a Q-JSGI application such that all responses have an | ||
* X-Response-Time header with the time between the request and the | ||
* response in milliseconds, not including any time needed to stream | ||
* the body to the client. | ||
* | ||
* @param {App} app | ||
* @returns {App} | ||
*/ | ||
exports.Time = function (app) { | ||
return function (request, response) { | ||
var start = new Date(); | ||
return Q.when(app(request, response), function (response) { | ||
var stop = new Date(); | ||
if (response && response.headers) { | ||
response.headers["x-response-time"] = "" + (stop - start); | ||
} | ||
return response; | ||
}); | ||
}; | ||
}; | ||
/** | ||
* Decorates a Q-JSGI application such that all responses have the | ||
* given additional headers. These headers do not override the | ||
* application's given response headers. | ||
* | ||
* @param {Object} headers | ||
* @param {App} app decorated application. | ||
*/ | ||
exports.Headers = function (app, headers) { | ||
return function (request, response) { | ||
return Q.when(app(request, response), function (response) { | ||
if (response && response.headers) { | ||
Object.keys(headers).forEach(function (key) { | ||
if (!(key in response.headers)) { | ||
response.headers[key] = headers[key]; | ||
} | ||
}); | ||
} | ||
return response; | ||
}); | ||
}; | ||
}; | ||
/** | ||
* Wraps a Q-JSGI application in a sequence of decorators. | ||
* @param {Array * Decorator} decorators | ||
* @param {App} app | ||
* @returns {App} | ||
*/ | ||
exports.Decorators = function (decorators, app) { | ||
decorators.reversed().forEach(function (Middleware) { | ||
app = Middleware(app); | ||
}); | ||
return app; | ||
}; | ||
/** | ||
* Wraps a Q-JSGI application such that the child application may | ||
* simply return an object, which will in turn be serialized into a | ||
* Q-JSGI response. | ||
* | ||
* @param {Function(Request):Object} app an application that accepts a | ||
* request and returns a JSON serializable object. | ||
* @returns {App} | ||
*/ | ||
exports.Json = function (app, visitor, tabs) { | ||
return function (request, response) { | ||
return Q.when(app(request, response), function (object) { | ||
return exports.json(object, visitor, tabs); | ||
}); | ||
}; | ||
}; | ||
/** | ||
* @param {Object} content data to serialize as JSON | ||
* @param {Function} visitor | ||
* @param {Number|String} tabs | ||
* @returns {Response} | ||
*/ | ||
exports.json = function (content, visitor, tabs) { | ||
try { | ||
var json = JSON.stringify(content, visitor, tabs); | ||
} catch (exception) { | ||
return Q.reject(exception); | ||
} | ||
return exports.ok([json]); | ||
}; | ||
/** | ||
*/ | ||
exports.ParseQuery = function (app) { | ||
return function (request, response) { | ||
request.query = QS.parse(URL.parse(request.url).query || ""); | ||
return app(request, response); | ||
}; | ||
}; | ||
/** | ||
* Wraps an app such that it expects to receive content | ||
* in the request body and passes that content as a string | ||
* to as the second argument to the wrapped JSGI app. | ||
* | ||
* @param {Function(Request, Object):Response} app | ||
* @returns {App} | ||
*/ | ||
exports.ContentRequest = function (app) { | ||
return function (request, response) { | ||
return Q.when(request.body.read(), function (body) { | ||
return app(body, request, response); | ||
}); | ||
}; | ||
}; | ||
/** | ||
* @param {Function(Request, Object):Response} app | ||
* @param {App} badRequest | ||
* @returns {App} | ||
*/ | ||
exports.JsonRequest = function (app, badRequest) { | ||
if (!badRequest) | ||
badRequest = exports.badRequest; | ||
return exports.ContentRequest(function (content, request, response) { | ||
try { | ||
var object = JSON.parse(content); | ||
} catch (error) { | ||
return badRequest(request, error); | ||
} | ||
return app(object, request, response); | ||
}); | ||
}; | ||
/** | ||
* @param {Function(Request):Object} | ||
* @returns {App} | ||
*/ | ||
exports.Inspect = function (app) { | ||
return exports.Method({"GET": function (request, response) { | ||
return Q.when(app(request, response), function (object) { | ||
return { | ||
"status": 200, | ||
"headers": { | ||
"content-type": "text/plain" | ||
}, | ||
"body": [inspect(object)] | ||
} | ||
}); | ||
}}); | ||
}; | ||
/** | ||
* Creates a persistent session associated with the HTTP client's | ||
* cookie. These sessions are intended to persist for the duration | ||
* that a user visits your site in the same browser. | ||
* | ||
* @param {Function(session):App} Session a function that creates a | ||
* new Q-JSGI application for each new session. | ||
* @returns {App} | ||
*/ | ||
exports.CookieSession = function (Session) { | ||
var sessions = {}; | ||
function nextUuid() { | ||
while (true) { | ||
var uuid = UUID.generate(); | ||
if (!Object.has(sessions, uuid)) | ||
return uuid; | ||
} | ||
} | ||
return function (request, response) { | ||
var cookie = QS.parse(request.headers["cookie"], /[;,]/g); | ||
var sessionIds = cookie["session.id"]; | ||
if (!Array.isArray(sessionIds)) | ||
sessionIds = [sessionIds]; | ||
sessionIds = sessionIds.filter(function (sessionId) { | ||
return Object.has(sessions, sessionId); | ||
}); | ||
// verifying cookie | ||
if (/^\/~session\//.test(request.pathInfo)) { | ||
if (cookie["session.id"]) | ||
return exports.TemporaryRedirect("../")(request, response); | ||
// TODO more flexible session error page | ||
return { | ||
"status": 404, | ||
"headers": { | ||
"content-type": "text/plain" | ||
}, | ||
"body": [ | ||
"Access requires cookies" | ||
] | ||
} | ||
// session exists | ||
} else if ( | ||
Object.has(cookie, "session.id") && | ||
sessionIds.length | ||
) { | ||
var session = sessions[sessionIds[0]]; | ||
session.lastAccess = new Date(); | ||
request.session = session; | ||
return session.route(request, response); | ||
// new session | ||
} else { | ||
var session = { | ||
"id": nextUuid(), | ||
"lastAccess": new Date() | ||
}; | ||
sessions[session.id] = session; | ||
session.route = Session(session); | ||
var response = exports.TemporaryRedirect(request.scriptInfo + "~session/")(request, response); | ||
response.headers["set-cookie"] = Cookie.stringify( | ||
"session.id", session.id, { | ||
"path": request.scriptInfo | ||
} | ||
); | ||
return response; | ||
} | ||
}; | ||
}; | ||
/** | ||
* A Q-JSGI application that creates a session associated with a | ||
* unique path. These sessions are intended to persist for the | ||
* duration that a user remains in a single browser window. | ||
* | ||
* @param {Function(session):App} a function that creates a new Q-JSGI | ||
* application for each new session. It receives an object with the | ||
* session's `id` and `lastAccess` `Date`. | ||
* @returns {App} | ||
*/ | ||
exports.PathSession = function (Session) { | ||
var sessions = {}; | ||
function nextUuid() { | ||
while (true) { | ||
var uuid = UUID.generate(); | ||
if (!Object.has(sessions, uuid)) | ||
return uuid; | ||
} | ||
} | ||
return function (request, response) { | ||
// TODO request.pathInfo and request.scriptInfo | ||
if (request.pathInfo == "/") { | ||
// new session | ||
var session = { | ||
"id": nextUuid(), | ||
"lastAccess": new Date() | ||
}; | ||
sessions[session.id] = session; | ||
session.route = Session(session); | ||
return exports.Json(function (request, response) { | ||
return session; | ||
})(request, response); | ||
} else if (Object.has(sessions, request.pathInfo.slice(1))) { | ||
return Object.get(sessions, request.pathInfo.slice(1)).route(request, response); | ||
} else { | ||
return exports.responseForStatus(404, "Session does not exist"); | ||
} | ||
}; | ||
}; | ||
/** | ||
* Returns the response of the first application that returns a | ||
* non-404 response status. | ||
* | ||
* @param {Array * App} apps a cascade of applications to try | ||
* successively until one of them returns a non-404 status. | ||
* @returns {App} | ||
*/ | ||
exports.FirstFound = function (cascade) { | ||
return function (request, response) { | ||
var i = 0, ii = cascade.length; | ||
function next() { | ||
var response = cascade[i++](request, response); | ||
if (i < ii) { | ||
return Q.when(response, function (response) { | ||
if (response.status === 404) { | ||
return next(); | ||
} else { | ||
return response; | ||
} | ||
}); | ||
} else { | ||
return response; | ||
} | ||
} | ||
return next(); | ||
}; | ||
}; | ||
/** | ||
* {Object * String} a mapping of HTTP status codes to | ||
* their standard descriptions. | ||
*/ | ||
// Every standard HTTP code mapped to the appropriate message. | ||
// Stolen from Rack which stole from Mongrel | ||
exports.HTTP_STATUS_CODES = { | ||
100 : 'Continue', | ||
101 : 'Switching Protocols', | ||
102 : 'Processing', | ||
200 : 'OK', | ||
201 : 'Created', | ||
202 : 'Accepted', | ||
203 : 'Non-Authoritative Information', | ||
204 : 'No Content', | ||
205 : 'Reset Content', | ||
206 : 'Partial Content', | ||
207 : 'Multi-Status', | ||
300 : 'Multiple Choices', | ||
301 : 'Moved Permanently', | ||
302 : 'Found', | ||
303 : 'See Other', | ||
304 : 'Not Modified', | ||
305 : 'Use Proxy', | ||
307 : 'Temporary Redirect', | ||
400 : 'Bad Request', | ||
401 : 'Unauthorized', | ||
402 : 'Payment Required', | ||
403 : 'Forbidden', | ||
404 : 'Not Found', | ||
405 : 'Method Not Allowed', | ||
406 : 'Not Acceptable', | ||
407 : 'Proxy Authentication Required', | ||
408 : 'Request Timeout', | ||
409 : 'Conflict', | ||
410 : 'Gone', | ||
411 : 'Length Required', | ||
412 : 'Precondition Failed', | ||
413 : 'Request Entity Too Large', | ||
414 : 'Request-URI Too Large', | ||
415 : 'Unsupported Media Type', | ||
416 : 'Request Range Not Satisfiable', | ||
417 : 'Expectation Failed', | ||
422 : 'Unprocessable Entity', | ||
423 : 'Locked', | ||
424 : 'Failed Dependency', | ||
500 : 'Internal Server Error', | ||
501 : 'Not Implemented', | ||
502 : 'Bad Gateway', | ||
503 : 'Service Unavailable', | ||
504 : 'Gateway Timeout', | ||
505 : 'HTTP Version Not Supported', | ||
507 : 'Insufficient Storage' | ||
}; | ||
/** | ||
* {Object * Number} a mapping from HTTP status descriptions | ||
* to HTTP status codes. | ||
*/ | ||
exports.HTTP_STATUS_MESSAGES = {}; | ||
for (var code in exports.HTTP_STATUS_CODES) | ||
exports.HTTP_STATUS_MESSAGES[exports.HTTP_STATUS_CODES[code]] = +code; | ||
/** | ||
* Determines whether an HTTP response should have a | ||
* response body, based on its status code. | ||
* @param {Number} status | ||
* @returns whether the HTTP response for the given status | ||
* code has content. | ||
*/ | ||
exports.STATUS_WITH_NO_ENTITY_BODY = function (status) { | ||
return (status >= 100 && status <= 199) || | ||
status == 204 || status == 304; | ||
}; | ||
/** | ||
* @param {Number} status | ||
* @returns {Function(Request) :Response} a JSGI app that returns | ||
* a plain text response with the given status code. | ||
*/ | ||
exports.appForStatus = function (status) { | ||
return function (request) { | ||
return exports.responseForStatus(status, request.method + " " + request.path); | ||
}; | ||
}; | ||
/** | ||
* @param {Number} status an HTTP status code | ||
* @param {String} message (optional) a message to include | ||
* in the response body. | ||
* @returns a JSGI HTTP response object with the given status | ||
* code and message as its body, if the status supports | ||
* a body. | ||
*/ | ||
exports.responseForStatus = function(status, optMessage) { | ||
if (exports.HTTP_STATUS_CODES[status] === undefined) | ||
throw "Unknown status code"; | ||
var message = exports.HTTP_STATUS_CODES[status]; | ||
if (optMessage) | ||
message += ": " + optMessage; | ||
var content = message + "\r\n"; | ||
var response = { | ||
"status": status, | ||
"headers": {} | ||
}; | ||
// RFC 2616, 10.2.5: | ||
// The 204 response MUST NOT include a message-body, and thus is always | ||
// terminated by the first empty line after the header fields. | ||
// RFC 2616, 10.3.5: | ||
// The 304 response MUST NOT contain a message-body, and thus is always | ||
// terminated by the first empty line after the header fields. | ||
if (!exports.STATUS_WITH_NO_ENTITY_BODY(status)) { | ||
response.headers['content-length'] = content.length; | ||
response.headers['content-type'] = 'text/plain'; | ||
response.body = [content]; | ||
} | ||
return response; | ||
}; | ||
/** | ||
* {App} an application that returns a 400 response. | ||
*/ | ||
exports.badRequest = exports.appForStatus(400); | ||
/** | ||
* {App} an application that returns a 404 response. | ||
*/ | ||
exports.notFound = exports.appForStatus(404); | ||
/** | ||
* {App} an application that returns a 405 response. | ||
*/ | ||
exports.methodNotAllowed = exports.appForStatus(405); | ||
/** | ||
* {App} an application that returns a 405 response. | ||
*/ | ||
exports.noLanguage = | ||
exports.notAcceptable = exports.appForStatus(406); | ||
exports.Normalize = function (app) { | ||
return function (request, response) { | ||
var request = HTTP.normalizeRequest(request); | ||
return Q.when(app(request, response), function (response) { | ||
return HTTP.normalizeResponse(response); | ||
}); | ||
}; | ||
}; | ||
exports.RedirectTrap = function (app, maxRedirects) { | ||
maxRedirects = maxRedirects || 20; | ||
return function (request, response) { | ||
var remaining = maxRedirects; | ||
var deferred = Q.defer(); | ||
var self = this; | ||
var args = arguments; | ||
request = HTTP.normalizeRequest(request); | ||
// try redirect loop | ||
function next() { | ||
Q.fcall(function () { | ||
return app(request, response); | ||
}) | ||
.then(function (response) { | ||
if (exports.isRedirect(response)) { | ||
if (remaining--) { | ||
request.url = response.headers.location; | ||
next(); | ||
} else { | ||
throw new Error("Maximum redirects."); | ||
} | ||
} else { | ||
deferred.resolve(response); | ||
} | ||
}) | ||
.fail(deferred.reject) | ||
} | ||
next(); | ||
return deferred.promise; | ||
}; | ||
}; | ||
exports.isRedirect = function (response) { | ||
return isRedirect[response.status] || false; | ||
}; | ||
var isRedirect = { | ||
301: true, | ||
302: true, | ||
303: true, | ||
307: true | ||
}; | ||
exports.CookieJar = function (app) { | ||
var hostCookies = {}; // to {} of pathCookies to [] of cookies | ||
return function (request) { | ||
var hosts = allHostsContaining(request.headers.host); | ||
var now = new Date(); | ||
var requestCookies = concat(hosts.map(function (host) { | ||
// delete expired cookies | ||
for (var host in hostCookies) { | ||
var pathCookies = hostCookies[host]; | ||
for (var path in pathCookies) { | ||
var cookies = pathCookies[path]; | ||
for (var name in cookies) { | ||
var cookie = cookies[name]; | ||
if (cookie.expires && cookie.expires > now) { | ||
delete cookie[name]; | ||
} | ||
} | ||
} | ||
} | ||
// collect applicable cookies | ||
return concat( | ||
Object.keys(hostCookies) | ||
.map(function (host) { | ||
if (!hostContains(host, request.headers.host)) | ||
return []; | ||
var pathCookies = hostCookies[host]; | ||
return concat( | ||
Object.keys(pathCookies) | ||
.map(function (path) { | ||
if (!pathContains(path, request.path)) | ||
return []; | ||
var cookies = pathCookies[path]; | ||
return ( | ||
Object.keys(cookies) | ||
.map(function (name) { | ||
return cookies[name]; | ||
}) | ||
.filter(function (cookie) { | ||
return cookie.secure ? | ||
request.ssl : | ||
true; | ||
}) | ||
); | ||
}) | ||
) | ||
}) | ||
); | ||
})); | ||
if (requestCookies.length) { | ||
request.headers["cookie"] = ( | ||
requestCookies | ||
.map(function (cookie) { | ||
return Cookie.stringify( | ||
cookie.key, | ||
cookie.value, | ||
cookie | ||
); | ||
}) | ||
.join("; ") | ||
); | ||
} | ||
return Q.when(app.apply(this, arguments), function (response) { | ||
response.headers = response.headers || {}; | ||
if (response.headers["set-cookie"]) { | ||
var requestHost = ipRe.test(request.headers.host) ? | ||
request.headers.host : | ||
"." + request.headers.host; | ||
// normalize to array | ||
if (!Array.isArray(response.headers["set-cookie"])) { | ||
response.headers["set-cookie"] = [response.headers["set-cookie"]]; | ||
} | ||
response.headers["set-cookie"].forEach(function (cookie) { | ||
var date = response.headers["date"] ? | ||
new Date(response.headers["date"]) : | ||
new Date(); | ||
cookie = Cookie.parse(cookie, date); | ||
// ignore illegal host | ||
if (cookie.host && !hostContains(requestHost, cookie.host)) | ||
delete cookie.host; | ||
var host = requestHost || cookie.host; | ||
var path = cookie.path || "/"; | ||
var pathCookies = hostCookies[host] = hostCookies[host] || {}; | ||
var cookies = pathCookies[path] = pathCookies[path] || {}; | ||
cookies[cookie.key] = cookie; | ||
}) | ||
delete response.headers["set-cookie"]; | ||
} | ||
return response; | ||
}); | ||
}; | ||
}; | ||
var ipRe = /^\d+\.\d+\.\d+\.\d+$/; | ||
function allHostsContaining(content) { | ||
if (ipRe.test(content)) { | ||
return [content]; | ||
} if (content === "localhost") { | ||
return [content]; | ||
} else { | ||
var parts = content.split("."); | ||
var hosts = []; | ||
while (parts.length > 1) { | ||
hosts.push("." + parts.join(".")); | ||
parts.shift(); | ||
} | ||
return hosts; | ||
} | ||
} | ||
function hostContains(container, content) { | ||
if (ipRe.test(container) || ipRe.test(content)) { | ||
return container === content; | ||
} else if (/^\./.test(container)) { | ||
return ( | ||
content.lastIndexOf(container) === | ||
content.length - container.length | ||
) || ( | ||
container.slice(1) === content | ||
); | ||
} else { | ||
return container === content; | ||
} | ||
}; | ||
function pathContains(container, content) { | ||
if (/^\/$/.test(container)) { | ||
return content.indexOf(container) === 0; | ||
} else { | ||
return ( | ||
content === container || | ||
content.indexOf(container + "/") === 0 | ||
); | ||
} | ||
} | ||
function concat(arrays) { | ||
return [].concat.apply([], arrays); | ||
} | ||
@@ -139,2 +139,3 @@ /** | ||
self.nodeServer = server; // Deprecated | ||
self.address = server.address.bind(server); | ||
@@ -194,3 +195,3 @@ return self; | ||
protocol: request.scheme, | ||
hostname: _request.headers.host, | ||
host: _request.headers.host, | ||
port: request.port === (ssl ? 443 : 80) ? null : request.port, | ||
@@ -197,0 +198,0 @@ path: request.path |
{ | ||
"name": "q-io", | ||
"version": "1.5.4", | ||
"version": "1.6.0", | ||
"description": "IO using Q promises", | ||
@@ -22,3 +22,3 @@ "homepage": "http://github.com/kriskowal/q-io/", | ||
"dependencies": { | ||
"q": "~0.8.11", | ||
"q": "~0.9.1", | ||
"qs": "~0.1.0", | ||
@@ -25,0 +25,0 @@ "url2": "~0.0.0", |
// http://labs.apache.org/webarch/http/draft-fielding-http/p5-range.html#range.units | ||
var Apps = require("../../http-apps"); | ||
var Apps = require("../../http-apps/fs"); | ||
@@ -6,0 +6,0 @@ var size = 10000; |
require("../lib/jasmine-promise"); | ||
var Q = require("q"); | ||
var Http = require("../../http"); | ||
@@ -11,4 +10,13 @@ var Apps = require("../../http-apps"); | ||
it("should read a partial range", function () { | ||
var fixture = FS.join(module.directory || __dirname, "fixtures", "1234.txt"); | ||
return Http.Server(Apps.Cap(Apps.File(fixture))) | ||
var app = new Apps.Chain() | ||
.use(Apps.Cap) | ||
.use(function () { | ||
return Apps.File(fixture); | ||
}) | ||
.end() | ||
return Http.Server(app) | ||
.listen(0) | ||
@@ -15,0 +23,0 @@ .then(function (server) { |
@@ -17,21 +17,26 @@ | ||
var server1 = Http.Server( | ||
Apps.Trap( | ||
Apps.Tap( | ||
Apps.Branch({ | ||
"foo": Apps.Branch({ | ||
"bar": Apps.Cap(Apps.Content(["Hello, World!"])) | ||
var app = Apps.Chain() | ||
.use(Apps.Trap, function (response) { | ||
responseActual = response; | ||
return response; | ||
}) | ||
.use(Apps.Tap, function (request) { | ||
requestActual = request; | ||
}) | ||
.use(function (next) { | ||
return Apps.Branch({ | ||
"foo": Apps.Branch({ | ||
"bar": new Apps.Chain() | ||
.use(Apps.Cap) | ||
.use(function () { | ||
return Apps.Content(["Hello, World!"]) | ||
}) | ||
}), | ||
function (request) { | ||
requestActual = request; | ||
} | ||
), | ||
function (response) { | ||
responseActual = response; | ||
return response; | ||
} | ||
) | ||
); | ||
.end() | ||
}) | ||
}) | ||
}) | ||
.end(); | ||
var server1 = Http.Server(app); | ||
return Q.when(server1.listen(0)) | ||
@@ -38,0 +43,0 @@ .then(function (server1) { |
@@ -25,3 +25,3 @@ | ||
.then(function (server) { | ||
var port = server.node.address().port; | ||
var port = server.address().port; | ||
@@ -28,0 +28,0 @@ var request = { |
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
203286
67
5239
4