redweb 0.13.5 → 0.15.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.
Files changed (72) hide show
  1. package/CHANGELOG.md +20 -3
  2. package/README.md +289 -288
  3. package/contract.d.ts +11 -3
  4. package/docs/APPLICATION.md +98 -0
  5. package/docs/CLI.md +1 -1
  6. package/docs/CLIENT_DEVELOPMENT.md +9 -5
  7. package/docs/COVERAGE_SCOPE_AUDIT.md +7 -0
  8. package/docs/DEFINE_APP_VERIFICATION.md +101 -0
  9. package/docs/DEVELOPMENT.md +1 -1
  10. package/docs/GETTING_STARTED.md +1 -1
  11. package/docs/LIVE_HTML.md +2 -2
  12. package/docs/MIGRATION.md +1 -1
  13. package/docs/MULTIPLAYER_OPERATIONS.md +6 -0
  14. package/docs/RELEASE_TRUST.md +29 -6
  15. package/docs/RUNTIME_DIAGNOSTICS.md +1 -1
  16. package/docs/SOCKET_CONTRACTS.md +6 -3
  17. package/docs/SOCKET_PAGES.md +99 -0
  18. package/docs/SOCKET_PAGE_RELEASE_PREPARATION.md +120 -0
  19. package/docs/SOCKET_PAGE_VERIFICATION.md +85 -0
  20. package/docs/STARTER_LIFECYCLE_VERIFICATION.md +9 -0
  21. package/docs/generated.json +2217 -2154
  22. package/docs/guides/http-websocket.md +4 -4
  23. package/docs/guides/jsx-without-react.md +1 -1
  24. package/docs/reference.json +40 -1
  25. package/docs/releases/0.14.0.json +2208 -0
  26. package/docs/releases/0.15.0.json +2217 -0
  27. package/docs/topics.json +3 -1
  28. package/examples/live-html/cards.js +87 -86
  29. package/examples/live-html/cards.ts +3 -2
  30. package/examples/live-html/chatroom.js +210 -207
  31. package/examples/live-html/chatroom.tsx +6 -2
  32. package/examples/live-html/components.js +103 -102
  33. package/examples/live-html/components.ts +3 -2
  34. package/examples/live-html/counter.js +74 -73
  35. package/examples/live-html/counter.ts +3 -2
  36. package/examples/live-html/jsx-page.js +2 -1
  37. package/examples/live-html/jsx-page.tsx +3 -2
  38. package/index.d.ts +59 -7
  39. package/index.js +3 -0
  40. package/package.json +8 -5
  41. package/recipes/chat/app.test.cjs +3 -1
  42. package/recipes/chat/app.tsx +4 -7
  43. package/recipes/dashboard/app.test.cjs +10 -10
  44. package/recipes/dashboard/app.tsx +29 -29
  45. package/recipes/http-ws/README.md +1 -1
  46. package/recipes/http-ws/app.test.cjs +8 -10
  47. package/recipes/http-ws/app.tsx +9 -20
  48. package/recipes/realtime/app.tsx +3 -6
  49. package/recipes/shared/README.md +10 -1
  50. package/recipes/shared/lifecycle.test.cjs +81 -0
  51. package/recipes/shared/network.cjs +10 -5
  52. package/recipes/site/app.tsx +3 -6
  53. package/recipes/socket/app.tsx +3 -10
  54. package/src/Application.js +239 -0
  55. package/src/StartupCleanup.js +24 -0
  56. package/src/cli/SourceInspector.js +5 -0
  57. package/src/cli/templates.js +6 -6
  58. package/src/htmx/Jsx.js +2 -2
  59. package/src/htmx/LiveHtmlServer.js +14 -5
  60. package/src/htmx/PageManager.js +13 -8
  61. package/src/htmx/PageSocketRoute.js +132 -0
  62. package/src/htmx/ReactiveRenderer.js +8 -2
  63. package/src/htmx/SocketAction.js +19 -0
  64. package/src/htmx/metadata.js +6 -2
  65. package/src/ws/BaseHandler.js +6 -4
  66. package/src/ws/BaseSocketServer.js +13 -13
  67. package/src/ws/HandlerGuard.js +4 -0
  68. package/src/ws/SocketAction.js +16 -0
  69. package/src/ws/SocketContract.js +3 -2
  70. package/src/ws/SocketRoute.js +9 -4
  71. package/recipes/shared/run-app.test.cjs +0 -158
  72. package/recipes/shared/run-app.ts +0 -50
