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,312 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ShutdownManager
|
|
3
|
+
* Framework-independent lifecycle coordinator for graceful shutdown, forced connection termination,
|
|
4
|
+
* application shutdown hooks, and plugin cleanup disposers.
|
|
5
|
+
* @module core/shutdown/shutdown-manager
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
class ShutdownManager {
|
|
9
|
+
/**
|
|
10
|
+
* @param {Object} [options]
|
|
11
|
+
* @param {boolean} [options.enabled=true] - Enable shutdown management
|
|
12
|
+
* @param {'graceful'|'force'} [options.mode='graceful'] - Shutdown mode ('graceful' or 'force')
|
|
13
|
+
* @param {number} [options.timeout=10000] - Graceful timeout in milliseconds before forcing close
|
|
14
|
+
* @param {string[]} [options.signals=['SIGINT', 'SIGTERM']] - Process signals to listen for
|
|
15
|
+
* @param {Object|null} [options.logger=console] - Logger instance (or null to disable logs)
|
|
16
|
+
* @param {boolean} [options.exitOnSignal=true] - Whether to call process.exit when triggered by a process signal
|
|
17
|
+
*/
|
|
18
|
+
constructor(options = {}) {
|
|
19
|
+
this.options = {
|
|
20
|
+
enabled: options.enabled !== false,
|
|
21
|
+
mode: options.mode === 'force' ? 'force' : 'graceful',
|
|
22
|
+
timeout: typeof options.timeout === 'number' && options.timeout >= 0 ? options.timeout : 10_000,
|
|
23
|
+
signals: Array.isArray(options.signals) ? options.signals : ['SIGINT', 'SIGTERM'],
|
|
24
|
+
logger: options.logger === null ? null : (options.logger || console),
|
|
25
|
+
exitOnSignal: options.exitOnSignal !== false,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
this.isShuttingDown = false;
|
|
29
|
+
this.isClosed = false;
|
|
30
|
+
|
|
31
|
+
/** @type {Array<Function>} */
|
|
32
|
+
this._hooks = [];
|
|
33
|
+
|
|
34
|
+
/** @type {Array<{ name: string, fn: Function }>} */
|
|
35
|
+
this._disposers = [];
|
|
36
|
+
|
|
37
|
+
/** @type {Array<{ close: () => Promise<void>, forceClose?: () => void, closeIdleConnections?: () => void }>} */
|
|
38
|
+
this._adapters = [];
|
|
39
|
+
|
|
40
|
+
/** @type {Promise<void>|null} */
|
|
41
|
+
this._shutdownPromise = null;
|
|
42
|
+
|
|
43
|
+
/** @type {Map<string, Function>} */
|
|
44
|
+
this._signalListeners = new Map();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
get mode() {
|
|
48
|
+
return this.options.mode;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
set mode(value) {
|
|
52
|
+
this.options.mode = value === 'force' ? 'force' : 'graceful';
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
get timeout() {
|
|
56
|
+
return this.options.timeout;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
set timeout(value) {
|
|
60
|
+
if (typeof value === 'number' && value >= 0) {
|
|
61
|
+
this.options.timeout = value;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Safe logger helper
|
|
67
|
+
* @param {'info'|'warn'|'error'|'debug'} level
|
|
68
|
+
* @param {string} message
|
|
69
|
+
* @param {unknown} [extra]
|
|
70
|
+
*/
|
|
71
|
+
_log(level, message, extra) {
|
|
72
|
+
const logger = this.options.logger;
|
|
73
|
+
if (!logger) return;
|
|
74
|
+
|
|
75
|
+
const formatted = `[webspresso] ${message}`;
|
|
76
|
+
if (typeof logger[level] === 'function') {
|
|
77
|
+
if (extra !== undefined) {
|
|
78
|
+
logger[level](formatted, extra);
|
|
79
|
+
} else {
|
|
80
|
+
logger[level](formatted);
|
|
81
|
+
}
|
|
82
|
+
} else if (typeof logger.log === 'function') {
|
|
83
|
+
if (extra !== undefined) {
|
|
84
|
+
logger.log(formatted, extra);
|
|
85
|
+
} else {
|
|
86
|
+
logger.log(formatted);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Register an application shutdown hook
|
|
93
|
+
* @param {Function} fn - Sync or async callback
|
|
94
|
+
* @returns {this}
|
|
95
|
+
*/
|
|
96
|
+
onShutdown(fn) {
|
|
97
|
+
if (typeof fn === 'function') {
|
|
98
|
+
this._hooks.push(fn);
|
|
99
|
+
}
|
|
100
|
+
return this;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Register a plugin disposer / cleanup function
|
|
105
|
+
* @param {string|Function} nameOrFn - Plugin name or disposer function
|
|
106
|
+
* @param {Function} [maybeFn] - Disposer function if name is provided
|
|
107
|
+
* @returns {this}
|
|
108
|
+
*/
|
|
109
|
+
registerDisposer(nameOrFn, maybeFn) {
|
|
110
|
+
let name = 'plugin';
|
|
111
|
+
let fn = nameOrFn;
|
|
112
|
+
|
|
113
|
+
if (typeof nameOrFn === 'string' && typeof maybeFn === 'function') {
|
|
114
|
+
name = nameOrFn;
|
|
115
|
+
fn = maybeFn;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (typeof fn === 'function') {
|
|
119
|
+
this._disposers.push({ name, fn });
|
|
120
|
+
}
|
|
121
|
+
return this;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Register a server adapter (e.g. NodeHttpAdapter)
|
|
126
|
+
* @param {{ close: () => Promise<void>, forceClose?: () => void, closeIdleConnections?: () => void }} adapter
|
|
127
|
+
* @returns {this}
|
|
128
|
+
*/
|
|
129
|
+
registerAdapter(adapter) {
|
|
130
|
+
if (adapter && typeof adapter.close === 'function') {
|
|
131
|
+
this._adapters.push(adapter);
|
|
132
|
+
}
|
|
133
|
+
return this;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Attach signal listeners (SIGINT, SIGTERM) to the Node.js process
|
|
138
|
+
* @returns {this}
|
|
139
|
+
*/
|
|
140
|
+
enableShutdownHooks() {
|
|
141
|
+
if (!this.options.enabled || typeof process === 'undefined' || typeof process.on !== 'function') {
|
|
142
|
+
return this;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
for (const signal of this.options.signals) {
|
|
146
|
+
if (!this._signalListeners.has(signal)) {
|
|
147
|
+
const listener = async () => {
|
|
148
|
+
this._log('info', `${signal} received`);
|
|
149
|
+
try {
|
|
150
|
+
await this.close(signal);
|
|
151
|
+
if (this.options.exitOnSignal) {
|
|
152
|
+
process.exit(0);
|
|
153
|
+
}
|
|
154
|
+
} catch (err) {
|
|
155
|
+
this._log('error', `Shutdown failed following ${signal}:`, err);
|
|
156
|
+
if (this.options.exitOnSignal) {
|
|
157
|
+
process.exit(1);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
this._signalListeners.set(signal, listener);
|
|
163
|
+
process.on(signal, listener);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return this;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Detach signal listeners from process
|
|
172
|
+
* @returns {this}
|
|
173
|
+
*/
|
|
174
|
+
disableShutdownHooks() {
|
|
175
|
+
if (typeof process === 'undefined' || typeof process.removeListener !== 'function') {
|
|
176
|
+
return this;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
for (const [signal, listener] of this._signalListeners.entries()) {
|
|
180
|
+
process.removeListener(signal, listener);
|
|
181
|
+
}
|
|
182
|
+
this._signalListeners.clear();
|
|
183
|
+
return this;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Execute the shutdown lifecycle pipeline.
|
|
188
|
+
* Idempotent: safe to call multiple times concurrently or sequentially.
|
|
189
|
+
* Note: manual calls to `close()` never call `process.exit()`.
|
|
190
|
+
* @param {string} [reason='manual']
|
|
191
|
+
* @returns {Promise<void>}
|
|
192
|
+
*/
|
|
193
|
+
async close(reason = 'manual') {
|
|
194
|
+
if (this.isClosed) {
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (this._shutdownPromise) {
|
|
199
|
+
return this._shutdownPromise;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
this._shutdownPromise = (async () => {
|
|
203
|
+
this.isShuttingDown = true;
|
|
204
|
+
const isForce = this.options.mode === 'force';
|
|
205
|
+
const modeLabel = isForce ? 'force' : 'graceful';
|
|
206
|
+
|
|
207
|
+
this._log('info', `Starting ${modeLabel} shutdown (reason: ${reason})`);
|
|
208
|
+
|
|
209
|
+
// 1. Close HTTP server adapters
|
|
210
|
+
if (this._adapters.length > 0) {
|
|
211
|
+
this._log('info', 'Closing HTTP server');
|
|
212
|
+
|
|
213
|
+
for (const adapter of this._adapters) {
|
|
214
|
+
if (isForce) {
|
|
215
|
+
// Force mode: immediately close listener & force terminate connections
|
|
216
|
+
try {
|
|
217
|
+
if (typeof adapter.forceClose === 'function') {
|
|
218
|
+
adapter.forceClose();
|
|
219
|
+
}
|
|
220
|
+
await adapter.close();
|
|
221
|
+
} catch (err) {
|
|
222
|
+
this._log('error', 'Error force closing adapter:', err);
|
|
223
|
+
}
|
|
224
|
+
} else {
|
|
225
|
+
// Graceful mode: close idle connections, close server, and force close on timeout
|
|
226
|
+
try {
|
|
227
|
+
if (typeof adapter.closeIdleConnections === 'function') {
|
|
228
|
+
adapter.closeIdleConnections();
|
|
229
|
+
}
|
|
230
|
+
} catch (e) {}
|
|
231
|
+
|
|
232
|
+
let timer = null;
|
|
233
|
+
let timedOut = false;
|
|
234
|
+
|
|
235
|
+
const closePromise = adapter.close();
|
|
236
|
+
|
|
237
|
+
if (this.options.timeout > 0) {
|
|
238
|
+
const timeoutPromise = new Promise((resolve) => {
|
|
239
|
+
timer = setTimeout(() => {
|
|
240
|
+
timedOut = true;
|
|
241
|
+
this._log('warn', `Graceful shutdown timed out after ${this.options.timeout}ms`);
|
|
242
|
+
this._log('info', 'Force closing active connections');
|
|
243
|
+
try {
|
|
244
|
+
if (typeof adapter.forceClose === 'function') {
|
|
245
|
+
adapter.forceClose();
|
|
246
|
+
}
|
|
247
|
+
} catch (err) {
|
|
248
|
+
this._log('error', 'Error force closing connections after timeout:', err);
|
|
249
|
+
}
|
|
250
|
+
resolve();
|
|
251
|
+
}, this.options.timeout);
|
|
252
|
+
|
|
253
|
+
if (typeof timer.unref === 'function') {
|
|
254
|
+
timer.unref();
|
|
255
|
+
}
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
await Promise.race([closePromise, timeoutPromise]);
|
|
259
|
+
} else {
|
|
260
|
+
await closePromise;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (timer) {
|
|
264
|
+
clearTimeout(timer);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Ensure closePromise has resolved
|
|
268
|
+
await closePromise.catch(() => {});
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// 2. Run application shutdown hooks (app.onShutdown)
|
|
274
|
+
if (this._hooks.length > 0) {
|
|
275
|
+
this._log('info', `Running ${this._hooks.length} shutdown hooks`);
|
|
276
|
+
for (const hook of this._hooks) {
|
|
277
|
+
try {
|
|
278
|
+
await hook();
|
|
279
|
+
} catch (err) {
|
|
280
|
+
this._log('error', 'Error in shutdown hook:', err);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// 3. Run plugin disposers in reverse registration order
|
|
286
|
+
if (this._disposers.length > 0) {
|
|
287
|
+
this._log('info', `Disposing ${this._disposers.length} plugins`);
|
|
288
|
+
// Reverse array to tear down dependencies in reverse order
|
|
289
|
+
const reverseDisposers = this._disposers.slice().reverse();
|
|
290
|
+
for (const { name, fn } of reverseDisposers) {
|
|
291
|
+
try {
|
|
292
|
+
await fn();
|
|
293
|
+
} catch (err) {
|
|
294
|
+
this._log('error', `Error in plugin disposer "${name}":`, err);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// 4. Detach signal hooks from process
|
|
300
|
+
this.disableShutdownHooks();
|
|
301
|
+
|
|
302
|
+
this.isClosed = true;
|
|
303
|
+
this._log('info', 'Shutdown completed');
|
|
304
|
+
})();
|
|
305
|
+
|
|
306
|
+
return this._shutdownPromise;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
module.exports = {
|
|
311
|
+
ShutdownManager,
|
|
312
|
+
};
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Webspresso SSR Streaming & Chunked Transfer Engine
|
|
3
|
+
* @module core/ssr/stream
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
'use strict';
|
|
7
|
+
|
|
8
|
+
const { Readable } = require('stream');
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Creates a readable HTML stream for Nunjucks templates supporting deferred data slots
|
|
12
|
+
* @param {Object} options
|
|
13
|
+
* @param {import('nunjucks').Environment} options.env - Nunjucks environment
|
|
14
|
+
* @param {string} options.templatePath - Template name or path
|
|
15
|
+
* @param {Object} [options.context={}] - Render context
|
|
16
|
+
* @param {Object} [options.defer] - Map of deferred promises { [slotName]: Promise<any> | { promise: Promise<any>, template?: string } }
|
|
17
|
+
* @returns {Readable}
|
|
18
|
+
*/
|
|
19
|
+
function createHtmlStream(options = {}) {
|
|
20
|
+
const { env, templatePath, context = {}, defer = {} } = options;
|
|
21
|
+
|
|
22
|
+
if (!env) {
|
|
23
|
+
throw new Error('createHtmlStream requires a valid Nunjucks environment in options.env');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const stream = new Readable({
|
|
27
|
+
read() {}, // No-op, data is pushed asynchronously
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
(async () => {
|
|
31
|
+
try {
|
|
32
|
+
const deferredKeys = Object.keys(defer);
|
|
33
|
+
|
|
34
|
+
if (deferredKeys.length === 0) {
|
|
35
|
+
// Standard template streaming (render and stream)
|
|
36
|
+
const html = env.render(templatePath, context);
|
|
37
|
+
stream.push(html);
|
|
38
|
+
stream.push(null);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Initial shell rendering with deferred slot placeholders
|
|
43
|
+
const shellContext = {
|
|
44
|
+
...context,
|
|
45
|
+
__streaming: true,
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
for (const key of deferredKeys) {
|
|
49
|
+
shellContext[key] = null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
let shellHtml = env.render(templatePath, shellContext);
|
|
53
|
+
|
|
54
|
+
// Check if template contains streaming slot markers, or split at first slot
|
|
55
|
+
// We push the initial shell chunk immediately to start browser TTFB & CSS download
|
|
56
|
+
stream.push(shellHtml);
|
|
57
|
+
|
|
58
|
+
// Resolve deferred promises in parallel
|
|
59
|
+
const resolutionPromises = deferredKeys.map(async (key) => {
|
|
60
|
+
const deferredItem = defer[key];
|
|
61
|
+
const promise = deferredItem && typeof deferredItem.then === 'function' ? deferredItem : deferredItem.promise;
|
|
62
|
+
const slotTemplate = deferredItem.template || null;
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
const resolvedData = await promise;
|
|
66
|
+
let slotHtml = '';
|
|
67
|
+
if (slotTemplate) {
|
|
68
|
+
slotHtml = env.render(slotTemplate, { ...context, [key]: resolvedData });
|
|
69
|
+
} else {
|
|
70
|
+
slotHtml = typeof resolvedData === 'string' ? resolvedData : JSON.stringify(resolvedData);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Push an out-of-order streaming chunk with inline replacement script or comment
|
|
74
|
+
const slotChunk = `
|
|
75
|
+
<template data-stream-slot="${key}">${slotHtml}</template>
|
|
76
|
+
<script>
|
|
77
|
+
(function(){
|
|
78
|
+
var t = document.querySelector('template[data-stream-slot="${key}"]');
|
|
79
|
+
var target = document.querySelector('[data-stream-target="${key}"]') || document.getElementById('stream-target-${key}');
|
|
80
|
+
if (t && target) {
|
|
81
|
+
target.innerHTML = '';
|
|
82
|
+
target.appendChild(t.content.cloneNode(true));
|
|
83
|
+
}
|
|
84
|
+
})();
|
|
85
|
+
</script>`;
|
|
86
|
+
stream.push(slotChunk);
|
|
87
|
+
} catch (slotErr) {
|
|
88
|
+
const errorChunk = `
|
|
89
|
+
<template data-stream-slot="${key}">
|
|
90
|
+
<!-- Slot Error (${key}): ${slotErr.message} -->
|
|
91
|
+
</template>`;
|
|
92
|
+
stream.push(errorChunk);
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
await Promise.all(resolutionPromises);
|
|
97
|
+
stream.push(null); // End of stream
|
|
98
|
+
} catch (err) {
|
|
99
|
+
stream.destroy(err);
|
|
100
|
+
}
|
|
101
|
+
})();
|
|
102
|
+
|
|
103
|
+
return stream;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Render template directly to Express response as a chunked stream
|
|
108
|
+
* @param {import('express').Response} res - Express response object
|
|
109
|
+
* @param {string} templatePath - Template name
|
|
110
|
+
* @param {Object} [context={}] - Context data
|
|
111
|
+
* @param {Object} [options] - Additional options
|
|
112
|
+
* @param {import('nunjucks').Environment} options.env - Nunjucks environment
|
|
113
|
+
* @param {Object} [options.defer] - Deferred slot promises
|
|
114
|
+
* @param {number} [options.status=200] - HTTP status code
|
|
115
|
+
* @param {boolean} [options.flushImmediately=true] - Flush headers immediately
|
|
116
|
+
* @returns {Promise<void>}
|
|
117
|
+
*/
|
|
118
|
+
function renderStream(res, templatePath, context = {}, options = {}) {
|
|
119
|
+
return new Promise((resolve, reject) => {
|
|
120
|
+
const {
|
|
121
|
+
env,
|
|
122
|
+
defer = {},
|
|
123
|
+
status = 200,
|
|
124
|
+
flushImmediately = true,
|
|
125
|
+
} = options;
|
|
126
|
+
|
|
127
|
+
if (!env) {
|
|
128
|
+
return reject(new Error('renderStream requires a valid Nunjucks environment in options.env'));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (res.headersSent) {
|
|
132
|
+
return reject(new Error('Cannot render stream: headers already sent'));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
res.status(status);
|
|
136
|
+
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
137
|
+
res.setHeader('Transfer-Encoding', 'chunked');
|
|
138
|
+
|
|
139
|
+
if (flushImmediately && typeof res.flushHeaders === 'function') {
|
|
140
|
+
res.flushHeaders();
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const stream = createHtmlStream({
|
|
144
|
+
env,
|
|
145
|
+
templatePath,
|
|
146
|
+
context,
|
|
147
|
+
defer,
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
stream.on('data', (chunk) => {
|
|
151
|
+
res.write(chunk);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
stream.on('end', () => {
|
|
155
|
+
res.end();
|
|
156
|
+
resolve();
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
stream.on('error', (err) => {
|
|
160
|
+
if (!res.headersSent) {
|
|
161
|
+
res.status(500).send('Streaming Render Error');
|
|
162
|
+
} else {
|
|
163
|
+
res.write(`<!-- Streaming Error: ${err.message} -->`);
|
|
164
|
+
res.end();
|
|
165
|
+
}
|
|
166
|
+
reject(err);
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
module.exports = {
|
|
172
|
+
createHtmlStream,
|
|
173
|
+
renderStream,
|
|
174
|
+
};
|