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.
Files changed (136) hide show
  1. package/.agents/ADMIN.md +163 -5
  2. package/.agents/AGENTS.md +1 -1
  3. package/.agents/ARCHITECTURE.md +10 -0
  4. package/.agents/AUTH.md +169 -0
  5. package/.agents/CLI.md +195 -1
  6. package/.agents/ORM.md +88 -4
  7. package/.agents/PLUGINS.md +221 -9
  8. package/.agents/QUEUE.md +127 -0
  9. package/.agents/ROUTING.md +127 -7
  10. package/AGENTS.md +1 -0
  11. package/bin/commands/add-esbuild.js +80 -0
  12. package/bin/commands/add-tailwind.js +76 -31
  13. package/bin/commands/analytics-prune.js +57 -0
  14. package/bin/commands/api.js +207 -15
  15. package/bin/commands/build.js +99 -0
  16. package/bin/commands/dev.js +64 -35
  17. package/bin/commands/doctor.js +26 -0
  18. package/bin/commands/email-prune.js +47 -0
  19. package/bin/commands/make-job.js +86 -0
  20. package/bin/commands/make-model.js +250 -0
  21. package/bin/commands/new.js +45 -28
  22. package/bin/commands/openapi.js +102 -0
  23. package/bin/commands/page.js +6 -3
  24. package/bin/commands/queue-prune.js +59 -0
  25. package/bin/commands/routes.js +52 -0
  26. package/bin/commands/service.js +6 -3
  27. package/bin/commands/typegen.js +106 -0
  28. package/bin/commands/upload-prune.js +79 -0
  29. package/bin/webspresso.js +22 -0
  30. package/core/applySchema.js +13 -6
  31. package/core/auth/hash.js +21 -12
  32. package/core/auth/index.js +60 -0
  33. package/core/auth/manager.js +134 -0
  34. package/core/auth/middleware.js +88 -0
  35. package/core/auth/oauth.js +517 -0
  36. package/core/auth/qrcode.js +499 -0
  37. package/core/auth/totp.js +504 -0
  38. package/core/compression/middleware.js +23 -17
  39. package/core/openapi/build-from-api-routes.js +160 -29
  40. package/core/orm/cache/fingerprint.js +15 -1
  41. package/core/orm/cache/memory-provider.js +27 -2
  42. package/core/orm/eager-loader.js +144 -10
  43. package/core/orm/index.js +20 -7
  44. package/core/orm/model.js +53 -7
  45. package/core/orm/query-builder.js +90 -4
  46. package/core/orm/repository.js +44 -3
  47. package/core/orm/search.js +213 -0
  48. package/core/queue/adapters/database.js +94 -0
  49. package/core/queue/adapters/memory.js +62 -0
  50. package/core/queue/adapters/redis.js +80 -0
  51. package/core/queue/cron.js +331 -0
  52. package/core/queue/index.js +8 -1
  53. package/core/queue/manager.js +205 -1
  54. package/core/queue/scheduler.js +359 -0
  55. package/core/sse/index.js +19 -0
  56. package/core/sse/stream.js +430 -0
  57. package/core/ssr/stream.js +11 -0
  58. package/index.d.ts +1188 -10
  59. package/index.js +88 -96
  60. package/package.json +1 -1
  61. package/plugins/admin-panel/api.js +279 -17
  62. package/plugins/admin-panel/app.js +114 -22
  63. package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +314 -9
  64. package/plugins/admin-panel/client/parts/04-field-renderers.js +264 -67
  65. package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +333 -36
  66. package/plugins/admin-panel/client/parts/06-login-setup-forms.js +119 -28
  67. package/plugins/admin-panel/client/parts/07-model-list.js +49 -8
  68. package/plugins/admin-panel/client/parts/08-record-list.js +248 -23
  69. package/plugins/admin-panel/client/parts/09-record-form.js +59 -12
  70. package/plugins/admin-panel/client/parts/10-export-registry.js +23 -0
  71. package/plugins/admin-panel/client/verify-spa-parts.js +2 -0
  72. package/plugins/admin-panel/core/admin-module.js +13 -7
  73. package/plugins/admin-panel/core/api-extensions.js +30 -2
  74. package/plugins/admin-panel/core/registry.js +96 -16
  75. package/plugins/admin-panel/field-renderers/file-upload.js +187 -104
  76. package/plugins/admin-panel/field-renderers/index.js +2 -0
  77. package/plugins/admin-panel/index.js +96 -0
  78. package/plugins/admin-panel/lib/login-rate-limiter.js +119 -0
  79. package/plugins/admin-panel/modules/admin-users.js +16 -1
  80. package/plugins/admin-panel/modules/bulk-actions.js +13 -5
  81. package/plugins/admin-panel/modules/custom-pages.js +492 -3
  82. package/plugins/admin-panel/modules/dashboard.js +283 -0
  83. package/plugins/admin-panel/modules/menu.js +56 -18
  84. package/plugins/admin-panel/modules/user-management.js +24 -6
  85. package/plugins/api-key/crypto.js +101 -0
  86. package/plugins/api-key/index.js +288 -0
  87. package/plugins/api-key/middleware.js +461 -0
  88. package/plugins/api-key/migration-template.js +48 -0
  89. package/plugins/api-key/model.js +50 -0
  90. package/plugins/api-key/scopes.js +125 -0
  91. package/plugins/audit-log/admin-component.js +487 -59
  92. package/plugins/audit-log/api-handlers.js +148 -0
  93. package/plugins/audit-log/index.js +153 -3
  94. package/plugins/audit-log/purge.js +60 -10
  95. package/plugins/dashboard/app.js +152 -27
  96. package/plugins/dashboard/index.js +143 -39
  97. package/plugins/dashboard/styles.js +78 -0
  98. package/plugins/data-exchange/export-xlsx.js +9 -2
  99. package/plugins/data-exchange/import.js +9 -2
  100. package/plugins/data-exchange/index.js +6 -3
  101. package/plugins/email/index.js +132 -2
  102. package/plugins/email/purge.js +60 -10
  103. package/plugins/file-manager/admin-component.js +20 -4
  104. package/plugins/file-manager/index.js +21 -4
  105. package/plugins/index.js +10 -0
  106. package/plugins/oauth/index.js +338 -0
  107. package/plugins/orm-cache-admin/index.js +4 -2
  108. package/plugins/queue/index.js +15 -0
  109. package/plugins/schema-explorer/admin-component.js +714 -0
  110. package/plugins/schema-explorer/relations-graph.js +205 -0
  111. package/plugins/schema-explorer.js +248 -69
  112. package/plugins/site-analytics/admin-component.js +23 -0
  113. package/plugins/site-analytics/api-handlers.js +42 -0
  114. package/plugins/site-analytics/index.js +147 -2
  115. package/plugins/site-analytics/purge.js +168 -0
  116. package/plugins/swagger.js +261 -13
  117. package/plugins/upload/index.js +26 -0
  118. package/plugins/upload/orphan-pruner.js +292 -0
  119. package/src/api/api-loader.js +4 -1
  120. package/src/bundle/index.js +517 -0
  121. package/src/bundle/manifest.js +165 -0
  122. package/src/dev/banner.js +296 -0
  123. package/src/dev/colors.js +70 -0
  124. package/src/dev/index.js +62 -0
  125. package/src/dev/profiler.js +354 -0
  126. package/src/dev/typegen.js +497 -0
  127. package/src/dev-reload/index.js +611 -0
  128. package/src/discovery/file-route-parser.js +44 -1
  129. package/src/discovery/scan-routes.js +2 -2
  130. package/src/file-router.js +347 -162
  131. package/src/helpers.js +967 -98
  132. package/src/njk-frontmatter.js +12 -0
  133. package/src/pages/page-loader.js +61 -11
  134. package/src/routing/cascading.js +378 -0
  135. package/src/routing/middleware-resolver.js +92 -0
  136. 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
+ };
@@ -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) {