webspresso 0.0.97 → 0.0.99
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/.agents/ADMIN.md +163 -5
- package/.agents/AGENTS.md +1 -1
- package/.agents/ARCHITECTURE.md +10 -0
- package/.agents/AUTH.md +169 -0
- package/.agents/CLI.md +195 -1
- package/.agents/ORM.md +88 -4
- package/.agents/PLUGINS.md +221 -9
- package/.agents/QUEUE.md +127 -0
- package/.agents/ROUTING.md +127 -7
- package/AGENTS.md +1 -0
- package/bin/commands/add-esbuild.js +80 -0
- package/bin/commands/add-tailwind.js +76 -31
- package/bin/commands/analytics-prune.js +57 -0
- package/bin/commands/api.js +207 -15
- package/bin/commands/build.js +99 -0
- package/bin/commands/dev.js +64 -35
- package/bin/commands/doctor.js +26 -0
- package/bin/commands/email-prune.js +47 -0
- package/bin/commands/make-job.js +86 -0
- package/bin/commands/make-model.js +250 -0
- package/bin/commands/new.js +45 -28
- package/bin/commands/openapi.js +102 -0
- package/bin/commands/page.js +6 -3
- package/bin/commands/queue-prune.js +59 -0
- package/bin/commands/routes.js +52 -0
- package/bin/commands/service.js +6 -3
- package/bin/commands/typegen.js +106 -0
- package/bin/commands/upload-prune.js +79 -0
- package/bin/webspresso.js +22 -0
- package/core/applySchema.js +13 -6
- package/core/auth/hash.js +21 -12
- package/core/auth/index.js +60 -0
- package/core/auth/manager.js +134 -0
- package/core/auth/middleware.js +88 -0
- package/core/auth/oauth.js +517 -0
- package/core/auth/qrcode.js +499 -0
- package/core/auth/totp.js +504 -0
- package/core/compression/middleware.js +23 -17
- package/core/openapi/build-from-api-routes.js +160 -29
- package/core/orm/cache/fingerprint.js +15 -1
- package/core/orm/cache/memory-provider.js +27 -2
- package/core/orm/eager-loader.js +144 -10
- package/core/orm/index.js +20 -7
- package/core/orm/model.js +53 -7
- package/core/orm/query-builder.js +90 -4
- package/core/orm/repository.js +44 -3
- package/core/orm/search.js +213 -0
- package/core/queue/adapters/database.js +94 -0
- package/core/queue/adapters/memory.js +62 -0
- package/core/queue/adapters/redis.js +80 -0
- package/core/queue/cron.js +331 -0
- package/core/queue/index.js +8 -1
- package/core/queue/manager.js +205 -1
- package/core/queue/scheduler.js +359 -0
- package/core/sse/index.js +19 -0
- package/core/sse/stream.js +430 -0
- package/core/ssr/stream.js +11 -0
- package/index.d.ts +1188 -10
- package/index.js +88 -96
- package/package.json +1 -1
- package/plugins/admin-panel/api.js +279 -17
- package/plugins/admin-panel/app.js +114 -22
- package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +314 -9
- package/plugins/admin-panel/client/parts/04-field-renderers.js +264 -67
- package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +333 -36
- package/plugins/admin-panel/client/parts/06-login-setup-forms.js +119 -28
- package/plugins/admin-panel/client/parts/07-model-list.js +49 -8
- package/plugins/admin-panel/client/parts/08-record-list.js +248 -23
- package/plugins/admin-panel/client/parts/09-record-form.js +59 -12
- package/plugins/admin-panel/client/parts/10-export-registry.js +23 -0
- package/plugins/admin-panel/client/verify-spa-parts.js +2 -0
- package/plugins/admin-panel/core/admin-module.js +13 -7
- package/plugins/admin-panel/core/api-extensions.js +30 -2
- package/plugins/admin-panel/core/registry.js +96 -16
- package/plugins/admin-panel/field-renderers/file-upload.js +187 -104
- package/plugins/admin-panel/field-renderers/index.js +2 -0
- package/plugins/admin-panel/index.js +96 -0
- package/plugins/admin-panel/lib/login-rate-limiter.js +119 -0
- package/plugins/admin-panel/modules/admin-users.js +16 -1
- package/plugins/admin-panel/modules/bulk-actions.js +13 -5
- package/plugins/admin-panel/modules/custom-pages.js +492 -3
- package/plugins/admin-panel/modules/dashboard.js +283 -0
- package/plugins/admin-panel/modules/menu.js +56 -18
- package/plugins/admin-panel/modules/user-management.js +24 -6
- package/plugins/api-key/crypto.js +101 -0
- package/plugins/api-key/index.js +288 -0
- package/plugins/api-key/middleware.js +461 -0
- package/plugins/api-key/migration-template.js +48 -0
- package/plugins/api-key/model.js +50 -0
- package/plugins/api-key/scopes.js +125 -0
- package/plugins/audit-log/admin-component.js +487 -59
- package/plugins/audit-log/api-handlers.js +148 -0
- package/plugins/audit-log/index.js +153 -3
- package/plugins/audit-log/purge.js +60 -10
- package/plugins/dashboard/app.js +152 -27
- package/plugins/dashboard/index.js +143 -39
- package/plugins/dashboard/styles.js +78 -0
- package/plugins/data-exchange/export-xlsx.js +9 -2
- package/plugins/data-exchange/import.js +9 -2
- package/plugins/data-exchange/index.js +6 -3
- package/plugins/email/index.js +132 -2
- package/plugins/email/purge.js +60 -10
- package/plugins/file-manager/admin-component.js +20 -4
- package/plugins/file-manager/index.js +21 -4
- package/plugins/index.js +10 -0
- package/plugins/oauth/index.js +338 -0
- package/plugins/orm-cache-admin/index.js +4 -2
- package/plugins/queue/index.js +15 -0
- package/plugins/schema-explorer/admin-component.js +714 -0
- package/plugins/schema-explorer/relations-graph.js +205 -0
- package/plugins/schema-explorer.js +248 -69
- package/plugins/site-analytics/admin-component.js +23 -0
- package/plugins/site-analytics/api-handlers.js +42 -0
- package/plugins/site-analytics/index.js +147 -2
- package/plugins/site-analytics/purge.js +168 -0
- package/plugins/swagger.js +261 -13
- package/plugins/upload/index.js +26 -0
- package/plugins/upload/orphan-pruner.js +292 -0
- package/src/api/api-loader.js +4 -1
- package/src/bundle/index.js +517 -0
- package/src/bundle/manifest.js +165 -0
- package/src/dev/banner.js +296 -0
- package/src/dev/colors.js +70 -0
- package/src/dev/index.js +62 -0
- package/src/dev/profiler.js +354 -0
- package/src/dev/typegen.js +497 -0
- package/src/dev-reload/index.js +611 -0
- package/src/discovery/file-route-parser.js +44 -1
- package/src/discovery/scan-routes.js +2 -2
- package/src/file-router.js +347 -162
- package/src/helpers.js +967 -98
- package/src/njk-frontmatter.js +12 -0
- package/src/pages/page-loader.js +61 -11
- package/src/routing/cascading.js +378 -0
- package/src/routing/middleware-resolver.js +92 -0
- package/src/server.js +635 -63
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-Sent Events (SSE) Streaming Engine for Webspresso
|
|
3
|
+
* Provides high-performance, zero-dependency HTTP response streaming for real-time events and LLM outputs.
|
|
4
|
+
* @module core/sse/stream
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
'use strict';
|
|
8
|
+
|
|
9
|
+
const EventEmitter = require('events');
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Format an SSE frame according to the W3C / WHATWG Server-Sent Events specification.
|
|
13
|
+
*
|
|
14
|
+
* @param {any} [data] - The data payload (string, object, buffer, etc.)
|
|
15
|
+
* @param {string} [event] - Optional event name
|
|
16
|
+
* @param {string|number} [id] - Optional event ID
|
|
17
|
+
* @param {number} [retry] - Optional client reconnection retry time in ms
|
|
18
|
+
* @returns {string} Formatted SSE frame
|
|
19
|
+
*/
|
|
20
|
+
function formatSseFrame(data, event, id, retry) {
|
|
21
|
+
let frame = '';
|
|
22
|
+
|
|
23
|
+
if (id !== undefined && id !== null) {
|
|
24
|
+
frame += `id: ${String(id).replace(/[\r\n]/g, '')}\n`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (event !== undefined && event !== null && event !== '') {
|
|
28
|
+
frame += `event: ${String(event).replace(/[\r\n]/g, '')}\n`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (retry !== undefined && retry !== null && Number.isFinite(Number(retry))) {
|
|
32
|
+
frame += `retry: ${Math.floor(Number(retry))}\n`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (data !== undefined && data !== null) {
|
|
36
|
+
const text = typeof data === 'object' && !Buffer.isBuffer(data)
|
|
37
|
+
? JSON.stringify(data)
|
|
38
|
+
: String(data);
|
|
39
|
+
|
|
40
|
+
const lines = text.split(/\r\n|\r|\n/);
|
|
41
|
+
for (const line of lines) {
|
|
42
|
+
frame += `data: ${line}\n`;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
frame += '\n';
|
|
47
|
+
return frame;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Controller for an active Server-Sent Events HTTP connection.
|
|
52
|
+
* @extends EventEmitter
|
|
53
|
+
*/
|
|
54
|
+
class ServerSentEventsStream extends EventEmitter {
|
|
55
|
+
/**
|
|
56
|
+
* @param {import('http').IncomingMessage} req
|
|
57
|
+
* @param {import('http').ServerResponse} res
|
|
58
|
+
* @param {Object} [options]
|
|
59
|
+
* @param {number} [options.status=200] - HTTP status code
|
|
60
|
+
* @param {Record<string, string>} [options.headers] - Additional custom HTTP headers
|
|
61
|
+
* @param {number|boolean} [options.heartbeat=15000] - Heartbeat interval in ms (or false/0 to disable)
|
|
62
|
+
* @param {string} [options.heartbeatComment='ping'] - Comment string for heartbeat
|
|
63
|
+
* @param {number} [options.retry] - Initial reconnection retry time in ms sent to client
|
|
64
|
+
*/
|
|
65
|
+
constructor(req, res, options = {}) {
|
|
66
|
+
super();
|
|
67
|
+
|
|
68
|
+
this.req = req;
|
|
69
|
+
this.res = res;
|
|
70
|
+
this.options = options;
|
|
71
|
+
|
|
72
|
+
this.isOpen = true;
|
|
73
|
+
this.isClosed = false;
|
|
74
|
+
this._heartbeatTimer = null;
|
|
75
|
+
this._cleanupBound = this._handleClose.bind(this);
|
|
76
|
+
|
|
77
|
+
this._init();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Initialize headers, socket optimizations, and heartbeat.
|
|
82
|
+
* @private
|
|
83
|
+
*/
|
|
84
|
+
_init() {
|
|
85
|
+
// 1. Opt out of HTTP compression (zlib buffering breaks streaming)
|
|
86
|
+
if (typeof this.res.compress === 'function') {
|
|
87
|
+
this.res.compress(false);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// 2. Socket-level optimizations for long-lived real-time streams
|
|
91
|
+
const socket = this.req.socket;
|
|
92
|
+
if (socket) {
|
|
93
|
+
if (typeof socket.setTimeout === 'function') socket.setTimeout(0);
|
|
94
|
+
if (typeof socket.setNoDelay === 'function') socket.setNoDelay(true);
|
|
95
|
+
if (typeof socket.setKeepAlive === 'function') socket.setKeepAlive(true);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// 3. Write SSE headers if not already sent
|
|
99
|
+
if (!this.res.headersSent) {
|
|
100
|
+
const status = this.options.status || 200;
|
|
101
|
+
const customHeaders = this.options.headers || {};
|
|
102
|
+
const headers = {
|
|
103
|
+
'Content-Type': 'text/event-stream; charset=utf-8',
|
|
104
|
+
'Cache-Control': 'no-cache, no-transform',
|
|
105
|
+
'Connection': 'keep-alive',
|
|
106
|
+
'X-Accel-Buffering': 'no',
|
|
107
|
+
...customHeaders,
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
if (typeof this.res.writeHead === 'function') {
|
|
111
|
+
this.res.writeHead(status, headers);
|
|
112
|
+
} else {
|
|
113
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
114
|
+
this.res.setHeader(key, value);
|
|
115
|
+
}
|
|
116
|
+
this.res.statusCode = status;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (typeof this.res.flushHeaders === 'function') {
|
|
121
|
+
this.res.flushHeaders();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// 4. Send initial retry configuration if provided
|
|
125
|
+
if (this.options.retry && Number.isFinite(Number(this.options.retry))) {
|
|
126
|
+
this.retry(this.options.retry);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// 5. Setup periodic heartbeat (defaults to 15s)
|
|
130
|
+
const heartbeatMs = this.options.heartbeat === false || this.options.heartbeat === 0
|
|
131
|
+
? 0
|
|
132
|
+
: (typeof this.options.heartbeat === 'number' && this.options.heartbeat > 0
|
|
133
|
+
? this.options.heartbeat
|
|
134
|
+
: 15000);
|
|
135
|
+
|
|
136
|
+
if (heartbeatMs > 0) {
|
|
137
|
+
const commentText = this.options.heartbeatComment || 'ping';
|
|
138
|
+
this._heartbeatTimer = setInterval(() => {
|
|
139
|
+
if (!this.isOpen || this.isClosed) {
|
|
140
|
+
this._stopHeartbeat();
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
this.comment(commentText);
|
|
144
|
+
}, heartbeatMs);
|
|
145
|
+
|
|
146
|
+
if (typeof this._heartbeatTimer.unref === 'function') {
|
|
147
|
+
this._heartbeatTimer.unref();
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// 6. Listen for connection close from either client or server
|
|
152
|
+
this.req.on('close', this._cleanupBound);
|
|
153
|
+
this.res.on('close', this._cleanupBound);
|
|
154
|
+
this.res.on('finish', this._cleanupBound);
|
|
155
|
+
this.res.on('error', this._cleanupBound);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Stop the heartbeat interval.
|
|
160
|
+
* @private
|
|
161
|
+
*/
|
|
162
|
+
_stopHeartbeat() {
|
|
163
|
+
if (this._heartbeatTimer) {
|
|
164
|
+
clearInterval(this._heartbeatTimer);
|
|
165
|
+
this._heartbeatTimer = null;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Handle socket/connection termination.
|
|
171
|
+
* @private
|
|
172
|
+
*/
|
|
173
|
+
_handleClose() {
|
|
174
|
+
if (this.isClosed) return;
|
|
175
|
+
this.isOpen = false;
|
|
176
|
+
this.isClosed = true;
|
|
177
|
+
|
|
178
|
+
this._stopHeartbeat();
|
|
179
|
+
|
|
180
|
+
this.req.removeListener('close', this._cleanupBound);
|
|
181
|
+
this.res.removeListener('close', this._cleanupBound);
|
|
182
|
+
this.res.removeListener('finish', this._cleanupBound);
|
|
183
|
+
this.res.removeListener('error', this._cleanupBound);
|
|
184
|
+
|
|
185
|
+
this.emit('close');
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Flush response buffer if available.
|
|
190
|
+
* @private
|
|
191
|
+
*/
|
|
192
|
+
_flush() {
|
|
193
|
+
if (typeof this.res.flush === 'function') {
|
|
194
|
+
try {
|
|
195
|
+
this.res.flush();
|
|
196
|
+
} catch (_) {}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Send an SSE event to the client.
|
|
202
|
+
* Supports both sse.send(data, event, id) and sse.send({ data, event, id, retry }).
|
|
203
|
+
* Handles backpressure if the underlying socket buffer fills.
|
|
204
|
+
*
|
|
205
|
+
* @param {any} payloadOrData - Data payload or event object ({ data, event, id, retry })
|
|
206
|
+
* @param {string} [event] - Event type name
|
|
207
|
+
* @param {string|number} [id] - Event ID
|
|
208
|
+
* @returns {Promise<boolean>} Resolves to true when written, false if stream is closed
|
|
209
|
+
*/
|
|
210
|
+
async send(payloadOrData, event, id) {
|
|
211
|
+
if (!this.isOpen || this.isClosed || this.res.destroyed || this.res.writableEnded) {
|
|
212
|
+
return false;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
let data;
|
|
216
|
+
let eventName = event;
|
|
217
|
+
let eventId = id;
|
|
218
|
+
let retryMs;
|
|
219
|
+
|
|
220
|
+
if (
|
|
221
|
+
payloadOrData !== null &&
|
|
222
|
+
typeof payloadOrData === 'object' &&
|
|
223
|
+
!Buffer.isBuffer(payloadOrData) &&
|
|
224
|
+
('data' in payloadOrData || 'event' in payloadOrData || 'id' in payloadOrData || 'retry' in payloadOrData)
|
|
225
|
+
) {
|
|
226
|
+
data = payloadOrData.data;
|
|
227
|
+
eventName = payloadOrData.event !== undefined ? payloadOrData.event : event;
|
|
228
|
+
eventId = payloadOrData.id !== undefined ? payloadOrData.id : id;
|
|
229
|
+
retryMs = payloadOrData.retry;
|
|
230
|
+
} else {
|
|
231
|
+
data = payloadOrData;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const frame = formatSseFrame(data, eventName, eventId, retryMs);
|
|
235
|
+
|
|
236
|
+
return new Promise((resolve) => {
|
|
237
|
+
try {
|
|
238
|
+
const canContinue = this.res.write(frame);
|
|
239
|
+
this._flush();
|
|
240
|
+
|
|
241
|
+
if (!canContinue && !this.res.destroyed && !this.res.writableEnded) {
|
|
242
|
+
this.res.once('drain', () => resolve(true));
|
|
243
|
+
} else {
|
|
244
|
+
resolve(true);
|
|
245
|
+
}
|
|
246
|
+
} catch (err) {
|
|
247
|
+
this._handleClose();
|
|
248
|
+
resolve(false);
|
|
249
|
+
}
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Write an SSE comment (useful for keepalive or debugging).
|
|
255
|
+
* Format: `: <text>\n\n`
|
|
256
|
+
*
|
|
257
|
+
* @param {string} text - Comment text
|
|
258
|
+
* @returns {boolean} Whether comment was successfully queued
|
|
259
|
+
*/
|
|
260
|
+
comment(text) {
|
|
261
|
+
if (!this.isOpen || this.isClosed || this.res.destroyed || this.res.writableEnded) {
|
|
262
|
+
return false;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
try {
|
|
266
|
+
const sanitized = String(text || '').replace(/[\r\n]/g, ' ');
|
|
267
|
+
this.res.write(`: ${sanitized}\n\n`);
|
|
268
|
+
this._flush();
|
|
269
|
+
return true;
|
|
270
|
+
} catch (_) {
|
|
271
|
+
this._handleClose();
|
|
272
|
+
return false;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Send a heartbeat comment to maintain proxy connections.
|
|
278
|
+
*
|
|
279
|
+
* @param {string} [comment='ping'] - Comment string
|
|
280
|
+
* @returns {boolean}
|
|
281
|
+
*/
|
|
282
|
+
heartbeat(comment = 'ping') {
|
|
283
|
+
return this.comment(comment);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Tell the client to change its reconnection retry interval.
|
|
288
|
+
*
|
|
289
|
+
* @param {number} ms - Retry interval in milliseconds
|
|
290
|
+
* @returns {boolean}
|
|
291
|
+
*/
|
|
292
|
+
retry(ms) {
|
|
293
|
+
if (!this.isOpen || this.isClosed || this.res.destroyed || this.res.writableEnded) {
|
|
294
|
+
return false;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
try {
|
|
298
|
+
const parsed = Math.floor(Number(ms));
|
|
299
|
+
if (!Number.isFinite(parsed) || parsed < 0) return false;
|
|
300
|
+
this.res.write(`retry: ${parsed}\n\n`);
|
|
301
|
+
this._flush();
|
|
302
|
+
return true;
|
|
303
|
+
} catch (_) {
|
|
304
|
+
this._handleClose();
|
|
305
|
+
return false;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Pipe an async iterable, generator, or readable stream directly into the SSE stream.
|
|
311
|
+
* Supports optional mapping function for transforming chunks.
|
|
312
|
+
*
|
|
313
|
+
* @param {AsyncIterable<any>|Iterable<any>|import('stream').Readable} source
|
|
314
|
+
* @param {Function} [mapFn] - Optional transformer (chunk) => transformedChunk or { data, event, id }
|
|
315
|
+
* @returns {Promise<void>}
|
|
316
|
+
*/
|
|
317
|
+
async pipe(source, mapFn) {
|
|
318
|
+
if (!source) return;
|
|
319
|
+
|
|
320
|
+
try {
|
|
321
|
+
for await (const rawChunk of source) {
|
|
322
|
+
if (!this.isOpen || this.isClosed) break;
|
|
323
|
+
|
|
324
|
+
let chunk = typeof mapFn === 'function' ? mapFn(rawChunk) : rawChunk;
|
|
325
|
+
if (chunk === undefined || chunk === null) continue;
|
|
326
|
+
|
|
327
|
+
await this.send(chunk);
|
|
328
|
+
}
|
|
329
|
+
} catch (err) {
|
|
330
|
+
this.emit('error', err);
|
|
331
|
+
throw err;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Stream output from an async generator function and automatically close the connection when done.
|
|
337
|
+
*
|
|
338
|
+
* @param {Function} generatorFn - Async generator function (stream) => AsyncGenerator
|
|
339
|
+
* @returns {Promise<void>}
|
|
340
|
+
*/
|
|
341
|
+
async stream(generatorFn) {
|
|
342
|
+
if (typeof generatorFn !== 'function') {
|
|
343
|
+
throw new TypeError('sse.stream() expects a function or async generator function');
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
try {
|
|
347
|
+
const iterator = generatorFn(this);
|
|
348
|
+
if (iterator && typeof iterator[Symbol.asyncIterator] === 'function') {
|
|
349
|
+
await this.pipe(iterator);
|
|
350
|
+
} else if (iterator && typeof iterator[Symbol.iterator] === 'function') {
|
|
351
|
+
await this.pipe(iterator);
|
|
352
|
+
} else if (iterator && typeof iterator.then === 'function') {
|
|
353
|
+
await iterator;
|
|
354
|
+
}
|
|
355
|
+
} finally {
|
|
356
|
+
this.close();
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Close the SSE stream and terminate the HTTP response.
|
|
362
|
+
*/
|
|
363
|
+
close() {
|
|
364
|
+
if (this.isClosed) return;
|
|
365
|
+
this.isOpen = false;
|
|
366
|
+
this.isClosed = true;
|
|
367
|
+
|
|
368
|
+
this._stopHeartbeat();
|
|
369
|
+
|
|
370
|
+
try {
|
|
371
|
+
if (!this.res.writableEnded && !this.res.destroyed) {
|
|
372
|
+
this.res.end();
|
|
373
|
+
}
|
|
374
|
+
} catch (_) {}
|
|
375
|
+
|
|
376
|
+
this._handleClose();
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Creates or attaches an SSE stream to an HTTP response.
|
|
382
|
+
*
|
|
383
|
+
* @param {import('http').IncomingMessage} req
|
|
384
|
+
* @param {import('http').ServerResponse} res
|
|
385
|
+
* @param {any} [sourceOrOptions] - Async iterable, generator function, or options object
|
|
386
|
+
* @param {Object} [maybeOptions] - Options when first argument is a stream or generator
|
|
387
|
+
* @returns {ServerSentEventsStream|Promise<void>}
|
|
388
|
+
*/
|
|
389
|
+
function createSseStream(req, res, sourceOrOptions, maybeOptions) {
|
|
390
|
+
let source = null;
|
|
391
|
+
let options = {};
|
|
392
|
+
|
|
393
|
+
if (sourceOrOptions) {
|
|
394
|
+
if (
|
|
395
|
+
typeof sourceOrOptions[Symbol.asyncIterator] === 'function' ||
|
|
396
|
+
(typeof sourceOrOptions[Symbol.iterator] === 'function' &&
|
|
397
|
+
typeof sourceOrOptions !== 'string' &&
|
|
398
|
+
!Buffer.isBuffer(sourceOrOptions)) ||
|
|
399
|
+
typeof sourceOrOptions.pipe === 'function' ||
|
|
400
|
+
typeof sourceOrOptions === 'function'
|
|
401
|
+
) {
|
|
402
|
+
source = sourceOrOptions;
|
|
403
|
+
options = maybeOptions || {};
|
|
404
|
+
} else if (typeof sourceOrOptions === 'object') {
|
|
405
|
+
options = sourceOrOptions;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// Reuse existing SSE stream instance on this response if already initialized
|
|
410
|
+
let sseInstance = res._sseStream;
|
|
411
|
+
if (!sseInstance) {
|
|
412
|
+
sseInstance = new ServerSentEventsStream(req, res, options);
|
|
413
|
+
res._sseStream = sseInstance;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
if (source) {
|
|
417
|
+
if (typeof source === 'function') {
|
|
418
|
+
return sseInstance.stream(source);
|
|
419
|
+
}
|
|
420
|
+
return sseInstance.pipe(source, options.map);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
return sseInstance;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
module.exports = {
|
|
427
|
+
ServerSentEventsStream,
|
|
428
|
+
createSseStream,
|
|
429
|
+
formatSseFrame,
|
|
430
|
+
};
|
package/core/ssr/stream.js
CHANGED
|
@@ -182,6 +182,17 @@ function renderStream(res, templatePath, context = {}, options = {}) {
|
|
|
182
182
|
};
|
|
183
183
|
|
|
184
184
|
res.on('close', cleanup);
|
|
185
|
+
if (typeof res.on === 'function') {
|
|
186
|
+
res.on('error', (err) => {
|
|
187
|
+
if (!finished) {
|
|
188
|
+
finished = true;
|
|
189
|
+
if (!stream.destroyed) {
|
|
190
|
+
stream.destroy(err);
|
|
191
|
+
}
|
|
192
|
+
reject(err);
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
}
|
|
185
196
|
|
|
186
197
|
stream.on('data', (chunk) => {
|
|
187
198
|
if (!finished && !res.writableEnded) {
|