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.
@@ -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 };
@@ -0,0 +1,117 @@
1
+ const { broadcast } = require('./util');
2
+
3
+ class RoomRegistry {
4
+ constructor(options = {}, { hasConnection, policy, onChange } = {}) {
5
+ if (!options || typeof options !== 'object' || Array.isArray(options)) {
6
+ throw new TypeError('`rooms` must be an object or true.');
7
+ }
8
+ const {
9
+ maxRooms = 1000,
10
+ maxMembersPerRoom = 1000,
11
+ maxRoomsPerConnection = 32,
12
+ maxRoomIdLength = 128,
13
+ } = options;
14
+ for (const [name, value] of Object.entries({ maxRooms, maxMembersPerRoom, maxRoomsPerConnection, maxRoomIdLength })) {
15
+ if (!Number.isInteger(value) || value < 1) throw new TypeError(`\`rooms.${name}\` must be a positive integer.`);
16
+ }
17
+ if (hasConnection !== undefined && typeof hasConnection !== 'function') {
18
+ throw new TypeError('`hasConnection` must be a function.');
19
+ }
20
+ if (onChange !== undefined && typeof onChange !== 'function') {
21
+ throw new TypeError('`onChange` must be a function.');
22
+ }
23
+ this.options = { maxRooms, maxMembersPerRoom, maxRoomsPerConnection, maxRoomIdLength };
24
+ this.hasConnection = hasConnection || (() => true);
25
+ this.policy = policy;
26
+ this.onChange = onChange || (() => {});
27
+ this.rooms = new Map();
28
+ this.memberships = new WeakMap();
29
+ this.closed = false;
30
+ }
31
+
32
+ validateRoomId(roomId) {
33
+ if (typeof roomId !== 'string' || !roomId || roomId.length > this.options.maxRoomIdLength) {
34
+ throw new TypeError(`Room IDs must be non-empty strings of at most ${this.options.maxRoomIdLength} characters.`);
35
+ }
36
+ }
37
+
38
+ join(roomId, socket) {
39
+ this.validateRoomId(roomId);
40
+ if (this.closed) return false;
41
+ if (!socket || !this.hasConnection(socket)) return false;
42
+ let members = this.rooms.get(roomId);
43
+ if (members?.has(socket)) return true;
44
+ const memberships = this.memberships.get(socket) || new Set();
45
+ if (!members && this.rooms.size >= this.options.maxRooms) return false;
46
+ if (members && members.size >= this.options.maxMembersPerRoom) return false;
47
+ if (memberships.size >= this.options.maxRoomsPerConnection) return false;
48
+ if (!members) {
49
+ members = new Set();
50
+ this.rooms.set(roomId, members);
51
+ }
52
+ members.add(socket);
53
+ memberships.add(roomId);
54
+ this.memberships.set(socket, memberships);
55
+ this.onChange('join', roomId, socket);
56
+ return true;
57
+ }
58
+
59
+ leave(roomId, socket) {
60
+ this.validateRoomId(roomId);
61
+ const members = this.rooms.get(roomId);
62
+ if (!members?.delete(socket)) return false;
63
+ const memberships = this.memberships.get(socket);
64
+ memberships?.delete(roomId);
65
+ if (!memberships?.size) this.memberships.delete(socket);
66
+ if (!members.size) this.rooms.delete(roomId);
67
+ this.onChange('leave', roomId, socket);
68
+ return true;
69
+ }
70
+
71
+ leaveAll(socket) {
72
+ const memberships = this.memberships.get(socket);
73
+ if (!memberships) return 0;
74
+ const roomIds = [...memberships];
75
+ roomIds.forEach(roomId => this.leave(roomId, socket));
76
+ return roomIds.length;
77
+ }
78
+
79
+ members(roomId) {
80
+ this.validateRoomId(roomId);
81
+ return [...(this.rooms.get(roomId) || [])];
82
+ }
83
+
84
+ has(roomId, socket) {
85
+ this.validateRoomId(roomId);
86
+ return Boolean(this.rooms.get(roomId)?.has(socket));
87
+ }
88
+
89
+ broadcast(roomId, data, { except } = {}) {
90
+ this.validateRoomId(roomId);
91
+ if (this.closed) return 0;
92
+ const members = this.rooms.get(roomId);
93
+ if (!members) return 0;
94
+ const recipients = except === undefined
95
+ ? members
96
+ : [...members].filter(socket => socket !== except);
97
+ return broadcast(recipients, data, this.policy);
98
+ }
99
+
100
+ clear() {
101
+ this.rooms.clear();
102
+ this.memberships = new WeakMap();
103
+ }
104
+
105
+ close() {
106
+ if (this.closed) return false;
107
+ this.closed = true;
108
+ this.clear();
109
+ return true;
110
+ }
111
+
112
+ get size() {
113
+ return this.rooms.size;
114
+ }
115
+ }
116
+
117
+ module.exports = RoomRegistry;
@@ -0,0 +1,146 @@
1
+ const { randomUUID } = require('crypto');
2
+ const { ADMISSION_CONTEXT } = require('./AdmissionPolicy');
3
+ const { PROTOCOL_CONTEXT } = require('./ProtocolPolicy');
4
+ const HeartbeatMonitor = require('./HeartbeatMonitor');
5
+ const RoomRegistry = require('./RoomRegistry');
6
+ const SessionRegistry = require('./SessionRegistry');
7
+ const DistributionBridge = require('./DistributionBridge');
8
+
9
+ function joinRoom(roomId) { return this.__redwebRuntimeOwner.rooms.join(roomId, this); }
10
+ function leaveRoom(roomId) { return this.__redwebRuntimeOwner.rooms.leave(roomId, this); }
11
+ function roomBroadcast(roomId, data, options) { return this.__redwebRuntimeOwner.rooms.broadcast(roomId, data, options); }
12
+ function createSession(sessionId, data) {
13
+ this.__redwebRuntimeOwner.ensureContext(this);
14
+ return this.__redwebRuntimeOwner.sessions.create(sessionId, data, this);
15
+ }
16
+ function resumeSession(sessionId) {
17
+ this.__redwebRuntimeOwner.ensureContext(this);
18
+ return this.__redwebRuntimeOwner.sessions.resume(sessionId, this);
19
+ }
20
+ function publishEvent(type, payload) { return this.__redwebRuntimeOwner.route.publish(type, payload); }
21
+
22
+ class RouteRuntime {
23
+ constructor(route, { heartbeat, rooms, sessions, distribution, drainHandlers }) {
24
+ this.route = route;
25
+ this.inFlight = drainHandlers ? new Set() : null;
26
+ this.abortController = drainHandlers ? new AbortController() : null;
27
+ this.acceptingWork = true;
28
+ try {
29
+ this.heartbeat = heartbeat === undefined ? null : new HeartbeatMonitor(heartbeat, route.logger);
30
+ this.rooms = rooms === undefined || rooms === false
31
+ ? null
32
+ : new RoomRegistry(rooms === true ? {} : rooms, {
33
+ hasConnection: socket => route.clients.get(socket.clientKey) === socket,
34
+ policy: route.transportPolicy,
35
+ onChange: action => {
36
+ route.metrics?.increment(`redweb.room.${action}`);
37
+ route.metrics?.gauge('redweb.rooms.active', this.rooms.size);
38
+ },
39
+ });
40
+ this.sessions = sessions === undefined || sessions === false
41
+ ? null
42
+ : new SessionRegistry(sessions === true ? {} : sessions, route.logger);
43
+ if (distribution !== undefined && distribution !== false && typeof distribution?.onEvent !== 'function') {
44
+ throw new TypeError('`distribution.onEvent` must be a function.');
45
+ }
46
+ this.distribution = distribution === undefined || distribution === false
47
+ ? null
48
+ : new DistributionBridge(distribution, event => distribution.onEvent(event, route), route.logger);
49
+ } catch (error) {
50
+ this.heartbeat?.stop();
51
+ this.sessions?.stop();
52
+ throw error;
53
+ }
54
+ }
55
+
56
+ expose() {
57
+ return {
58
+ heartbeatMonitor: this.heartbeat,
59
+ rooms: this.rooms,
60
+ sessions: this.sessions,
61
+ distribution: this.distribution,
62
+ inFlight: this.inFlight,
63
+ abortController: this.abortController,
64
+ };
65
+ }
66
+
67
+ decorate(socket, request) {
68
+ if (request?.[ADMISSION_CONTEXT] || request?.[PROTOCOL_CONTEXT] || this.abortController) this.ensureContext(socket, request);
69
+ if (this.rooms || this.sessions || this.distribution) socket.__redwebRuntimeOwner = this;
70
+ if (this.rooms) {
71
+ socket.joinRoom = joinRoom;
72
+ socket.leaveRoom = leaveRoom;
73
+ socket.roomBroadcast = roomBroadcast;
74
+ }
75
+ if (this.sessions) {
76
+ socket.createSession = createSession;
77
+ socket.resumeSession = resumeSession;
78
+ }
79
+ if (this.distribution) socket.publishEvent = publishEvent;
80
+ }
81
+
82
+ ensureContext(socket, request) {
83
+ if (socket.context) return socket.context;
84
+ socket.context = {
85
+ connectionId: randomUUID(),
86
+ principal: request?.[ADMISSION_CONTEXT]?.principal,
87
+ session: null,
88
+ metadata: Object.create(null),
89
+ signal: this.abortController?.signal,
90
+ protocol: request?.[PROTOCOL_CONTEXT],
91
+ };
92
+ return socket.context;
93
+ }
94
+
95
+ attach(socket) {
96
+ this.heartbeat?.attach(socket);
97
+ }
98
+
99
+ detach(socket) {
100
+ this.rooms?.leaveAll(socket);
101
+ this.sessions?.release(socket);
102
+ this.heartbeat?.detach(socket);
103
+ }
104
+
105
+ run(task) {
106
+ if (!this.acceptingWork) return Promise.resolve(false);
107
+ if (!this.inFlight) return task();
108
+ const promise = Promise.resolve().then(task);
109
+ this.inFlight.add(promise);
110
+ const cleanup = () => this.inFlight.delete(promise);
111
+ void promise.then(cleanup, cleanup);
112
+ return promise;
113
+ }
114
+
115
+ beginDrain() {
116
+ this.abortController?.abort();
117
+ this.route.clients.forEach(socket => socket.__redwebRuntime?.queue?.close());
118
+ }
119
+
120
+ isReady() {
121
+ return !this.distribution?.required || this.distribution.isReady();
122
+ }
123
+
124
+ stopHeartbeat() {
125
+ this.heartbeat?.stop();
126
+ }
127
+
128
+ closeDistribution() {
129
+ return this.distribution?.close();
130
+ }
131
+
132
+ closeState() {
133
+ this.rooms?.close();
134
+ this.sessions?.stop();
135
+ }
136
+
137
+ clearInFlight() {
138
+ this.inFlight?.clear();
139
+ }
140
+
141
+ stopAcceptingWork() {
142
+ this.acceptingWork = false;
143
+ }
144
+ }
145
+
146
+ module.exports = RouteRuntime;
@@ -7,17 +7,14 @@ const { BaseSocketServer } = require('./BaseSocketServer');
7
7
  * @param {SocketServerOptions} options - Configuration options for SecureSocketServer.
