redweb 0.7.7 → 0.9.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 +11 -0
- package/README.md +458 -276
- package/client.d.ts +42 -0
- package/client.js +55 -0
- package/docs/MULTIPLAYER_OPERATIONS.md +50 -0
- package/docs/PRODUCTION_READINESS.md +68 -0
- package/docs/VERIFICATION_EVIDENCE.md +20 -0
- package/index.d.ts +308 -60
- package/index.js +24 -6
- package/package.json +25 -6
- package/src/htmx/HtmxRenderer.js +10 -2
- package/src/http/BaseHttpServer.js +84 -29
- package/src/http/HttpServer.js +18 -10
- package/src/http/HttpsServer.js +19 -11
- package/src/serverLifecycle.js +46 -0
- package/src/ws/AdmissionPolicy.js +145 -0
- package/src/ws/BaseHandler.js +40 -32
- package/src/ws/BaseSocketServer.js +182 -19
- package/src/ws/DefaultHandler.js +2 -3
- package/src/ws/DefaultRoute.js +4 -3
- package/src/ws/DistributionBridge.js +271 -0
- package/src/ws/FixedStepService.js +74 -0
- package/src/ws/HeartbeatMonitor.js +75 -0
- package/src/ws/Metrics.js +34 -0
- package/src/ws/ProtocolPolicy.js +130 -0
- package/src/ws/RoomRegistry.js +117 -0
- package/src/ws/RouteRuntime.js +146 -0
- package/src/ws/SecureSocketServer.js +9 -12
- package/src/ws/SessionRegistry.js +135 -0
- package/src/ws/SocketRoute.js +503 -116
- package/src/ws/SocketServer.js +8 -11
- package/src/ws/TaskQueue.js +64 -0
- package/src/ws/TokenBucket.js +31 -0
- package/src/ws/TransportPolicy.js +68 -0
- package/src/ws/index.js +7 -2
- package/src/ws/protocol-schema.json +13 -0
- package/src/ws/protocol-validation.js +21 -0
- package/src/ws/shutdown.js +33 -0
- package/src/ws/util.js +34 -5
package/src/ws/SocketServer.js
CHANGED
|
@@ -6,16 +6,13 @@ const { BaseSocketServer } = require('./BaseSocketServer');
|
|
|
6
6
|
* @param {SocketServerOptions} options - Configuration options for SocketServer.
|
|
7
7
|
* @return {Object} WebSocket server instance.
|
|
8
8
|
*/
|
|
9
|
-
class SocketServer extends BaseSocketServer {
|
|
10
|
-
constructor(options
|
|
11
|
-
const ownsServer = !options?.server;
|
|
12
|
-
const server = options?.server || http.createServer();
|
|
13
|
-
super(server, options);
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
return this;
|
|
18
|
-
}
|
|
19
|
-
}
|
|
9
|
+
class SocketServer extends BaseSocketServer {
|
|
10
|
+
constructor(options) {
|
|
11
|
+
const ownsServer = !options?.server;
|
|
12
|
+
const server = options?.server || http.createServer();
|
|
13
|
+
super(server, options, ownsServer, 'SocketServer');
|
|
14
|
+
return this;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
20
17
|
|
|
21
18
|
module.exports = SocketServer;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
class TaskQueue {
|
|
2
|
+
constructor(maxPending, onError = () => {}, errorContext) {
|
|
3
|
+
if (!Number.isInteger(maxPending) || maxPending < 1) {
|
|
4
|
+
throw new TypeError('`maxPending` must be a positive integer.');
|
|
5
|
+
}
|
|
6
|
+
if (typeof onError !== 'function') throw new TypeError('`onError` must be a function.');
|
|
7
|
+
this.maxPending = maxPending;
|
|
8
|
+
this.onError = onError;
|
|
9
|
+
this.errorContext = errorContext;
|
|
10
|
+
this.tasks = [];
|
|
11
|
+
this.running = false;
|
|
12
|
+
this.closed = false;
|
|
13
|
+
this.idleWaiters = [];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
enqueue(task) {
|
|
17
|
+
if (typeof task !== 'function') throw new TypeError('Queued tasks must be functions.');
|
|
18
|
+
if (this.closed || this.tasks.length >= this.maxPending) return false;
|
|
19
|
+
this.tasks.push(task);
|
|
20
|
+
if (!this.running) void this.drain();
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async drain() {
|
|
25
|
+
this.running = true;
|
|
26
|
+
while (!this.closed && this.tasks.length) {
|
|
27
|
+
const task = this.tasks.shift();
|
|
28
|
+
try {
|
|
29
|
+
await task();
|
|
30
|
+
} catch (error) {
|
|
31
|
+
try {
|
|
32
|
+
await this.onError.call(this.errorContext, error);
|
|
33
|
+
} catch {
|
|
34
|
+
// Error reporting must never interrupt queue cleanup.
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
this.running = false;
|
|
39
|
+
this.resolveIdle();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
close() {
|
|
43
|
+
if (this.closed) return;
|
|
44
|
+
this.closed = true;
|
|
45
|
+
this.tasks.length = 0;
|
|
46
|
+
if (!this.running) this.resolveIdle();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
whenIdle() {
|
|
50
|
+
if (!this.running && !this.tasks.length) return Promise.resolve();
|
|
51
|
+
return new Promise(resolve => this.idleWaiters.push(resolve));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
resolveIdle() {
|
|
55
|
+
const waiters = this.idleWaiters.splice(0);
|
|
56
|
+
waiters.forEach(resolve => resolve());
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
get pending() {
|
|
60
|
+
return this.tasks.length + (this.running ? 1 : 0);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
module.exports = TaskQueue;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
const { performance } = require('perf_hooks');
|
|
2
|
+
|
|
3
|
+
class TokenBucket {
|
|
4
|
+
constructor({ capacity, refillPerSecond }, clock = () => performance.now()) {
|
|
5
|
+
if (!Number.isFinite(capacity) || capacity <= 0) {
|
|
6
|
+
throw new TypeError('`capacity` must be a positive number.');
|
|
7
|
+
}
|
|
8
|
+
if (!Number.isFinite(refillPerSecond) || refillPerSecond < 0) {
|
|
9
|
+
throw new TypeError('`refillPerSecond` must be a non-negative number.');
|
|
10
|
+
}
|
|
11
|
+
if (typeof clock !== 'function') throw new TypeError('`clock` must be a function.');
|
|
12
|
+
this.capacity = capacity;
|
|
13
|
+
this.refillPerMs = refillPerSecond / 1000;
|
|
14
|
+
this.tokens = capacity;
|
|
15
|
+
this.clock = clock;
|
|
16
|
+
this.updatedAt = clock();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
consume(cost = 1) {
|
|
20
|
+
if (!Number.isFinite(cost) || cost <= 0) throw new TypeError('`cost` must be a positive number.');
|
|
21
|
+
const now = this.clock();
|
|
22
|
+
const elapsed = Math.max(0, now - this.updatedAt);
|
|
23
|
+
this.tokens = Math.min(this.capacity, this.tokens + elapsed * this.refillPerMs);
|
|
24
|
+
this.updatedAt = now;
|
|
25
|
+
if (this.tokens < cost) return false;
|
|
26
|
+
this.tokens -= cost;
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
module.exports = TokenBucket;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
const TokenBucket = require('./TokenBucket');
|
|
2
|
+
const TaskQueue = require('./TaskQueue');
|
|
3
|
+
|
|
4
|
+
const RATE_ACTIONS = new Set(['drop', 'disconnect']);
|
|
5
|
+
const SLOW_ACTIONS = new Set(['drop', 'disconnect']);
|
|
6
|
+
|
|
7
|
+
class TransportPolicy {
|
|
8
|
+
constructor(limits = {}, orderedMessages = false) {
|
|
9
|
+
if (!limits || typeof limits !== 'object' || Array.isArray(limits)) {
|
|
10
|
+
throw new TypeError('`limits` must be an object.');
|
|
11
|
+
}
|
|
12
|
+
const {
|
|
13
|
+
maxConnections = Infinity,
|
|
14
|
+
maxBufferedBytes = Infinity,
|
|
15
|
+
maxPendingMessages = 64,
|
|
16
|
+
messageRate = null,
|
|
17
|
+
slowConsumerAction = 'disconnect',
|
|
18
|
+
} = limits;
|
|
19
|
+
if (!(maxConnections === Infinity || Number.isInteger(maxConnections) && maxConnections > 0)) {
|
|
20
|
+
throw new TypeError('`limits.maxConnections` must be a positive integer.');
|
|
21
|
+
}
|
|
22
|
+
if (!(maxBufferedBytes === Infinity || Number.isInteger(maxBufferedBytes) && maxBufferedBytes >= 0)) {
|
|
23
|
+
throw new TypeError('`limits.maxBufferedBytes` must be a non-negative integer.');
|
|
24
|
+
}
|
|
25
|
+
if (!Number.isInteger(maxPendingMessages) || maxPendingMessages < 1) {
|
|
26
|
+
throw new TypeError('`limits.maxPendingMessages` must be a positive integer.');
|
|
27
|
+
}
|
|
28
|
+
if (!SLOW_ACTIONS.has(slowConsumerAction)) {
|
|
29
|
+
throw new TypeError('`limits.slowConsumerAction` must be "drop" or "disconnect".');
|
|
30
|
+
}
|
|
31
|
+
if (messageRate !== null) {
|
|
32
|
+
if (!messageRate || typeof messageRate !== 'object' || Array.isArray(messageRate)) {
|
|
33
|
+
throw new TypeError('`limits.messageRate` must be an object.');
|
|
34
|
+
}
|
|
35
|
+
if (!RATE_ACTIONS.has(messageRate.action ?? 'disconnect')) {
|
|
36
|
+
throw new TypeError('`limits.messageRate.action` must be "drop" or "disconnect".');
|
|
37
|
+
}
|
|
38
|
+
// Validate once without retaining the probe.
|
|
39
|
+
new TokenBucket(messageRate);
|
|
40
|
+
}
|
|
41
|
+
this.maxConnections = maxConnections;
|
|
42
|
+
this.maxBufferedBytes = maxBufferedBytes;
|
|
43
|
+
this.maxPendingMessages = maxPendingMessages;
|
|
44
|
+
this.messageRate = messageRate && { ...messageRate, action: messageRate.action ?? 'disconnect' };
|
|
45
|
+
this.slowConsumerAction = slowConsumerAction;
|
|
46
|
+
this.orderedMessages = Boolean(orderedMessages);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
createRuntime(onError, errorContext) {
|
|
50
|
+
return {
|
|
51
|
+
limiter: this.messageRate ? new TokenBucket(this.messageRate) : null,
|
|
52
|
+
queue: this.orderedMessages ? new TaskQueue(this.maxPendingMessages, onError, errorContext) : null,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
acceptsMessage(runtime) {
|
|
57
|
+
return runtime?.limiter ? runtime.limiter.consume() : true;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
acceptsSend(socket, payloadBytes) {
|
|
61
|
+
if (this.maxBufferedBytes === Infinity) return true;
|
|
62
|
+
if ((socket.bufferedAmount || 0) + payloadBytes <= this.maxBufferedBytes) return true;
|
|
63
|
+
if (this.slowConsumerAction === 'disconnect') socket.close?.(1013, 'Slow consumer');
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
module.exports = TransportPolicy;
|
package/src/ws/index.js
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
const { SOCKET_OPTIONS } = require('./BaseSocketServer');
|
|
2
|
+
const { ERROR_CODES } = require('./ProtocolPolicy');
|
|
2
3
|
module.exports = {
|
|
3
4
|
SecureSocketServer: require('./SecureSocketServer'),
|
|
4
5
|
SocketServer: require('./SocketServer'),
|
|
5
6
|
SocketRoute: require('./SocketRoute'),
|
|
6
7
|
SocketService: require('./SocketService'),
|
|
8
|
+
FixedStepService: require('./FixedStepService'),
|
|
7
9
|
SocketRegistry: require('./SocketRegistry'),
|
|
8
|
-
|
|
9
|
-
|
|
10
|
+
RoomRegistry: require('./RoomRegistry'),
|
|
11
|
+
SessionRegistry: require('./SessionRegistry'),
|
|
12
|
+
SOCKET_OPTIONS,
|
|
13
|
+
ERROR_CODES
|
|
14
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
function validString(value, maxLength) {
|
|
2
|
+
return typeof value === 'string' && value.length > 0 && value.length <= maxLength;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
function validateEnvelope(message, version) {
|
|
6
|
+
if (!message || typeof message !== 'object' || message.v !== version || !validString(message.type, 256)) return false;
|
|
7
|
+
if (message.requestId !== undefined && !validString(message.requestId, 256)) return false;
|
|
8
|
+
if (message.sequence !== undefined && (!Number.isSafeInteger(message.sequence) || message.sequence < 0)) return false;
|
|
9
|
+
if (message.type === 'error') {
|
|
10
|
+
return Boolean(
|
|
11
|
+
!Object.prototype.hasOwnProperty.call(message, 'payload') &&
|
|
12
|
+
message.error &&
|
|
13
|
+
typeof message.error === 'object' &&
|
|
14
|
+
validString(message.error.code, 256) &&
|
|
15
|
+
validString(message.error.message, 1024)
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
return Object.prototype.hasOwnProperty.call(message, 'payload') && message.error === undefined;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
module.exports = { validateEnvelope };
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
function closeWebSocketServer(server, clients, timeoutMs) {
|
|
2
|
+
return new Promise((resolve, reject) => {
|
|
3
|
+
let settled = false;
|
|
4
|
+
const finish = (error) => {
|
|
5
|
+
if (settled) return;
|
|
6
|
+
settled = true;
|
|
7
|
+
clearTimeout(timer);
|
|
8
|
+
if (error) reject(error);
|
|
9
|
+
else resolve();
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const timer = setTimeout(() => {
|
|
13
|
+
const errors = [];
|
|
14
|
+
clients.forEach(socket => {
|
|
15
|
+
try {
|
|
16
|
+
socket.terminate?.();
|
|
17
|
+
} catch (error) {
|
|
18
|
+
errors.push(error);
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
finish(errors[0]);
|
|
22
|
+
}, timeoutMs);
|
|
23
|
+
timer.unref();
|
|
24
|
+
|
|
25
|
+
try {
|
|
26
|
+
server.close(finish);
|
|
27
|
+
} catch (error) {
|
|
28
|
+
finish(error);
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
module.exports = { closeWebSocketServer };
|
package/src/ws/util.js
CHANGED
|
@@ -1,9 +1,38 @@
|
|
|
1
|
-
function
|
|
2
|
-
|
|
1
|
+
function canSend(socket) {
|
|
2
|
+
return Boolean(
|
|
3
|
+
socket &&
|
|
4
|
+
typeof socket.send === 'function' &&
|
|
5
|
+
(socket.readyState === undefined || socket.readyState === 1 || socket.readyState === socket.OPEN)
|
|
6
|
+
);
|
|
3
7
|
}
|
|
4
8
|
|
|
5
|
-
function
|
|
6
|
-
|
|
9
|
+
function sendPayload(socket, payload, policy) {
|
|
10
|
+
if (!canSend(socket)) return false;
|
|
11
|
+
if (policy && !policy.acceptsSend(socket, Buffer.byteLength(payload))) return false;
|
|
12
|
+
try {
|
|
13
|
+
socket.send(payload);
|
|
14
|
+
return true;
|
|
15
|
+
} catch {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
7
18
|
}
|
|
8
19
|
|
|
9
|
-
|
|
20
|
+
function sendJson(socket, data, policy) {
|
|
21
|
+
if (arguments.length < 3) {
|
|
22
|
+
if (!canSend(socket)) return false;
|
|
23
|
+
socket.send(JSON.stringify(data));
|
|
24
|
+
return true;
|
|
25
|
+
}
|
|
26
|
+
return sendPayload(socket, JSON.stringify(data), policy);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function broadcast(sockets, data, policy) {
|
|
30
|
+
const payload = JSON.stringify(data);
|
|
31
|
+
let sent = 0;
|
|
32
|
+
sockets.forEach((socket) => {
|
|
33
|
+
if (sendPayload(socket, payload, policy)) sent += 1;
|
|
34
|
+
});
|
|
35
|
+
return sent;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
module.exports = { sendJson, sendPayload, broadcast, canSend };
|