webspresso 0.0.90 → 0.0.93
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/README.md +98 -2509
- package/bin/commands/admin-password.js +8 -2
- package/bin/commands/api.js +7 -1
- package/bin/commands/favicon-generate.js +6 -4
- package/bin/commands/mcp.js +162 -0
- package/bin/commands/new.js +7 -1
- package/bin/commands/page.js +7 -1
- package/bin/commands/seed.js +7 -1
- package/bin/commands/service.js +199 -0
- package/bin/commands/skill.js +7 -1
- package/bin/webspresso.js +4 -0
- package/core/auth/middleware.js +9 -0
- package/core/compileSchema.js +1 -5
- package/core/compression/compressible.js +63 -0
- package/core/compression/compression-stream.js +59 -0
- package/core/compression/index.js +25 -0
- package/core/compression/middleware.js +310 -0
- package/core/compression/negotiate.js +165 -0
- package/core/errors/base.js +31 -0
- package/core/errors/domain.js +193 -0
- package/core/errors/http.js +133 -0
- package/core/errors/index.js +77 -0
- package/core/errors/middleware.js +162 -0
- package/core/errors/normalize.js +171 -0
- package/core/kernel/app.js +2 -1
- package/core/kernel/index.js +5 -1
- package/core/orm/cache/fingerprint.js +23 -5
- package/core/orm/complexity.js +120 -0
- package/core/orm/eager-loader.js +54 -9
- package/core/orm/events.js +64 -14
- package/core/orm/index.js +83 -12
- package/core/orm/model.js +13 -2
- package/core/orm/query-builder.js +30 -4
- package/core/orm/repository.js +57 -25
- package/core/orm/transaction.js +39 -5
- package/core/orm/utils.js +6 -3
- package/core/queue/adapters/database.js +317 -0
- package/core/queue/adapters/memory.js +176 -0
- package/core/queue/adapters/redis.js +357 -0
- package/core/queue/index.js +21 -0
- package/core/queue/job.js +103 -0
- package/core/queue/manager.js +413 -0
- package/core/realtime/adapters/redis.js +431 -0
- package/core/realtime/adapters/socket-io.js +203 -0
- package/core/realtime/adapters/sse.js +210 -0
- package/core/realtime/adapters/websocket.js +337 -0
- package/core/realtime/auth-manager.js +140 -0
- package/core/realtime/core.js +359 -0
- package/core/realtime/emitter.js +98 -0
- package/core/realtime/errors.js +71 -0
- package/core/realtime/index.js +79 -0
- package/core/realtime/reconnect-manager.js +118 -0
- package/core/realtime/registry.js +243 -0
- package/core/shutdown/http-adapter.js +134 -0
- package/core/shutdown/index.js +12 -0
- package/core/shutdown/shutdown-manager.js +312 -0
- package/core/ssr/index.js +13 -0
- package/core/ssr/stream.js +174 -0
- package/core/validation/index.js +70 -0
- package/index.d.ts +924 -53
- package/index.js +110 -1
- package/package.json +30 -2
- package/plugins/admin-panel/api.js +37 -4
- package/plugins/admin-panel/app.js +57 -17
- package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +10 -2
- package/plugins/admin-panel/client/parts/04-field-renderers.js +102 -58
- package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +51 -27
- package/plugins/admin-panel/client/parts/07-model-list.js +9 -4
- package/plugins/admin-panel/client/parts/08-record-list.js +37 -26
- package/plugins/admin-panel/client/parts/09-record-form.js +168 -19
- package/plugins/admin-panel/core/admin-module.js +2 -2
- package/plugins/admin-panel/core/api-extensions.js +149 -0
- package/plugins/admin-panel/field-renderers/rich-text.js +3 -2
- package/plugins/admin-panel/index.js +155 -23
- package/plugins/admin-panel/modules/bulk-actions.js +9 -2
- package/plugins/admin-panel/modules/custom-pages.js +144 -45
- package/plugins/admin-panel/modules/dashboard.js +8 -1
- package/plugins/admin-panel/modules/menu.js +85 -0
- package/plugins/admin-panel/modules/user-management.js +5 -2
- package/plugins/admin-panel/vendor/mithril.min.js +1 -0
- package/plugins/admin-panel/vendor/quill.min.js +7 -0
- package/plugins/admin-panel/vendor/quill.snow.min.css +945 -0
- package/plugins/admin-panel/vendor/tailwind.min.js +65 -0
- package/plugins/basic-auth/index.js +293 -0
- package/plugins/data-exchange/export-xlsx.js +28 -2
- package/plugins/data-exchange/import.js +55 -16
- package/plugins/data-exchange/index.js +19 -0
- package/plugins/data-exchange/parse-table.js +14 -5
- package/plugins/data-exchange/record-selection.js +2 -0
- package/plugins/email/index.js +14 -1
- package/plugins/index.js +33 -0
- package/plugins/mcp/auth.js +104 -0
- package/plugins/mcp/discovery.js +88 -0
- package/plugins/mcp/index.js +208 -0
- package/plugins/mcp/prompts.js +149 -0
- package/plugins/mcp/providers/orm.js +325 -0
- package/plugins/mcp/providers/services.js +190 -0
- package/plugins/mcp/providers/system.js +87 -0
- package/plugins/mcp/server.js +472 -0
- package/plugins/mcp/transports/sse.js +139 -0
- package/plugins/mcp/transports/stdio.js +108 -0
- package/plugins/queue/index.js +108 -0
- package/plugins/realtime/index.js +150 -0
- package/plugins/recaptcha/index.js +11 -0
- package/plugins/redirect/index.js +9 -1
- package/plugins/rest-resources/index.js +280 -11
- package/plugins/upload/index.js +16 -0
- package/src/app-context.js +46 -6
- package/src/file-router.js +366 -130
- package/src/helpers.js +20 -2
- package/src/njk-frontmatter.js +24 -14
- package/src/plugin-manager.js +124 -4
- package/src/server.js +332 -54
- package/src/services/builtins/auth.js +273 -0
- package/src/services/builtins/exchange.js +229 -0
- package/src/services/builtins/mail.js +134 -0
- package/src/services/builtins/media.js +196 -0
- package/src/services/builtins/system.js +221 -0
- package/src/services/discovery.js +141 -0
- package/src/services/executor.js +292 -0
- package/src/services/index.js +70 -0
- package/src/services/memoize.js +256 -0
- package/src/services/registry.js +290 -0
- package/src/services/validator.js +230 -0
- package/templates/skills/webspresso-usage/REFERENCE-framework.md +6 -4
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Redis Pub/Sub Distributed Adapter for Webspresso Realtime
|
|
3
|
+
* Enables cross-instance realtime broadcasting across PM2 clusters, Docker containers, and Kubernetes pods.
|
|
4
|
+
* @module core/realtime/adapters/redis
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const crypto = require('crypto');
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Default Redis message envelope serializer
|
|
11
|
+
*/
|
|
12
|
+
function defaultSerialize(envelope) {
|
|
13
|
+
return JSON.stringify(envelope);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Default Redis message envelope deserializer
|
|
18
|
+
*/
|
|
19
|
+
function defaultDeserialize(raw) {
|
|
20
|
+
try {
|
|
21
|
+
return JSON.parse(raw);
|
|
22
|
+
} catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* In-memory distributed bus singleton for fallback / local multi-client simulation
|
|
29
|
+
*/
|
|
30
|
+
class InMemoryDistributedBus {
|
|
31
|
+
constructor() {
|
|
32
|
+
this._channels = new Map();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
subscribe(channel, callback) {
|
|
36
|
+
if (!this._channels.has(channel)) {
|
|
37
|
+
this._channels.set(channel, new Set());
|
|
38
|
+
}
|
|
39
|
+
this._channels.get(channel).add(callback);
|
|
40
|
+
return () => {
|
|
41
|
+
const set = this._channels.get(channel);
|
|
42
|
+
if (set) {
|
|
43
|
+
set.delete(callback);
|
|
44
|
+
if (set.size === 0) {
|
|
45
|
+
this._channels.delete(channel);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
publish(channel, rawMessage) {
|
|
52
|
+
const set = this._channels.get(channel);
|
|
53
|
+
if (set) {
|
|
54
|
+
for (const cb of Array.from(set)) {
|
|
55
|
+
try {
|
|
56
|
+
cb(channel, rawMessage);
|
|
57
|
+
} catch {
|
|
58
|
+
// ignore receiver errors
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
clear() {
|
|
65
|
+
this._channels.clear();
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const globalInMemoryBus = new InMemoryDistributedBus();
|
|
70
|
+
|
|
71
|
+
class RedisAdapter {
|
|
72
|
+
/**
|
|
73
|
+
* @param {Object} options
|
|
74
|
+
* @param {Object} [options.pubClient] - Redis client for publishing
|
|
75
|
+
* @param {Object} [options.subClient] - Dedicated Redis client for subscribing
|
|
76
|
+
* @param {Object|Function} [options.redis] - Redis client instance with .duplicate() or factory
|
|
77
|
+
* @param {string} [options.channelPrefix='webspresso:rt:'] - Redis channel prefix
|
|
78
|
+
* @param {string} [options.nodeId] - Unique cluster node identifier
|
|
79
|
+
* @param {Function} [options.serialize] - Custom serializer
|
|
80
|
+
* @param {Function} [options.deserialize] - Custom deserializer
|
|
81
|
+
* @param {boolean} [options.inMemory=false] - Force in-memory distributed bus fallback
|
|
82
|
+
* @param {Object} [options.capabilities] - Custom capabilities override
|
|
83
|
+
*/
|
|
84
|
+
constructor(options = {}) {
|
|
85
|
+
this.options = options;
|
|
86
|
+
this.capabilities = {
|
|
87
|
+
send: true,
|
|
88
|
+
perform: true,
|
|
89
|
+
multiplexing: true,
|
|
90
|
+
serverEvents: true,
|
|
91
|
+
distributed: true,
|
|
92
|
+
binary: false,
|
|
93
|
+
...(options.capabilities || {}),
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
this.channelPrefix = options.channelPrefix || 'webspresso:rt:';
|
|
97
|
+
this.nodeId = options.nodeId || `node_${crypto.randomBytes(6).toString('hex')}`;
|
|
98
|
+
this.serialize = options.serialize || defaultSerialize;
|
|
99
|
+
this.deserialize = options.deserialize || defaultDeserialize;
|
|
100
|
+
|
|
101
|
+
this.pubClient = options.pubClient || null;
|
|
102
|
+
this.subClient = options.subClient || null;
|
|
103
|
+
this._managedClients = false;
|
|
104
|
+
|
|
105
|
+
this._isOpen = false;
|
|
106
|
+
this._listeners = new Map();
|
|
107
|
+
this._channelSubscriptions = new Map(); // fullChannelName -> Set<{ callbacks, params, identifier }>
|
|
108
|
+
this._inMemoryBus = options.inMemoryBus || globalInMemoryBus;
|
|
109
|
+
this._inMemoryUnsubscribers = new Map(); // fullChannelName -> unsubFn
|
|
110
|
+
this._useInMemory = Boolean(options.inMemory || (!this.pubClient && !this.subClient && !options.redis));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
get isInMemory() {
|
|
114
|
+
return this._useInMemory;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
on(event, handler) {
|
|
118
|
+
if (!this._listeners.has(event)) {
|
|
119
|
+
this._listeners.set(event, new Set());
|
|
120
|
+
}
|
|
121
|
+
this._listeners.get(event).add(handler);
|
|
122
|
+
return () => this.off(event, handler);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
off(event, handler) {
|
|
126
|
+
const set = this._listeners.get(event);
|
|
127
|
+
if (set) {
|
|
128
|
+
set.delete(handler);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
_emit(event, ...args) {
|
|
133
|
+
const set = this._listeners.get(event);
|
|
134
|
+
if (set) {
|
|
135
|
+
for (const handler of Array.from(set)) {
|
|
136
|
+
try {
|
|
137
|
+
handler(...args);
|
|
138
|
+
} catch {
|
|
139
|
+
// ignore handler errors
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
_resolveChannel(identifier) {
|
|
146
|
+
return `${this.channelPrefix}${identifier}`;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async connect(context = {}) {
|
|
150
|
+
if (this._isOpen) {
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
try {
|
|
155
|
+
if (!this._useInMemory) {
|
|
156
|
+
// If a single redis instance with .duplicate() was passed
|
|
157
|
+
if (!this.pubClient && !this.subClient && this.options.redis) {
|
|
158
|
+
if (typeof this.options.redis === 'function') {
|
|
159
|
+
this.pubClient = await this.options.redis('pub');
|
|
160
|
+
this.subClient = await this.options.redis('sub');
|
|
161
|
+
this._managedClients = true;
|
|
162
|
+
} else if (typeof this.options.redis.duplicate === 'function') {
|
|
163
|
+
this.pubClient = this.options.redis;
|
|
164
|
+
this.subClient = this.options.redis.duplicate();
|
|
165
|
+
this._managedClients = true;
|
|
166
|
+
} else {
|
|
167
|
+
this.pubClient = this.options.redis;
|
|
168
|
+
this.subClient = this.options.redis;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Connect if clients have .connect() method (e.g. node-redis v4)
|
|
173
|
+
if (this.pubClient && typeof this.pubClient.connect === 'function' && !this.pubClient.isOpen && !this.pubClient.isReady) {
|
|
174
|
+
await this.pubClient.connect();
|
|
175
|
+
}
|
|
176
|
+
if (this.subClient && typeof this.subClient.connect === 'function' && !this.subClient.isOpen && !this.subClient.isReady) {
|
|
177
|
+
await this.subClient.connect();
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Wire incoming messages from subClient
|
|
181
|
+
this._wireSubClient();
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
this._isOpen = true;
|
|
185
|
+
this._emit('open', { nodeId: this.nodeId, inMemory: this._useInMemory });
|
|
186
|
+
} catch (err) {
|
|
187
|
+
this._isOpen = false;
|
|
188
|
+
this._emit('error', err);
|
|
189
|
+
throw err;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
_wireSubClient() {
|
|
194
|
+
if (!this.subClient) return;
|
|
195
|
+
|
|
196
|
+
const messageHandler = (channel, rawMessage) => {
|
|
197
|
+
this._handleIncomingRawMessage(channel, rawMessage);
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
// Standard EventEmitter / ioredis style (subClient.on('message'))
|
|
201
|
+
if (typeof this.subClient.on === 'function') {
|
|
202
|
+
this.subClient.on('message', messageHandler);
|
|
203
|
+
this.subClient.on('error', (err) => this._emit('error', err));
|
|
204
|
+
this.subClient.on('close', () => {
|
|
205
|
+
this._isOpen = false;
|
|
206
|
+
this._emit('close');
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
_handleIncomingRawMessage(channel, rawMessage) {
|
|
212
|
+
const envelope = this.deserialize(rawMessage);
|
|
213
|
+
if (!envelope || typeof envelope !== 'object') {
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const subs = this._channelSubscriptions.get(channel);
|
|
218
|
+
if (!subs || subs.size === 0) {
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
for (const sub of Array.from(subs)) {
|
|
223
|
+
if (envelope.type === 'action') {
|
|
224
|
+
if (typeof sub.callbacks.action === 'function') {
|
|
225
|
+
sub.callbacks.action(envelope.action, envelope.data, envelope);
|
|
226
|
+
} else if (typeof sub.callbacks.received === 'function') {
|
|
227
|
+
sub.callbacks.received(envelope.data, envelope);
|
|
228
|
+
}
|
|
229
|
+
} else {
|
|
230
|
+
if (typeof sub.callbacks.received === 'function') {
|
|
231
|
+
sub.callbacks.received(envelope.data, envelope);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
isConnected() {
|
|
238
|
+
return this._isOpen;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async disconnect() {
|
|
242
|
+
this._isOpen = false;
|
|
243
|
+
|
|
244
|
+
// Unsubscribe from in-memory bus
|
|
245
|
+
for (const [, unsub] of this._inMemoryUnsubscribers) {
|
|
246
|
+
try {
|
|
247
|
+
unsub();
|
|
248
|
+
} catch {}
|
|
249
|
+
}
|
|
250
|
+
this._inMemoryUnsubscribers.clear();
|
|
251
|
+
|
|
252
|
+
// Unsubscribe from Redis
|
|
253
|
+
if (this.subClient) {
|
|
254
|
+
for (const channel of this._channelSubscriptions.keys()) {
|
|
255
|
+
try {
|
|
256
|
+
if (typeof this.subClient.unsubscribe === 'function') {
|
|
257
|
+
await this.subClient.unsubscribe(channel);
|
|
258
|
+
}
|
|
259
|
+
} catch {}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
this._channelSubscriptions.clear();
|
|
264
|
+
|
|
265
|
+
// Close clients if managed
|
|
266
|
+
if (this._managedClients) {
|
|
267
|
+
if (this.subClient && typeof this.subClient.quit === 'function') {
|
|
268
|
+
try { await this.subClient.quit(); } catch {}
|
|
269
|
+
}
|
|
270
|
+
if (this.pubClient && typeof this.pubClient.quit === 'function') {
|
|
271
|
+
try { await this.pubClient.quit(); } catch {}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
this._emit('close');
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
async destroy() {
|
|
279
|
+
await this.disconnect();
|
|
280
|
+
this._listeners.clear();
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Low-level publish raw payload to Redis channel
|
|
285
|
+
* @param {string} identifier
|
|
286
|
+
* @param {Object} envelope
|
|
287
|
+
*/
|
|
288
|
+
async _publishEnvelope(identifier, envelope) {
|
|
289
|
+
const channel = this._resolveChannel(identifier);
|
|
290
|
+
const raw = this.serialize(envelope);
|
|
291
|
+
|
|
292
|
+
if (this._useInMemory) {
|
|
293
|
+
this._inMemoryBus.publish(channel, raw);
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if (this.pubClient) {
|
|
298
|
+
if (typeof this.pubClient.publish === 'function') {
|
|
299
|
+
await this.pubClient.publish(channel, raw);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Broadcast message to all cluster nodes on given identifier
|
|
306
|
+
* @param {string} identifier
|
|
307
|
+
* @param {*} data
|
|
308
|
+
*/
|
|
309
|
+
async publish(identifier, data) {
|
|
310
|
+
const envelope = {
|
|
311
|
+
type: 'message',
|
|
312
|
+
identifier,
|
|
313
|
+
data,
|
|
314
|
+
senderId: this.nodeId,
|
|
315
|
+
timestamp: Date.now(),
|
|
316
|
+
};
|
|
317
|
+
await this._publishEnvelope(identifier, envelope);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Broadcast named RPC action to all cluster nodes on given identifier
|
|
322
|
+
* @param {string} identifier
|
|
323
|
+
* @param {string} action
|
|
324
|
+
* @param {*} data
|
|
325
|
+
*/
|
|
326
|
+
async perform(identifier, action, data) {
|
|
327
|
+
const envelope = {
|
|
328
|
+
type: 'action',
|
|
329
|
+
identifier,
|
|
330
|
+
action,
|
|
331
|
+
data,
|
|
332
|
+
senderId: this.nodeId,
|
|
333
|
+
timestamp: Date.now(),
|
|
334
|
+
};
|
|
335
|
+
await this._publishEnvelope(identifier, envelope);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Subscribe to a distributed channel identifier
|
|
340
|
+
* @param {string} identifier
|
|
341
|
+
* @param {Object} params
|
|
342
|
+
* @param {Object} callbacks
|
|
343
|
+
* @returns {Object} subscription handle
|
|
344
|
+
*/
|
|
345
|
+
subscribe(identifier, params = {}, callbacks = {}) {
|
|
346
|
+
const channel = this._resolveChannel(identifier);
|
|
347
|
+
|
|
348
|
+
if (!this._channelSubscriptions.has(channel)) {
|
|
349
|
+
this._channelSubscriptions.set(channel, new Set());
|
|
350
|
+
|
|
351
|
+
if (this._useInMemory) {
|
|
352
|
+
const unsub = this._inMemoryBus.subscribe(channel, (ch, raw) => {
|
|
353
|
+
this._handleIncomingRawMessage(ch, raw);
|
|
354
|
+
});
|
|
355
|
+
this._inMemoryUnsubscribers.set(channel, unsub);
|
|
356
|
+
} else if (this.subClient) {
|
|
357
|
+
if (typeof this.subClient.subscribe === 'function') {
|
|
358
|
+
// Supports node-redis v4 callback style or ioredis promise
|
|
359
|
+
const res = this.subClient.subscribe(channel, (message) => {
|
|
360
|
+
// node-redis v4 listener
|
|
361
|
+
this._handleIncomingRawMessage(channel, message);
|
|
362
|
+
});
|
|
363
|
+
if (res && typeof res.catch === 'function') {
|
|
364
|
+
res.catch((err) => this._emit('error', err));
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const subEntry = {
|
|
371
|
+
identifier,
|
|
372
|
+
params,
|
|
373
|
+
callbacks,
|
|
374
|
+
};
|
|
375
|
+
|
|
376
|
+
this._channelSubscriptions.get(channel).add(subEntry);
|
|
377
|
+
|
|
378
|
+
// Trigger connected callback
|
|
379
|
+
if (typeof callbacks.connected === 'function') {
|
|
380
|
+
Promise.resolve().then(() => {
|
|
381
|
+
if (this._channelSubscriptions.get(channel)?.has(subEntry)) {
|
|
382
|
+
callbacks.connected();
|
|
383
|
+
}
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
return {
|
|
388
|
+
send: (data) => {
|
|
389
|
+
return this.publish(identifier, data);
|
|
390
|
+
},
|
|
391
|
+
perform: (action, data) => {
|
|
392
|
+
return this.perform(identifier, action, data);
|
|
393
|
+
},
|
|
394
|
+
unsubscribe: () => {
|
|
395
|
+
const set = this._channelSubscriptions.get(channel);
|
|
396
|
+
if (set) {
|
|
397
|
+
set.delete(subEntry);
|
|
398
|
+
if (set.size === 0) {
|
|
399
|
+
this._channelSubscriptions.delete(channel);
|
|
400
|
+
if (this._useInMemory) {
|
|
401
|
+
const unsub = this._inMemoryUnsubscribers.get(channel);
|
|
402
|
+
if (unsub) {
|
|
403
|
+
unsub();
|
|
404
|
+
this._inMemoryUnsubscribers.delete(channel);
|
|
405
|
+
}
|
|
406
|
+
} else if (this.subClient && typeof this.subClient.unsubscribe === 'function') {
|
|
407
|
+
this.subClient.unsubscribe(channel);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
},
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Factory for creating a Redis distributed adapter instance
|
|
418
|
+
* @param {Object} options
|
|
419
|
+
* @returns {RedisAdapter}
|
|
420
|
+
*/
|
|
421
|
+
function createRedisAdapter(options = {}) {
|
|
422
|
+
return new RedisAdapter(options);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
module.exports = {
|
|
426
|
+
createRedisAdapter,
|
|
427
|
+
redis: createRedisAdapter,
|
|
428
|
+
RedisAdapter,
|
|
429
|
+
InMemoryDistributedBus,
|
|
430
|
+
globalInMemoryBus,
|
|
431
|
+
};
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Socket.IO Adapter for Webspresso Realtime
|
|
3
|
+
* @module core/realtime/adapters/socket-io
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
class SocketIoAdapter {
|
|
7
|
+
/**
|
|
8
|
+
* @param {Object} options
|
|
9
|
+
* @param {string|Function} options.url - Target URL
|
|
10
|
+
* @param {string} [options.path='/socket.io'] - Socket.IO path
|
|
11
|
+
* @param {Function} [options.io] - Socket.IO client factory injection
|
|
12
|
+
* @param {Object} [options.socketOptions] - Custom Socket.IO client options
|
|
13
|
+
* @param {Object} [options.capabilities] - Custom capabilities override
|
|
14
|
+
*/
|
|
15
|
+
constructor(options = {}) {
|
|
16
|
+
this.options = options;
|
|
17
|
+
this.capabilities = {
|
|
18
|
+
send: true,
|
|
19
|
+
perform: true,
|
|
20
|
+
multiplexing: true,
|
|
21
|
+
serverEvents: true,
|
|
22
|
+
binary: true,
|
|
23
|
+
...(options.capabilities || {}),
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
this.ioFactory = options.io || (typeof globalThis.io === 'function' ? globalThis.io : null);
|
|
27
|
+
this.socket = null;
|
|
28
|
+
this._listeners = new Map();
|
|
29
|
+
this._subscriptions = new Map();
|
|
30
|
+
this._isOpen = false;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
isConnected() {
|
|
34
|
+
return Boolean(this.socket && (this.socket.connected || this._isOpen));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
on(event, handler) {
|
|
38
|
+
if (!this._listeners.has(event)) {
|
|
39
|
+
this._listeners.set(event, new Set());
|
|
40
|
+
}
|
|
41
|
+
this._listeners.get(event).add(handler);
|
|
42
|
+
return () => {
|
|
43
|
+
const set = this._listeners.get(event);
|
|
44
|
+
if (set) set.delete(handler);
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
_emit(event, ...args) {
|
|
49
|
+
const set = this._listeners.get(event);
|
|
50
|
+
if (!set) return;
|
|
51
|
+
for (const handler of set) {
|
|
52
|
+
try {
|
|
53
|
+
handler(...args);
|
|
54
|
+
} catch (err) {
|
|
55
|
+
console.error(`[socket-io-adapter] Error in event listener for "${event}":`, err);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async connect(context) {
|
|
61
|
+
if (!this.ioFactory) {
|
|
62
|
+
throw new Error('Socket.IO client implementation not found. Inject options.io or install socket.io-client.');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
let url = typeof this.options.url === 'function' ? await this.options.url(context) : (this.options.url || '');
|
|
66
|
+
|
|
67
|
+
const clientOpts = {
|
|
68
|
+
path: this.options.path || '/socket.io',
|
|
69
|
+
auth: context.auth ? { token: context.auth.token, ...context.auth } : {},
|
|
70
|
+
autoConnect: true,
|
|
71
|
+
...(this.options.socketOptions || {}),
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
return new Promise((resolve, reject) => {
|
|
75
|
+
let isSettled = false;
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
this.socket = this.ioFactory(url, clientOpts);
|
|
79
|
+
} catch (err) {
|
|
80
|
+
return reject(err);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
this.socket.on('connect', () => {
|
|
84
|
+
this._isOpen = true;
|
|
85
|
+
if (!isSettled) {
|
|
86
|
+
isSettled = true;
|
|
87
|
+
resolve();
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
this.socket.on('disconnect', (reason) => {
|
|
92
|
+
this._isOpen = false;
|
|
93
|
+
this._emit('disconnected', reason);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
this.socket.on('connect_error', (err) => {
|
|
97
|
+
this._isOpen = false;
|
|
98
|
+
const msg = err && err.message ? err.message.toLowerCase() : '';
|
|
99
|
+
if (msg.includes('unauthorized') || msg.includes('forbidden') || msg.includes('auth')) {
|
|
100
|
+
this._emit('unauthorized', err);
|
|
101
|
+
} else {
|
|
102
|
+
this._emit('error', err);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (!isSettled) {
|
|
106
|
+
isSettled = true;
|
|
107
|
+
reject(err);
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
subscribe(identifier, params, callbacks) {
|
|
114
|
+
if (!this._subscriptions.has(identifier)) {
|
|
115
|
+
this._subscriptions.set(identifier, new Map());
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const subId = Symbol('socketio-sub');
|
|
119
|
+
this._subscriptions.get(identifier).set(subId, callbacks);
|
|
120
|
+
|
|
121
|
+
if (this.isConnected()) {
|
|
122
|
+
this.socket.emit('subscribe', { identifier, params }, (response) => {
|
|
123
|
+
if (response && response.error) {
|
|
124
|
+
callbacks.rejected?.(new Error(response.error));
|
|
125
|
+
} else {
|
|
126
|
+
callbacks.connected?.();
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const eventHandler = (data) => {
|
|
132
|
+
callbacks.received?.(data);
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
if (this.socket) {
|
|
136
|
+
this.socket.on(identifier, eventHandler);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
send: (data) => this.send(identifier, data),
|
|
141
|
+
perform: (action, data) => this.perform(identifier, action, data),
|
|
142
|
+
unsubscribe: () => this.unsubscribe(identifier, params, subId, eventHandler),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
unsubscribe(identifier, params, subId, eventHandler) {
|
|
147
|
+
if (this._subscriptions.has(identifier)) {
|
|
148
|
+
const subs = this._subscriptions.get(identifier);
|
|
149
|
+
if (subId) subs.delete(subId);
|
|
150
|
+
if (subs.size === 0 || !subId) {
|
|
151
|
+
this._subscriptions.delete(identifier);
|
|
152
|
+
if (this.socket) {
|
|
153
|
+
this.socket.emit('unsubscribe', { identifier, params });
|
|
154
|
+
if (eventHandler) {
|
|
155
|
+
this.socket.off(identifier, eventHandler);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
send(identifier, data) {
|
|
163
|
+
if (!this.isConnected()) {
|
|
164
|
+
throw new Error('Socket.IO is not connected');
|
|
165
|
+
}
|
|
166
|
+
this.socket.emit('message', { identifier, data });
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
perform(identifier, action, data) {
|
|
170
|
+
if (!this.isConnected()) {
|
|
171
|
+
throw new Error('Socket.IO is not connected');
|
|
172
|
+
}
|
|
173
|
+
this.socket.emit('perform', { identifier, action, data });
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
disconnect() {
|
|
177
|
+
this._isOpen = false;
|
|
178
|
+
if (this.socket) {
|
|
179
|
+
try {
|
|
180
|
+
this.socket.disconnect();
|
|
181
|
+
} catch (e) {
|
|
182
|
+
// Disconnected
|
|
183
|
+
}
|
|
184
|
+
this.socket = null;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
destroy() {
|
|
189
|
+
this.disconnect();
|
|
190
|
+
this._subscriptions.clear();
|
|
191
|
+
this._listeners.clear();
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function createSocketIoAdapter(options = {}) {
|
|
196
|
+
return new SocketIoAdapter(options);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
module.exports = {
|
|
200
|
+
createSocketIoAdapter,
|
|
201
|
+
socketIo: createSocketIoAdapter,
|
|
202
|
+
SocketIoAdapter,
|
|
203
|
+
};
|