1 | /*
|
2 | Kettle Server component, corresponding directly with an express/node HTTP server
|
3 |
|
4 | Copyright 2012-2013 OCAD University
|
5 |
|
6 | Licensed under the New BSD license. You may not use this file except in
|
7 | compliance with this License.
|
8 |
|
9 | You may obtain a copy of the License at
|
10 | https://github.com/fluid-project/kettle/blob/master/LICENSE.txt
|
11 | */
|
12 |
|
13 | ;
|
14 |
|
15 | var fluid = require("infusion"),
|
16 | http = require("http"),
|
17 | https = require("https"),
|
18 | express = require("express"),
|
19 | kettle = fluid.registerNamespace("kettle");
|
20 |
|
21 | fluid.defaults("kettle.server", {
|
22 | gradeNames: ["fluid.component"],
|
23 | mergePolicy: {
|
24 | rootMiddleware: "noexpand"
|
25 | },
|
26 | rootMiddleware: { // free hash of middleware namespaces to components
|
27 | urlencoded: {
|
28 | middleware: "{middlewareHolder}.urlencoded",
|
29 | priority: "before:json"
|
30 | },
|
31 | json: {
|
32 | middleware: "{middlewareHolder}.json"
|
33 | }
|
34 | },
|
35 | invokers: {
|
36 | stop: {
|
37 | funcName: "kettle.server.stop",
|
38 | args: "{that}"
|
39 | },
|
40 | trackConnections: {
|
41 | funcName: "kettle.server.trackConnections",
|
42 | args: ["{that}.sockets", "{that}.httpServer"]
|
43 | },
|
44 | closeConnections: {
|
45 | funcName: "kettle.server.closeConnections",
|
46 | args: "{that}.sockets"
|
47 | }
|
48 | },
|
49 | components: {
|
50 | middlewareHolder: {
|
51 | type: "kettle.standardMiddleware"
|
52 | },
|
53 | httpRouter: {
|
54 | type: "kettle.router.http"
|
55 | }
|
56 | },
|
57 | members: {
|
58 | expressApp: "@expand:kettle.server.makeExpressApp()",
|
59 | httpServer: "@expand:kettle.server.httpServer({that}.expressApp)",
|
60 | dispatcher: "@expand:kettle.server.getDispatcher({that})",
|
61 | rootSequence: "@expand:kettle.middleware.getHandlerSequence({that}, rootMiddleware, {that}.options.rootMiddleware)",
|
62 | apps: [], // a list of kettle.app nested in this server
|
63 | sockets: [] // a list of currently active sockets, to be aborted in case of server shutdown
|
64 | },
|
65 | events: {
|
66 | onContributeMiddleware: null, // for 3rd parties to contribute root middleware using app.use
|
67 | onContributeRouteHandlers: null, // for 3rd parties to contribute handlers using app.get/app.post etc.
|
68 | onListen: null,
|
69 | beforeStop: null,
|
70 | onStopped: null
|
71 | },
|
72 | listeners: {
|
73 | "onCreate.setLogging": {
|
74 | funcName: "fluid.setLogging",
|
75 | args: "{that}.options.logging"
|
76 | },
|
77 | "onCreate.contributeMiddleware": {
|
78 | func: "{that}.events.onContributeMiddleware.fire",
|
79 | args: "{that}",
|
80 | priority: "after:setLogging"
|
81 | },
|
82 | "onCreate.contributeRouteHandlers": {
|
83 | func: "{that}.events.onContributeRouteHandlers.fire",
|
84 | args: "{that}",
|
85 | priority: "after:contributeMiddleware"
|
86 | },
|
87 | "onCreate.registerRouteHandlers": {
|
88 | funcName: "kettle.server.registerRouteHandlers",
|
89 | args: "{that}",
|
90 | priority: "after:contributeRouteHandlers"
|
91 | },
|
92 | "onCreate.listen": {
|
93 | funcName: "kettle.server.listen",
|
94 | args: "{that}",
|
95 | priority: "after:registerRouteHandlers"
|
96 | },
|
97 | onListen: "{that}.trackConnections",
|
98 | onDestroy: "{that}.stop",
|
99 | beforeStop: "{that}.closeConnections",
|
100 | onStopped: "kettle.server.shred({that})"
|
101 | },
|
102 | port: 8081,
|
103 | logging: true
|
104 | });
|
105 |
|
106 | /** Push the supplied Kettle app onto the collection of apps managed by this server. This is called by an `onCreate` listener
|
107 | * for the individual apps. Note that Kettle is currently not dynamic and that apps may not be destroyed separately
|
108 | * from their host server.
|
109 | * @param {kettle.server} server - The server with which the app is to be registered
|
110 | * @param {kettle.app} app - The app to be registered
|
111 | */
|
112 | kettle.server.registerApp = function (server, app) {
|
113 | server.apps.push(app);
|
114 | };
|
115 |
|
116 | // Update and factor this table in the unlikely event we support further request types
|
117 | kettle.server.mismatchRequestMessages = {
|
118 | "kettle.request.ws": {
|
119 | statusCode: 400,
|
120 | message: "Error: Mismatched request protocol - sent a WebSockets request to an endpoint expecting a plain HTTP request"
|
121 | },
|
122 | "kettle.request.http": {
|
123 | statusCode: 426,
|
124 | message: "Error: Mismatched request protocol - sent an HTTP request to an endpoint expecting a WebSockets request - upgrade required"
|
125 | }
|
126 | };
|
127 |
|
128 | /**
|
129 | * Options supplied to a request dispatcher by a particular variety of server
|
130 | * @typedef dispatcherOptions
|
131 | * @member {String} expectedRequestGrade A grade name expected to appear in the hierarchy of the request component selected
|
132 | * to handle this request
|
133 | */
|
134 |
|
135 | /** Checks whether the request selected for a route is compatible with the expected request type (currently, whether it
|
136 | * matches in terms of being an HTTP or WebSockets request handler
|
137 | * @param {String} expectedRequestGrade - The request grade expected by the server's dispatcher
|
138 | * @param {String[]} handlerGrades - The list of grades actually present in the selected request's hierarchy
|
139 | * @return {Object|Null} Either `null` if the request's grade hierarchy matches, or a mismatch error message structure
|
140 | * to be dispatched to the client if there is a mismatch
|
141 | */
|
142 | kettle.server.checkCompatibleRequest = function (expectedRequestGrade, handlerGrades) {
|
143 | return fluid.contains(handlerGrades, expectedRequestGrade) ? null : kettle.server.mismatchRequestMessages[expectedRequestGrade];
|
144 | };
|
145 |
|
146 | /** Evaluates whether the incoming request matches the routing specification of any Kettle request attached to the server.
|
147 | * @param {kettle.server} server - The server whose routing table should be queried
|
148 | * @param {http.IncomingMessage} req - Node's native HTTP request object
|
149 | * @param {dispatcherOptions} originOptions - The dispatcher's options determining the types of compatible request
|
150 | * @return {routeMatch} A `routeMatch` structure which determines the request which will be created
|
151 | */
|
152 | kettle.server.evaluateRoute = function (server, req, originOptions) {
|
153 | var router = kettle.server.getRouter(server, req);
|
154 | var match = router.match(req);
|
155 | if (match) {
|
156 | var handler = match.handler;
|
157 | if (!handler.type) {
|
158 | fluid.fail("Error in Kettle application definition - handler ", fluid.censorKeys(handler, ["app"]), " must have a request grade name registered as member \"type\"");
|
159 | }
|
160 | fluid.log("Invoking handler " + handler.type + " for route " + handler.route + " with expectedGrade " + originOptions.expectedRequestGrade);
|
161 | var defaults = fluid.getMergedDefaults(handler.type, handler.gradeNames);
|
162 | if (!fluid.hasGrade(defaults, "kettle.request")) {
|
163 | fluid.fail("Error in Kettle application definition - couldn't load handler " + handler.type + " and gradeNames " +
|
164 | JSON.stringify(fluid.makeArray(handler.gradeNames)) + " to a component derived from kettle.request - got defaults of " + JSON.stringify(defaults));
|
165 | }
|
166 | match.output.mismatchError = kettle.server.checkCompatibleRequest(originOptions.expectedRequestGrade, defaults.gradeNames);
|
167 | if (match.output.mismatchError) { // In the case of a request type mismatch, create a special "mismatch" request handling component
|
168 | handler.type = originOptions.expectedRequestGrade + ".mismatch";
|
169 | handler.gradeNames = [];
|
170 | }
|
171 | }
|
172 | return match;
|
173 | };
|
174 |
|
175 | /** Determine whether Kettle can finding a matching request handler, and if so, create an instance of the appropriate
|
176 | * type
|
177 | * @param {kettle.server} server - The server holding (via a `kettle.app`) the request definition
|
178 | * @param {http.IncomingMessage} req - Node's native HTTP request object
|
179 | * @param {http.ServerResponse} res - Node's native HTTP response object
|
180 | * @param {Function} next - Express' `next` routing function
|
181 | * @param {Object} originOptions - Options to be mixed in to any created request - generally the gradeNames specifying
|
182 | * whether it is an HTTP or WS request
|
183 | * @return {Boolean} `true` if a matching request was found and created
|
184 | */
|
185 | kettle.server.checkCreateRequest = function (server, req, res, next, originOptions) {
|
186 | var match = kettle.server.evaluateRoute(server, req, originOptions);
|
187 | if (match) {
|
188 | fluid.extend(req, match.output); // TODO: allow match to output to other locations
|
189 | var handler = match.handler;
|
190 | if (handler.prefix) {
|
191 | /* istanbul ignore if - defensive test that we don't know how to trigger */
|
192 | if (req.url.indexOf(handler.prefix) !== 0) {
|
193 | fluid.fail("Failure in route matcher - request url " + req.url + " does not start with prefix " + handler.prefix + " even though it has been matched");
|
194 | } else {
|
195 | // This is apparently the time-honoured behaviour implemented by all express "routers" - the original url is preserved within req.originalUrl
|
196 | req.url = req.url.substring(handler.prefix.length);
|
197 | req.baseUrl = handler.prefix;
|
198 | }
|
199 | }
|
200 | var options = fluid.extend({gradeNames: handler.gradeNames}, originOptions);
|
201 | handler.app.requests.events.createRequest.fire({
|
202 | type: handler.type,
|
203 | options: options
|
204 | }, req, res, next);
|
205 | return true;
|
206 | }
|
207 | return false;
|
208 | };
|
209 |
|
210 | /** Get the router to be used for a particular request and handler in the context of a particular server. In the
|
211 | * current implementation each server just has one hardwired router stored directly as a member, and this function
|
212 | * returns that one.
|
213 | * @param {kettle.server} that - The server for which the router is required
|
214 | * @return {kettle.router} The server's router
|
215 | */
|
216 | kettle.server.getRouter = function (that /*, req, handler */) {
|
217 | return that.httpRouter; // default policy simply returns the single httpRouter
|
218 | };
|
219 |
|
220 | kettle.server.getDispatcher = function (that) {
|
221 | return function (req, res, next, options) {
|
222 | var match = kettle.server.checkCreateRequest(that, req, res, next, options);
|
223 | if (!match) {
|
224 | fluid.log("Kettle server getDispatcher found no matching handlers, continuing");
|
225 | next();
|
226 | }
|
227 | };
|
228 | };
|
229 |
|
230 | /** Register one request handler record as it appears in an app's configuration into the server's routing table
|
231 | * @param {kettle.server} that - The server whose router is to be updated
|
232 | * @param {kettle.app} app - The app holding the handler record to be registered
|
233 | * @param {handlerRecord} handler - The handler record to be registered. In this form, `method` may take the form
|
234 | * of a comma-separated list of method specifications which this function will explode.
|
235 | */
|
236 | kettle.server.registerOneHandler = function (that, app, handler) {
|
237 | var router = kettle.server.getRouter(that, null, handler);
|
238 | fluid.log("Registering request handler ", handler);
|
239 | var extend = {
|
240 | app: app
|
241 | };
|
242 | if (handler.method) {
|
243 | var methods = fluid.transform(handler.method.split(","), fluid.trim);
|
244 | fluid.each(methods, function (method) {
|
245 | extend.method = method;
|
246 | kettle.router.registerOneHandlerImpl(router, handler, extend);
|
247 | });
|
248 | } else {
|
249 | kettle.router.registerOneHandlerImpl(router, handler, extend);
|
250 | }
|
251 | };
|
252 |
|
253 | /** Register all nested request handlers held in apps nested within this server into the server's routing tables. This
|
254 | * executes partway through the `onCreate` event for the server.
|
255 | * @param {kettle.server} that - The Kettle server for which all nested request handlers are to be registered
|
256 | */
|
257 | kettle.server.registerRouteHandlers = function (that) {
|
258 | fluid.each(that.apps, function (app) {
|
259 | fluid.each(app.options.requestHandlers, function (requestHandler, key) {
|
260 | if (requestHandler) {
|
261 | kettle.server.registerOneHandler(that, app, requestHandler);
|
262 | } else {
|
263 | // A typical style of overriding handlers sets them to `null` in derived grades - ignore these
|
264 | // A better system will arrive with FLUID-4982 work allowing "local mergePolicies" to remove options material
|
265 | fluid.log("Skipping empty handler with key " + key + " for app " + fluid.dumpThat(app));
|
266 | }
|
267 | });
|
268 | });
|
269 | that.expressApp.use(function (req, res, next) {
|
270 | that.dispatcher(req, res, next, {expectedRequestGrade: "kettle.request.http"});
|
271 | });
|
272 | };
|
273 |
|
274 | // Remove some members on stop, to give early diagnostics on erroneous late use
|
275 | kettle.server.shred = function (that) {
|
276 | delete that.httpServer;
|
277 | delete that.expressApp;
|
278 | };
|
279 |
|
280 | /** Stop the supplied Kettle server. The `beforeStop` event will be fired, then the server will be closed, and when
|
281 | * that is concluded, the `onStopped` event will be fired.
|
282 | * @param {kettle.server} that - The server to be stopped
|
283 | */
|
284 | kettle.server.stop = function (that) {
|
285 | if (!that.httpServer) {
|
286 | return;
|
287 | }
|
288 | var port = that.options.port;
|
289 | fluid.log("Stopping Kettle Server " + that.id + " on port ", port);
|
290 | that.events.beforeStop.fire();
|
291 |
|
292 | that.httpServer.close(function () {
|
293 | fluid.log("Kettle Server " + that.id + " on port ", port, " is stopped");
|
294 | that.events.onStopped.fire();
|
295 | });
|
296 | };
|
297 |
|
298 | /** Forcibly call `destroy` on each sockets in the supplied array. This is done for historical reasons in which it
|
299 | * was observed (c. 2013) that servers would not shut down in a timely way if sockets were open. Should be revisited.
|
300 | * @param {Socket[]} sockets - The list of sockets which should be closed
|
301 | */
|
302 | kettle.server.closeConnections = function (sockets) {
|
303 | fluid.each(sockets, function (socket) {
|
304 | socket.destroy();
|
305 | });
|
306 | };
|
307 |
|
308 | /**
|
309 | * The standard request listener signature implementing an Express app or other middleware.
|
310 | *
|
311 | * @callback RequestListener
|
312 | * @param {http.IncomingMessage} req - Node's native HTTP request object
|
313 | * @param {http.ServerResponse} res - Node's native HTTP response object
|
314 | * @param {Function} next - Passes control to the next request listener in the processing chain/network
|
315 | * @param {Function} err - Used to signal failure and halt processing of this section of the processing chain
|
316 | */
|
317 |
|
318 | /**
|
319 | * Create an HTTP server.
|
320 | * @param {RequestListener} [app] - An optional requestListener, to be attached to the `request` event on startup
|
321 | * @return {http.Server} A node HTTP Server
|
322 | */
|
323 | kettle.server.httpServer = function (app) {
|
324 | fluid.log("Initializing the HTTP server");
|
325 | return http.createServer(app);
|
326 | };
|
327 |
|
328 | /**
|
329 | * Create an HTTPS server.
|
330 | * @param {Object} options - A collection of options as described in https://nodejs.org/api/https.html#https_https_createserver_options_requestlistener
|
331 | * @param {RequestListener} [app] - An optional requestListener, to be attached to the `request` event on startup
|
332 | * @return {http.Server} A node HTTP Server
|
333 | */
|
334 | kettle.server.httpsServer = function (options, app) {
|
335 | fluid.log("Initializing the HTTPS server");
|
336 | return https.createServer(options, app);
|
337 | };
|
338 |
|
339 | /** Construct a fresh express app
|
340 | * @return {RequestListener} A freshly initialised express app
|
341 | */
|
342 | kettle.server.makeExpressApp = function () {
|
343 | fluid.log("Initializing the Express app");
|
344 | return express();
|
345 | };
|
346 |
|
347 | /** Begin the process of listening on the configured TCP/IP port for this server. Listening will be triggered,
|
348 | * and once it has started, the `onListen` event will be fired.
|
349 | * @param {kettle.server} that - The server for which listening should begin
|
350 | */
|
351 | kettle.server.listen = function (that) {
|
352 | var port = that.options.port;
|
353 | fluid.log("Opening Kettle Server on port ", port);
|
354 | that.httpServer.listen(port, function () {
|
355 | fluid.log("Kettle Server " + that.id + " is listening on port " + port);
|
356 | that.events.onListen.fire();
|
357 | });
|
358 | };
|
359 |
|
360 | /** Keeps track of all currently open sockets so that the server can be fully shut down.
|
361 | * @param {Socket[]} sockets - An array of sockets which will hold those that are currently open.
|
362 | * @param {http.Server} httpServer - The node HTTP server for which open sockets should be tracked
|
363 | */
|
364 | kettle.server.trackConnections = function (sockets, httpServer) {
|
365 | // Keep track of all connections to be able to fully shut down the server.
|
366 | httpServer.on("connection", function (socket) {
|
367 | sockets.push(socket);
|
368 | socket.on("close", function () {
|
369 | sockets.splice(sockets.indexOf(socket), 1);
|
370 | });
|
371 | });
|
372 | };
|
373 |
|
374 | fluid.defaults("kettle.server.config", {
|
375 | gradeNames: ["fluid.component"],
|
376 | listeners: {
|
377 | onCreate: "{that}.configure"
|
378 | },
|
379 | invokers: {
|
380 | configure: "kettle.server.config.configure"
|
381 | }
|
382 | });
|