proteum 2.5.24 → 2.5.25
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/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "proteum",
|
|
3
3
|
"description": "LLM-first Opinionated Typescript Framework for web applications.",
|
|
4
|
-
"version": "2.5.
|
|
4
|
+
"version": "2.5.25",
|
|
5
5
|
"author": "Gaetan Le Gac (https://github.com/gaetanlegac)",
|
|
6
6
|
"repository": "git://github.com/gaetanlegac/proteum.git",
|
|
7
7
|
"license": "MIT",
|
|
@@ -125,6 +125,12 @@ const createContentSecurityPolicy = (config: Config['csp']): TContentSecurityPol
|
|
|
125
125
|
|
|
126
126
|
const connectedProjectBootRetryCount = 10;
|
|
127
127
|
const connectedProjectBootRetryDelayMs = 5_000;
|
|
128
|
+
/**
|
|
129
|
+
* How long `cleanup()` waits for in-flight responses before force-closing their sockets.
|
|
130
|
+
* Kept under the host's drain window (Railway `drainingSeconds: 20` on the Unique Domains
|
|
131
|
+
* services), so the process exits on its own terms instead of being killed mid-response.
|
|
132
|
+
*/
|
|
133
|
+
export const httpDrainDeadlineMs = 10_000;
|
|
128
134
|
|
|
129
135
|
const wait = async (durationMs: number) =>
|
|
130
136
|
await new Promise<void>((resolve) => {
|
|
@@ -588,8 +594,25 @@ export default class HttpServer<TRouter extends TServerRouter = TServerRouter> {
|
|
|
588
594
|
});
|
|
589
595
|
}
|
|
590
596
|
|
|
597
|
+
/**
|
|
598
|
+
* Stop accepting connections and wait for in-flight responses before the process exits.
|
|
599
|
+
* `server/index.ts` calls `process.exit(0)` as soon as this resolves, so an unawaited
|
|
600
|
+
* `close()` cut every response still being written. The wait is bounded because a
|
|
601
|
+
* keep-alive or streaming client can hold a socket open indefinitely.
|
|
602
|
+
*/
|
|
591
603
|
public async cleanup() {
|
|
592
|
-
|
|
604
|
+
await new Promise<void>((resolve) => {
|
|
605
|
+
const deadline = setTimeout(() => {
|
|
606
|
+
this.http.closeAllConnections();
|
|
607
|
+
resolve();
|
|
608
|
+
}, httpDrainDeadlineMs);
|
|
609
|
+
this.http.close(() => {
|
|
610
|
+
clearTimeout(deadline);
|
|
611
|
+
resolve();
|
|
612
|
+
});
|
|
613
|
+
// Idle keep-alive sockets would otherwise hold `close()` open until the deadline.
|
|
614
|
+
this.http.closeIdleConnections();
|
|
615
|
+
});
|
|
593
616
|
}
|
|
594
617
|
|
|
595
618
|
private registerDevTraceRoutes(routes: express.Express) {
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
const assert = require('node:assert/strict');
|
|
2
|
+
const Module = require('node:module');
|
|
3
|
+
const path = require('node:path');
|
|
4
|
+
|
|
5
|
+
const coreRoot = path.join(__dirname, '..');
|
|
6
|
+
require('module-alias').addAliases({
|
|
7
|
+
'@client': path.join(coreRoot, 'client'),
|
|
8
|
+
'@common': path.join(coreRoot, 'common'),
|
|
9
|
+
'@server': path.join(coreRoot, 'server'),
|
|
10
|
+
});
|
|
11
|
+
process.env.TS_NODE_PROJECT = path.join(coreRoot, 'cli', 'tsconfig.json');
|
|
12
|
+
process.env.TS_NODE_TRANSPILE_ONLY = '1';
|
|
13
|
+
require('ts-node/register/transpile-only');
|
|
14
|
+
|
|
15
|
+
// The app container reads the app's proteum.config.ts at import; the HTTP server only needs it at runtime.
|
|
16
|
+
const httpServerModulePath = path.join(coreRoot, 'server/services/router/http/index.ts');
|
|
17
|
+
const originalLoad = Module._load;
|
|
18
|
+
Module._load = function patchedLoad(request, parent, isMain) {
|
|
19
|
+
if (parent?.filename === httpServerModulePath && request === '@server/app/container') {
|
|
20
|
+
return { __esModule: true, default: {} };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return originalLoad.call(this, request, parent, isMain);
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
let HttpServer;
|
|
27
|
+
let httpDrainDeadlineMs;
|
|
28
|
+
try {
|
|
29
|
+
({ default: HttpServer, httpDrainDeadlineMs } = require('../server/services/router/http/index.ts'));
|
|
30
|
+
} finally {
|
|
31
|
+
Module._load = originalLoad;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** A real HttpServer wired to a fake app, with its node server swapped for a recorder. */
|
|
35
|
+
const createDrainingServer = () => {
|
|
36
|
+
const hooks = {};
|
|
37
|
+
const app = {
|
|
38
|
+
env: { name: 'local' },
|
|
39
|
+
on: (name, callback) => {
|
|
40
|
+
hooks[name] = callback;
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
const server = new HttpServer({ domain: 'localhost', port: 0, ssl: false }, { app });
|
|
44
|
+
|
|
45
|
+
const calls = [];
|
|
46
|
+
let closeCallback;
|
|
47
|
+
server.http = {
|
|
48
|
+
close: (callback) => {
|
|
49
|
+
calls.push('close');
|
|
50
|
+
closeCallback = callback;
|
|
51
|
+
},
|
|
52
|
+
closeIdleConnections: () => calls.push('closeIdleConnections'),
|
|
53
|
+
closeAllConnections: () => calls.push('closeAllConnections'),
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
let settled = false;
|
|
57
|
+
const runCleanupHook = () => {
|
|
58
|
+
const drained = hooks.cleanup();
|
|
59
|
+
void drained.then(() => {
|
|
60
|
+
settled = true;
|
|
61
|
+
});
|
|
62
|
+
return drained;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
return { calls, runCleanupHook, isSettled: () => settled, finishClose: () => closeCallback() };
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const flushMicrotasks = async () => {
|
|
69
|
+
for (let index = 0; index < 5; index++) await Promise.resolve();
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
afterEach(() => {
|
|
73
|
+
vi.useRealTimers();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('http server cleanup waits for close and clears the drain deadline', async () => {
|
|
77
|
+
vi.useFakeTimers();
|
|
78
|
+
const server = createDrainingServer();
|
|
79
|
+
|
|
80
|
+
const drained = server.runCleanupHook();
|
|
81
|
+
await flushMicrotasks();
|
|
82
|
+
|
|
83
|
+
assert.equal(server.isSettled(), false, 'cleanup must not resolve while responses are still in flight');
|
|
84
|
+
assert.deepEqual(server.calls, ['close', 'closeIdleConnections']);
|
|
85
|
+
assert.equal(vi.getTimerCount(), 1);
|
|
86
|
+
|
|
87
|
+
server.finishClose();
|
|
88
|
+
await drained;
|
|
89
|
+
|
|
90
|
+
assert.equal(server.isSettled(), true);
|
|
91
|
+
assert.equal(vi.getTimerCount(), 0, 'the drain deadline must be cleared once close() calls back');
|
|
92
|
+
assert.deepEqual(server.calls, ['close', 'closeIdleConnections']);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('http server cleanup force-closes connections when the drain deadline passes', async () => {
|
|
96
|
+
vi.useFakeTimers();
|
|
97
|
+
const server = createDrainingServer();
|
|
98
|
+
|
|
99
|
+
const drained = server.runCleanupHook();
|
|
100
|
+
|
|
101
|
+
await vi.advanceTimersByTimeAsync(httpDrainDeadlineMs - 1);
|
|
102
|
+
assert.equal(server.isSettled(), false);
|
|
103
|
+
assert.deepEqual(server.calls, ['close', 'closeIdleConnections']);
|
|
104
|
+
|
|
105
|
+
await vi.advanceTimersByTimeAsync(1);
|
|
106
|
+
await drained;
|
|
107
|
+
|
|
108
|
+
assert.equal(server.isSettled(), true);
|
|
109
|
+
assert.deepEqual(server.calls, ['close', 'closeIdleConnections', 'closeAllConnections']);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('http server drain deadline stays under the 20 second host drain window', () => {
|
|
113
|
+
assert.equal(httpDrainDeadlineMs, 10_000);
|
|
114
|
+
assert.ok(httpDrainDeadlineMs < 20_000);
|
|
115
|
+
});
|