@@ -178,7 +178,10 @@ function page(routePath, options = {}) {
178
178
  if (!['connection', 'shared'].includes(scope)) {
179
179
  throw new TypeError('Page scope must be "connection" or "shared".');
180
180
  }
181
- if (typeof live !== 'boolean') throw new TypeError('Page live must be a boolean.');
181
+ if (typeof live !== 'boolean') throw new TypeError('Page live must be a boolean.');
182
+ if (options.socket !== undefined && (typeof options.socket !== 'function' || !live || scope !== 'connection')) {
183
+ throw new TypeError('Page socket requires a route class and a live connection-scoped page.');
184
+ }
182
185
  if (layout !== undefined && typeof layout !== 'function') throw new TypeError('Page layout must be a function.');
183
186
  const head = pageHead(options.head);
184
187
  const cache = pageCache(options.cache, live);
@@ -189,7 +192,8 @@ function page(routePath, options = {}) {
189
192
  PAGE_METADATA.set(PageClass, Object.freeze({
190
193
  path: routePath,
191
194
  template,
192
- scope,
195
+ scope,
196
+ ...(options.socket && { socket: options.socket }),
193
197
  ...(live === false && { live: false }),
194
198
  ...(head && { head }),
195
199
  ...(cache && { cache }),
@@ -20,10 +20,12 @@ class BaseHandler {
20
20
  */
21
21
  async handleMessage(socket, message) {
22
22
  const validationResult = await this.validateMessage(message, socket);
23
- if (validationResult === false) {
24
- throw new Error('Invalid message');
25
- }
26
- return this.onMessage(socket, message);
23
+ if (validationResult === false) {
24
+ throw new Error('Invalid message');
25
+ }
26
+ const guard = require('./HandlerGuard').guards.get(socket);
27
+ if (guard) await guard();
28
+ return this.onMessage(socket, message);
27
29
  }
28
30
 
29
31
  validateMessage() {
@@ -12,6 +12,7 @@ const { PLACEMENT_REDIRECT, ADMISSION_SETTLEMENT } = require('./AdmissionPolicy'
12
12
  const { PROTOCOL_REJECTION } = require('./ProtocolPolicy');
13
13
  const { RequestFailure, UPGRADE_REJECTION } = require('../access/RequestFailure');
14
14
  const OwnedServerLifecycle = require('../OwnedServerLifecycle');
15
+ const { scheduleStartupCleanup } = require('../StartupCleanup');
15
16
  const {
16
17
  listenServer,
17
18
  closeServer,
@@ -62,15 +63,14 @@ class BaseSocketServer {
62
63
  for (const RouteClass of RouteClasses) {
63
64
  const route = new RouteClass(server, { logger: this.logger });
64
65
  if (this.routes.some(existing => existing.path === route.path)) {
65
- this.disposeRoutes([route]);
66
+ this.routes.push(route);
66
67
  throw new Error('WebSocket route paths must be unique.');
67
68
  }
68
69
  this.routes.push(route);
69
70
  }
70
71
  } catch (error) {
71
72
  this._ownedServer?.dispose();
72
- this.disposeRoutes(this.routes);
73
- throw error;
73
+ throw scheduleStartupCleanup(error, () => this.disposeRoutes(this.routes));
74
74
  }
75
75
 
76
76
  this._upgradeHandler = this.handleUpgrade.bind(this);
@@ -90,17 +90,18 @@ class BaseSocketServer {
90
90
  } catch (error) {
91
91
  this.server.off('upgrade', this._upgradeHandler);
92
92
  this._ownedServer?.dispose();
93
- this.disposeRoutes(this.routes);
94
- throw error;
93
+ throw scheduleStartupCleanup(error, () => this.disposeRoutes(this.routes));
95
94
  }
96
95
  }
97
96
 
98
- disposeRoutes(routes) {
99
- routes.forEach(route => {
100
- Promise.resolve()
101
- .then(() => route.shutdown?.())
102
- .catch(error => this.logger?.error?.('Error shutting down route:', error));
103
- });
97
+ async disposeRoutes(routes) {
98
+ const errors = await settleTasks(routes.map(route => () => route.shutdown?.()));
99
+ if (errors.length) {
100
+ for (const error of errors) {
101
+ try { this.logger?.error?.('Error shutting down route:', error); } catch { /* Preserve the cleanup failure even if logging fails. */ }
102
+ }
103
+ throw new AggregateError(errors, 'Route construction rollback failed.');
104
+ }
104
105
  }
105
106
 
106
107
  handleUpgrade(req, sock, head) {
@@ -208,8 +209,7 @@ class BaseSocketServer {
208
209
  addRoute(RouteClass) {
209
210
  const route = new RouteClass(this.server, { logger: this.logger });
210
211
  if (this.routes.some(existing => existing.path === route.path)) {
211
- this.disposeRoutes([route]);
212
- throw new Error(`A WebSocket route already exists at ${route.path}.`);
212
+ throw scheduleStartupCleanup(new Error(`A WebSocket route already exists at ${route.path}.`), () => this.disposeRoutes([route]));
213
213
  }
214
214
  this.routes.push(route);
215
215
  return route;
@@ -0,0 +1,4 @@
1
+ 'use strict';
2
+ // A private hook, never a field supplied by an incoming message.
3
+ const guards = new WeakMap();
4
+ module.exports = { guards };
@@ -0,0 +1,16 @@
1
+ 'use strict';
2
+
3
+ // Browser-safe metadata keeps the standalone contract entry free of Node rendering dependencies.
4
+ const bindings = new WeakMap();
5
+ function register(Handler, type) {
6
+ bindings.set(Handler, { Handler, type });
7
+ Object.defineProperty(Handler, 'with', { value(payload) {
8
+ const serialized = JSON.stringify(payload);
9
+ if (serialized === undefined || serialized.length > 65536) throw new TypeError('Socket action payload must be bounded JSON.');
10
+ const binding = Object.freeze({});
11
+ bindings.set(binding, { Handler, type, payload: JSON.parse(serialized) });
12
+ return binding;
13
+ } });
14
+ return Handler;
15
+ }
16
+ module.exports = { register, bindings };
@@ -83,7 +83,7 @@ class SocketContract {
83
83
  if (!this.#validators.has(type)) throw new TypeError('Handler message type is not defined in the contract.');
84
84
  if (typeof callback !== 'function') throw new TypeError('A contract handler requires a callback.');
85
85
  const contract = this;
86
- return class ContractHandler extends BaseHandler {
86
+ class ContractHandler extends BaseHandler {
87
87
  constructor() { super(type); }
88
88
  async handleMessage(socket, message) {
89
89
  if (socket.context?.protocol?.version !== contract.version) throw new TypeError('The route must negotiate this contract version before handling messages.');
@@ -93,7 +93,8 @@ class SocketContract {
93
93
  return super.handleMessage(socket, { ...message, payload });
94
94
  }
95
95
  onMessage(socket, message) { return callback(socket, message.payload, message); }
96
- };
96
+ }
97
+ return require('./SocketAction').register(ContractHandler, type);
97
98
  }
98
99
 
99
100
  client(socket) { return new ContractClient(this, socket); }
@@ -117,6 +117,10 @@ class SocketRoute {
117
117
  const reservedOption = ['noServer', 'path', 'server', 'port']
118
118
  .find(option => Object.prototype.hasOwnProperty.call(websocketOptions, option));
119
119
  if (reservedOption) throw new TypeError(`Redweb controls websocketOptions.${reservedOption}.`);
120
+ const { closeTimeout = 5000 } = websocketOptions;
121
+ if (!Number.isInteger(closeTimeout) || closeTimeout < 1 || closeTimeout > 2147483647) {
122
+ throw new TypeError('`websocketOptions.closeTimeout` must be an integer between 1 and 2147483647 milliseconds.');
123
+ }
120
124
  if (!Number.isInteger(shutdownTimeoutMs) || shutdownTimeoutMs < 0) {
121
125
  throw new TypeError('`shutdownTimeoutMs` must be a non-negative integer.');
122
126
  }
@@ -135,7 +139,7 @@ class SocketRoute {
135
139
  * @type {string}
136
140
  */
137
141
  this.path = path;
138
- this.websocketOptions = { ...websocketOptions };
142
+ this.websocketOptions = { ...websocketOptions, closeTimeout };
139
143
  this.logger = logger || { log() {}, warn() {}, error() {} };
140
144
  this.trustProxy = trustProxy;
141
145
  this.getClientKey = getClientKey;
@@ -165,7 +169,7 @@ class SocketRoute {
165
169
  throw new Error('Handler names must be unique within a route.');
166
170
  }
167
171
  this.clients = new Map();
168
- this.server = new WebSocketServer({ ...websocketOptions, noServer: true });
172
+ this.server = new WebSocketServer({ ...this.websocketOptions, noServer: true });
169
173
  this.server.on('connection', this.handleConnection.bind(this));
170
174
  this.allowDuplicateConnections = Boolean(allowDuplicateConnections);
171
175
 
@@ -448,8 +452,9 @@ class SocketRoute {
448
452
  return false;
449
453
  } else {
450
454
  try {
451
- await handler.handleMessage(sock, data);
452
- return true;
455
+ // A handler may send a recoverable protocol error and explicitly decline
456
+ // success. Undefined remains successful for existing command handlers.
457
+ return await handler.handleMessage(sock, data) !== false;
453
458
  } catch (error) {
454
459
  if (this.sendAccessFailure(sock, error, { requestId: data.requestId })) return false;
455
460
  if (error instanceof InboundContractValidationError) {
@@ -1,158 +0,0 @@
1
- const assert = require('node:assert/strict');
2
- const { test } = require('node:test');
3
- const { spawn } = require('node:child_process');
4
-
5
- // Each case uses its own Node process, real HTTP/TCP/WS resources and real timers.
6
- // Windows cannot deliver POSIX signals through child.kill, so only that platform
7
- // explicitly emits the signal event inside the child. Linux uses real OS signals.
8
- const fixture = String.raw`
9
- const assert = require('node:assert/strict');
10
- const http = require('node:http');
11
- const net = require('node:net');
12
- const { once } = require('node:events');
13
- const WebSocket = require('ws');
14
- const mode = process.argv[1];
15
- const signals = ['SIGINT', 'SIGTERM'];
16
- const initial = signals.map(signal => process.listenerCount(signal));
17
- const { runApp } = require('./dist/run-app.js');
18
- assert.deepEqual(signals.map(signal => process.listenerCount(signal)), initial);
19
- require('./dist/app.js');
20
- assert.deepEqual(signals.map(signal => process.listenerCount(signal)), initial);
21
- let cleanups = 0;
22
- process.once('beforeExit', () => console.log(JSON.stringify({ cleanups, signals: signals.map(signal => process.listenerCount(signal)), initial })));
23
- const signal = name => process.platform === 'win32' ? process.emit(name) : process.kill(process.pid, name);
24
- if (mode === 'invalid') {
25
- for (const value of [0, -1, NaN, Infinity, 1.5, 2147483648]) assert.throws(() => runApp(() => { throw Error('must not execute'); }, value), RangeError);
26
- } else if (mode === 'factory') {
27
- assert.equal(runApp(() => { throw Error('private startup detail'); }), undefined);
28
- } else {
29
- if (mode === 'preserve') process.exitCode = '7';
30
- const server = http.createServer((_request, response) => response.end('ready'));
31
- const wss = new WebSocket.Server({ server });
32
- wss.on('error', () => {}); // The HTTP listener error is owned by runApp.
33
- const peers = new Set();
34
- server.on('connection', peer => { peers.add(peer); peer.on('close', () => peers.delete(peer)); });
35
- const close = async () => {
36
- for (const peer of peers) peer.destroy();
37
- for (const peer of wss.clients) peer.terminate();
38
- await new Promise(resolve => wss.close(resolve));
39
- await new Promise(resolve => server.close(resolve));
40
- };
41
- const app = runApp(() => ({ server, shutdown() {
42
- cleanups++;
43
- console.log('cleanup-started');
44
- if (mode === 'throw') { void close(); throw Error('private cleanup detail'); }
45
- if (mode === 'reject-open') return Promise.reject(Error('private cleanup detail'));
46
- return close().then(async () => {
47
- if (mode === 'hung') return new Promise(() => {});
48
- if (mode === 'reject') throw Error('private cleanup detail');
49
- if (mode === 'repeat') {
50
- signal('SIGINT'); signal('SIGTERM');
51
- server.emit('error', Error('private listener detail'));
52
- }
53
- await new Promise(resolve => setTimeout(resolve, 20));
54
- });
55
- } }), 200);
56
- assert.equal(app.server, server);
57
- (async () => {
58
- if (mode === 'occupied') {
59
- const other = http.createServer();
60
- await new Promise(resolve => other.listen(0, '127.0.0.1', resolve));
61
- server.once('error', () => other.close());
62
- server.listen(other.address().port, '127.0.0.1');
63
- return;
64
- }
65
- await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
66
- const port = server.address().port;
67
- const response = await fetch('http://127.0.0.1:' + port);
68
- assert.equal(await response.text(), 'ready');
69
- const peer = net.connect(port, '127.0.0.1');
70
- peer.on('error', () => {});
71
- await once(peer, 'connect');
72
- peer.write('GET / HTTP/1.1\r\nHost: localhost\r\n');
73
- const socket = new WebSocket('ws://127.0.0.1:' + port);
74
- socket.on('error', () => {});
75
- await once(socket, 'open');
76
- if (mode === 'native-close') {
77
- for (const connection of peers) connection.destroy();
78
- server.close();
79
- return;
80
- }
81
- // A partial HTTP peer otherwise prevents native close; application cleanup
82
- // begins via the signal and the later native close must not end its timer.
83
- signal(mode === 'interrupt' ? 'SIGINT' : 'SIGTERM');
84
- })().catch(error => { console.error(error); process.exit(99); });
85
- }
86
- `;
87
-
88
- function execute(mode, t, args = ['-e', fixture, mode], env = process.env) {
89
- return new Promise((resolve, reject) => {
90
- const child = spawn(process.execPath, args, { cwd: process.cwd(), env, windowsHide: true });
91
- let stdout = '', stderr = '';
92
- let timedOut = false, finished = false;
93
- const closed = new Promise(resolve => child.once('close', () => { finished = true; resolve(); }));
94
- const deadline = setTimeout(() => { timedOut = true; child.kill('SIGKILL'); }, 5000);
95
- t.after(async () => {
96
- clearTimeout(deadline);
97
- if (!finished) { child.kill('SIGKILL'); await closed; }
98
- });
99
- child.stdout.on('data', data => { stdout += data; });
100
- child.stderr.on('data', data => { stderr += data; });
101
- child.once('error', reject);
102
- child.once('close', (code, signal) => {
103
- clearTimeout(deadline);
104
- if (timedOut) reject(new Error(`Lifecycle child timed out: ${mode}\n${stdout}\n${stderr}`));
105
- else resolve({ code, signal, stdout, stderr });
106
- });
107
- });
108
- }
109
-
110
- test('the actual application entrypoint exits cleanly when its port is occupied', { timeout: 7000 }, async t => {
111
- const net = require('node:net');
112
- const { once } = require('node:events');
113
- const fs = require('node:fs');
114
- const path = require('node:path');
115
- const directory = fs.mkdtempSync(path.join(require('node:os').tmpdir(), 'redweb-entrypoint-'));
116
- const occupied = net.createServer(socket => socket.destroy());
117
- const loopback = net.createServer(socket => socket.destroy());
118
- let failure;
119
- try {
120
- occupied.listen(0, '0.0.0.0');
121
- await once(occupied, 'listening');
122
- // Windows permits distinct wildcard/loopback binds on the same port.
123
- // Hold both addresses; Unix may already reject the second bind.
124
- loopback.listen(occupied.address().port, '127.0.0.1');
125
- try { await once(loopback, 'listening'); }
126
- catch (error) { assert.equal(error.code, 'EADDRINUSE'); }
127
- const env = { ...process.env, PORT: String(occupied.address().port), NODE_ENV: 'test', DASHBOARD_DATABASE: path.join(directory, 'test.sqlite') };
128
- delete env.DASHBOARD_ORIGIN;
129
- const result = await execute('actual-entrypoint', t, ['dist/app.js'], env);
130
- assert.equal(result.code, 1, `${result.stdout}\n${result.stderr}`);
131
- assert.equal(result.signal, null);
132
- assert.match(result.stderr, /Application listener failed/);
133
- } catch (error) { failure = error; }
134
- const cleanup = await Promise.allSettled([
135
- ...[occupied, loopback].map(server => new Promise((resolve, reject) => server.close(error =>
136
- error && error.code !== 'ERR_SERVER_NOT_RUNNING' ? reject(error) : resolve()))),
137
- fs.promises.rm(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }),
138
- ]);
139
- const failures = [...(failure ? [failure] : []), ...cleanup.filter(result => result.status === 'rejected').map(result => result.reason)];
140
- if (failures.length) throw new AggregateError(failures, 'Entrypoint verification or cleanup failed');
141
- });
142
-
143
- for (const mode of ['normal', 'interrupt', 'native-close', 'invalid', 'factory', 'throw', 'reject', 'reject-open', 'hung', 'occupied', 'repeat', 'preserve']) {
144
- test(`entrypoint cleanup: ${mode}`, { timeout: 7000 }, async t => {
145
- const result = await execute(mode, t);
146
- const expected = ['normal', 'interrupt', 'native-close', 'invalid'].includes(mode) ? 0 : mode === 'preserve' ? 7 : 1;
147
- assert.equal(result.code, expected, `${result.stdout}\n${result.stderr}`);
148
- assert.equal(result.signal, null);
149
- assert.doesNotMatch(result.stderr, /private .* detail/);
150
- const noApp = ['invalid', 'factory'].includes(mode);
151
- assert.equal((result.stdout.match(/cleanup-started/g) || []).length, noApp ? 0 : 1);
152
- if (['hung', 'reject-open'].includes(mode)) assert.match(result.stderr, /exceeded its deadline/);
153
- if (['normal', 'interrupt', 'native-close', 'invalid', 'factory', 'preserve'].includes(mode)) {
154
- const snapshot = JSON.parse(result.stdout.trim().split(/\r?\n/).at(-1));
155
- assert.deepEqual(snapshot.signals, snapshot.initial);
156
- }
157
- });
158
- }
@@ -1,50 +0,0 @@
1
- import type { Server } from 'node:http';
2
-
3
- interface Application { server: Server; shutdown(): Promise<void>; }
4
-
5
- /** Entry-point policy only: importing a recipe never installs process handlers. */
6
- export function runApp<T extends Application>(createApp: () => T, shutdownTimeoutMs = 5000): T | undefined {
7
- if (!Number.isInteger(shutdownTimeoutMs) || shutdownTimeoutMs < 1 || shutdownTimeoutMs > 2147483647) {
8
- throw new RangeError('Application shutdown timeout must be a positive timer-safe integer.');
9
- }
10
- const fail = (message: string) => {
11
- console.error(message);
12
- if (Number(process.exitCode ?? 0) === 0) process.exitCode = 1;
13
- };
14
- let app: T;
15
- try { app = createApp(); }
16
- catch { fail('Application startup failed.'); return undefined; }
17
-
18
- let closing: Promise<void> | undefined;
19
- const stop = () => {
20
- if (!closing) {
21
- let failed = false;
22
- const deadline = setTimeout(() => {
23
- fail('Application cleanup exceeded its deadline; terminating the process.');
24
- process.exit();
25
- }, shutdownTimeoutMs);
26
- closing = Promise.resolve().then(() => app.shutdown()).catch(() => {
27
- failed = true;
28
- fail('Application cleanup failed.');
29
- }).finally(() => {
30
- // Failed cleanup may leave live handles. Permit natural exit if none
31
- // remain, but still force a bounded exit when resources were leaked.
32
- if (failed) { deadline.unref(); return; }
33
- clearTimeout(deadline);
34
- process.off('SIGINT', stop);
35
- process.off('SIGTERM', stop);
36
- app.server.off('error', onError);
37
- app.server.off('close', stop);
38
- });
39
- }
40
- return closing;
41
- };
42
- const onError = () => { fail('Application listener failed.'); void stop(); };
43
- // Persistent handlers keep repeated signals from bypassing active cleanup.
44
- process.on('SIGINT', stop);
45
- process.on('SIGTERM', stop);
46
- app.server.on('error', onError);
47
- // Native close can precede database/worker cleanup: it starts, never ends, shutdown.
48
- app.server.once('close', stop);
49
- return app;
50
- }