@afterlink/server 1.0.0 → 1.1.1
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 +3 -2
- package/src/Connection.js +90 -3
- package/src/Router.js +17 -2
- package/src/Server.js +176 -21
- package/src/index.js +2 -1
- package/src/middleware/rate-limit.js +81 -0
- package/src/shutdown/graceful.js +95 -0
- package/src/tls/dev-certs.js +33 -0
- package/src/transport/tls.js +86 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@afterlink/server",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.1",
|
|
4
4
|
"description": "AfterLink Protocol Server SDK - TCP server with routing, middleware, and pub/sub",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"license": "MIT",
|
|
@@ -29,7 +29,8 @@
|
|
|
29
29
|
"test": "vitest run"
|
|
30
30
|
},
|
|
31
31
|
"dependencies": {
|
|
32
|
-
"@afterlink/core": "1.
|
|
32
|
+
"@afterlink/core": "^1.1.0",
|
|
33
|
+
"selfsigned": "^5.5.0"
|
|
33
34
|
},
|
|
34
35
|
"devDependencies": {
|
|
35
36
|
"vitest": "^1.6.0"
|
package/src/Connection.js
CHANGED
|
@@ -2,6 +2,7 @@ const {
|
|
|
2
2
|
Frame,
|
|
3
3
|
FrameTypes: { HELLO, HELLO_ACK, ERROR },
|
|
4
4
|
Serializer,
|
|
5
|
+
compression,
|
|
5
6
|
} = require('@afterlink/core');
|
|
6
7
|
const FrameAccumulator = require('./FrameAccumulator');
|
|
7
8
|
|
|
@@ -14,6 +15,20 @@ class Connection {
|
|
|
14
15
|
this._id = `conn_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
15
16
|
this._closed = false;
|
|
16
17
|
|
|
18
|
+
// Compression state (negotiated during handshake)
|
|
19
|
+
this._compression = {
|
|
20
|
+
enabled: false,
|
|
21
|
+
algorithm: 'none',
|
|
22
|
+
level: 6,
|
|
23
|
+
threshold: 1024,
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// Rate limiting bucket (attached by server if enabled)
|
|
27
|
+
this._rateBucket = null;
|
|
28
|
+
|
|
29
|
+
// Active request tracking for graceful shutdown
|
|
30
|
+
this._activeRequests = new Set();
|
|
31
|
+
|
|
17
32
|
try {
|
|
18
33
|
this.accumulator = new FrameAccumulator(this._handleFrame.bind(this));
|
|
19
34
|
} catch (err) {
|
|
@@ -56,7 +71,28 @@ class Connection {
|
|
|
56
71
|
if (frame.type === HELLO && !this.session) {
|
|
57
72
|
this._handleHandshake(frame);
|
|
58
73
|
} else if (this.session) {
|
|
74
|
+
// Decompress incoming frame if compressed
|
|
75
|
+
if (compression.isCompressed(frame.flags)) {
|
|
76
|
+
try {
|
|
77
|
+
frame.payload = compression.decompress(
|
|
78
|
+
frame.payload,
|
|
79
|
+
true,
|
|
80
|
+
this._compression.algorithm
|
|
81
|
+
);
|
|
82
|
+
} catch (err) {
|
|
83
|
+
this.sendError('DECOMPRESSION_ERROR', 'Failed to decompress payload');
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Track active requests for graceful shutdown
|
|
89
|
+
const { REQUEST } = require('@afterlink/core').FrameTypes;
|
|
90
|
+
if (frame.type === REQUEST) {
|
|
91
|
+
this._activeRequests.add(frame.messageId);
|
|
92
|
+
}
|
|
93
|
+
|
|
59
94
|
this.router.dispatch(frame, this).catch((err) => {
|
|
95
|
+
this._activeRequests.delete(frame.messageId);
|
|
60
96
|
this._onError(err);
|
|
61
97
|
});
|
|
62
98
|
} else {
|
|
@@ -74,18 +110,49 @@ class Connection {
|
|
|
74
110
|
this._validateAuth(data.auth);
|
|
75
111
|
}
|
|
76
112
|
|
|
113
|
+
// Negotiate compression
|
|
114
|
+
const serverCompression = this.options.compression || {};
|
|
115
|
+
const clientAlgorithm = data.compression || 'none';
|
|
116
|
+
const serverEnabled = serverCompression.enabled !== false;
|
|
117
|
+
const serverAlgorithm = serverCompression.algorithm || 'zlib';
|
|
118
|
+
|
|
119
|
+
// Use client's preferred algorithm if server supports it
|
|
120
|
+
let agreedAlgorithm = 'none';
|
|
121
|
+
if (serverEnabled && clientAlgorithm !== 'none') {
|
|
122
|
+
if (clientAlgorithm === serverAlgorithm || clientAlgorithm === 'brotli') {
|
|
123
|
+
agreedAlgorithm = clientAlgorithm;
|
|
124
|
+
} else if (serverAlgorithm !== 'none') {
|
|
125
|
+
agreedAlgorithm = serverAlgorithm;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
this._compression = {
|
|
130
|
+
enabled: agreedAlgorithm !== 'none',
|
|
131
|
+
algorithm: agreedAlgorithm,
|
|
132
|
+
level: serverCompression.level ?? 6,
|
|
133
|
+
threshold: serverCompression.threshold ?? 1024,
|
|
134
|
+
};
|
|
135
|
+
|
|
77
136
|
this.session = {
|
|
78
137
|
id: sessionId,
|
|
79
138
|
version: data.version || 'AL/1',
|
|
80
139
|
capabilities: data.capabilities || [],
|
|
81
140
|
connectedAt: new Date().toISOString(),
|
|
82
141
|
remoteAddress: this.getRemoteAddress(),
|
|
142
|
+
compression: agreedAlgorithm,
|
|
83
143
|
};
|
|
84
144
|
|
|
85
145
|
const ackPayload = Serializer.encode({
|
|
86
146
|
session_id: sessionId,
|
|
87
|
-
server_version: 'AL/1',
|
|
88
|
-
capabilities: ['streaming', 'pubsub', 'compression'],
|
|
147
|
+
server_version: 'AL/1.1',
|
|
148
|
+
capabilities: ['streaming', 'pubsub', 'compression', 'rate-limit'],
|
|
149
|
+
compression: agreedAlgorithm,
|
|
150
|
+
rateLimit: this.options.rateLimit?.enabled
|
|
151
|
+
? {
|
|
152
|
+
requestsPerSecond: this.options.rateLimit.requestsPerSecond,
|
|
153
|
+
burstSize: this.options.rateLimit.burstSize,
|
|
154
|
+
}
|
|
155
|
+
: undefined,
|
|
89
156
|
});
|
|
90
157
|
this.send(HELLO_ACK, 0, frame.messageId, ackPayload);
|
|
91
158
|
} catch (err) {
|
|
@@ -104,8 +171,28 @@ class Connection {
|
|
|
104
171
|
send(type, flags, messageId, payload) {
|
|
105
172
|
if (this._closed || this.socket.destroyed) return;
|
|
106
173
|
try {
|
|
107
|
-
|
|
174
|
+
// Compress payload if enabled and above threshold
|
|
175
|
+
let finalPayload = payload;
|
|
176
|
+
let finalFlags = flags;
|
|
177
|
+
if (this._compression.enabled && this.session) {
|
|
178
|
+
const { data, compressed } = compression.compress(
|
|
179
|
+
payload,
|
|
180
|
+
this._compression.algorithm,
|
|
181
|
+
this._compression.level,
|
|
182
|
+
this._compression.threshold
|
|
183
|
+
);
|
|
184
|
+
finalPayload = data;
|
|
185
|
+
finalFlags = compression.setCompressedFlag(flags, compressed);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const frame = Frame.encode(type, finalFlags, messageId, finalPayload);
|
|
108
189
|
this.socket.write(frame);
|
|
190
|
+
|
|
191
|
+
// Track response sent - remove from active requests
|
|
192
|
+
const { RESPONSE, ERROR } = require('@afterlink/core').FrameTypes;
|
|
193
|
+
if (type === RESPONSE || type === ERROR) {
|
|
194
|
+
this._activeRequests.delete(messageId);
|
|
195
|
+
}
|
|
109
196
|
} catch (err) {
|
|
110
197
|
this._onError(err);
|
|
111
198
|
}
|
package/src/Router.js
CHANGED
|
@@ -80,7 +80,7 @@ class Router {
|
|
|
80
80
|
}
|
|
81
81
|
|
|
82
82
|
let responseSent = false;
|
|
83
|
-
const req = { body, session: connection.session, route };
|
|
83
|
+
const req = { body, session: connection.session, route, connection };
|
|
84
84
|
const res = {
|
|
85
85
|
send: (data) => {
|
|
86
86
|
if (responseSent) return;
|
|
@@ -100,7 +100,22 @@ class Router {
|
|
|
100
100
|
});
|
|
101
101
|
} catch (err) {
|
|
102
102
|
if (!responseSent) {
|
|
103
|
-
|
|
103
|
+
if (err.code === 'RATE_LIMITED') {
|
|
104
|
+
const errorPayload = Serializer.encode({
|
|
105
|
+
code: err.code,
|
|
106
|
+
message: err.message,
|
|
107
|
+
retryAfter: err.retryAfter,
|
|
108
|
+
limit: err.limit,
|
|
109
|
+
remaining: err.remaining,
|
|
110
|
+
});
|
|
111
|
+
connection.send(ERROR, 0, messageId, errorPayload);
|
|
112
|
+
|
|
113
|
+
if (err.closeConnection) {
|
|
114
|
+
connection.destroy();
|
|
115
|
+
}
|
|
116
|
+
} else {
|
|
117
|
+
connection.sendError('INTERNAL_ERROR', err.message, messageId);
|
|
118
|
+
}
|
|
104
119
|
}
|
|
105
120
|
}
|
|
106
121
|
}
|
package/src/Server.js
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
const
|
|
1
|
+
const { createServerTransport, isTLSSocket, getTLSInfo } = require('./transport/tls');
|
|
2
|
+
const { TokenBucket, createRateLimitMiddleware } = require('./middleware/rate-limit');
|
|
3
|
+
const { GracefulShutdown } = require('./shutdown/graceful');
|
|
4
|
+
|
|
2
5
|
const Connection = require('./Connection');
|
|
3
6
|
const Router = require('./Router');
|
|
4
7
|
|
|
@@ -8,12 +11,48 @@ class Server {
|
|
|
8
11
|
port: 4000,
|
|
9
12
|
host: '0.0.0.0',
|
|
10
13
|
maxConnections: 10000,
|
|
14
|
+
compression: {
|
|
15
|
+
enabled: false,
|
|
16
|
+
algorithm: 'zlib',
|
|
17
|
+
threshold: 1024,
|
|
18
|
+
level: 6,
|
|
19
|
+
},
|
|
20
|
+
rateLimit: {
|
|
21
|
+
enabled: false,
|
|
22
|
+
requestsPerSecond: 100,
|
|
23
|
+
burstSize: 200,
|
|
24
|
+
closeAfterViolations: null,
|
|
25
|
+
errorMessage: 'Rate limit exceeded. Please slow down.',
|
|
26
|
+
onLimited: null,
|
|
27
|
+
},
|
|
28
|
+
shutdown: {
|
|
29
|
+
drainTimeout: 5000,
|
|
30
|
+
reason: 'planned_restart',
|
|
31
|
+
notifyClients: true,
|
|
32
|
+
},
|
|
11
33
|
...config,
|
|
12
34
|
};
|
|
35
|
+
// Merge nested configs deeply
|
|
36
|
+
if (config.compression) {
|
|
37
|
+
this.config.compression = { ...this.config.compression, ...config.compression };
|
|
38
|
+
}
|
|
39
|
+
if (config.rateLimit) {
|
|
40
|
+
this.config.rateLimit = { ...this.config.rateLimit, ...config.rateLimit };
|
|
41
|
+
}
|
|
42
|
+
if (config.shutdown) {
|
|
43
|
+
this.config.shutdown = { ...this.config.shutdown, ...config.shutdown };
|
|
44
|
+
}
|
|
45
|
+
|
|
13
46
|
this.router = new Router();
|
|
14
47
|
this.connections = new Set();
|
|
15
|
-
this.
|
|
48
|
+
this.transport = null;
|
|
16
49
|
this._listening = false;
|
|
50
|
+
this._tlsEnabled = !!config.tls?.enabled;
|
|
51
|
+
this._shutdown = null;
|
|
52
|
+
this._eventListeners = new Map();
|
|
53
|
+
|
|
54
|
+
// Initialize graceful shutdown handler
|
|
55
|
+
this._shutdown = new GracefulShutdown(this, this.config.shutdown);
|
|
17
56
|
}
|
|
18
57
|
|
|
19
58
|
on(route, handler, schema = null) {
|
|
@@ -37,51 +76,147 @@ class Server {
|
|
|
37
76
|
return reject(new Error('Server is already listening'));
|
|
38
77
|
}
|
|
39
78
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
79
|
+
try {
|
|
80
|
+
this.transport = createServerTransport(this.config);
|
|
81
|
+
} catch (err) {
|
|
82
|
+
return reject(err);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Auto-add rate limiting middleware if enabled
|
|
86
|
+
if (this.config.rateLimit.enabled) {
|
|
87
|
+
const rateLimitMiddleware = createRateLimitMiddleware(this.config.rateLimit);
|
|
88
|
+
if (rateLimitMiddleware) {
|
|
89
|
+
this.router.addMiddleware(rateLimitMiddleware);
|
|
44
90
|
}
|
|
91
|
+
}
|
|
45
92
|
|
|
46
|
-
|
|
47
|
-
|
|
93
|
+
this.transport.on('secureConnection', (socket) => {
|
|
94
|
+
this._handleConnection(socket);
|
|
95
|
+
});
|
|
48
96
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
this.
|
|
53
|
-
socket.on('close', () => this.connections.delete(conn));
|
|
97
|
+
this.transport.on('connection', (socket) => {
|
|
98
|
+
// For TLS servers, skip the raw connection event (handled by secureConnection)
|
|
99
|
+
if (this._tlsEnabled) return;
|
|
100
|
+
this._handleConnection(socket);
|
|
54
101
|
});
|
|
55
102
|
|
|
56
|
-
this.
|
|
103
|
+
this.transport.on('error', (err) => {
|
|
57
104
|
if (err.code === 'EADDRINUSE') {
|
|
58
105
|
reject(new Error(`Port ${port} is already in use`));
|
|
106
|
+
} else if (err.code === 'ERR_TLS_CERT_ALTNAME_INVALID') {
|
|
107
|
+
const tlsErr = new Error('TLS certificate does not match hostname');
|
|
108
|
+
tlsErr.code = 'TLS_CERT_ERROR';
|
|
109
|
+
reject(tlsErr);
|
|
59
110
|
} else {
|
|
60
111
|
console.error('[AfterLink] Server error:', err.message);
|
|
61
112
|
}
|
|
62
113
|
});
|
|
63
114
|
|
|
64
|
-
this.
|
|
115
|
+
this.transport.listen(port, this.config.host, () => {
|
|
65
116
|
this._listening = true;
|
|
66
|
-
|
|
117
|
+
const proto = this._tlsEnabled ? 'TLS' : 'TCP';
|
|
118
|
+
console.log(`[AfterLink] ${proto} Server listening on ${this.config.host}:${port}`);
|
|
67
119
|
resolve(this);
|
|
68
120
|
});
|
|
69
121
|
});
|
|
70
122
|
}
|
|
71
123
|
|
|
72
|
-
|
|
124
|
+
_handleConnection(socket) {
|
|
125
|
+
if (this.connections.size >= this.config.maxConnections) {
|
|
126
|
+
socket.destroy();
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
socket.setKeepAlive(true, 60000);
|
|
131
|
+
socket.setNoDelay(true);
|
|
132
|
+
|
|
133
|
+
const conn = new Connection(socket, this.router, {
|
|
134
|
+
auth: this.config.auth,
|
|
135
|
+
compression: this.config.compression,
|
|
136
|
+
rateLimit: this.config.rateLimit,
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// Attach rate bucket if rate limiting is enabled
|
|
140
|
+
if (this.config.rateLimit.enabled) {
|
|
141
|
+
const { requestsPerSecond, burstSize } = this.config.rateLimit;
|
|
142
|
+
conn._rateBucket = new TokenBucket(burstSize, requestsPerSecond / 1000);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Attach TLS info if available
|
|
146
|
+
if (isTLSSocket(socket)) {
|
|
147
|
+
conn.tlsInfo = getTLSInfo(socket);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
this.connections.add(conn);
|
|
151
|
+
socket.on('close', () => this.connections.delete(conn));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
on(routeOrEvent, handlerOrListener, schema = null) {
|
|
155
|
+
// Check if this is route registration (handler is a function with route pattern)
|
|
156
|
+
if (typeof routeOrEvent === 'string' && typeof handlerOrListener === 'function' && !this._eventListeners.has(routeOrEvent)) {
|
|
157
|
+
// Route registration
|
|
158
|
+
this.router.register(routeOrEvent, handlerOrListener, schema);
|
|
159
|
+
return this;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Event listener
|
|
163
|
+
const event = routeOrEvent;
|
|
164
|
+
const listener = handlerOrListener;
|
|
165
|
+
if (!this._eventListeners.has(event)) {
|
|
166
|
+
this._eventListeners.set(event, new Set());
|
|
167
|
+
}
|
|
168
|
+
this._eventListeners.get(event).add(listener);
|
|
169
|
+
return this;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
off(event, listener) {
|
|
173
|
+
const listeners = this._eventListeners.get(event);
|
|
174
|
+
if (listeners) {
|
|
175
|
+
listeners.delete(listener);
|
|
176
|
+
}
|
|
177
|
+
return this;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
_emit(event, data) {
|
|
181
|
+
const listeners = this._eventListeners.get(event);
|
|
182
|
+
if (listeners) {
|
|
183
|
+
for (const listener of listeners) {
|
|
184
|
+
try {
|
|
185
|
+
listener(data);
|
|
186
|
+
} catch (err) {
|
|
187
|
+
console.error(`[AfterLink] Event listener error for '${event}':`, err.message);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async close(options = {}) {
|
|
73
194
|
return new Promise((resolve) => {
|
|
74
|
-
if (!this.
|
|
195
|
+
if (!this.transport || !this._listening) {
|
|
75
196
|
return resolve();
|
|
76
197
|
}
|
|
77
198
|
|
|
78
199
|
this._listening = false;
|
|
79
200
|
|
|
80
|
-
|
|
81
|
-
|
|
201
|
+
if (options.force) {
|
|
202
|
+
// Force close - skip drain
|
|
203
|
+
for (const conn of this.connections) {
|
|
204
|
+
conn.destroy();
|
|
205
|
+
}
|
|
206
|
+
this.transport.close(() => {
|
|
207
|
+
this._emit('closed');
|
|
208
|
+
resolve();
|
|
209
|
+
});
|
|
210
|
+
return;
|
|
82
211
|
}
|
|
83
212
|
|
|
84
|
-
|
|
213
|
+
// Graceful shutdown
|
|
214
|
+
this._shutdown.initiate().then(() => {
|
|
215
|
+
this.transport.close(() => {
|
|
216
|
+
this._emit('closed');
|
|
217
|
+
resolve();
|
|
218
|
+
});
|
|
219
|
+
});
|
|
85
220
|
});
|
|
86
221
|
}
|
|
87
222
|
|
|
@@ -96,6 +231,26 @@ class Server {
|
|
|
96
231
|
isListening() {
|
|
97
232
|
return this._listening;
|
|
98
233
|
}
|
|
234
|
+
|
|
235
|
+
isTLS() {
|
|
236
|
+
return this._tlsEnabled;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
handleProcessSignals() {
|
|
240
|
+
process.on('SIGTERM', async () => {
|
|
241
|
+
console.log('[AfterLink] SIGTERM received — shutting down gracefully');
|
|
242
|
+
await this.close();
|
|
243
|
+
process.exit(0);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
process.on('SIGINT', async () => {
|
|
247
|
+
console.log('[AfterLink] SIGINT received — shutting down gracefully');
|
|
248
|
+
await this.close();
|
|
249
|
+
process.exit(0);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
return this;
|
|
253
|
+
}
|
|
99
254
|
}
|
|
100
255
|
|
|
101
256
|
module.exports = Server;
|
package/src/index.js
CHANGED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
class TokenBucket {
|
|
2
|
+
constructor(capacity, refillRate) {
|
|
3
|
+
this.capacity = capacity;
|
|
4
|
+
this.refillRate = refillRate;
|
|
5
|
+
this.tokens = capacity;
|
|
6
|
+
this.lastRefill = Date.now();
|
|
7
|
+
this.violations = 0;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
consume() {
|
|
11
|
+
this._refill();
|
|
12
|
+
if (this.tokens < 1) {
|
|
13
|
+
this.violations++;
|
|
14
|
+
return {
|
|
15
|
+
allowed: false,
|
|
16
|
+
retryAfter: Math.ceil((1 - this.tokens) / this.refillRate),
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
this.tokens--;
|
|
20
|
+
return { allowed: true, retryAfter: 0 };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
_refill() {
|
|
24
|
+
const now = Date.now();
|
|
25
|
+
const elapsed = now - this.lastRefill;
|
|
26
|
+
this.tokens = Math.min(this.capacity, this.tokens + elapsed * this.refillRate);
|
|
27
|
+
this.lastRefill = now;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function createRateLimitMiddleware(options = {}) {
|
|
32
|
+
const {
|
|
33
|
+
enabled = false,
|
|
34
|
+
requestsPerSecond = 100,
|
|
35
|
+
burstSize = 200,
|
|
36
|
+
closeAfterViolations = null,
|
|
37
|
+
errorMessage = 'Rate limit exceeded. Please slow down.',
|
|
38
|
+
onLimited = null,
|
|
39
|
+
} = options;
|
|
40
|
+
|
|
41
|
+
if (!enabled) {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const refillRatePerMs = requestsPerSecond / 1000;
|
|
46
|
+
|
|
47
|
+
return function rateLimitMiddleware(req, next) {
|
|
48
|
+
const bucket = req.connection._rateBucket;
|
|
49
|
+
if (!bucket) {
|
|
50
|
+
return next();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const result = bucket.consume();
|
|
54
|
+
|
|
55
|
+
if (!result.allowed) {
|
|
56
|
+
const err = new Error(errorMessage);
|
|
57
|
+
err.code = 'RATE_LIMITED';
|
|
58
|
+
err.retryAfter = result.retryAfter;
|
|
59
|
+
err.limit = requestsPerSecond;
|
|
60
|
+
err.remaining = 0;
|
|
61
|
+
|
|
62
|
+
if (onLimited) {
|
|
63
|
+
try {
|
|
64
|
+
onLimited(req.connection);
|
|
65
|
+
} catch {
|
|
66
|
+
// Ignore callback errors
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (closeAfterViolations && bucket.violations >= closeAfterViolations) {
|
|
71
|
+
err.closeConnection = true;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
throw err;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return next();
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
module.exports = { TokenBucket, createRateLimitMiddleware };
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
const { Frame, Serializer } = require('@afterlink/core');
|
|
2
|
+
|
|
3
|
+
const SERVER_CLOSING = 0x11;
|
|
4
|
+
|
|
5
|
+
class GracefulShutdown {
|
|
6
|
+
constructor(server, config = {}) {
|
|
7
|
+
this.server = server;
|
|
8
|
+
this.drainTimeout = config.drainTimeout ?? 5000;
|
|
9
|
+
this.reason = config.reason ?? 'planned_restart';
|
|
10
|
+
this.notifyClients = config.notifyClients !== false;
|
|
11
|
+
this._closing = false;
|
|
12
|
+
this._closed = false;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async initiate() {
|
|
16
|
+
if (this._closing || this._closed) {
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
this._closing = true;
|
|
21
|
+
|
|
22
|
+
const activeConnections = this.server.connections;
|
|
23
|
+
const activeRequests = this._countActiveRequests(activeConnections);
|
|
24
|
+
|
|
25
|
+
this.server._emit('closing', {
|
|
26
|
+
activeConnections: activeConnections.size,
|
|
27
|
+
activeRequests,
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
if (this.notifyClients) {
|
|
31
|
+
this._broadcastClosing(activeConnections);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const drainStart = Date.now();
|
|
35
|
+
await this._waitForDrain(activeConnections);
|
|
36
|
+
|
|
37
|
+
const drainElapsed = Date.now() - drainStart;
|
|
38
|
+
if (drainElapsed >= this.drainTimeout) {
|
|
39
|
+
this.server._emit('drained', { timedOut: true });
|
|
40
|
+
} else {
|
|
41
|
+
this.server._emit('drained', { timedOut: false });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
this._forceCloseRemaining(activeConnections);
|
|
45
|
+
this._closed = true;
|
|
46
|
+
this._closing = false;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
_countActiveRequests(connections) {
|
|
50
|
+
let total = 0;
|
|
51
|
+
for (const conn of connections) {
|
|
52
|
+
total += conn._activeRequests?.size ?? 0;
|
|
53
|
+
}
|
|
54
|
+
return total;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
_broadcastClosing(connections) {
|
|
58
|
+
const payload = Serializer.encode({
|
|
59
|
+
drainTimeout: this.drainTimeout,
|
|
60
|
+
reason: this.reason,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
for (const conn of connections) {
|
|
64
|
+
try {
|
|
65
|
+
conn.send(SERVER_CLOSING, 0, 0, payload);
|
|
66
|
+
} catch {
|
|
67
|
+
// Ignore send errors during shutdown
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async _waitForDrain(connections) {
|
|
73
|
+
const deadline = Date.now() + this.drainTimeout;
|
|
74
|
+
|
|
75
|
+
while (Date.now() < deadline) {
|
|
76
|
+
const active = this._countActiveRequests(connections);
|
|
77
|
+
if (active === 0) {
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
await this._sleep(100);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
_forceCloseRemaining(connections) {
|
|
85
|
+
for (const conn of connections) {
|
|
86
|
+
conn.destroy();
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
_sleep(ms) {
|
|
91
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
module.exports = { GracefulShutdown, SERVER_CLOSING };
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
const selfsigned = require('selfsigned');
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Generates self-signed development certificates.
|
|
5
|
+
* Uses the `selfsigned` package for reliable X.509 generation.
|
|
6
|
+
* NEVER use these in production.
|
|
7
|
+
*
|
|
8
|
+
* @param {object} [options]
|
|
9
|
+
* @param {string} [options.commonName='afterlink-dev'] - Certificate common name
|
|
10
|
+
* @param {number} [options.days=365] - Certificate validity in days
|
|
11
|
+
* @returns {Promise<{key: Buffer, cert: Buffer}>}
|
|
12
|
+
*/
|
|
13
|
+
async function generateDevCerts(options = {}) {
|
|
14
|
+
const {
|
|
15
|
+
commonName = 'afterlink-dev',
|
|
16
|
+
days = 365,
|
|
17
|
+
} = options;
|
|
18
|
+
|
|
19
|
+
const attrs = [{ name: 'commonName', value: commonName }];
|
|
20
|
+
|
|
21
|
+
const pems = await selfsigned.generate(attrs, {
|
|
22
|
+
days,
|
|
23
|
+
algorithm: 'sha256',
|
|
24
|
+
keySize: 2048,
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
return {
|
|
28
|
+
key: Buffer.from(pems.private),
|
|
29
|
+
cert: Buffer.from(pems.cert),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
module.exports = { generateDevCerts };
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
const tls = require('tls');
|
|
2
|
+
const net = require('net');
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Creates a server transport (TCP or TLS) based on configuration.
|
|
6
|
+
* @param {object} options - Server configuration
|
|
7
|
+
* @param {object} [options.tls] - TLS configuration
|
|
8
|
+
* @param {boolean} [options.tls.enabled=false] - Enable TLS encryption
|
|
9
|
+
* @param {Buffer|string} [options.tls.key] - Private key PEM buffer or path
|
|
10
|
+
* @param {Buffer|string} [options.tls.cert] - Certificate PEM buffer or path
|
|
11
|
+
* @param {Buffer|string} [options.tls.ca] - CA certificate for mutual TLS
|
|
12
|
+
* @param {boolean} [options.tls.rejectUnauthorized=true] - Reject unauthorized clients
|
|
13
|
+
* @param {string} [options.tls.minVersion='TLSv1.2'] - Minimum TLS version
|
|
14
|
+
* @param {number} [options.tls.sessionTimeout=300] - TLS session cache timeout (seconds)
|
|
15
|
+
* @returns {tls.Server|net.Server}
|
|
16
|
+
*/
|
|
17
|
+
function createServerTransport(options) {
|
|
18
|
+
if (options.tls?.enabled) {
|
|
19
|
+
validateTlsConfig(options.tls);
|
|
20
|
+
|
|
21
|
+
return tls.createServer({
|
|
22
|
+
key: options.tls.key,
|
|
23
|
+
cert: options.tls.cert,
|
|
24
|
+
ca: options.tls.ca,
|
|
25
|
+
requestCert: !!options.tls.ca,
|
|
26
|
+
rejectUnauthorized: options.tls.rejectUnauthorized ?? true,
|
|
27
|
+
minVersion: options.tls.minVersion ?? 'TLSv1.2',
|
|
28
|
+
sessionTimeout: options.tls.sessionTimeout ?? 300,
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return net.createServer();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Validates TLS configuration and throws descriptive errors.
|
|
37
|
+
* @param {object} tlsConfig
|
|
38
|
+
* @throws {Error} TLS_CONFIG_ERROR if configuration is invalid
|
|
39
|
+
*/
|
|
40
|
+
function validateTlsConfig(tlsConfig) {
|
|
41
|
+
if (!tlsConfig.key) {
|
|
42
|
+
const err = new Error('TLS enabled but no private key provided');
|
|
43
|
+
err.code = 'TLS_CONFIG_ERROR';
|
|
44
|
+
throw err;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (!tlsConfig.cert) {
|
|
48
|
+
const err = new Error('TLS enabled but no certificate provided');
|
|
49
|
+
err.code = 'TLS_CONFIG_ERROR';
|
|
50
|
+
throw err;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Checks if a socket is a TLS socket.
|
|
56
|
+
* @param {net.Socket|tls.TLSSocket} socket
|
|
57
|
+
* @returns {boolean}
|
|
58
|
+
*/
|
|
59
|
+
function isTLSSocket(socket) {
|
|
60
|
+
return socket instanceof tls.TLSSocket;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Gets TLS connection info from a socket.
|
|
65
|
+
* @param {net.Socket|tls.TLSSocket} socket
|
|
66
|
+
* @returns {object|null} TLS info or null if not TLS
|
|
67
|
+
*/
|
|
68
|
+
function getTLSInfo(socket) {
|
|
69
|
+
if (!isTLSSocket(socket)) return null;
|
|
70
|
+
|
|
71
|
+
const tlsSocket = socket;
|
|
72
|
+
return {
|
|
73
|
+
encrypted: true,
|
|
74
|
+
protocol: tlsSocket.getProtocol(),
|
|
75
|
+
cipher: tlsSocket.getCipher(),
|
|
76
|
+
authorized: tlsSocket.authorized,
|
|
77
|
+
authorizationError: tlsSocket.authorizationError,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
module.exports = {
|
|
82
|
+
createServerTransport,
|
|
83
|
+
validateTlsConfig,
|
|
84
|
+
isTLSSocket,
|
|
85
|
+
getTLSInfo,
|
|
86
|
+
};
|