8
8
  * @return {Object} WebSocket server instance.
9
9
  */
10
- class SecureSocketServer extends BaseSocketServer {
11
- constructor(options = {}) {
12
- const ownsServer = !options?.server;
13
- const sslOptions = ownsServer ? loadSslConfig(options.ssl) : null;
14
- const server = options?.server || https.createServer(sslOptions);
15
- super(server, options);
16
- if ((ownsServer && this.listen !== false) || (!ownsServer && options.listen === true)) {
17
- server.listen(this.port, () => console.log(`RedWeb SecureSocketServer listening on port ${this.port}`));
18
- }
19
- return this;
20
- }
21
- }
10
+ class SecureSocketServer extends BaseSocketServer {
11
+ constructor(options) {
12
+ const ownsServer = !options?.server;
13
+ const sslOptions = ownsServer ? loadSslConfig(options?.ssl) : null;
14
+ const server = options?.server || https.createServer(sslOptions);
15
+ super(server, options, ownsServer, 'SecureSocketServer');
16
+ return this;
17
+ }
18
+ }
22
19
 
23
20
  module.exports = SecureSocketServer;
@@ -0,0 +1,135 @@
1
+ const { performance } = require('perf_hooks');
2
+
3
+ class SessionRegistry {
4
+ constructor(options = {}, logger = console, clock = () => performance.now()) {
5
+ if (!options || typeof options !== 'object' || Array.isArray(options)) {
6
+ throw new TypeError('`sessions` must be an object or true.');
7
+ }
8
+ const {
9
+ ttlMs = 30_000,
10
+ maxSessions = 10_000,
11
+ maxSessionIdLength = 256,
12
+ sweepIntervalMs = Math.min(ttlMs, 1000),
13
+ } = options;
14
+ for (const [name, value] of Object.entries({ ttlMs, maxSessions, maxSessionIdLength, sweepIntervalMs })) {
15
+ if (!Number.isInteger(value) || value < 1) throw new TypeError(`\`sessions.${name}\` must be a positive integer.`);
16
+ }
17
+ if (typeof clock !== 'function') throw new TypeError('`clock` must be a function.');
18
+ this.ttlMs = ttlMs;
19
+ this.maxSessions = maxSessions;
20
+ this.maxSessionIdLength = maxSessionIdLength;
21
+ this.logger = logger;
22
+ this.clock = clock;
23
+ this.sessions = new Map();
24
+ this.closed = false;
25
+ this.timer = setInterval(() => this.sweep(), sweepIntervalMs);
26
+ this.timer.unref();
27
+ }
28
+
29
+ validateId(sessionId) {
30
+ if (typeof sessionId !== 'string' || !sessionId || sessionId.length > this.maxSessionIdLength) {
31
+ throw new TypeError(`Session IDs must be non-empty strings of at most ${this.maxSessionIdLength} characters.`);
32
+ }
33
+ }
34
+
35
+ create(sessionId, data, socket) {
36
+ this.validateId(sessionId);
37
+ if (this.closed || this.sessions.has(sessionId)) return false;
38
+ if (this.sessions.size >= this.maxSessions) this.sweep();
39
+ if (this.sessions.size >= this.maxSessions) return false;
40
+ const record = { data, socket: null, expiresAt: this.clock() + this.ttlMs };
41
+ this.sessions.set(sessionId, record);
42
+ if (socket) this.assign(sessionId, record, socket);
43
+ return true;
44
+ }
45
+
46
+ resume(sessionId, socket) {
47
+ this.validateId(sessionId);
48
+ if (this.closed) return null;
49
+ const record = this.sessions.get(sessionId);
50
+ if (!record) return null;
51
+ if (!record.socket && record.expiresAt <= this.clock()) {
52
+ this.sessions.delete(sessionId);
53
+ return null;
54
+ }
55
+ this.assign(sessionId, record, socket);
56
+ return record.data;
57
+ }
58
+
59
+ assign(sessionId, record, socket) {
60
+ if (!socket) throw new TypeError('A socket is required to own a session.');
61
+ const previousSessionId = socket.__redwebSessionId;
62
+ if (previousSessionId && previousSessionId !== sessionId) this.release(socket);
63
+ const previousSocket = record.socket;
64
+ record.socket = socket;
65
+ record.expiresAt = Infinity;
66
+ socket.__redwebSessionId = sessionId;
67
+ if (socket.context) socket.context.session = { id: sessionId, data: record.data };
68
+ if (previousSocket && previousSocket !== socket) {
69
+ previousSocket.__redwebSessionId = undefined;
70
+ if (previousSocket.context) previousSocket.context.session = null;
71
+ try {
72
+ previousSocket.close?.(4000, 'Session resumed elsewhere');
73
+ } catch (error) {
74
+ this.logger?.error?.('Error closing replaced session socket:', error);
75
+ }
76
+ }
77
+ }
78
+
79
+ release(socket) {
80
+ const sessionId = socket?.__redwebSessionId;
81
+ if (!sessionId) return false;
82
+ const record = this.sessions.get(sessionId);
83
+ socket.__redwebSessionId = undefined;
84
+ if (socket.context) socket.context.session = null;
85
+ if (!record || record.socket !== socket) return false;
86
+ record.socket = null;
87
+ record.expiresAt = this.clock() + this.ttlMs;
88
+ return true;
89
+ }
90
+
91
+ remove(sessionId) {
92
+ this.validateId(sessionId);
93
+ if (this.closed) return false;
94
+ const record = this.sessions.get(sessionId);
95
+ if (!record) return false;
96
+ if (record.socket) {
97
+ record.socket.__redwebSessionId = undefined;
98
+ if (record.socket.context) record.socket.context.session = null;
99
+ }
100
+ return this.sessions.delete(sessionId);
101
+ }
102
+
103
+ get(sessionId) {
104
+ this.validateId(sessionId);
105
+ if (this.closed) return undefined;
106
+ return this.sessions.get(sessionId)?.data;
107
+ }
108
+
109
+ sweep() {
110
+ const now = this.clock();
111
+ this.sessions.forEach((record, sessionId) => {
112
+ if (!record.socket && record.expiresAt <= now) this.sessions.delete(sessionId);
113
+ });
114
+ }
115
+
116
+ stop() {
117
+ if (this.closed) return;
118
+ this.closed = true;
119
+ if (this.timer) clearInterval(this.timer);
120
+ this.timer = null;
121
+ this.sessions.forEach(record => {
122
+ if (record.socket) {
123
+ record.socket.__redwebSessionId = undefined;
124
+ if (record.socket.context) record.socket.context.session = null;
125
+ }
126
+ });
127
+ this.sessions.clear();
128
+ }
129
+
130
+ get size() {
131
+ return this.sessions.size;
132
+ }
133
+ }
134
+
135
+ module.exports = SessionRegistry;