webspresso 0.0.90 → 0.0.91
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 +358 -8
- package/bin/commands/service.js +193 -0
- package/bin/webspresso.js +2 -0
- package/core/auth/middleware.js +9 -0
- package/core/compression/compressible.js +51 -0
- package/core/compression/compression-stream.js +59 -0
- package/core/compression/index.js +25 -0
- package/core/compression/middleware.js +301 -0
- package/core/compression/negotiate.js +153 -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/orm/complexity.js +120 -0
- package/core/orm/events.js +64 -14
- package/core/orm/index.js +45 -10
- package/core/orm/model.js +2 -0
- 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 +284 -0
- package/core/queue/adapters/memory.js +155 -0
- package/core/queue/adapters/redis.js +336 -0
- package/core/queue/index.js +21 -0
- package/core/queue/job.js +103 -0
- package/core/queue/manager.js +387 -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/index.d.ts +829 -52
- package/index.js +103 -0
- package/package.json +22 -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 +4 -1
- package/plugins/admin-panel/client/parts/04-field-renderers.js +102 -58
- package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +37 -23
- package/plugins/admin-panel/client/parts/08-record-list.js +17 -24
- 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 +90 -17
- package/plugins/admin-panel/modules/custom-pages.js +144 -45
- 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 +5 -1
- package/plugins/data-exchange/import.js +16 -2
- package/plugins/data-exchange/index.js +14 -0
- package/plugins/email/index.js +14 -1
- package/plugins/index.js +29 -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 +196 -21
- package/src/helpers.js +20 -2
- package/src/plugin-manager.js +124 -4
- package/src/server.js +304 -44
- 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 +140 -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 +288 -0
- package/src/services/validator.js +281 -0
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-Sent Events (SSE) Adapter for Webspresso Realtime
|
|
3
|
+
* @module core/realtime/adapters/sse
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Resolve target SSE URL with auth query parameters
|
|
8
|
+
*
|
|
9
|
+
* @param {string|Function} urlOption
|
|
10
|
+
* @param {Object} context
|
|
11
|
+
* @param {string} [queryParamName='token']
|
|
12
|
+
* @returns {Promise<string>}
|
|
13
|
+
*/
|
|
14
|
+
async function resolveSseUrl(urlOption, context, queryParamName = 'token') {
|
|
15
|
+
let resolved = '';
|
|
16
|
+
if (typeof urlOption === 'function') {
|
|
17
|
+
resolved = await urlOption(context);
|
|
18
|
+
} else if (typeof urlOption === 'string') {
|
|
19
|
+
resolved = urlOption;
|
|
20
|
+
} else {
|
|
21
|
+
throw new Error('SSE adapter requires a valid url string or async resolver function');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (context.auth && context.auth.token) {
|
|
25
|
+
const separator = resolved.includes('?') ? '&' : '?';
|
|
26
|
+
const encodedToken = encodeURIComponent(context.auth.token);
|
|
27
|
+
resolved = `${resolved}${separator}${queryParamName}=${encodedToken}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return resolved;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
class SseAdapter {
|
|
34
|
+
/**
|
|
35
|
+
* @param {Object} options
|
|
36
|
+
* @param {string|Function} options.url - SSE endpoint URL or async resolver
|
|
37
|
+
* @param {string} [options.queryParamName='token'] - Query parameter name for token
|
|
38
|
+
* @param {Function} [options.EventSource] - EventSource constructor injection
|
|
39
|
+
* @param {Object} [options.capabilities] - Custom capabilities override
|
|
40
|
+
*/
|
|
41
|
+
constructor(options = {}) {
|
|
42
|
+
this.options = options;
|
|
43
|
+
this.capabilities = {
|
|
44
|
+
send: false,
|
|
45
|
+
perform: false,
|
|
46
|
+
multiplexing: true,
|
|
47
|
+
serverEvents: true,
|
|
48
|
+
binary: false,
|
|
49
|
+
...(options.capabilities || {}),
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
this.EventSourceClass = options.EventSource || (typeof EventSource !== 'undefined' ? EventSource : null);
|
|
53
|
+
this.eventSource = null;
|
|
54
|
+
this._listeners = new Map();
|
|
55
|
+
this._subscriptions = new Map();
|
|
56
|
+
this._isOpen = false;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
isConnected() {
|
|
60
|
+
return Boolean(this.eventSource && this._isOpen);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
on(event, handler) {
|
|
64
|
+
if (!this._listeners.has(event)) {
|
|
65
|
+
this._listeners.set(event, new Set());
|
|
66
|
+
}
|
|
67
|
+
this._listeners.get(event).add(handler);
|
|
68
|
+
return () => {
|
|
69
|
+
const set = this._listeners.get(event);
|
|
70
|
+
if (set) set.delete(handler);
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
_emit(event, ...args) {
|
|
75
|
+
const set = this._listeners.get(event);
|
|
76
|
+
if (!set) return;
|
|
77
|
+
for (const handler of set) {
|
|
78
|
+
try {
|
|
79
|
+
handler(...args);
|
|
80
|
+
} catch (err) {
|
|
81
|
+
console.error(`[sse-adapter] Error in event listener for "${event}":`, err);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async connect(context) {
|
|
87
|
+
if (!this.EventSourceClass) {
|
|
88
|
+
throw new Error('EventSource implementation not found. Inject options.EventSource or run in a browser environment.');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const queryParamName = this.options.queryParamName || 'token';
|
|
92
|
+
const url = await resolveSseUrl(this.options.url, context, queryParamName);
|
|
93
|
+
|
|
94
|
+
return new Promise((resolve, reject) => {
|
|
95
|
+
let isSettled = false;
|
|
96
|
+
|
|
97
|
+
try {
|
|
98
|
+
this.eventSource = new this.EventSourceClass(url);
|
|
99
|
+
} catch (err) {
|
|
100
|
+
return reject(err);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
this.eventSource.onopen = () => {
|
|
104
|
+
this._isOpen = true;
|
|
105
|
+
if (!isSettled) {
|
|
106
|
+
isSettled = true;
|
|
107
|
+
resolve();
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
this.eventSource.onerror = (err) => {
|
|
112
|
+
this._isOpen = false;
|
|
113
|
+
this._emit('disconnected', err);
|
|
114
|
+
if (!isSettled) {
|
|
115
|
+
isSettled = true;
|
|
116
|
+
reject(err || new Error('SSE connection failed'));
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
this.eventSource.onmessage = (event) => {
|
|
121
|
+
this._handleMessage(event.data);
|
|
122
|
+
};
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
_handleMessage(rawData) {
|
|
127
|
+
let data = rawData;
|
|
128
|
+
try {
|
|
129
|
+
data = JSON.parse(rawData);
|
|
130
|
+
} catch {
|
|
131
|
+
// Raw string
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const identifier = data && typeof data === 'object' ? data.identifier : null;
|
|
135
|
+
|
|
136
|
+
if (identifier && this._subscriptions.has(identifier)) {
|
|
137
|
+
const subs = this._subscriptions.get(identifier);
|
|
138
|
+
for (const cb of subs) {
|
|
139
|
+
cb.received?.(data.data !== undefined ? data.data : data);
|
|
140
|
+
}
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
for (const subs of this._subscriptions.values()) {
|
|
145
|
+
for (const cb of subs) {
|
|
146
|
+
cb.received?.(data);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
subscribe(identifier, params, callbacks) {
|
|
152
|
+
if (!this._subscriptions.has(identifier)) {
|
|
153
|
+
this._subscriptions.set(identifier, new Set());
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
this._subscriptions.get(identifier).add(callbacks);
|
|
157
|
+
callbacks.connected?.();
|
|
158
|
+
|
|
159
|
+
return {
|
|
160
|
+
send: () => {
|
|
161
|
+
throw new Error('SSE adapter is unidirectional and does not support send()');
|
|
162
|
+
},
|
|
163
|
+
perform: () => {
|
|
164
|
+
throw new Error('SSE adapter is unidirectional and does not support perform()');
|
|
165
|
+
},
|
|
166
|
+
unsubscribe: () => this.unsubscribe(identifier, params, callbacks),
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
unsubscribe(identifier, params, callbacks) {
|
|
171
|
+
if (this._subscriptions.has(identifier)) {
|
|
172
|
+
const subs = this._subscriptions.get(identifier);
|
|
173
|
+
if (callbacks) {
|
|
174
|
+
subs.delete(callbacks);
|
|
175
|
+
}
|
|
176
|
+
if (subs.size === 0 || !callbacks) {
|
|
177
|
+
this._subscriptions.delete(identifier);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
disconnect() {
|
|
183
|
+
this._isOpen = false;
|
|
184
|
+
if (this.eventSource) {
|
|
185
|
+
try {
|
|
186
|
+
this.eventSource.close();
|
|
187
|
+
} catch (err) {
|
|
188
|
+
// Closed
|
|
189
|
+
}
|
|
190
|
+
this.eventSource = null;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
destroy() {
|
|
195
|
+
this.disconnect();
|
|
196
|
+
this._subscriptions.clear();
|
|
197
|
+
this._listeners.clear();
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function createSseAdapter(options = {}) {
|
|
202
|
+
return new SseAdapter(options);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
module.exports = {
|
|
206
|
+
createSseAdapter,
|
|
207
|
+
sse: createSseAdapter,
|
|
208
|
+
SseAdapter,
|
|
209
|
+
resolveSseUrl,
|
|
210
|
+
};
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generic WebSocket Adapter for Webspresso Realtime
|
|
3
|
+
* @module core/realtime/adapters/websocket
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Resolve target WebSocket URL with optional auth query parameters
|
|
8
|
+
*
|
|
9
|
+
* @param {string|Function} urlOption
|
|
10
|
+
* @param {Object} context
|
|
11
|
+
* @param {string} [authTransport='query']
|
|
12
|
+
* @param {string} [queryParamName='token']
|
|
13
|
+
* @returns {Promise<string>}
|
|
14
|
+
*/
|
|
15
|
+
async function resolveWebSocketUrl(urlOption, context, authTransport = 'query', queryParamName = 'token') {
|
|
16
|
+
let resolved = '';
|
|
17
|
+
if (typeof urlOption === 'function') {
|
|
18
|
+
resolved = await urlOption(context);
|
|
19
|
+
} else if (typeof urlOption === 'string') {
|
|
20
|
+
resolved = urlOption;
|
|
21
|
+
} else {
|
|
22
|
+
throw new Error('WebSocket adapter requires a valid url string or async resolver function');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Handle query parameter auth transport if token exists
|
|
26
|
+
if (authTransport === 'query' && context.auth && context.auth.token) {
|
|
27
|
+
const separator = resolved.includes('?') ? '&' : '?';
|
|
28
|
+
const encodedToken = encodeURIComponent(context.auth.token);
|
|
29
|
+
resolved = `${resolved}${separator}${queryParamName}=${encodedToken}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return resolved;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Default WebSocket message formatters
|
|
37
|
+
*/
|
|
38
|
+
const DEFAULT_MESSAGE_FORMATTERS = {
|
|
39
|
+
subscribe: ({ identifier, params }) => ({
|
|
40
|
+
type: 'subscribe',
|
|
41
|
+
identifier,
|
|
42
|
+
params,
|
|
43
|
+
}),
|
|
44
|
+
unsubscribe: ({ identifier, params }) => ({
|
|
45
|
+
type: 'unsubscribe',
|
|
46
|
+
identifier,
|
|
47
|
+
params,
|
|
48
|
+
}),
|
|
49
|
+
perform: ({ identifier, action, data }) => ({
|
|
50
|
+
type: 'perform',
|
|
51
|
+
identifier,
|
|
52
|
+
action,
|
|
53
|
+
data,
|
|
54
|
+
}),
|
|
55
|
+
send: ({ identifier, data }) => ({
|
|
56
|
+
type: 'message',
|
|
57
|
+
identifier,
|
|
58
|
+
data,
|
|
59
|
+
}),
|
|
60
|
+
auth: ({ token, metadata }) => ({
|
|
61
|
+
type: 'auth',
|
|
62
|
+
token,
|
|
63
|
+
...metadata,
|
|
64
|
+
}),
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
class WebSocketAdapter {
|
|
68
|
+
/**
|
|
69
|
+
* @param {Object} options
|
|
70
|
+
* @param {string|Function} options.url - WebSocket URL or async function
|
|
71
|
+
* @param {string} [options.authTransport='query'] - 'query' | 'header' | 'protocol' | 'message'
|
|
72
|
+
* @param {string} [options.queryParamName='token'] - Query parameter name for token
|
|
73
|
+
* @param {string|string[]} [options.protocols] - Subprotocols
|
|
74
|
+
* @param {Function} [options.WebSocket] - WebSocket constructor injection
|
|
75
|
+
* @param {Object} [options.messages] - Message formatters
|
|
76
|
+
* @param {Object} [options.capabilities] - Custom capabilities
|
|
77
|
+
*/
|
|
78
|
+
constructor(options = {}) {
|
|
79
|
+
this.options = options;
|
|
80
|
+
this.capabilities = {
|
|
81
|
+
send: true,
|
|
82
|
+
perform: true,
|
|
83
|
+
multiplexing: true,
|
|
84
|
+
serverEvents: true,
|
|
85
|
+
binary: true,
|
|
86
|
+
...(options.capabilities || {}),
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
this.messages = {
|
|
90
|
+
...DEFAULT_MESSAGE_FORMATTERS,
|
|
91
|
+
...(options.messages || {}),
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
this.WebSocketClass = options.WebSocket || (typeof WebSocket !== 'undefined' ? WebSocket : null);
|
|
95
|
+
this.socket = null;
|
|
96
|
+
this._listeners = new Map();
|
|
97
|
+
this._activeSubscriptions = new Map();
|
|
98
|
+
this._isOpen = false;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
isConnected() {
|
|
102
|
+
return Boolean(this.socket && this._isOpen);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
on(event, handler) {
|
|
106
|
+
if (!this._listeners.has(event)) {
|
|
107
|
+
this._listeners.set(event, new Set());
|
|
108
|
+
}
|
|
109
|
+
this._listeners.get(event).add(handler);
|
|
110
|
+
return () => {
|
|
111
|
+
const set = this._listeners.get(event);
|
|
112
|
+
if (set) set.delete(handler);
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
_emit(event, ...args) {
|
|
117
|
+
const set = this._listeners.get(event);
|
|
118
|
+
if (!set) return;
|
|
119
|
+
for (const handler of set) {
|
|
120
|
+
try {
|
|
121
|
+
handler(...args);
|
|
122
|
+
} catch (err) {
|
|
123
|
+
console.error(`[websocket-adapter] Error in event listener for "${event}":`, err);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async connect(context) {
|
|
129
|
+
if (!this.WebSocketClass) {
|
|
130
|
+
throw new Error('WebSocket implementation not found. Inject options.WebSocket or run in a browser environment.');
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const authTransport = this.options.authTransport || 'query';
|
|
134
|
+
const queryParamName = this.options.queryParamName || 'token';
|
|
135
|
+
|
|
136
|
+
const url = await resolveWebSocketUrl(this.options.url, context, authTransport, queryParamName);
|
|
137
|
+
|
|
138
|
+
let protocols = this.options.protocols;
|
|
139
|
+
if (authTransport === 'protocol' && context.auth && context.auth.token) {
|
|
140
|
+
const authProtocol = `token.${context.auth.token}`;
|
|
141
|
+
protocols = Array.isArray(protocols) ? [...protocols, authProtocol] : [authProtocol];
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return new Promise((resolve, reject) => {
|
|
145
|
+
let isSettled = false;
|
|
146
|
+
|
|
147
|
+
try {
|
|
148
|
+
this.socket = protocols ? new this.WebSocketClass(url, protocols) : new this.WebSocketClass(url);
|
|
149
|
+
} catch (err) {
|
|
150
|
+
return reject(err);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
this.socket.onopen = () => {
|
|
154
|
+
this._isOpen = true;
|
|
155
|
+
isSettled = true;
|
|
156
|
+
|
|
157
|
+
if (authTransport === 'message' && context.auth && context.auth.token) {
|
|
158
|
+
try {
|
|
159
|
+
const authMsg = this.messages.auth({
|
|
160
|
+
token: context.auth.token,
|
|
161
|
+
metadata: context.auth,
|
|
162
|
+
});
|
|
163
|
+
this.socket.send(typeof authMsg === 'string' ? authMsg : JSON.stringify(authMsg));
|
|
164
|
+
} catch (e) {
|
|
165
|
+
console.error('[websocket-adapter] Failed to send auth message:', e);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
resolve();
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
this.socket.onclose = (event) => {
|
|
173
|
+
this._isOpen = false;
|
|
174
|
+
const code = event ? event.code : null;
|
|
175
|
+
const reason = event ? event.reason : '';
|
|
176
|
+
|
|
177
|
+
if (code === 4001 || code === 4003 || code === 4401 || (typeof reason === 'string' && reason.toLowerCase().includes('unauthorized'))) {
|
|
178
|
+
this._emit('unauthorized', new Error(reason || 'Unauthorized'));
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
this._emit('disconnected', { code, reason });
|
|
182
|
+
|
|
183
|
+
if (!isSettled) {
|
|
184
|
+
isSettled = true;
|
|
185
|
+
reject(new Error(`WebSocket connection closed before opening: ${code} ${reason}`));
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
this.socket.onerror = (err) => {
|
|
190
|
+
this._emit('error', err);
|
|
191
|
+
if (!isSettled) {
|
|
192
|
+
isSettled = true;
|
|
193
|
+
reject(err || new Error('WebSocket connection failed'));
|
|
194
|
+
}
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
this.socket.onmessage = (event) => {
|
|
198
|
+
this._handleMessage(event.data);
|
|
199
|
+
};
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
_handleMessage(rawData) {
|
|
204
|
+
let message = rawData;
|
|
205
|
+
if (typeof rawData === 'string') {
|
|
206
|
+
try {
|
|
207
|
+
message = JSON.parse(rawData);
|
|
208
|
+
} catch {
|
|
209
|
+
// Raw string message
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (!message || typeof message !== 'object') {
|
|
214
|
+
for (const subs of this._activeSubscriptions.values()) {
|
|
215
|
+
for (const cb of subs.values()) {
|
|
216
|
+
cb.received?.(message);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const { identifier, type, data, error } = message;
|
|
223
|
+
|
|
224
|
+
if (type === 'unauthorized' || type === 'auth_error') {
|
|
225
|
+
this._emit('unauthorized', new Error(message.message || 'Unauthorized'));
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (identifier && this._activeSubscriptions.has(identifier)) {
|
|
230
|
+
const subs = this._activeSubscriptions.get(identifier);
|
|
231
|
+
for (const cb of subs.values()) {
|
|
232
|
+
if (type === 'rejected') {
|
|
233
|
+
cb.rejected?.(error || new Error(message.message || 'Subscription rejected'));
|
|
234
|
+
} else if (type === 'subscribed' || type === 'connected') {
|
|
235
|
+
cb.connected?.();
|
|
236
|
+
} else {
|
|
237
|
+
cb.received?.(data !== undefined ? data : message);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
for (const subs of this._activeSubscriptions.values()) {
|
|
244
|
+
for (const cb of subs.values()) {
|
|
245
|
+
cb.received?.(message);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
subscribe(identifier, params, callbacks) {
|
|
251
|
+
if (!this._activeSubscriptions.has(identifier)) {
|
|
252
|
+
this._activeSubscriptions.set(identifier, new Map());
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const subId = Symbol('sub');
|
|
256
|
+
this._activeSubscriptions.get(identifier).set(subId, callbacks);
|
|
257
|
+
|
|
258
|
+
if (this.isConnected()) {
|
|
259
|
+
const msg = this.messages.subscribe({ identifier, params });
|
|
260
|
+
this._sendRaw(msg);
|
|
261
|
+
callbacks.connected?.();
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return {
|
|
265
|
+
send: (data) => this.send(identifier, data),
|
|
266
|
+
perform: (action, data) => this.perform(identifier, action, data),
|
|
267
|
+
unsubscribe: () => this.unsubscribe(identifier, params, subId),
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
unsubscribe(identifier, params, subId) {
|
|
272
|
+
if (this._activeSubscriptions.has(identifier)) {
|
|
273
|
+
const subs = this._activeSubscriptions.get(identifier);
|
|
274
|
+
if (subId) {
|
|
275
|
+
subs.delete(subId);
|
|
276
|
+
}
|
|
277
|
+
if (subs.size === 0 || !subId) {
|
|
278
|
+
this._activeSubscriptions.delete(identifier);
|
|
279
|
+
if (this.isConnected()) {
|
|
280
|
+
const msg = this.messages.unsubscribe({ identifier, params });
|
|
281
|
+
this._sendRaw(msg);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
send(identifier, data) {
|
|
288
|
+
const msg = this.messages.send({ identifier, data });
|
|
289
|
+
this._sendRaw(msg);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
perform(identifier, action, data) {
|
|
293
|
+
const msg = this.messages.perform({ identifier, action, data });
|
|
294
|
+
this._sendRaw(msg);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
_sendRaw(msg) {
|
|
298
|
+
if (!this.isConnected()) {
|
|
299
|
+
throw new Error('WebSocket is not connected');
|
|
300
|
+
}
|
|
301
|
+
const payload = typeof msg === 'string' ? msg : JSON.stringify(msg);
|
|
302
|
+
this.socket.send(payload);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
disconnect() {
|
|
306
|
+
this._isOpen = false;
|
|
307
|
+
if (this.socket) {
|
|
308
|
+
try {
|
|
309
|
+
this.socket.onopen = null;
|
|
310
|
+
this.socket.onclose = null;
|
|
311
|
+
this.socket.onerror = null;
|
|
312
|
+
this.socket.onmessage = null;
|
|
313
|
+
this.socket.close();
|
|
314
|
+
} catch (err) {
|
|
315
|
+
// Socket close
|
|
316
|
+
}
|
|
317
|
+
this.socket = null;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
destroy() {
|
|
322
|
+
this.disconnect();
|
|
323
|
+
this._activeSubscriptions.clear();
|
|
324
|
+
this._listeners.clear();
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function createWebSocketAdapter(options = {}) {
|
|
329
|
+
return new WebSocketAdapter(options);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
module.exports = {
|
|
333
|
+
createWebSocketAdapter,
|
|
334
|
+
websocket: createWebSocketAdapter,
|
|
335
|
+
WebSocketAdapter,
|
|
336
|
+
resolveWebSocketUrl,
|
|
337
|
+
};
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Realtime Authentication Strategy Manager
|
|
3
|
+
* @module core/realtime/auth-manager
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const { RealtimeError } = require('./errors');
|
|
7
|
+
|
|
8
|
+
class AuthManager {
|
|
9
|
+
/**
|
|
10
|
+
* @param {Object|boolean} [authConfig=false]
|
|
11
|
+
*/
|
|
12
|
+
constructor(authConfig = false) {
|
|
13
|
+
this.config = authConfig;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Check if authentication is enabled
|
|
18
|
+
* @returns {boolean}
|
|
19
|
+
*/
|
|
20
|
+
isEnabled() {
|
|
21
|
+
return Boolean(this.config && this.config !== false);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Get active auth strategy ('token' | 'cookie' | 'custom' | 'none')
|
|
26
|
+
* @returns {'token' | 'cookie' | 'custom' | 'none'}
|
|
27
|
+
*/
|
|
28
|
+
getStrategy() {
|
|
29
|
+
if (!this.isEnabled()) return 'none';
|
|
30
|
+
return this.config.strategy || 'token';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Resolve framework-agnostic connection auth metadata before establishing connection
|
|
35
|
+
*
|
|
36
|
+
* @returns {Promise<Object>} Metadata to pass to adapter
|
|
37
|
+
*/
|
|
38
|
+
async resolveAuthMetadata() {
|
|
39
|
+
if (!this.isEnabled()) {
|
|
40
|
+
return { type: 'none' };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const strategy = this.getStrategy();
|
|
44
|
+
|
|
45
|
+
if (strategy === 'cookie') {
|
|
46
|
+
return { type: 'cookie' };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (strategy === 'token') {
|
|
50
|
+
if (typeof this.config.getToken !== 'function') {
|
|
51
|
+
throw new RealtimeError(
|
|
52
|
+
'UNAUTHORIZED',
|
|
53
|
+
'Auth strategy "token" requires a getToken() function'
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
try {
|
|
57
|
+
const token = await this.config.getToken();
|
|
58
|
+
if (!token) {
|
|
59
|
+
throw new RealtimeError('UNAUTHORIZED', 'No authentication token provided by getToken()');
|
|
60
|
+
}
|
|
61
|
+
return {
|
|
62
|
+
type: 'token',
|
|
63
|
+
token,
|
|
64
|
+
};
|
|
65
|
+
} catch (err) {
|
|
66
|
+
if (err instanceof RealtimeError) throw err;
|
|
67
|
+
throw new RealtimeError('UNAUTHORIZED', 'Failed to retrieve authentication token', err);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (strategy === 'custom') {
|
|
72
|
+
if (typeof this.config.resolve !== 'function') {
|
|
73
|
+
throw new RealtimeError(
|
|
74
|
+
'UNAUTHORIZED',
|
|
75
|
+
'Auth strategy "custom" requires a resolve() function'
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
try {
|
|
79
|
+
const metadata = await this.config.resolve();
|
|
80
|
+
return {
|
|
81
|
+
type: 'custom',
|
|
82
|
+
...(metadata && typeof metadata === 'object' ? metadata : {}),
|
|
83
|
+
};
|
|
84
|
+
} catch (err) {
|
|
85
|
+
if (err instanceof RealtimeError) throw err;
|
|
86
|
+
throw new RealtimeError('UNAUTHORIZED', 'Custom auth resolution failed', err);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
throw new RealtimeError('UNAUTHORIZED', `Unsupported auth strategy "${strategy}"`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Handle token refresh or re-authentication
|
|
95
|
+
*
|
|
96
|
+
* @returns {Promise<Object>} New auth metadata
|
|
97
|
+
*/
|
|
98
|
+
async refreshToken() {
|
|
99
|
+
if (!this.isEnabled()) {
|
|
100
|
+
return { type: 'none' };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const strategy = this.getStrategy();
|
|
104
|
+
|
|
105
|
+
if (strategy === 'token' && typeof this.config.refreshToken === 'function') {
|
|
106
|
+
try {
|
|
107
|
+
const token = await this.config.refreshToken();
|
|
108
|
+
if (!token) {
|
|
109
|
+
throw new RealtimeError('TOKEN_REFRESH_FAILED', 'refreshToken() returned empty token');
|
|
110
|
+
}
|
|
111
|
+
return {
|
|
112
|
+
type: 'token',
|
|
113
|
+
token,
|
|
114
|
+
};
|
|
115
|
+
} catch (err) {
|
|
116
|
+
if (err instanceof RealtimeError) throw err;
|
|
117
|
+
throw new RealtimeError('TOKEN_REFRESH_FAILED', 'Failed to refresh token', err);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Fallback to standard resolve
|
|
122
|
+
return this.resolveAuthMetadata();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Notify unauthorized handler if configured
|
|
127
|
+
* @param {RealtimeError} error
|
|
128
|
+
*/
|
|
129
|
+
handleUnauthorized(error) {
|
|
130
|
+
if (this.config && typeof this.config.onUnauthorized === 'function') {
|
|
131
|
+
try {
|
|
132
|
+
this.config.onUnauthorized(error);
|
|
133
|
+
} catch (e) {
|
|
134
|
+
console.error('[realtime] Error in onUnauthorized handler:', e);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
module.exports = { AuthManager };
|