redweb 0.8.0 → 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 -307
- 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 +320 -114
- package/index.js +27 -12
- package/package.json +28 -15
- package/src/htmx/HtmxRenderer.js +13 -13
- package/src/http/BaseHttpServer.js +112 -112
- package/src/http/HttpServer.js +18 -18
- package/src/http/HttpsServer.js +20 -20
- package/src/serverLifecycle.js +46 -46
- package/src/ws/AdmissionPolicy.js +145 -0
- package/src/ws/BaseHandler.js +40 -40
- package/src/ws/BaseSocketServer.js +195 -100
- package/src/ws/DefaultHandler.js +5 -5
- package/src/ws/DefaultRoute.js +8 -8
- 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 -9
- package/src/ws/SessionRegistry.js +135 -0
- package/src/ws/SocketRoute.js +523 -254
- package/src/ws/SocketServer.js +8 -8
- 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 -33
- package/src/ws/util.js +38 -30
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
const { randomUUID } = require('crypto');
|
|
2
|
+
const { performance } = require('perf_hooks');
|
|
3
|
+
const { throwCleanupErrors } = require('../serverLifecycle');
|
|
4
|
+
|
|
5
|
+
class DistributionBridge {
|
|
6
|
+
constructor(options, onEvent, logger = console, clock = () => performance.now()) {
|
|
7
|
+
if (!options || typeof options !== 'object' || Array.isArray(options)) {
|
|
8
|
+
throw new TypeError('`distribution` must be an object.');
|
|
9
|
+
}
|
|
10
|
+
const {
|
|
11
|
+
adapter,
|
|
12
|
+
channel,
|
|
13
|
+
nodeId = randomUUID(),
|
|
14
|
+
maxEventBytes = 64 * 1024,
|
|
15
|
+
maxSeenEvents = 10_000,
|
|
16
|
+
seenTtlMs = 60_000,
|
|
17
|
+
lifecycleTimeoutMs = 5000,
|
|
18
|
+
publishTimeoutMs = lifecycleTimeoutMs,
|
|
19
|
+
maxConcurrentPublishes = 64,
|
|
20
|
+
maxConcurrentEvents = 64,
|
|
21
|
+
required = false,
|
|
22
|
+
} = options;
|
|
23
|
+
if (!adapter || typeof adapter !== 'object') throw new TypeError('`distribution.adapter` is required.');
|
|
24
|
+
['publish', 'subscribe'].forEach(method => {
|
|
25
|
+
if (typeof adapter[method] !== 'function') throw new TypeError(`\`distribution.adapter.${method}\` must be a function.`);
|
|
26
|
+
});
|
|
27
|
+
['start', 'unsubscribe', 'close'].forEach(method => {
|
|
28
|
+
if (adapter[method] !== undefined && typeof adapter[method] !== 'function') {
|
|
29
|
+
throw new TypeError(`\`distribution.adapter.${method}\` must be a function.`);
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
if (typeof channel !== 'string' || !channel) throw new TypeError('`distribution.channel` must be a non-empty string.');
|
|
33
|
+
if (typeof nodeId !== 'string' || !nodeId || nodeId.length > 256) {
|
|
34
|
+
throw new TypeError('`distribution.nodeId` must be a non-empty string of at most 256 characters.');
|
|
35
|
+
}
|
|
36
|
+
const integers = {
|
|
37
|
+
maxEventBytes,
|
|
38
|
+
maxSeenEvents,
|
|
39
|
+
seenTtlMs,
|
|
40
|
+
lifecycleTimeoutMs,
|
|
41
|
+
publishTimeoutMs,
|
|
42
|
+
maxConcurrentPublishes,
|
|
43
|
+
maxConcurrentEvents,
|
|
44
|
+
};
|
|
45
|
+
for (const [name, value] of Object.entries(integers)) {
|
|
46
|
+
if (!Number.isInteger(value) || value < 1) throw new TypeError(`\`distribution.${name}\` must be a positive integer.`);
|
|
47
|
+
}
|
|
48
|
+
if (typeof required !== 'boolean') throw new TypeError('`distribution.required` must be a boolean.');
|
|
49
|
+
if (typeof onEvent !== 'function') throw new TypeError('`distribution.onEvent` must be a function.');
|
|
50
|
+
if (typeof clock !== 'function') throw new TypeError('`clock` must be a function.');
|
|
51
|
+
Object.assign(this, {
|
|
52
|
+
adapter,
|
|
53
|
+
channel,
|
|
54
|
+
nodeId,
|
|
55
|
+
maxEventBytes,
|
|
56
|
+
maxSeenEvents,
|
|
57
|
+
seenTtlMs,
|
|
58
|
+
lifecycleTimeoutMs,
|
|
59
|
+
publishTimeoutMs,
|
|
60
|
+
maxConcurrentPublishes,
|
|
61
|
+
maxConcurrentEvents,
|
|
62
|
+
required,
|
|
63
|
+
onEvent,
|
|
64
|
+
logger,
|
|
65
|
+
clock,
|
|
66
|
+
});
|
|
67
|
+
this.seen = new Map();
|
|
68
|
+
this.publishes = new Set();
|
|
69
|
+
this.events = new Set();
|
|
70
|
+
this.closed = false;
|
|
71
|
+
this.healthy = false;
|
|
72
|
+
this.subscribed = false;
|
|
73
|
+
this.ready = this.start();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async start() {
|
|
77
|
+
try {
|
|
78
|
+
await this.withTimeout(
|
|
79
|
+
signal => this.adapter.start?.(signal),
|
|
80
|
+
this.lifecycleTimeoutMs,
|
|
81
|
+
'start',
|
|
82
|
+
() => this.adapter.close?.()
|
|
83
|
+
);
|
|
84
|
+
if (this.closed) return false;
|
|
85
|
+
const unsubscribe = await this.withTimeout(
|
|
86
|
+
signal => this.adapter.subscribe(this.channel, event => this.receive(event), signal),
|
|
87
|
+
this.lifecycleTimeoutMs,
|
|
88
|
+
'subscribe',
|
|
89
|
+
lateUnsubscribe => typeof lateUnsubscribe === 'function'
|
|
90
|
+
? lateUnsubscribe()
|
|
91
|
+
: this.adapter.unsubscribe?.(this.channel)
|
|
92
|
+
);
|
|
93
|
+
if (typeof unsubscribe === 'function') this.unsubscribe = unsubscribe;
|
|
94
|
+
this.subscribed = true;
|
|
95
|
+
this.healthy = true;
|
|
96
|
+
return true;
|
|
97
|
+
} catch (error) {
|
|
98
|
+
this.logger?.error?.('Distribution adapter failed to start:', error);
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
withTimeout(operation, timeoutMs, name, compensateLate) {
|
|
104
|
+
let timer;
|
|
105
|
+
let timedOut = false;
|
|
106
|
+
const controller = new AbortController();
|
|
107
|
+
const task = Promise.resolve().then(() => operation(controller.signal));
|
|
108
|
+
void task.then(value => {
|
|
109
|
+
if (!timedOut || !compensateLate) return;
|
|
110
|
+
Promise.resolve()
|
|
111
|
+
.then(() => compensateLate(value))
|
|
112
|
+
.catch(error => this.logger?.error?.(`Late distribution ${name} cleanup failed:`, error));
|
|
113
|
+
}, error => {
|
|
114
|
+
if (timedOut) this.logger?.error?.(`Distribution adapter ${name} failed after timeout:`, error);
|
|
115
|
+
});
|
|
116
|
+
const timeout = new Promise((_, reject) => {
|
|
117
|
+
timer = setTimeout(() => {
|
|
118
|
+
timedOut = true;
|
|
119
|
+
controller.abort();
|
|
120
|
+
reject(new Error(`Distribution adapter ${name} timed out.`));
|
|
121
|
+
}, timeoutMs);
|
|
122
|
+
timer.unref();
|
|
123
|
+
});
|
|
124
|
+
return Promise.race([task, timeout]).finally(() => clearTimeout(timer));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
isReady() {
|
|
128
|
+
return this.healthy && !this.closed;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
publish(type, payload) {
|
|
132
|
+
if (this.closed || typeof type !== 'string' || !type || this.publishes.size >= this.maxConcurrentPublishes) {
|
|
133
|
+
return Promise.resolve(false);
|
|
134
|
+
}
|
|
135
|
+
const event = { id: randomUUID(), source: this.nodeId, type, payload };
|
|
136
|
+
const serialized = this.serialize(event);
|
|
137
|
+
if (!serialized) return Promise.resolve(false);
|
|
138
|
+
const task = this.performPublish(serialized);
|
|
139
|
+
this.track(this.publishes, task);
|
|
140
|
+
return task;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async performPublish(serialized) {
|
|
144
|
+
if (!await this.ready || this.closed) return false;
|
|
145
|
+
try {
|
|
146
|
+
await this.withTimeout(
|
|
147
|
+
signal => this.adapter.publish(this.channel, serialized, signal),
|
|
148
|
+
this.publishTimeoutMs,
|
|
149
|
+
'publish'
|
|
150
|
+
);
|
|
151
|
+
this.healthy = true;
|
|
152
|
+
return true;
|
|
153
|
+
} catch (error) {
|
|
154
|
+
this.healthy = false;
|
|
155
|
+
this.logger?.error?.('Distribution publish failed:', error);
|
|
156
|
+
return false;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
track(collection, task) {
|
|
161
|
+
collection.add(task);
|
|
162
|
+
const cleanup = () => collection.delete(task);
|
|
163
|
+
void task.then(cleanup, cleanup);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
receive(input) {
|
|
167
|
+
if (this.closed || this.events.size >= this.maxConcurrentEvents) return false;
|
|
168
|
+
let event;
|
|
169
|
+
try {
|
|
170
|
+
const serialized = typeof input === 'string' ? input : JSON.stringify(input);
|
|
171
|
+
if (Buffer.byteLength(serialized) > this.maxEventBytes) return false;
|
|
172
|
+
event = typeof input === 'string' ? JSON.parse(input) : input;
|
|
173
|
+
} catch {
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
if (!this.isValid(event) || event.source === this.nodeId || this.hasSeen(event.id)) return false;
|
|
177
|
+
this.remember(event.id);
|
|
178
|
+
const task = Promise.resolve()
|
|
179
|
+
.then(() => this.onEvent(event))
|
|
180
|
+
.catch(error => this.logger?.error?.('Distribution event handler failed:', error));
|
|
181
|
+
this.track(this.events, task);
|
|
182
|
+
return true;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
isValid(event) {
|
|
186
|
+
return Boolean(
|
|
187
|
+
event &&
|
|
188
|
+
typeof event === 'object' &&
|
|
189
|
+
typeof event.id === 'string' && event.id && event.id.length <= 256 &&
|
|
190
|
+
typeof event.source === 'string' && event.source && event.source.length <= 256 &&
|
|
191
|
+
typeof event.type === 'string' && event.type && event.type.length <= 256
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
serialize(event) {
|
|
196
|
+
try {
|
|
197
|
+
const serialized = JSON.stringify(event);
|
|
198
|
+
return Buffer.byteLength(serialized) <= this.maxEventBytes ? serialized : null;
|
|
199
|
+
} catch {
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
hasSeen(id) {
|
|
205
|
+
const expiry = this.seen.get(id);
|
|
206
|
+
if (expiry === undefined) return false;
|
|
207
|
+
if (expiry <= this.clock()) {
|
|
208
|
+
this.seen.delete(id);
|
|
209
|
+
return false;
|
|
210
|
+
}
|
|
211
|
+
return true;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
evictExpired(now) {
|
|
215
|
+
while (this.seen.size) {
|
|
216
|
+
const [eventId, expiry] = this.seen.entries().next().value;
|
|
217
|
+
if (expiry > now) return;
|
|
218
|
+
this.seen.delete(eventId);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
remember(id) {
|
|
223
|
+
const now = this.clock();
|
|
224
|
+
this.evictExpired(now);
|
|
225
|
+
while (this.seen.size >= this.maxSeenEvents) this.seen.delete(this.seen.keys().next().value);
|
|
226
|
+
this.seen.set(id, now + this.seenTtlMs);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async stopSubscription() {
|
|
230
|
+
if (!this.subscribed) return;
|
|
231
|
+
this.subscribed = false;
|
|
232
|
+
await this.withTimeout(
|
|
233
|
+
signal => this.unsubscribe ? this.unsubscribe() : this.adapter.unsubscribe?.(this.channel, signal),
|
|
234
|
+
this.lifecycleTimeoutMs,
|
|
235
|
+
'unsubscribe'
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
async drainActivity() {
|
|
240
|
+
await this.withTimeout(
|
|
241
|
+
() => Promise.allSettled([...this.publishes, ...this.events]),
|
|
242
|
+
this.lifecycleTimeoutMs,
|
|
243
|
+
'drain'
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async close() {
|
|
248
|
+
if (this.closed) return;
|
|
249
|
+
this.closed = true;
|
|
250
|
+
this.healthy = false;
|
|
251
|
+
await this.ready;
|
|
252
|
+
const errors = [];
|
|
253
|
+
for (const operation of [
|
|
254
|
+
() => this.stopSubscription(),
|
|
255
|
+
() => this.drainActivity(),
|
|
256
|
+
() => this.withTimeout(signal => this.adapter.close?.(signal), this.lifecycleTimeoutMs, 'close'),
|
|
257
|
+
]) {
|
|
258
|
+
try {
|
|
259
|
+
await operation();
|
|
260
|
+
} catch (error) {
|
|
261
|
+
errors.push(error);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
this.seen.clear();
|
|
265
|
+
this.publishes.clear();
|
|
266
|
+
this.events.clear();
|
|
267
|
+
throwCleanupErrors(errors, 'One or more distribution adapter cleanup operations failed.');
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
module.exports = DistributionBridge;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
const { performance } = require('perf_hooks');
|
|
2
|
+
const SocketService = require('./SocketService');
|
|
3
|
+
|
|
4
|
+
class FixedStepService extends SocketService {
|
|
5
|
+
constructor(name, tickRateMs, maxCatchUpTicks = 5, maxRetainedLagMs = tickRateMs * maxCatchUpTicks) {
|
|
6
|
+
super(name, tickRateMs);
|
|
7
|
+
if (!Number.isInteger(tickRateMs) || tickRateMs < 1) {
|
|
8
|
+
throw new TypeError('`tickRateMs` must be a positive integer.');
|
|
9
|
+
}
|
|
10
|
+
if (!Number.isInteger(maxCatchUpTicks) || maxCatchUpTicks < 1) {
|
|
11
|
+
throw new TypeError('`maxCatchUpTicks` must be a positive integer.');
|
|
12
|
+
}
|
|
13
|
+
if (!Number.isInteger(maxRetainedLagMs) || maxRetainedLagMs < tickRateMs) {
|
|
14
|
+
throw new TypeError('`maxRetainedLagMs` must be an integer greater than or equal to `tickRateMs`.');
|
|
15
|
+
}
|
|
16
|
+
this.maxCatchUpTicks = maxCatchUpTicks;
|
|
17
|
+
this.maxRetainedLagMs = maxRetainedLagMs;
|
|
18
|
+
this.tick = 0;
|
|
19
|
+
this.accumulatorMs = 0;
|
|
20
|
+
this._runningPromise = null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
now() {
|
|
24
|
+
return performance.now();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
onInit(route) {
|
|
28
|
+
this.route = route;
|
|
29
|
+
this.lastTime = this.now();
|
|
30
|
+
this._tickHandle = setInterval(() => this.pulse(), this.tickRateMs);
|
|
31
|
+
this._tickHandle.unref?.();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
pulse() {
|
|
35
|
+
if (this._runningPromise) return this._runningPromise;
|
|
36
|
+
const now = this.now();
|
|
37
|
+
const accumulated = this.accumulatorMs + Math.max(0, now - this.lastTime);
|
|
38
|
+
const droppedLagMs = Math.max(0, accumulated - this.maxRetainedLagMs);
|
|
39
|
+
this.accumulatorMs = Math.min(accumulated, this.maxRetainedLagMs);
|
|
40
|
+
this.lastTime = now;
|
|
41
|
+
if (droppedLagMs) {
|
|
42
|
+
this.route?.metrics?.observe('redweb.fixed_step.lag_dropped', droppedLagMs);
|
|
43
|
+
try {
|
|
44
|
+
this.onLagDropped?.(droppedLagMs);
|
|
45
|
+
} catch (error) {
|
|
46
|
+
this.route?.logger?.error?.('Fixed-step lag hook failed:', error);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
const due = Math.min(this.maxCatchUpTicks, Math.floor(this.accumulatorMs / this.tickRateMs));
|
|
50
|
+
if (!due) return Promise.resolve();
|
|
51
|
+
this.accumulatorMs -= due * this.tickRateMs;
|
|
52
|
+
this._runningPromise = this.runTicks(due).finally(() => { this._runningPromise = null; });
|
|
53
|
+
return this._runningPromise;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async runTicks(count) {
|
|
57
|
+
for (let index = 0; index < count; index += 1) {
|
|
58
|
+
this.tick += 1;
|
|
59
|
+
try {
|
|
60
|
+
await this.onTick?.(this.tickRateMs, this.tick);
|
|
61
|
+
} catch (error) {
|
|
62
|
+
this.route?.logger?.error?.('Fixed-step service tick failed:', error);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async onShutdown() {
|
|
68
|
+
if (this._tickHandle) clearInterval(this._tickHandle);
|
|
69
|
+
this._tickHandle = null;
|
|
70
|
+
await this._runningPromise;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
module.exports = FixedStepService;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
const { performance } = require('perf_hooks');
|
|
2
|
+
|
|
3
|
+
function acknowledgePong() {
|
|
4
|
+
const state = this.__redwebHeartbeatState;
|
|
5
|
+
if (state) state.awaitingPong = false;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
class HeartbeatMonitor {
|
|
9
|
+
constructor({ intervalMs, timeoutMs }, logger = console, clock = () => performance.now()) {
|
|
10
|
+
if (!Number.isInteger(intervalMs) || intervalMs < 1) {
|
|
11
|
+
throw new TypeError('`heartbeat.intervalMs` must be a positive integer.');
|
|
12
|
+
}
|
|
13
|
+
if (!Number.isInteger(timeoutMs) || timeoutMs < 1) {
|
|
14
|
+
throw new TypeError('`heartbeat.timeoutMs` must be a positive integer.');
|
|
15
|
+
}
|
|
16
|
+
if (typeof clock !== 'function') throw new TypeError('`clock` must be a function.');
|
|
17
|
+
this.intervalMs = intervalMs;
|
|
18
|
+
this.timeoutMs = timeoutMs;
|
|
19
|
+
this.logger = logger;
|
|
20
|
+
this.clock = clock;
|
|
21
|
+
this.sockets = new Map();
|
|
22
|
+
this.timer = setInterval(() => this.tick(), Math.min(intervalMs, timeoutMs));
|
|
23
|
+
this.timer.unref();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
attach(socket) {
|
|
27
|
+
this.detach(socket);
|
|
28
|
+
const state = { awaitingPong: false, lastPing: null };
|
|
29
|
+
socket.__redwebHeartbeatState = state;
|
|
30
|
+
this.sockets.set(socket, state);
|
|
31
|
+
socket.on('pong', acknowledgePong);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
detach(socket) {
|
|
35
|
+
const state = this.sockets.get(socket);
|
|
36
|
+
if (!state) return false;
|
|
37
|
+
socket.off?.('pong', acknowledgePong);
|
|
38
|
+
delete socket.__redwebHeartbeatState;
|
|
39
|
+
this.sockets.delete(socket);
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
tick() {
|
|
44
|
+
const now = this.clock();
|
|
45
|
+
this.sockets.forEach((state, socket) => {
|
|
46
|
+
if (state.awaitingPong && now - state.lastPing >= this.timeoutMs) {
|
|
47
|
+
this.detach(socket);
|
|
48
|
+
try {
|
|
49
|
+
socket.terminate?.();
|
|
50
|
+
} catch (error) {
|
|
51
|
+
this.logger?.error?.('Error terminating unresponsive socket:', error);
|
|
52
|
+
}
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
if (!state.awaitingPong && (state.lastPing === null || now - state.lastPing >= this.intervalMs)) {
|
|
56
|
+
state.lastPing = now;
|
|
57
|
+
state.awaitingPong = true;
|
|
58
|
+
try {
|
|
59
|
+
socket.ping?.();
|
|
60
|
+
} catch (error) {
|
|
61
|
+
this.logger?.error?.('Error pinging socket:', error);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
stop() {
|
|
68
|
+
if (!this.timer) return;
|
|
69
|
+
clearInterval(this.timer);
|
|
70
|
+
this.timer = null;
|
|
71
|
+
[...this.sockets.keys()].forEach(socket => this.detach(socket));
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
module.exports = HeartbeatMonitor;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
class Metrics {
|
|
2
|
+
constructor(sink, routePath, logger = console) {
|
|
3
|
+
if (!sink || typeof sink !== 'object' || Array.isArray(sink)) {
|
|
4
|
+
throw new TypeError('`metrics` must be an object.');
|
|
5
|
+
}
|
|
6
|
+
['increment', 'gauge', 'observe'].forEach(method => {
|
|
7
|
+
if (sink[method] !== undefined && typeof sink[method] !== 'function') {
|
|
8
|
+
throw new TypeError(`\`metrics.${method}\` must be a function.`);
|
|
9
|
+
}
|
|
10
|
+
});
|
|
11
|
+
if (!['increment', 'gauge', 'observe'].some(method => typeof sink[method] === 'function')) {
|
|
12
|
+
throw new TypeError('`metrics` requires at least one metric method.');
|
|
13
|
+
}
|
|
14
|
+
this.sink = sink;
|
|
15
|
+
this.attributes = Object.freeze({ route: routePath });
|
|
16
|
+
this.logger = logger;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
emit(method, name, value = 1) {
|
|
20
|
+
if (typeof this.sink[method] !== 'function') return;
|
|
21
|
+
try {
|
|
22
|
+
Promise.resolve(this.sink[method](name, value, this.attributes))
|
|
23
|
+
.catch(error => this.logger?.error?.('Metrics sink failed:', error));
|
|
24
|
+
} catch (error) {
|
|
25
|
+
this.logger?.error?.('Metrics sink failed:', error);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
increment(name, value) { this.emit('increment', name, value); }
|
|
30
|
+
gauge(name, value) { this.emit('gauge', name, value); }
|
|
31
|
+
observe(name, value) { this.emit('observe', name, value); }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
module.exports = Metrics;
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
const { Buffer } = require('buffer');
|
|
2
|
+
const schema = require('./protocol-schema.json');
|
|
3
|
+
const { validateEnvelope } = require('./protocol-validation');
|
|
4
|
+
|
|
5
|
+
const PROTOCOL_CONTEXT = Symbol('redweb.protocolContext');
|
|
6
|
+
const PROTOCOL_REJECTION = Symbol('redweb.protocolRejection');
|
|
7
|
+
|
|
8
|
+
const ERROR_CODES = Object.freeze(Object.fromEntries(schema.errorCodes.map(code => [code, code])));
|
|
9
|
+
|
|
10
|
+
function nonEmptyBoundedString(value, name, maxLength = 64) {
|
|
11
|
+
if (typeof value !== 'string' || !value || value.length > maxLength) {
|
|
12
|
+
throw new TypeError(`\`${name}\` must be a non-empty string of at most ${maxLength} characters.`);
|
|
13
|
+
}
|
|
14
|
+
return value;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
class ProtocolPolicy {
|
|
18
|
+
constructor(options) {
|
|
19
|
+
if (!options || typeof options !== 'object' || Array.isArray(options)) {
|
|
20
|
+
throw new TypeError('`protocol` must be an object.');
|
|
21
|
+
}
|
|
22
|
+
const {
|
|
23
|
+
versions,
|
|
24
|
+
required = true,
|
|
25
|
+
queryParameter = 'redwebVersion',
|
|
26
|
+
header = 'x-redweb-version',
|
|
27
|
+
binary,
|
|
28
|
+
} = options;
|
|
29
|
+
if (!Array.isArray(versions) || versions.length === 0 || versions.length > 16) {
|
|
30
|
+
throw new TypeError('`protocol.versions` must contain between 1 and 16 versions.');
|
|
31
|
+
}
|
|
32
|
+
this.versions = versions.map(version => nonEmptyBoundedString(version, 'protocol version'));
|
|
33
|
+
if (new Set(this.versions).size !== this.versions.length) {
|
|
34
|
+
throw new TypeError('`protocol.versions` entries must be unique.');
|
|
35
|
+
}
|
|
36
|
+
if (typeof required !== 'boolean') throw new TypeError('`protocol.required` must be a boolean.');
|
|
37
|
+
this.queryParameter = nonEmptyBoundedString(queryParameter, 'protocol.queryParameter');
|
|
38
|
+
this.header = nonEmptyBoundedString(header, 'protocol.header').toLowerCase();
|
|
39
|
+
this.required = required;
|
|
40
|
+
this.binary = this.validateBinary(binary);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
validateBinary(binary) {
|
|
44
|
+
if (binary === undefined || binary === false) return null;
|
|
45
|
+
if (!binary || typeof binary !== 'object' || Array.isArray(binary)) {
|
|
46
|
+
throw new TypeError('`protocol.binary` must be an object.');
|
|
47
|
+
}
|
|
48
|
+
if (typeof binary.encode !== 'function' || typeof binary.decode !== 'function') {
|
|
49
|
+
throw new TypeError('`protocol.binary` requires `encode` and `decode` functions.');
|
|
50
|
+
}
|
|
51
|
+
const maxBytes = binary.maxBytes ?? 64 * 1024;
|
|
52
|
+
if (!Number.isInteger(maxBytes) || maxBytes < 1) {
|
|
53
|
+
throw new TypeError('`protocol.binary.maxBytes` must be a positive integer.');
|
|
54
|
+
}
|
|
55
|
+
return { encode: binary.encode, decode: binary.decode, maxBytes };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
negotiate(request) {
|
|
59
|
+
let requested;
|
|
60
|
+
try {
|
|
61
|
+
const host = request.headers?.host || 'localhost';
|
|
62
|
+
requested = new URL(request.url || '/', `http://${host}`).searchParams.get(this.queryParameter)
|
|
63
|
+
|| request.headers?.[this.header];
|
|
64
|
+
} catch {
|
|
65
|
+
return this.reject(request, 'Malformed protocol negotiation request.');
|
|
66
|
+
}
|
|
67
|
+
if (Array.isArray(requested)) requested = requested[0];
|
|
68
|
+
const version = requested || (this.required ? null : this.versions[0]);
|
|
69
|
+
if (!version || !this.versions.includes(version)) {
|
|
70
|
+
return this.reject(request, 'A supported protocol version is required.');
|
|
71
|
+
}
|
|
72
|
+
request[PROTOCOL_CONTEXT] = Object.freeze({ version });
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
reject(request, message) {
|
|
77
|
+
request[PROTOCOL_REJECTION] = {
|
|
78
|
+
statusCode: 426,
|
|
79
|
+
statusText: 'Upgrade Required',
|
|
80
|
+
headers: { 'Redweb-Versions': this.versions.join(', ') },
|
|
81
|
+
message,
|
|
82
|
+
};
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
envelope(version, type, payload, metadata = {}) {
|
|
87
|
+
nonEmptyBoundedString(type, 'protocol event type', 256);
|
|
88
|
+
const envelope = { v: version, type, payload };
|
|
89
|
+
if (metadata.requestId !== undefined) {
|
|
90
|
+
envelope.requestId = nonEmptyBoundedString(metadata.requestId, 'protocol requestId', 256);
|
|
91
|
+
}
|
|
92
|
+
if (metadata.sequence !== undefined) {
|
|
93
|
+
if (!Number.isSafeInteger(metadata.sequence) || metadata.sequence < 0) {
|
|
94
|
+
throw new TypeError('`protocol sequence` must be a non-negative safe integer.');
|
|
95
|
+
}
|
|
96
|
+
envelope.sequence = metadata.sequence;
|
|
97
|
+
}
|
|
98
|
+
return envelope;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
error(version, code, message, metadata = {}) {
|
|
102
|
+
nonEmptyBoundedString(code, 'protocol error code', 256);
|
|
103
|
+
nonEmptyBoundedString(message, 'protocol error message', 1024);
|
|
104
|
+
const envelope = this.envelope(version, 'error', undefined, metadata);
|
|
105
|
+
delete envelope.payload;
|
|
106
|
+
envelope.error = { code, message };
|
|
107
|
+
return envelope;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
validateEnvelope(message, version) {
|
|
111
|
+
return validateEnvelope(message, version);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async decodeBinary(buffer, context) {
|
|
115
|
+
if (!this.binary || buffer.length > this.binary.maxBytes) return null;
|
|
116
|
+
return this.binary.decode(buffer, context);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async encodeBinary(value, context) {
|
|
120
|
+
if (!this.binary) return null;
|
|
121
|
+
const encoded = await this.binary.encode(value, context);
|
|
122
|
+
if (!(Buffer.isBuffer(encoded) || encoded instanceof Uint8Array || encoded instanceof ArrayBuffer)) {
|
|
123
|
+
throw new TypeError('`protocol.binary.encode` must return Buffer, Uint8Array, or ArrayBuffer.');
|
|
124
|
+
}
|
|
125
|
+
const buffer = Buffer.from(encoded);
|
|
126
|
+
return buffer.length <= this.binary.maxBytes ? buffer : null;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
module.exports = { ProtocolPolicy, PROTOCOL_CONTEXT, PROTOCOL_REJECTION, ERROR_CODES };
|