hapii-js 0.0.0-stage → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/core.js ADDED
@@ -0,0 +1,718 @@
1
+ 'use strict';
2
+
3
+ const Http = require('http');
4
+ const Https = require('https');
5
+ const Os = require('os');
6
+ const Path = require('path');
7
+
8
+ const Boom = require('@hapi/boom');
9
+ const Bounce = require('@hapi/bounce');
10
+ const Call = require('@hapi/call');
11
+ const Catbox = require('@hapi/catbox');
12
+ const { Engine: CatboxMemory } = require('@hapi/catbox-memory');
13
+ const { Heavy } = require('@hapi/heavy');
14
+ const Hoek = require('@hapi/hoek');
15
+ const { Mimos } = require('@hapi/mimos');
16
+ const Podium = require('@hapi/podium');
17
+ const Statehood = require('@hapi/statehood');
18
+
19
+ const Auth = require('./auth');
20
+ const Compression = require('./compression');
21
+ const Config = require('./config');
22
+ const Cors = require('./cors');
23
+ const Ext = require('./ext');
24
+ const Methods = require('./methods');
25
+ const Request = require('./request');
26
+ const Response = require('./response');
27
+ const Route = require('./route');
28
+ const Toolkit = require('./toolkit');
29
+ const Validation = require('./validation');
30
+
31
+
32
+ const internals = {
33
+ counter: {
34
+ min: 10000,
35
+ max: 99999
36
+ },
37
+ events: [
38
+ { name: 'cachePolicy', spread: true },
39
+ { name: 'log', channels: ['app', 'internal'], tags: true },
40
+ { name: 'request', channels: ['app', 'internal', 'error'], tags: true, spread: true },
41
+ 'response',
42
+ 'route',
43
+ 'start',
44
+ 'closing',
45
+ 'stop'
46
+ ],
47
+ badRequestResponse: Buffer.from('HTTP/1.1 400 Bad Request\r\n\r\n', 'ascii')
48
+ };
49
+
50
+
51
+ exports = module.exports = internals.Core = class {
52
+
53
+ actives = new WeakMap(); // Active requests being processed
54
+ app = {};
55
+ auth = new Auth(this);
56
+ caches = new Map(); // Cache clients
57
+ compression = new Compression();
58
+ controlled = null; // Other servers linked to the phases of this server
59
+ dependencies = []; // Plugin dependencies
60
+ events = new Podium.Podium(internals.events);
61
+ heavy = null;
62
+ info = null;
63
+ instances = new Set();
64
+ listener = null;
65
+ methods = new Methods(this); // Server methods
66
+ mime = null;
67
+ onConnection = null; // Used to remove event listener on stop
68
+ phase = 'stopped'; // 'stopped', 'initializing', 'initialized', 'starting', 'started', 'stopping', 'invalid'
69
+ plugins = {}; // Exposed plugin properties by name
70
+ registrations = {}; // Tracks plugin for dependency validation { name -> { version } }
71
+ registring = 0; // > 0 while register() is waiting for plugin callbacks
72
+ Request = class extends Request { };
73
+ Response = class extends Response { };
74
+ requestCounter = { value: internals.counter.min, min: internals.counter.min, max: internals.counter.max };
75
+ root = null;
76
+ router = null;
77
+ settings = null;
78
+ sockets = null; // Track open sockets for graceful shutdown
79
+ started = false;
80
+ states = null;
81
+ toolkit = new Toolkit.Manager();
82
+ type = null;
83
+ validator = null;
84
+
85
+ extensionsSeq = 0; // Used to keep absolute order of extensions based on the order added across locations
86
+ extensions = {
87
+ server: {
88
+ onPreStart: new Ext('onPreStart', this),
89
+ onPostStart: new Ext('onPostStart', this),
90
+ onPreStop: new Ext('onPreStop', this),
91
+ onPostStop: new Ext('onPostStop', this)
92
+ },
93
+ route: {
94
+ onRequest: new Ext('onRequest', this),
95
+ onPreAuth: new Ext('onPreAuth', this),
96
+ onCredentials: new Ext('onCredentials', this),
97
+ onPostAuth: new Ext('onPostAuth', this),
98
+ onPreHandler: new Ext('onPreHandler', this),
99
+ onPostHandler: new Ext('onPostHandler', this),
100
+ onPreResponse: new Ext('onPreResponse', this),
101
+ onPostResponse: new Ext('onPostResponse', this)
102
+ }
103
+ };
104
+
105
+ decorations = {
106
+ handler: new Map(),
107
+ request: new Map(),
108
+ response: new Map(),
109
+ server: new Map(),
110
+ toolkit: new Map(),
111
+ requestApply: null,
112
+ public: { handler: [], request: [], response: [], server: [], toolkit: [] }
113
+ };
114
+
115
+ constructor(options) {
116
+
117
+ const { settings, type } = internals.setup(options);
118
+
119
+ this.settings = settings;
120
+ this.type = type;
121
+
122
+ this.heavy = new Heavy(this.settings.load);
123
+ this.mime = new Mimos(this.settings.mime);
124
+ this.router = new Call.Router(this.settings.router);
125
+ this.states = new Statehood.Definitions(this.settings.state);
126
+
127
+ this._debug();
128
+ this._initializeCache();
129
+
130
+ if (this.settings.routes.validate.validator) {
131
+ this.validator = Validation.validator(this.settings.routes.validate.validator);
132
+ }
133
+
134
+ this.listener = this._createListener();
135
+ this._initializeListener();
136
+ this.info = this._info();
137
+ }
138
+
139
+ _debug() {
140
+
141
+ const debug = this.settings.debug;
142
+ if (!debug) {
143
+ return;
144
+ }
145
+
146
+ // Subscribe to server log events
147
+
148
+ const method = (event) => {
149
+
150
+ const data = event.error ?? event.data;
151
+ console.error('Debug:', event.tags.join(', '), data ? '\n ' + (data.stack ?? (typeof data === 'object' ? Hoek.stringify(data) : data)) : '');
152
+ };
153
+
154
+ if (debug.log) {
155
+ const filter = debug.log.some((tag) => tag === '*') ? undefined : debug.log;
156
+ this.events.on({ name: 'log', filter }, method);
157
+ }
158
+
159
+ if (debug.request) {
160
+ const filter = debug.request.some((tag) => tag === '*') ? undefined : debug.request;
161
+ this.events.on({ name: 'request', filter }, (request, event) => method(event));
162
+ }
163
+ }
164
+
165
+ _initializeCache() {
166
+
167
+ if (this.settings.cache) {
168
+ this._createCache(this.settings.cache);
169
+ }
170
+
171
+ if (!this.caches.has('_default')) {
172
+ this._createCache([{ provider: CatboxMemory }]); // Defaults to memory-based
173
+ }
174
+ }
175
+
176
+ _info() {
177
+
178
+ const now = Date.now();
179
+ const protocol = this.type === 'tcp' ? (this.settings.tls ? 'https' : 'http') : this.type;
180
+ const host = this.settings.host || Os.hostname() || 'localhost';
181
+ const port = this.settings.port;
182
+
183
+ const info = {
184
+ created: now,
185
+ started: 0,
186
+ host,
187
+ port,
188
+ protocol,
189
+ id: Os.hostname() + ':' + process.pid + ':' + now.toString(36),
190
+ uri: this.settings.uri ?? (protocol + ':' + (this.type === 'tcp' ? '//' + host + (port ? ':' + port : '') : port))
191
+ };
192
+
193
+ return info;
194
+ }
195
+
196
+ _counter() {
197
+
198
+ const next = ++this.requestCounter.value;
199
+
200
+ if (this.requestCounter.value > this.requestCounter.max) {
201
+ this.requestCounter.value = this.requestCounter.min;
202
+ }
203
+
204
+ return next - 1;
205
+ }
206
+
207
+ _createCache(configs) {
208
+
209
+ Hoek.assert(this.phase !== 'initializing', 'Cannot provision server cache while server is initializing');
210
+
211
+ configs = Config.apply('cache', configs);
212
+
213
+ const added = [];
214
+ for (let config of configs) {
215
+
216
+ // <function>
217
+ // { provider: <function> }
218
+ // { provider: { constructor: <function>, options } }
219
+ // { engine }
220
+
221
+ if (typeof config === 'function') {
222
+ config = { provider: { constructor: config } };
223
+ }
224
+
225
+ const name = config.name ?? '_default';
226
+ Hoek.assert(!this.caches.has(name), 'Cannot configure the same cache more than once: ', name === '_default' ? 'default cache' : name);
227
+
228
+ let client = null;
229
+
230
+ if (config.provider) {
231
+ let provider = config.provider;
232
+ if (typeof provider === 'function') {
233
+ provider = { constructor: provider };
234
+ }
235
+
236
+ client = new Catbox.Client(provider.constructor, provider.options ?? { partition: 'hapi-cache' });
237
+ }
238
+ else {
239
+ client = new Catbox.Client(config.engine);
240
+ }
241
+
242
+ this.caches.set(name, { client, segments: {}, shared: config.shared ?? false });
243
+ added.push(client);
244
+ }
245
+
246
+ return added;
247
+ }
248
+
249
+ registerServer(server) {
250
+
251
+ if (!this.root) {
252
+ this.root = server;
253
+ this._defaultRoutes();
254
+ }
255
+
256
+ this.instances.add(server);
257
+ }
258
+
259
+ async _start() {
260
+
261
+ if (this.phase === 'initialized' ||
262
+ this.phase === 'started') {
263
+
264
+ this._validateDeps();
265
+ }
266
+
267
+ if (this.phase === 'started') {
268
+ return;
269
+ }
270
+
271
+ if (this.phase !== 'stopped' &&
272
+ this.phase !== 'initialized') {
273
+
274
+ throw new Error('Cannot start server while it is in ' + this.phase + ' phase');
275
+ }
276
+
277
+ if (this.phase !== 'initialized') {
278
+ await this._initialize();
279
+ }
280
+
281
+ this.phase = 'starting';
282
+ this.started = true;
283
+ this.info.started = Date.now();
284
+
285
+ try {
286
+ await this._listen();
287
+ }
288
+ catch (err) {
289
+ this.started = false;
290
+ this.phase = 'invalid';
291
+ throw err;
292
+ }
293
+
294
+ this.phase = 'started';
295
+ this.events.emit('start');
296
+
297
+ try {
298
+ if (this.controlled) {
299
+ await Promise.all(this.controlled.map((control) => control.start()));
300
+ }
301
+
302
+ await this._invoke('onPostStart');
303
+ }
304
+ catch (err) {
305
+ this.phase = 'invalid';
306
+ throw err;
307
+ }
308
+ }
309
+
310
+ _listen() {
311
+
312
+ return new Promise((resolve, reject) => {
313
+
314
+ if (!this.settings.autoListen) {
315
+ resolve();
316
+ return;
317
+ }
318
+
319
+ const onError = (err) => {
320
+
321
+ reject(err);
322
+ return;
323
+ };
324
+
325
+ this.listener.once('error', onError);
326
+
327
+ const finalize = () => {
328
+
329
+ this.listener.removeListener('error', onError);
330
+ resolve();
331
+ return;
332
+ };
333
+
334
+ if (this.type !== 'tcp') {
335
+ this.listener.listen(this.settings.port, finalize);
336
+ }
337
+ else {
338
+ // Default is the unspecified address, :: if IPv6 is available or otherwise the IPv4 address 0.0.0.0
339
+ const address = this.settings.address || this.settings.host || null;
340
+ this.listener.listen(this.settings.port, address, finalize);
341
+ }
342
+ });
343
+ }
344
+
345
+ async _initialize() {
346
+
347
+ if (this.registring) {
348
+ throw new Error('Cannot start server before plugins finished registration');
349
+ }
350
+
351
+ if (this.phase === 'initialized') {
352
+ return;
353
+ }
354
+
355
+ if (this.phase !== 'stopped') {
356
+ throw new Error('Cannot initialize server while it is in ' + this.phase + ' phase');
357
+ }
358
+
359
+ this._validateDeps();
360
+ this.phase = 'initializing';
361
+
362
+ // Start cache
363
+
364
+ try {
365
+ const caches = [];
366
+ this.caches.forEach((cache) => caches.push(cache.client.start()));
367
+ await Promise.all(caches);
368
+ await this._invoke('onPreStart');
369
+ this.heavy.start();
370
+ this.phase = 'initialized';
371
+
372
+ if (this.controlled) {
373
+ await Promise.all(this.controlled.map((control) => control.initialize()));
374
+ }
375
+ }
376
+ catch (err) {
377
+ this.phase = 'invalid';
378
+ throw err;
379
+ }
380
+ }
381
+
382
+ _validateDeps() {
383
+
384
+ for (const { deps, plugin } of this.dependencies) {
385
+ for (const dep in deps) {
386
+ const version = deps[dep];
387
+ Hoek.assert(this.registrations[dep], 'Plugin', plugin, 'missing dependency', dep);
388
+ Hoek.assert(version === '*' || Config.versionMatch(this.registrations[dep].version, version), 'Plugin', plugin, 'requires', dep, 'version', version, 'but found', this.registrations[dep].version);
389
+ }
390
+ }
391
+ }
392
+
393
+ async _stop(options = {}) {
394
+
395
+ options.timeout = options.timeout ?? 5000; // Default timeout to 5 seconds
396
+
397
+ if (['stopped', 'initialized', 'started', 'invalid'].indexOf(this.phase) === -1) {
398
+ throw new Error('Cannot stop server while in ' + this.phase + ' phase');
399
+ }
400
+
401
+ this.phase = 'stopping';
402
+
403
+ try {
404
+ await this._invoke('onPreStop');
405
+
406
+ if (this.started) {
407
+ this.started = false;
408
+ this.info.started = 0;
409
+
410
+ await this._unlisten(options.timeout);
411
+ }
412
+
413
+ const caches = [];
414
+ this.caches.forEach((cache) => caches.push(cache.client.stop()));
415
+ await Promise.all(caches);
416
+
417
+ this.events.emit('stop');
418
+ this.heavy.stop();
419
+
420
+ if (this.controlled) {
421
+ await Promise.all(this.controlled.map((control) => control.stop(options)));
422
+ }
423
+
424
+ await this._invoke('onPostStop');
425
+ this.phase = 'stopped';
426
+ }
427
+ catch (err) {
428
+ this.phase = 'invalid';
429
+ throw err;
430
+ }
431
+ }
432
+
433
+ _unlisten(timeout) {
434
+
435
+ let timeoutId = null;
436
+ if (this.settings.operations.cleanStop) {
437
+
438
+ // Set connections timeout
439
+
440
+ const destroy = () => {
441
+
442
+ for (const connection of this.sockets) {
443
+ connection.destroy();
444
+ }
445
+
446
+ this.sockets.clear();
447
+ };
448
+
449
+ timeoutId = setTimeout(destroy, timeout);
450
+
451
+ // Tell idle keep-alive connections to close
452
+
453
+ for (const connection of this.sockets) {
454
+ if (!this.actives.has(connection)) {
455
+ connection.end();
456
+ }
457
+ }
458
+ }
459
+
460
+ // Close connection
461
+
462
+ return new Promise((resolve) => {
463
+
464
+ this.listener.close(() => {
465
+
466
+ if (this.settings.operations.cleanStop) {
467
+ this.listener.removeListener(this.settings.tls ? 'secureConnection' : 'connection', this.onConnection);
468
+ clearTimeout(timeoutId);
469
+ }
470
+
471
+ this._initializeListener();
472
+ resolve();
473
+ });
474
+
475
+ this.events.emit('closing');
476
+ });
477
+ }
478
+
479
+ async _invoke(type) {
480
+
481
+ const exts = this.extensions.server[type];
482
+ if (!exts.nodes) {
483
+ return;
484
+ }
485
+
486
+ // Execute extensions
487
+
488
+ for (const ext of exts.nodes) {
489
+ const bind = ext.bind ?? ext.realm.settings.bind;
490
+ const operation = ext.func.call(bind, ext.server, bind);
491
+ await Toolkit.timed(operation, { timeout: ext.timeout, name: type });
492
+ }
493
+ }
494
+
495
+ _defaultRoutes() {
496
+
497
+ this.router.special('notFound', new Route({ method: '_special', path: '/{p*}', handler: internals.notFound }, this.root, { special: true }));
498
+ this.router.special('badRequest', new Route({ method: '_special', path: '/{p*}', handler: internals.badRequest }, this.root, { special: true }));
499
+
500
+ if (this.settings.routes.cors) {
501
+ Cors.handler(this.root);
502
+ }
503
+ }
504
+
505
+ _dispatch(options = {}) {
506
+
507
+ return (req, res) => {
508
+
509
+ // Create request
510
+
511
+ const request = Request.generate(this.root, req, res, options);
512
+
513
+ // Track socket request processing state
514
+
515
+ if (this.settings.operations.cleanStop &&
516
+ req.socket) {
517
+
518
+ this.actives.set(req.socket, request);
519
+ const env = { core: this, req };
520
+ res.on('finish', internals.onFinish.bind(res, env));
521
+ }
522
+
523
+ // Check load
524
+
525
+ if (this.settings.load.sampleInterval) {
526
+ try {
527
+ this.heavy.check();
528
+ }
529
+ catch (err) {
530
+ Bounce.rethrow(err, 'system');
531
+ this._log(['load'], this.heavy.load);
532
+ request._reply(err);
533
+ return;
534
+ }
535
+ }
536
+
537
+ request._execute();
538
+ };
539
+ }
540
+
541
+ _createListener() {
542
+
543
+ const listener = this.settings.listener ?? (this.settings.tls ? Https.createServer(this.settings.tls) : Http.createServer());
544
+ listener.on('request', this._dispatch());
545
+ listener.on('checkContinue', this._dispatch({ expectContinue: true }));
546
+
547
+ listener.on('clientError', (err, socket) => {
548
+
549
+ this._log(['connection', 'client', 'error'], err);
550
+
551
+ if (socket.readable) {
552
+ const request = this.settings.operations.cleanStop && this.actives.get(socket);
553
+ if (request) {
554
+
555
+ // If a request is available, it means that the connection and parsing has progressed far enough to have created the request.
556
+
557
+ if (err.code === 'HPE_INVALID_METHOD') {
558
+
559
+ // This parser error is for a pipelined request. Schedule destroy once current request is done.
560
+
561
+ request.raw.res.once('close', () => {
562
+
563
+ if (socket.readable) {
564
+ socket.end(internals.badRequestResponse);
565
+ }
566
+ else {
567
+ socket.destroy(err);
568
+ }
569
+ });
570
+ return;
571
+ }
572
+
573
+ const error = Boom.badRequest();
574
+ error.output.headers = { connection: 'close' };
575
+ request._reply(error);
576
+ }
577
+ else {
578
+ socket.end(internals.badRequestResponse);
579
+ }
580
+ }
581
+ else {
582
+ socket.destroy(err);
583
+ }
584
+ });
585
+
586
+ return listener;
587
+ }
588
+
589
+ _initializeListener() {
590
+
591
+ this.listener.once('listening', () => {
592
+
593
+ // Update the address, port, and uri with active values
594
+
595
+ if (this.type === 'tcp') {
596
+ const address = this.listener.address();
597
+ this.info.address = address.address;
598
+ this.info.port = address.port;
599
+ this.info.uri = this.settings.uri ?? this.info.protocol + '://' + this.info.host + ':' + this.info.port;
600
+ }
601
+
602
+ if (this.settings.operations.cleanStop) {
603
+ this.sockets = new Set();
604
+
605
+ const self = this;
606
+ const onClose = function () { // 'this' is bound to the emitter
607
+
608
+ self.sockets.delete(this);
609
+ };
610
+
611
+ this.onConnection = (connection) => {
612
+
613
+ this.sockets.add(connection);
614
+ connection.on('close', onClose);
615
+ };
616
+
617
+ this.listener.on(this.settings.tls ? 'secureConnection' : 'connection', this.onConnection);
618
+ }
619
+ });
620
+ }
621
+
622
+ _cachePolicy(options, _segment, realm) {
623
+
624
+ options = Config.apply('cachePolicy', options);
625
+
626
+ const plugin = realm?.plugin;
627
+ const segment = options.segment ?? _segment ?? (plugin ? `!${plugin}` : '');
628
+ Hoek.assert(segment, 'Missing cache segment name');
629
+
630
+ const cacheName = options.cache ?? '_default';
631
+ const cache = this.caches.get(cacheName);
632
+ Hoek.assert(cache, 'Unknown cache', cacheName);
633
+ Hoek.assert(!cache.segments[segment] || cache.shared || options.shared, 'Cannot provision the same cache segment more than once');
634
+ cache.segments[segment] = true;
635
+
636
+ const policy = new Catbox.Policy(options, cache.client, segment);
637
+ this.events.emit('cachePolicy', [policy, options.cache, segment]);
638
+
639
+ return policy;
640
+ }
641
+
642
+ log(tags, data) {
643
+
644
+ return this._log(tags, data, 'app');
645
+ }
646
+
647
+ _log(tags, data, channel = 'internal') {
648
+
649
+ if (!this.events.hasListeners('log')) {
650
+ return;
651
+ }
652
+
653
+ if (!Array.isArray(tags)) {
654
+ tags = [tags];
655
+ }
656
+
657
+ const timestamp = Date.now();
658
+ const field = data instanceof Error ? 'error' : 'data';
659
+
660
+ let event = { timestamp, tags, [field]: data, channel };
661
+
662
+ if (typeof data === 'function') {
663
+ event = () => ({ timestamp, tags, data: data(), channel });
664
+ }
665
+
666
+ this.events.emit({ name: 'log', tags, channel }, event);
667
+ }
668
+ };
669
+
670
+
671
+ internals.setup = function (options = {}) {
672
+
673
+ let settings = Hoek.clone(options, { shallow: ['cache', 'listener', 'routes.bind'] });
674
+ settings.app = settings.app ?? {};
675
+ settings.routes = Config.enable(settings.routes);
676
+ settings = Config.apply('server', settings);
677
+
678
+ if (settings.port === undefined) {
679
+ settings.port = 0;
680
+ }
681
+
682
+ const type = (typeof settings.port === 'string' ? 'socket' : 'tcp');
683
+ if (type === 'socket') {
684
+ settings.port = (settings.port.indexOf('/') !== -1 ? Path.resolve(settings.port) : settings.port.toLowerCase());
685
+ }
686
+
687
+ if (settings.autoListen === undefined) {
688
+ settings.autoListen = true;
689
+ }
690
+
691
+ Hoek.assert(settings.autoListen || !settings.port, 'Cannot specify port when autoListen is false');
692
+ Hoek.assert(settings.autoListen || !settings.address, 'Cannot specify address when autoListen is false');
693
+
694
+ return { settings, type };
695
+ };
696
+
697
+
698
+ internals.notFound = function () {
699
+
700
+ throw Boom.notFound();
701
+ };
702
+
703
+
704
+ internals.badRequest = function () {
705
+
706
+ throw Boom.badRequest();
707
+ };
708
+
709
+
710
+ internals.onFinish = function (env) {
711
+
712
+ const { core, req } = env;
713
+
714
+ core.actives.delete(req.socket);
715
+ if (!core.started) {
716
+ req.socket.end();
717
+ }
718
+ };