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.
- package/CHANGELOG.md +20 -3
- package/README.md +289 -288
- package/contract.d.ts +11 -3
- package/docs/APPLICATION.md +98 -0
- package/docs/CLI.md +1 -1
- package/docs/CLIENT_DEVELOPMENT.md +9 -5
- package/docs/COVERAGE_SCOPE_AUDIT.md +7 -0
- package/docs/DEFINE_APP_VERIFICATION.md +101 -0
- package/docs/DEVELOPMENT.md +1 -1
- package/docs/GETTING_STARTED.md +1 -1
- package/docs/LIVE_HTML.md +2 -2
- package/docs/MIGRATION.md +1 -1
- package/docs/MULTIPLAYER_OPERATIONS.md +6 -0
- package/docs/RELEASE_TRUST.md +29 -6
- package/docs/RUNTIME_DIAGNOSTICS.md +1 -1
- package/docs/SOCKET_CONTRACTS.md +6 -3
- package/docs/SOCKET_PAGES.md +99 -0
- package/docs/SOCKET_PAGE_RELEASE_PREPARATION.md +120 -0
- package/docs/SOCKET_PAGE_VERIFICATION.md +85 -0
- package/docs/STARTER_LIFECYCLE_VERIFICATION.md +9 -0
- package/docs/generated.json +2217 -2154
- package/docs/guides/http-websocket.md +4 -4
- package/docs/guides/jsx-without-react.md +1 -1
- package/docs/reference.json +40 -1
- package/docs/releases/0.14.0.json +2208 -0
- package/docs/releases/0.15.0.json +2217 -0
- package/docs/topics.json +3 -1
- package/examples/live-html/cards.js +87 -86
- package/examples/live-html/cards.ts +3 -2
- package/examples/live-html/chatroom.js +210 -207
- package/examples/live-html/chatroom.tsx +6 -2
- package/examples/live-html/components.js +103 -102
- package/examples/live-html/components.ts +3 -2
- package/examples/live-html/counter.js +74 -73
- package/examples/live-html/counter.ts +3 -2
- package/examples/live-html/jsx-page.js +2 -1
- package/examples/live-html/jsx-page.tsx +3 -2
- package/index.d.ts +59 -7
- package/index.js +3 -0
- package/package.json +8 -5
- package/recipes/chat/app.test.cjs +3 -1
- package/recipes/chat/app.tsx +4 -7
- package/recipes/dashboard/app.test.cjs +10 -10
- package/recipes/dashboard/app.tsx +29 -29
- package/recipes/http-ws/README.md +1 -1
- package/recipes/http-ws/app.test.cjs +8 -10
- package/recipes/http-ws/app.tsx +9 -20
- package/recipes/realtime/app.tsx +3 -6
- package/recipes/shared/README.md +10 -1
- package/recipes/shared/lifecycle.test.cjs +81 -0
- package/recipes/shared/network.cjs +10 -5
- package/recipes/site/app.tsx +3 -6
- package/recipes/socket/app.tsx +3 -10
- package/src/Application.js +239 -0
- package/src/StartupCleanup.js +24 -0
- package/src/cli/SourceInspector.js +5 -0
- package/src/cli/templates.js +6 -6
- package/src/htmx/Jsx.js +2 -2
- package/src/htmx/LiveHtmlServer.js +14 -5
- package/src/htmx/PageManager.js +13 -8
- package/src/htmx/PageSocketRoute.js +132 -0
- package/src/htmx/ReactiveRenderer.js +8 -2
- package/src/htmx/SocketAction.js +19 -0
- package/src/htmx/metadata.js +6 -2
- package/src/ws/BaseHandler.js +6 -4
- package/src/ws/BaseSocketServer.js +13 -13
- package/src/ws/HandlerGuard.js +4 -0
- package/src/ws/SocketAction.js +16 -0
- package/src/ws/SocketContract.js +3 -2
- package/src/ws/SocketRoute.js +9 -4
- package/recipes/shared/run-app.test.cjs +0 -158
- package/recipes/shared/run-app.ts +0 -50
package/src/htmx/metadata.js
CHANGED
|
@@ -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 }),
|
package/src/ws/BaseHandler.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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.
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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,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 };
|
package/src/ws/SocketContract.js
CHANGED
|
@@ -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
|
-
|
|
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); }
|
package/src/ws/SocketRoute.js
CHANGED
|
@@ -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
|
-
|
|
452
|
-
|
|
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
|
-
}
|