webspresso 0.0.97 → 0.0.98
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 +153 -5
- package/.agents/ARCHITECTURE.md +10 -0
- package/.agents/CLI.md +195 -1
- package/.agents/PLUGINS.md +153 -9
- package/.agents/QUEUE.md +43 -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/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/index.js +12 -7
- package/core/orm/model.js +49 -7
- 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/manager.js +123 -0
- package/core/ssr/stream.js +11 -0
- package/index.d.ts +660 -10
- package/index.js +44 -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 +10 -5
- 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 +243 -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/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/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/audit-log/admin-component.js +11 -0
- package/plugins/audit-log/api-handlers.js +34 -0
- package/plugins/audit-log/index.js +129 -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/index.js +4 -0
- package/plugins/orm-cache-admin/index.js +4 -2
- package/plugins/queue/index.js +11 -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 +3 -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/file-router.js +210 -62
- package/src/helpers.js +958 -98
- package/src/njk-frontmatter.js +12 -0
- package/src/pages/page-loader.js +61 -11
- package/src/server.js +629 -63
|
@@ -0,0 +1,611 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Webspresso Development Live & Hot Reload Hub
|
|
3
|
+
* Zero-dependency SSE-based CSS Hot Reload & Page Reload
|
|
4
|
+
* @module dev-reload
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const fs = require('fs');
|
|
8
|
+
const path = require('path');
|
|
9
|
+
|
|
10
|
+
const CLIENT_SCRIPT_PATH = '/__webspresso/dev-reload.js';
|
|
11
|
+
const SSE_ENDPOINT_PATH = '/__webspresso/dev-reload';
|
|
12
|
+
|
|
13
|
+
const CLIENT_RUNTIME_SCRIPT = `(function() {
|
|
14
|
+
if (typeof window === 'undefined' || !window.EventSource) return;
|
|
15
|
+
|
|
16
|
+
var es = null;
|
|
17
|
+
var reconnectTimer = null;
|
|
18
|
+
|
|
19
|
+
function reloadCss(targetFile) {
|
|
20
|
+
var links = document.querySelectorAll('link[rel="stylesheet"]');
|
|
21
|
+
var fileName = targetFile ? targetFile.split(/[\\\\/]/).pop() : null;
|
|
22
|
+
var count = 0;
|
|
23
|
+
|
|
24
|
+
for (var i = 0; i < links.length; i++) {
|
|
25
|
+
var link = links[i];
|
|
26
|
+
var href = link.getAttribute('href');
|
|
27
|
+
if (!href) continue;
|
|
28
|
+
|
|
29
|
+
var cleanHref = href.split('?')[0];
|
|
30
|
+
var isLocal = !cleanHref.startsWith('http://') && !cleanHref.startsWith('https://') && !cleanHref.startsWith('//');
|
|
31
|
+
if (isLocal) {
|
|
32
|
+
if (!fileName || cleanHref.endsWith(fileName) || fileName.endsWith('.css')) {
|
|
33
|
+
link.href = cleanHref + '?_ws_reload=' + Date.now();
|
|
34
|
+
count++;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (count > 0) {
|
|
40
|
+
console.log('[Webspresso] ⚡ CSS hot reloaded (' + count + ' stylesheet' + (count > 1 ? 's' : '') + ')');
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
var overlayEl = null;
|
|
45
|
+
|
|
46
|
+
function clearOverlay() {
|
|
47
|
+
if (overlayEl) {
|
|
48
|
+
try { overlayEl.remove(); } catch (_) {}
|
|
49
|
+
overlayEl = null;
|
|
50
|
+
}
|
|
51
|
+
var old = document.getElementById('webspresso-dev-error-overlay');
|
|
52
|
+
if (old) {
|
|
53
|
+
try { old.remove(); } catch (_) {}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function escapeHtml(str) {
|
|
58
|
+
if (!str) return '';
|
|
59
|
+
return String(str)
|
|
60
|
+
.replace(/&/g, '&')
|
|
61
|
+
.replace(/</g, '<')
|
|
62
|
+
.replace(/>/g, '>')
|
|
63
|
+
.replace(/"/g, '"')
|
|
64
|
+
.replace(/'/g, ''');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function showOverlay(data) {
|
|
68
|
+
clearOverlay();
|
|
69
|
+
|
|
70
|
+
var title = data.title || 'Development Error';
|
|
71
|
+
var message = data.message || 'An error occurred during build or rendering.';
|
|
72
|
+
var file = data.file || (data.location && data.location.file) || '';
|
|
73
|
+
var line = data.line || (data.location && data.location.line) || '';
|
|
74
|
+
var column = data.column || (data.location && data.location.column) || '';
|
|
75
|
+
var lineText = data.lineText || (data.location && data.location.lineText) || '';
|
|
76
|
+
|
|
77
|
+
var locationStr = file ? (file + (line ? ':' + line + (column ? ':' + column : '') : '')) : '';
|
|
78
|
+
|
|
79
|
+
var host = document.createElement('div');
|
|
80
|
+
host.id = 'webspresso-dev-error-overlay';
|
|
81
|
+
host.style.cssText = 'position:fixed;top:0;left:0;width:100vw;height:100vh;z-index:9999999;display:flex;align-items:center;justify-content:center;padding:24px;box-sizing:border-box;background:rgba(0,0,0,0.7);backdrop-filter:blur(6px);font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;';
|
|
82
|
+
|
|
83
|
+
var card = document.createElement('div');
|
|
84
|
+
card.style.cssText = 'background:#18181b;color:#f4f4f5;border:1px solid #ef4444;border-radius:12px;width:100%;max-width:860px;max-height:90vh;overflow-y:auto;box-shadow:0 25px 50px -12px rgba(0,0,0,0.5),0 0 25px -5px rgba(239,68,68,0.3);box-sizing:border-box;display:flex;flex-direction:column;';
|
|
85
|
+
|
|
86
|
+
var header = document.createElement('div');
|
|
87
|
+
header.style.cssText = 'display:flex;align-items:center;justify-content:space-between;padding:16px 20px;border-bottom:1px solid #27272a;background:#09090b;border-top-left-radius:12px;border-top-right-radius:12px;';
|
|
88
|
+
|
|
89
|
+
var titleBox = document.createElement('div');
|
|
90
|
+
titleBox.style.cssText = 'display:flex;align-items:center;gap:10px;';
|
|
91
|
+
titleBox.innerHTML = '<span style="display:inline-block;padding:3px 8px;background:#ef4444;color:#fff;border-radius:6px;font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:0.5px;">Error</span> <strong style="font-size:14px;color:#f87171;">' + escapeHtml(title) + '</strong>';
|
|
92
|
+
|
|
93
|
+
var closeBtn = document.createElement('button');
|
|
94
|
+
closeBtn.innerText = '✕';
|
|
95
|
+
closeBtn.title = 'Dismiss overlay (Esc)';
|
|
96
|
+
closeBtn.style.cssText = 'background:transparent;border:none;color:#a1a1aa;font-size:18px;cursor:pointer;padding:4px 8px;border-radius:4px;line-height:1;transition:color 0.15s,background 0.15s;';
|
|
97
|
+
closeBtn.onmouseenter = function() { closeBtn.style.color = '#fff'; closeBtn.style.background = '#27272a'; };
|
|
98
|
+
closeBtn.onmouseleave = function() { closeBtn.style.color = '#a1a1aa'; closeBtn.style.background = 'transparent'; };
|
|
99
|
+
closeBtn.onclick = clearOverlay;
|
|
100
|
+
|
|
101
|
+
header.appendChild(titleBox);
|
|
102
|
+
header.appendChild(closeBtn);
|
|
103
|
+
|
|
104
|
+
var body = document.createElement('div');
|
|
105
|
+
body.style.cssText = 'padding:20px;display:flex;flex-direction:column;gap:14px;';
|
|
106
|
+
|
|
107
|
+
var msgEl = document.createElement('div');
|
|
108
|
+
msgEl.style.cssText = 'font-size:15px;line-height:1.5;color:#fca5a5;font-weight:600;white-space:pre-wrap;word-break:break-word;';
|
|
109
|
+
msgEl.innerText = message;
|
|
110
|
+
body.appendChild(msgEl);
|
|
111
|
+
|
|
112
|
+
if (locationStr) {
|
|
113
|
+
var locEl = document.createElement('div');
|
|
114
|
+
locEl.style.cssText = 'display:inline-flex;align-items:center;padding:6px 12px;background:#27272a;border-radius:6px;font-size:12px;color:#93c5fd;word-break:break-all;';
|
|
115
|
+
locEl.innerText = '📍 ' + locationStr;
|
|
116
|
+
body.appendChild(locEl);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (lineText) {
|
|
120
|
+
var codeBox = document.createElement('div');
|
|
121
|
+
codeBox.style.cssText = 'background:#09090b;border:1px solid #27272a;border-radius:8px;padding:14px 16px;overflow-x:auto;font-size:13px;line-height:1.6;color:#e4e4e7;';
|
|
122
|
+
|
|
123
|
+
var codeHtml = '';
|
|
124
|
+
if (line) {
|
|
125
|
+
codeHtml += '<span style="color:#71717a;margin-right:12px;user-select:none;">' + line + ' |</span>';
|
|
126
|
+
}
|
|
127
|
+
codeHtml += escapeHtml(lineText) + '\\n';
|
|
128
|
+
if (column) {
|
|
129
|
+
var indent = '';
|
|
130
|
+
var colNum = parseInt(column, 10);
|
|
131
|
+
if (line) {
|
|
132
|
+
indent += ' '.repeat(String(line).length + 3);
|
|
133
|
+
}
|
|
134
|
+
indent += ' '.repeat(Math.max(0, colNum - 1));
|
|
135
|
+
codeHtml += '<span style="color:#ef4444;font-weight:bold;">' + indent + '^</span>';
|
|
136
|
+
}
|
|
137
|
+
codeBox.innerHTML = '<pre style="margin:0;font-family:inherit;">' + codeHtml + '</pre>';
|
|
138
|
+
body.appendChild(codeBox);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (Array.isArray(data.errors) && data.errors.length > 1) {
|
|
142
|
+
var moreEl = document.createElement('div');
|
|
143
|
+
moreEl.style.cssText = 'font-size:12px;color:#a1a1aa;';
|
|
144
|
+
moreEl.innerText = '+ ' + (data.errors.length - 1) + ' more error(s) in terminal';
|
|
145
|
+
body.appendChild(moreEl);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
var footer = document.createElement('div');
|
|
149
|
+
footer.style.cssText = 'padding:12px 20px;background:#09090b;border-top:1px solid #27272a;border-bottom-left-radius:12px;border-bottom-right-radius:12px;font-size:11px;color:#71717a;display:flex;align-items:center;justify-content:space-between;';
|
|
150
|
+
footer.innerHTML = '<span>Fix the error in your editor and save — this overlay will close automatically.</span><span>Press <strong>Esc</strong> to dismiss</span>';
|
|
151
|
+
|
|
152
|
+
card.appendChild(header);
|
|
153
|
+
card.appendChild(body);
|
|
154
|
+
card.appendChild(footer);
|
|
155
|
+
host.appendChild(card);
|
|
156
|
+
|
|
157
|
+
document.body.appendChild(host);
|
|
158
|
+
overlayEl = host;
|
|
159
|
+
|
|
160
|
+
function onKeyDown(ev) {
|
|
161
|
+
if (ev.key === 'Escape') {
|
|
162
|
+
clearOverlay();
|
|
163
|
+
document.removeEventListener('keydown', onKeyDown);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
document.addEventListener('keydown', onKeyDown);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function connect() {
|
|
170
|
+
if (es) {
|
|
171
|
+
try { es.close(); } catch (_) {}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
es = new window.EventSource('${SSE_ENDPOINT_PATH}');
|
|
175
|
+
|
|
176
|
+
es.addEventListener('reload', function(e) {
|
|
177
|
+
try {
|
|
178
|
+
var data = JSON.parse(e.data);
|
|
179
|
+
if (data.type === 'css') {
|
|
180
|
+
clearOverlay();
|
|
181
|
+
reloadCss(data.file);
|
|
182
|
+
} else if (data.type === 'full') {
|
|
183
|
+
clearOverlay();
|
|
184
|
+
var reasonMsg = data.reason ? ' (' + data.reason + ')' : '';
|
|
185
|
+
console.log('[Webspresso] 🔄 Reloading page' + reasonMsg + '...');
|
|
186
|
+
window.location.reload();
|
|
187
|
+
} else if (data.type === 'error') {
|
|
188
|
+
showOverlay(data);
|
|
189
|
+
} else if (data.type === 'clear-error') {
|
|
190
|
+
clearOverlay();
|
|
191
|
+
}
|
|
192
|
+
} catch (err) {
|
|
193
|
+
console.error('[Webspresso] Dev reload error:', err);
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
es.onerror = function() {
|
|
198
|
+
es.close();
|
|
199
|
+
if (!reconnectTimer) {
|
|
200
|
+
reconnectTimer = setTimeout(function() {
|
|
201
|
+
reconnectTimer = null;
|
|
202
|
+
connect();
|
|
203
|
+
}, 2000);
|
|
204
|
+
}
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (document.readyState === 'loading') {
|
|
209
|
+
document.addEventListener('DOMContentLoaded', connect);
|
|
210
|
+
} else {
|
|
211
|
+
connect();
|
|
212
|
+
}
|
|
213
|
+
})();
|
|
214
|
+
`;
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Create Dev Reload Hub
|
|
218
|
+
* @param {Object} config
|
|
219
|
+
* @param {import('express').Express} [config.app]
|
|
220
|
+
* @param {string} [config.publicDir]
|
|
221
|
+
* @param {string} [config.pagesDir]
|
|
222
|
+
* @param {string} [config.viewsDir]
|
|
223
|
+
* @param {Object} [config.options]
|
|
224
|
+
* @param {boolean} [config.enabled]
|
|
225
|
+
* @param {Object} [config.logger]
|
|
226
|
+
* @param {Function} [config.onTemplateChange]
|
|
227
|
+
* @param {Object} [config.nunjucksEnv]
|
|
228
|
+
*/
|
|
229
|
+
function createDevReloadHub(config = {}) {
|
|
230
|
+
const {
|
|
231
|
+
app,
|
|
232
|
+
publicDir,
|
|
233
|
+
pagesDir,
|
|
234
|
+
viewsDir,
|
|
235
|
+
options = {},
|
|
236
|
+
enabled = false,
|
|
237
|
+
logger = null,
|
|
238
|
+
onTemplateChange = null,
|
|
239
|
+
nunjucksEnv = null,
|
|
240
|
+
} = config;
|
|
241
|
+
|
|
242
|
+
if (!enabled) {
|
|
243
|
+
return null;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const debounceMs = typeof options.debounce === 'number' ? options.debounce : 50;
|
|
247
|
+
const watchCss = options.watchCss !== false;
|
|
248
|
+
const watchTemplates = options.watchTemplates !== false;
|
|
249
|
+
const autoInject = options.autoInject !== false;
|
|
250
|
+
const watchPublicAll = options.watchPublicAll === true;
|
|
251
|
+
const extraWatch = Array.isArray(options.watch) ? options.watch : [];
|
|
252
|
+
|
|
253
|
+
const templateExtensions = new Set(
|
|
254
|
+
(Array.isArray(options.templateExtensions)
|
|
255
|
+
? options.templateExtensions
|
|
256
|
+
: ['.njk', '.html', '.jinja', '.jinja2']
|
|
257
|
+
).map((ext) => (ext.startsWith('.') ? ext.toLowerCase() : `.${ext.toLowerCase()}`))
|
|
258
|
+
);
|
|
259
|
+
|
|
260
|
+
const pageExtensions = new Set(
|
|
261
|
+
(Array.isArray(options.pageExtensions)
|
|
262
|
+
? options.pageExtensions
|
|
263
|
+
: ['.njk', '.html', '.jinja', '.jinja2', '.js', '.json']
|
|
264
|
+
).map((ext) => (ext.startsWith('.') ? ext.toLowerCase() : `.${ext.toLowerCase()}`))
|
|
265
|
+
);
|
|
266
|
+
|
|
267
|
+
const clients = new Set();
|
|
268
|
+
const watchers = [];
|
|
269
|
+
const watchedPaths = new Set();
|
|
270
|
+
let heartbeatTimer = null;
|
|
271
|
+
let cssDebounceTimer = null;
|
|
272
|
+
let templateDebounceTimer = null;
|
|
273
|
+
let isClosed = false;
|
|
274
|
+
|
|
275
|
+
let activeNunjucksEnv = nunjucksEnv;
|
|
276
|
+
let onTemplateChangeCallback = onTemplateChange;
|
|
277
|
+
|
|
278
|
+
function broadcast(type, payload = {}) {
|
|
279
|
+
if (isClosed) return;
|
|
280
|
+
const data = JSON.stringify({ type, timestamp: Date.now(), ...payload });
|
|
281
|
+
const message = `event: reload\ndata: ${data}\n\n`;
|
|
282
|
+
for (const client of clients) {
|
|
283
|
+
try {
|
|
284
|
+
client.write(message);
|
|
285
|
+
} catch (_) {
|
|
286
|
+
clients.delete(client);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function invalidateTemplateCache(changedFile) {
|
|
292
|
+
try {
|
|
293
|
+
const { clearNjkFrontmatterCaches } = require('../njk-frontmatter');
|
|
294
|
+
clearNjkFrontmatterCaches();
|
|
295
|
+
} catch (_) {}
|
|
296
|
+
|
|
297
|
+
if (activeNunjucksEnv) {
|
|
298
|
+
try {
|
|
299
|
+
if (typeof activeNunjucksEnv.invalidateCache === 'function') {
|
|
300
|
+
activeNunjucksEnv.invalidateCache();
|
|
301
|
+
}
|
|
302
|
+
if (Array.isArray(activeNunjucksEnv.loaders)) {
|
|
303
|
+
for (const loader of activeNunjucksEnv.loaders) {
|
|
304
|
+
if (loader && loader.cache) {
|
|
305
|
+
loader.cache = {};
|
|
306
|
+
}
|
|
307
|
+
if (loader && typeof loader.invalidateCache === 'function') {
|
|
308
|
+
loader.invalidateCache();
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
} catch (_) {}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
if (typeof onTemplateChangeCallback === 'function') {
|
|
316
|
+
try {
|
|
317
|
+
onTemplateChangeCallback(changedFile);
|
|
318
|
+
} catch (_) {}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const hub = {
|
|
323
|
+
broadcast,
|
|
324
|
+
close,
|
|
325
|
+
getClientsCount: () => clients.size,
|
|
326
|
+
getWatchersCount: () => watchers.length,
|
|
327
|
+
getWatchedPaths: () => Array.from(watchedPaths),
|
|
328
|
+
handleSse,
|
|
329
|
+
handleScript,
|
|
330
|
+
autoInjectMiddleware,
|
|
331
|
+
attachNunjucks: (env) => {
|
|
332
|
+
activeNunjucksEnv = env;
|
|
333
|
+
},
|
|
334
|
+
setOnTemplateChange: (fn) => {
|
|
335
|
+
onTemplateChangeCallback = fn;
|
|
336
|
+
},
|
|
337
|
+
invalidateTemplateCache,
|
|
338
|
+
triggerTemplateReload: (filename, reason = 'template') => {
|
|
339
|
+
onTemplateFileChanged(filename, reason);
|
|
340
|
+
},
|
|
341
|
+
triggerCssReload: (filename) => {
|
|
342
|
+
onCssFileChanged(filename);
|
|
343
|
+
},
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
// Heartbeat ping every 25s
|
|
347
|
+
heartbeatTimer = setInterval(() => {
|
|
348
|
+
if (isClosed) return;
|
|
349
|
+
for (const client of clients) {
|
|
350
|
+
try {
|
|
351
|
+
client.write(': ping\n\n');
|
|
352
|
+
} catch (_) {
|
|
353
|
+
clients.delete(client);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}, 25000);
|
|
357
|
+
if (heartbeatTimer.unref) {
|
|
358
|
+
heartbeatTimer.unref();
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// SSE route handler
|
|
362
|
+
function handleSse(req, res) {
|
|
363
|
+
res.writeHead(200, {
|
|
364
|
+
'Content-Type': 'text/event-stream',
|
|
365
|
+
'Cache-Control': 'no-cache, no-transform',
|
|
366
|
+
'Connection': 'keep-alive',
|
|
367
|
+
'X-Accel-Buffering': 'no',
|
|
368
|
+
});
|
|
369
|
+
res.write('event: connected\ndata: {"status":"connected"}\n\n');
|
|
370
|
+
clients.add(res);
|
|
371
|
+
|
|
372
|
+
req.on('close', () => {
|
|
373
|
+
clients.delete(res);
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// Client JS route handler
|
|
378
|
+
function handleScript(req, res) {
|
|
379
|
+
res.type('application/javascript');
|
|
380
|
+
res.send(CLIENT_RUNTIME_SCRIPT);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// Auto-inject middleware into HTML responses before </body>
|
|
384
|
+
function autoInjectMiddleware(req, res, next) {
|
|
385
|
+
if (!autoInject) return next();
|
|
386
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') return next();
|
|
387
|
+
|
|
388
|
+
const origSend = res.send.bind(res);
|
|
389
|
+
res.send = function(body) {
|
|
390
|
+
if (typeof body === 'string' && body.includes('</body>')) {
|
|
391
|
+
const scriptTag = `<script defer src="${CLIENT_SCRIPT_PATH}"></script>`;
|
|
392
|
+
if (!body.includes(CLIENT_SCRIPT_PATH)) {
|
|
393
|
+
body = body.replace('</body>', scriptTag + '\n</body>');
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
return origSend(body);
|
|
397
|
+
};
|
|
398
|
+
next();
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
if (app) {
|
|
402
|
+
app.get(SSE_ENDPOINT_PATH, handleSse);
|
|
403
|
+
app.get(CLIENT_SCRIPT_PATH, handleScript);
|
|
404
|
+
if (autoInject) {
|
|
405
|
+
app.use(autoInjectMiddleware);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function shouldIgnore(filename) {
|
|
410
|
+
if (!filename) return false;
|
|
411
|
+
const basename = path.basename(filename);
|
|
412
|
+
if (basename.startsWith('.')) return true;
|
|
413
|
+
if (
|
|
414
|
+
basename.endsWith('~') ||
|
|
415
|
+
basename.endsWith('.swp') ||
|
|
416
|
+
basename.endsWith('.swo') ||
|
|
417
|
+
basename.endsWith('.tmp')
|
|
418
|
+
) {
|
|
419
|
+
return true;
|
|
420
|
+
}
|
|
421
|
+
if (basename.endsWith('.test.js') || basename.endsWith('.spec.js')) {
|
|
422
|
+
return true;
|
|
423
|
+
}
|
|
424
|
+
const normalized = filename.replace(/\\/g, '/');
|
|
425
|
+
if (
|
|
426
|
+
normalized.includes('/node_modules/') ||
|
|
427
|
+
normalized.startsWith('node_modules/') ||
|
|
428
|
+
normalized.includes('/.git/') ||
|
|
429
|
+
normalized.startsWith('.git/')
|
|
430
|
+
) {
|
|
431
|
+
return true;
|
|
432
|
+
}
|
|
433
|
+
return false;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function onCssFileChanged(filename) {
|
|
437
|
+
if (filename && !filename.endsWith('.css')) return;
|
|
438
|
+
if (cssDebounceTimer) clearTimeout(cssDebounceTimer);
|
|
439
|
+
cssDebounceTimer = setTimeout(() => {
|
|
440
|
+
cssDebounceTimer = null;
|
|
441
|
+
if (logger) logger.log(`[Webspresso] ⚡ CSS change detected: ${filename || 'style.css'}`);
|
|
442
|
+
hub.broadcast('css', { file: filename || 'style.css' });
|
|
443
|
+
}, debounceMs);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function onTemplateFileChanged(filename, reason = 'template') {
|
|
447
|
+
if (templateDebounceTimer) clearTimeout(templateDebounceTimer);
|
|
448
|
+
templateDebounceTimer = setTimeout(() => {
|
|
449
|
+
templateDebounceTimer = null;
|
|
450
|
+
invalidateTemplateCache(filename);
|
|
451
|
+
if (logger) logger.log(`[Webspresso] 🔄 Template change detected: ${filename || 'view.njk'}`);
|
|
452
|
+
hub.broadcast('full', { file: filename || 'view.njk', reason });
|
|
453
|
+
}, debounceMs);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function watchDirectory(targetDir, handler) {
|
|
457
|
+
if (!targetDir) return;
|
|
458
|
+
const absDir = path.resolve(targetDir);
|
|
459
|
+
if (!fs.existsSync(absDir)) return;
|
|
460
|
+
if (watchedPaths.has(absDir)) return;
|
|
461
|
+
watchedPaths.add(absDir);
|
|
462
|
+
|
|
463
|
+
function addWatcher(dirToWatch, recursive) {
|
|
464
|
+
try {
|
|
465
|
+
const w = fs.watch(dirToWatch, { recursive }, (eventType, filename) => {
|
|
466
|
+
handler(eventType, filename, dirToWatch);
|
|
467
|
+
});
|
|
468
|
+
w.on('error', () => {});
|
|
469
|
+
watchers.push(w);
|
|
470
|
+
return true;
|
|
471
|
+
} catch (_) {
|
|
472
|
+
return false;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
const recursiveSuccess = addWatcher(absDir, true);
|
|
477
|
+
if (!recursiveSuccess) {
|
|
478
|
+
function walkAndWatch(currentDir) {
|
|
479
|
+
addWatcher(currentDir, false);
|
|
480
|
+
try {
|
|
481
|
+
const entries = fs.readdirSync(currentDir, { withFileTypes: true });
|
|
482
|
+
for (const entry of entries) {
|
|
483
|
+
if (entry.isDirectory() && !entry.name.startsWith('.') && entry.name !== 'node_modules') {
|
|
484
|
+
walkAndWatch(path.join(currentDir, entry.name));
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
} catch (_) {}
|
|
488
|
+
}
|
|
489
|
+
walkAndWatch(absDir);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function startWatchers() {
|
|
494
|
+
// 1. Watch publicDir for CSS hot-reload (and optional public assets)
|
|
495
|
+
if (watchCss && publicDir && fs.existsSync(publicDir)) {
|
|
496
|
+
watchDirectory(publicDir, (eventType, filename) => {
|
|
497
|
+
if (!filename || shouldIgnore(filename)) return;
|
|
498
|
+
const ext = path.extname(filename).toLowerCase();
|
|
499
|
+
if (ext === '.css') {
|
|
500
|
+
onCssFileChanged(filename);
|
|
501
|
+
} else if (watchPublicAll) {
|
|
502
|
+
onTemplateFileChanged(filename, 'public');
|
|
503
|
+
}
|
|
504
|
+
});
|
|
505
|
+
const cssSub = path.join(path.resolve(publicDir), 'css');
|
|
506
|
+
if (fs.existsSync(cssSub)) {
|
|
507
|
+
watchDirectory(cssSub, (eventType, filename) => {
|
|
508
|
+
if (!filename || shouldIgnore(filename)) return;
|
|
509
|
+
if (filename.endsWith('.css')) {
|
|
510
|
+
onCssFileChanged(filename);
|
|
511
|
+
}
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// 2. Watch viewsDir for template live-reload
|
|
517
|
+
if (watchTemplates && viewsDir && fs.existsSync(viewsDir)) {
|
|
518
|
+
watchDirectory(viewsDir, (eventType, filename) => {
|
|
519
|
+
if (!filename || shouldIgnore(filename)) return;
|
|
520
|
+
const ext = path.extname(filename).toLowerCase();
|
|
521
|
+
if (ext === '.css') {
|
|
522
|
+
onCssFileChanged(filename);
|
|
523
|
+
} else if (templateExtensions.has(ext)) {
|
|
524
|
+
onTemplateFileChanged(filename, 'views');
|
|
525
|
+
}
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// 3. Watch pagesDir for page templates and loaders
|
|
530
|
+
if (watchTemplates && pagesDir && fs.existsSync(pagesDir)) {
|
|
531
|
+
watchDirectory(pagesDir, (eventType, filename) => {
|
|
532
|
+
if (!filename || shouldIgnore(filename)) return;
|
|
533
|
+
const ext = path.extname(filename).toLowerCase();
|
|
534
|
+
if (ext === '.css') {
|
|
535
|
+
onCssFileChanged(filename);
|
|
536
|
+
} else if (pageExtensions.has(ext)) {
|
|
537
|
+
onTemplateFileChanged(filename, 'pages');
|
|
538
|
+
}
|
|
539
|
+
});
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// 4. Extra watch paths
|
|
543
|
+
for (const extra of extraWatch) {
|
|
544
|
+
if (!extra || !fs.existsSync(extra)) continue;
|
|
545
|
+
const absExtra = path.resolve(extra);
|
|
546
|
+
try {
|
|
547
|
+
const stat = fs.statSync(absExtra);
|
|
548
|
+
if (stat.isDirectory()) {
|
|
549
|
+
watchDirectory(absExtra, (eventType, filename) => {
|
|
550
|
+
if (!filename || shouldIgnore(filename)) return;
|
|
551
|
+
const ext = path.extname(filename).toLowerCase();
|
|
552
|
+
if (ext === '.css') {
|
|
553
|
+
onCssFileChanged(filename);
|
|
554
|
+
} else {
|
|
555
|
+
onTemplateFileChanged(filename, 'extra');
|
|
556
|
+
}
|
|
557
|
+
});
|
|
558
|
+
} else {
|
|
559
|
+
const w = fs.watch(absExtra, (eventType, filename) => {
|
|
560
|
+
const fileTarget = filename || path.basename(absExtra);
|
|
561
|
+
if (!fileTarget || shouldIgnore(fileTarget)) return;
|
|
562
|
+
const ext = path.extname(fileTarget).toLowerCase();
|
|
563
|
+
if (ext === '.css') {
|
|
564
|
+
onCssFileChanged(fileTarget);
|
|
565
|
+
} else {
|
|
566
|
+
onTemplateFileChanged(fileTarget, 'extra');
|
|
567
|
+
}
|
|
568
|
+
});
|
|
569
|
+
w.on('error', () => {});
|
|
570
|
+
watchers.push(w);
|
|
571
|
+
watchedPaths.add(absExtra);
|
|
572
|
+
}
|
|
573
|
+
} catch (_) {}
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
startWatchers();
|
|
578
|
+
|
|
579
|
+
function close() {
|
|
580
|
+
isClosed = true;
|
|
581
|
+
if (heartbeatTimer) {
|
|
582
|
+
clearInterval(heartbeatTimer);
|
|
583
|
+
heartbeatTimer = null;
|
|
584
|
+
}
|
|
585
|
+
if (cssDebounceTimer) {
|
|
586
|
+
clearTimeout(cssDebounceTimer);
|
|
587
|
+
cssDebounceTimer = null;
|
|
588
|
+
}
|
|
589
|
+
if (templateDebounceTimer) {
|
|
590
|
+
clearTimeout(templateDebounceTimer);
|
|
591
|
+
templateDebounceTimer = null;
|
|
592
|
+
}
|
|
593
|
+
for (const w of watchers) {
|
|
594
|
+
try { w.close(); } catch (_) {}
|
|
595
|
+
}
|
|
596
|
+
watchers.length = 0;
|
|
597
|
+
watchedPaths.clear();
|
|
598
|
+
for (const client of clients) {
|
|
599
|
+
try { client.end(); } catch (_) {}
|
|
600
|
+
}
|
|
601
|
+
clients.clear();
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
return hub;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
module.exports = {
|
|
608
|
+
createDevReloadHub,
|
|
609
|
+
CLIENT_SCRIPT_PATH,
|
|
610
|
+
SSE_ENDPOINT_PATH,
|
|
611
|
+
};
|