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
@@ -116,7 +116,9 @@ function createApiHandler(descriptor, context) {
116
116
  return next(loadErr);
117
117
  }
118
118
 
119
- apiDef = typeof apiModule === 'function' ? { handler: apiModule } : apiModule.default || apiModule;
119
+ apiDef = typeof apiModule === 'function'
120
+ ? Object.assign({ handler: apiModule }, apiModule)
121
+ : (apiModule.default || apiModule);
120
122
  handlerFn = apiDef.handler || (typeof apiModule === 'function' ? apiModule : null);
121
123
 
122
124
  if (typeof handlerFn !== 'function') {
@@ -196,6 +198,7 @@ function createApiHandler(descriptor, context) {
196
198
  config: appOptions,
197
199
  logger: console,
198
200
  db,
201
+ sse: (sourceOrOptions, maybeOptions) => res.sse(sourceOrOptions, maybeOptions),
199
202
  };
200
203
 
201
204
  req.ctx = apiCtx;
@@ -0,0 +1,517 @@
1
+ /**
2
+ * Dev Bundler — esbuild context() API incremental bundling & dev watching
3
+ *
4
+ * Integrates esbuild's official context() API for sub-millisecond incremental
5
+ * rebuilds, auto-detection of client entrypoints, zero zombie processes, and
6
+ * seamless integration with Webspresso Dev Reload (SSE).
7
+ */
8
+
9
+ const fs = require('fs');
10
+ const path = require('path');
11
+
12
+ const DEFAULT_ENTRYPOINTS = [
13
+ 'src/client/main.js',
14
+ 'src/client/main.ts',
15
+ 'src/client/index.js',
16
+ 'src/client/index.ts',
17
+ 'src/client/app.js',
18
+ 'src/client/app.ts',
19
+ ];
20
+
21
+ /**
22
+ * Recursively scans directory for page script entrypoints (.js, .mjs, .ts, .jsx, .tsx)
23
+ * Excludes .d.ts, .test.*, .spec.*, and hidden files
24
+ * @param {string} dir - Directory to scan (e.g. src/client/pages)
25
+ * @param {string} baseDir - Base client directory (e.g. src/client)
26
+ * @returns {Array<{ entryName: string, filePath: string }>}
27
+ */
28
+ function scanClientPages(dir, baseDir) {
29
+ if (!fs.existsSync(dir)) return [];
30
+ const entries = [];
31
+ const validExts = new Set(['.js', '.mjs', '.ts', '.jsx', '.tsx']);
32
+
33
+ function walk(currentDir) {
34
+ let list;
35
+ try {
36
+ list = fs.readdirSync(currentDir, { withFileTypes: true });
37
+ } catch (_) {
38
+ return;
39
+ }
40
+ for (const item of list) {
41
+ if (item.name.startsWith('.')) continue;
42
+ const fullPath = path.join(currentDir, item.name);
43
+ if (item.isDirectory()) {
44
+ walk(fullPath);
45
+ } else if (item.isFile()) {
46
+ const ext = path.extname(item.name).toLowerCase();
47
+ if (validExts.has(ext)) {
48
+ if (item.name.endsWith('.d.ts') || item.name.includes('.test.') || item.name.includes('.spec.')) {
49
+ continue;
50
+ }
51
+ const relPath = path.relative(baseDir, fullPath).replace(/\\/g, '/');
52
+ const entryName = relPath.slice(0, -ext.length);
53
+ entries.push({ entryName, filePath: fullPath });
54
+ }
55
+ }
56
+ }
57
+ }
58
+
59
+ walk(dir);
60
+ return entries;
61
+ }
62
+
63
+ /**
64
+ * Discovers page-specific client entrypoints in src/client/pages/ or src/pages/
65
+ * @param {string} [cwd=process.cwd()] - Project root directory
66
+ * @returns {Record<string, string>} Map of entry names to absolute file paths (e.g. { 'pages/home': '.../src/client/pages/home.js' })
67
+ */
68
+ function findPageEntryPoints(cwd = process.cwd()) {
69
+ const pageEntries = {};
70
+
71
+ // 1. Primary convention: src/client/pages/
72
+ const clientPagesDir = path.resolve(cwd, 'src/client/pages');
73
+ const clientDir = path.resolve(cwd, 'src/client');
74
+ if (fs.existsSync(clientPagesDir)) {
75
+ const scanned = scanClientPages(clientPagesDir, clientDir);
76
+ for (const item of scanned) {
77
+ pageEntries[item.entryName] = item.filePath;
78
+ }
79
+ }
80
+
81
+ // 2. Secondary convention: src/pages/ with *.client.{js,ts,jsx,tsx}
82
+ const srcPagesDir = path.resolve(cwd, 'src/pages');
83
+ if (fs.existsSync(srcPagesDir)) {
84
+ const validExts = new Set(['.js', '.mjs', '.ts', '.jsx', '.tsx']);
85
+ function walkSrcPages(currentDir) {
86
+ let list;
87
+ try {
88
+ list = fs.readdirSync(currentDir, { withFileTypes: true });
89
+ } catch (_) {
90
+ return;
91
+ }
92
+ for (const item of list) {
93
+ if (item.name.startsWith('.')) continue;
94
+ const fullPath = path.join(currentDir, item.name);
95
+ if (item.isDirectory()) {
96
+ walkSrcPages(fullPath);
97
+ } else if (item.isFile()) {
98
+ const ext = path.extname(item.name).toLowerCase();
99
+ if (validExts.has(ext) && item.name.includes('.client.')) {
100
+ const rel = path.relative(srcPagesDir, fullPath).replace(/\\/g, '/');
101
+ const cleanName = rel.replace(/\.client\.[a-z0-9]+$/i, '');
102
+ const entryName = 'pages/' + cleanName;
103
+ if (!pageEntries[entryName]) {
104
+ pageEntries[entryName] = fullPath;
105
+ }
106
+ }
107
+ }
108
+ }
109
+ }
110
+ walkSrcPages(srcPagesDir);
111
+ }
112
+
113
+ return pageEntries;
114
+ }
115
+
116
+ /**
117
+ * Simple glob expansion for client entrypoints (supports single and double wildcards)
118
+ * @param {string} pattern - Glob pattern (e.g. 'src/client/pages/*.js')
119
+ * @param {string} [cwd=process.cwd()] - Base directory
120
+ * @returns {string[]} Matching absolute file paths
121
+ */
122
+ function expandGlob(pattern, cwd = process.cwd()) {
123
+ const normalized = pattern.replace(/\\/g, '/');
124
+ const starIndex = normalized.indexOf('*');
125
+ if (starIndex === -1) {
126
+ const p = path.resolve(cwd, pattern);
127
+ return fs.existsSync(p) ? [p] : [];
128
+ }
129
+ const prefix = normalized.slice(0, starIndex);
130
+ const baseDir = path.resolve(cwd, prefix.endsWith('/') ? prefix : path.dirname(prefix));
131
+ if (!fs.existsSync(baseDir)) return [];
132
+
133
+ const isRecursive = normalized.includes('**');
134
+ const regexPattern = '^' + normalized
135
+ .replace(/\./g, '\\.')
136
+ .replace(/\*\*\//g, '(.+/)?')
137
+ .replace(/\*\*/g, '.*')
138
+ .replace(/\*/g, '[^/]*') + '$';
139
+ const regex = new RegExp(regexPattern);
140
+
141
+ const matched = [];
142
+ function walk(dir) {
143
+ let items;
144
+ try {
145
+ items = fs.readdirSync(dir, { withFileTypes: true });
146
+ } catch (_) {
147
+ return;
148
+ }
149
+ for (const item of items) {
150
+ if (item.name.startsWith('.')) continue;
151
+ const fullPath = path.join(dir, item.name);
152
+ const relPath = path.relative(cwd, fullPath).replace(/\\/g, '/');
153
+ if (item.isDirectory()) {
154
+ if (isRecursive) walk(fullPath);
155
+ } else if (item.isFile()) {
156
+ if (regex.test(relPath)) {
157
+ matched.push(fullPath);
158
+ }
159
+ }
160
+ }
161
+ }
162
+ walk(baseDir);
163
+ return matched;
164
+ }
165
+
166
+ /**
167
+ * Resolves entry points from options, expanding globs or discovering defaults
168
+ * @param {string[]|Record<string, string>|string} [entryPoints]
169
+ * @param {string} [cwd=process.cwd()]
170
+ * @returns {string[]|Record<string, string>}
171
+ */
172
+ function resolveEntryPoints(entryPoints, cwd = process.cwd()) {
173
+ if (!entryPoints || (Array.isArray(entryPoints) && entryPoints.length === 0)) {
174
+ return findDefaultEntryPoints(cwd);
175
+ }
176
+
177
+ // Object mapping (e.g. { 'main': 'src/client/main.js', 'pages/home': 'src/client/pages/home.js' })
178
+ if (typeof entryPoints === 'object' && !Array.isArray(entryPoints)) {
179
+ return entryPoints;
180
+ }
181
+
182
+ // Array of paths / globs
183
+ const list = Array.isArray(entryPoints) ? entryPoints : [entryPoints];
184
+ const expanded = [];
185
+ for (const item of list) {
186
+ if (typeof item === 'string' && item.includes('*')) {
187
+ expanded.push(...expandGlob(item, cwd));
188
+ } else if (typeof item === 'string') {
189
+ expanded.push(item);
190
+ }
191
+ }
192
+
193
+ return expanded;
194
+ }
195
+
196
+ /**
197
+ * Finds default client entry points if none were specified
198
+ * Discovers main bundle and page-specific entrypoints in src/client/pages/
199
+ * @param {string} [cwd=process.cwd()]
200
+ * @returns {string[]|Record<string, string>}
201
+ */
202
+ function findDefaultEntryPoints(cwd = process.cwd()) {
203
+ let mainEntry = null;
204
+ for (const entry of DEFAULT_ENTRYPOINTS) {
205
+ const fullPath = path.resolve(cwd, entry);
206
+ if (fs.existsSync(fullPath)) {
207
+ mainEntry = fullPath;
208
+ break;
209
+ }
210
+ }
211
+
212
+ const pageEntries = findPageEntryPoints(cwd);
213
+ const pageEntryKeys = Object.keys(pageEntries);
214
+
215
+ // If no page entries exist, return single-item array or empty array (100% backward compatible)
216
+ if (pageEntryKeys.length === 0) {
217
+ return mainEntry ? [mainEntry] : [];
218
+ }
219
+
220
+ // If page entries exist, return an entrypoints dictionary
221
+ const entryMap = {};
222
+ if (mainEntry) {
223
+ const ext = path.extname(mainEntry);
224
+ const mainBase = path.basename(mainEntry, ext); // 'main', 'index', or 'app'
225
+ entryMap[mainBase] = mainEntry;
226
+ }
227
+ for (const [key, filePath] of Object.entries(pageEntries)) {
228
+ entryMap[key] = filePath;
229
+ }
230
+
231
+ return entryMap;
232
+ }
233
+
234
+ /**
235
+ * Resolves esbuild module dynamically from project or options
236
+ * @param {object} options
237
+ * @param {string} cwd
238
+ * @returns {object|null}
239
+ */
240
+ function resolveEsbuild(options = {}, cwd = process.cwd()) {
241
+ if (options.esbuild) return options.esbuild;
242
+ try {
243
+ return require('esbuild');
244
+ } catch (_) {
245
+ try {
246
+ return require(path.join(cwd, 'node_modules', 'esbuild'));
247
+ } catch (_) {
248
+ return null;
249
+ }
250
+ }
251
+ }
252
+
253
+ /**
254
+ * Creates an esbuild-powered dev bundler using esbuild context() API
255
+ *
256
+ * @param {object} [options={}]
257
+ * @param {object} [devReloadHub=null]
258
+ * @returns {DevBundler}
259
+ */
260
+ function createDevBundler(options = {}, devReloadHub = null) {
261
+ const cwd = options.cwd || process.cwd();
262
+ const esbuild = resolveEsbuild(options, cwd);
263
+ const isAvailable = Boolean(esbuild);
264
+
265
+ const entryPoints = resolveEntryPoints(options.entryPoints, cwd);
266
+
267
+ const outdir = options.outdir ? path.resolve(cwd, options.outdir) : path.resolve(cwd, 'public/js');
268
+ const bundle = options.bundle !== false;
269
+ const sourcemap = options.sourcemap !== undefined ? options.sourcemap : true;
270
+ const minify = options.minify !== undefined ? options.minify : false;
271
+ const target = options.target || ['es2020'];
272
+ const format = options.format || 'esm';
273
+ const metafile = options.metafile !== false;
274
+ const userPlugins = Array.isArray(options.plugins) ? options.plugins : [];
275
+ const logger = options.logger !== undefined ? options.logger : console;
276
+
277
+ let ctx = null;
278
+ let isWatching = false;
279
+ let isDisposed = false;
280
+
281
+ // Custom dev-reload plugin for esbuild
282
+ const devReloadPlugin = {
283
+ name: 'webspresso-dev-reload',
284
+ setup(build) {
285
+ let startTime = 0;
286
+ let hadError = false;
287
+
288
+ build.onStart(() => {
289
+ startTime = Date.now();
290
+ });
291
+
292
+ build.onEnd((result) => {
293
+ const duration = Date.now() - startTime;
294
+ if (result.errors && result.errors.length > 0) {
295
+ hadError = true;
296
+ if (logger && logger.error) {
297
+ logger.error(`❌ [Webspresso esbuild] Build failed (${duration}ms):`, result.errors);
298
+ }
299
+ if (devReloadHub && typeof devReloadHub.broadcast === 'function') {
300
+ const firstErr = result.errors[0] || {};
301
+ const loc = firstErr.location || {};
302
+ devReloadHub.broadcast('error', {
303
+ title: 'esbuild Build Error',
304
+ message: firstErr.text || 'Build failed with syntax error',
305
+ file: loc.file,
306
+ line: loc.line,
307
+ column: loc.column,
308
+ lineText: loc.lineText,
309
+ errors: result.errors.map((e) => ({
310
+ text: e.text,
311
+ file: e.location && e.location.file,
312
+ line: e.location && e.location.line,
313
+ column: e.location && e.location.column,
314
+ lineText: e.location && e.location.lineText,
315
+ })),
316
+ });
317
+ }
318
+ return;
319
+ }
320
+
321
+ if (logger && logger.log) {
322
+ logger.log(`⚡ [Webspresso esbuild] Incremental rebuild finished in ${duration}ms`);
323
+ }
324
+
325
+ if (devReloadHub && typeof devReloadHub.broadcast === 'function') {
326
+ if (hadError) {
327
+ devReloadHub.broadcast('clear-error');
328
+ hadError = false;
329
+ }
330
+
331
+ let hasCssOnly = false;
332
+ if (result.metafile && result.metafile.outputs) {
333
+ const outputFiles = Object.keys(result.metafile.outputs);
334
+ const hasJs = outputFiles.some((f) => f.endsWith('.js') || f.endsWith('.mjs'));
335
+ const hasCss = outputFiles.some((f) => f.endsWith('.css'));
336
+ if (hasCss && !hasJs) {
337
+ hasCssOnly = true;
338
+ }
339
+ }
340
+
341
+ if (hasCssOnly) {
342
+ devReloadHub.broadcast('css');
343
+ } else {
344
+ devReloadHub.broadcast('full');
345
+ }
346
+ }
347
+ });
348
+ },
349
+ };
350
+
351
+ async function getOrInitContext() {
352
+ if (ctx) return ctx;
353
+ if (!isAvailable) {
354
+ throw new Error(
355
+ "esbuild is not installed. Run 'npm install --save-dev esbuild' or 'npx webspresso add esbuild' to enable client bundling."
356
+ );
357
+ }
358
+
359
+ const hasEntryPoints = Array.isArray(entryPoints)
360
+ ? entryPoints.length > 0
361
+ : Boolean(entryPoints && Object.keys(entryPoints).length > 0);
362
+
363
+ if (!hasEntryPoints) {
364
+ throw new Error(
365
+ "No entrypoints found for esbuild bundler. Place your entry script in 'src/client/main.js' or specify 'entryPoints' option."
366
+ );
367
+ }
368
+
369
+ const outbase = options.outbase || (fs.existsSync(path.resolve(cwd, 'src/client')) ? path.resolve(cwd, 'src/client') : undefined);
370
+ const esbuildConfig = {
371
+ entryPoints,
372
+ outdir,
373
+ bundle,
374
+ sourcemap,
375
+ minify,
376
+ target,
377
+ format,
378
+ metafile,
379
+ outbase,
380
+ plugins: [devReloadPlugin, ...userPlugins],
381
+ ...(options.esbuildOptions || {}),
382
+ };
383
+
384
+ if (typeof esbuild.context !== 'function') {
385
+ throw new Error("esbuild.context() is not available. Please ensure esbuild >= 0.17.0 is installed.");
386
+ }
387
+
388
+ ctx = await esbuild.context(esbuildConfig);
389
+ return ctx;
390
+ }
391
+
392
+ async function startWatch() {
393
+ if (isDisposed) throw new Error('DevBundler has already been disposed.');
394
+ if (isWatching) return ctx;
395
+ const context = await getOrInitContext();
396
+ await context.watch();
397
+ isWatching = true;
398
+ if (logger && logger.log) {
399
+ logger.log('⚡ [Webspresso esbuild] Incremental watch mode active using context() API');
400
+ }
401
+ return context;
402
+ }
403
+
404
+ async function rebuild() {
405
+ if (isDisposed) throw new Error('DevBundler has already been disposed.');
406
+ const context = await getOrInitContext();
407
+ return await context.rebuild();
408
+ }
409
+
410
+ async function dispose() {
411
+ if (isDisposed) return;
412
+ isDisposed = true;
413
+ isWatching = false;
414
+ if (ctx) {
415
+ try {
416
+ await ctx.dispose();
417
+ } catch (_) {}
418
+ ctx = null;
419
+ }
420
+ }
421
+
422
+ return {
423
+ isAvailable: () => isAvailable,
424
+ isWatching: () => isWatching,
425
+ isDisposed: () => isDisposed,
426
+ getEntryPoints: () => entryPoints,
427
+ getOutdir: () => outdir,
428
+ startWatch,
429
+ rebuild,
430
+ dispose,
431
+ getContext: () => ctx,
432
+ };
433
+ }
434
+
435
+ const { generateManifest, createManifestPlugin } = require('./manifest');
436
+
437
+ /**
438
+ * One-shot production bundle builder with automatic metafile: true and manifest.json generation
439
+ *
440
+ * @param {object} [options={}]
441
+ * @returns {Promise<{ result: any, manifest: any, manifestPath: string }>}
442
+ */
443
+ async function buildBundle(options = {}) {
444
+ const cwd = options.cwd || process.cwd();
445
+ const esbuild = resolveEsbuild(options, cwd);
446
+ if (!esbuild) {
447
+ throw new Error("esbuild is not installed. Run 'npm install --save-dev esbuild' to build assets.");
448
+ }
449
+
450
+ const entryPoints = resolveEntryPoints(options.entryPoints, cwd);
451
+ const hasEntryPoints = Array.isArray(entryPoints)
452
+ ? entryPoints.length > 0
453
+ : Boolean(entryPoints && Object.keys(entryPoints).length > 0);
454
+
455
+ if (!hasEntryPoints) {
456
+ throw new Error("No client entry points found in 'src/client/main.js' or 'src/client/pages/'. Specify entryPoints option.");
457
+ }
458
+
459
+ const outdir = options.outdir ? path.resolve(cwd, options.outdir) : path.resolve(cwd, 'public/dist');
460
+ const publicDir = options.publicDir ? path.resolve(cwd, options.publicDir) : path.resolve(cwd, 'public');
461
+ const bundle = options.bundle !== false;
462
+ const minify = options.minify !== undefined ? options.minify : true;
463
+ const sourcemap = options.sourcemap !== undefined ? options.sourcemap : true;
464
+ const target = options.target || ['es2020'];
465
+ const format = options.format || 'esm';
466
+ const outbase = options.outbase || (fs.existsSync(path.resolve(cwd, 'src/client')) ? path.resolve(cwd, 'src/client') : undefined);
467
+ const entryNames = options.entryNames || (options.hash !== false ? '[dir]/[name].[hash]' : '[dir]/[name]');
468
+ const manifestPath = options.manifestPath || path.join(outdir, 'manifest.json');
469
+ const userPlugins = Array.isArray(options.plugins) ? options.plugins : [];
470
+
471
+ const manifestPlugin = createManifestPlugin({
472
+ outdir,
473
+ publicDir,
474
+ manifestPath,
475
+ logger: options.logger,
476
+ });
477
+
478
+ const buildConfig = {
479
+ entryPoints,
480
+ outdir,
481
+ bundle,
482
+ minify,
483
+ sourcemap,
484
+ target,
485
+ format,
486
+ entryNames,
487
+ outbase,
488
+ metafile: true,
489
+ plugins: [manifestPlugin, ...userPlugins],
490
+ ...(options.esbuildOptions || {}),
491
+ };
492
+
493
+ const result = await esbuild.build(buildConfig);
494
+ const manifest = generateManifest(result.metafile, {
495
+ outdir,
496
+ publicDir,
497
+ });
498
+
499
+ return {
500
+ result,
501
+ manifest,
502
+ manifestPath,
503
+ };
504
+ }
505
+
506
+ module.exports = {
507
+ createDevBundler,
508
+ buildBundle,
509
+ generateManifest,
510
+ createManifestPlugin,
511
+ findDefaultEntryPoints,
512
+ findPageEntryPoints,
513
+ scanClientPages,
514
+ expandGlob,
515
+ resolveEntryPoints,
516
+ DEFAULT_ENTRYPOINTS,
517
+ };
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Manifest Generator & esbuild Plugin for Webspresso Asset Management
3
+ *
4
+ * Generates production manifest.json from esbuild metafile for zero-config
5
+ * cache busting and seamless integration with Webspresso AssetManager (fsy.asset, fsy.js, fsy.css).
6
+ */
7
+
8
+ const fs = require('fs');
9
+ const path = require('path');
10
+
11
+ /**
12
+ * Generates a Webspresso / Vite-compatible manifest object from an esbuild metafile
13
+ *
14
+ * @param {object} metafile - esbuild build result metafile
15
+ * @param {object} [options={}]
16
+ * @param {string} [options.outdir='public/dist']
17
+ * @param {string} [options.publicDir='public']
18
+ * @returns {Record<string, { file: string, src: string, bytes: number }>}
19
+ */
20
+ function generateManifest(metafile, options = {}) {
21
+ const publicDir = (options.publicDir || 'public').replace(/\\/g, '/').replace(/\/+$/, '');
22
+ const manifest = {};
23
+
24
+ if (!metafile || !metafile.outputs) {
25
+ return manifest;
26
+ }
27
+
28
+ for (const [outPathRaw, meta] of Object.entries(metafile.outputs)) {
29
+ // Skip sourcemap entries
30
+ if (outPathRaw.endsWith('.map')) continue;
31
+
32
+ const outPath = outPathRaw.replace(/\\/g, '/');
33
+ let publicUrl = outPath;
34
+ if (publicUrl.startsWith(publicDir + '/')) {
35
+ publicUrl = publicUrl.slice(publicDir.length + 1);
36
+ } else if (publicUrl.startsWith(publicDir)) {
37
+ publicUrl = publicUrl.slice(publicDir.length);
38
+ }
39
+ if (publicUrl.startsWith('/')) {
40
+ publicUrl = publicUrl.slice(1);
41
+ }
42
+
43
+ const entryPoint = meta.entryPoint ? meta.entryPoint.replace(/\\/g, '/') : null;
44
+ const baseOutName = path.basename(publicUrl);
45
+
46
+ const record = {
47
+ file: publicUrl,
48
+ src: entryPoint || baseOutName,
49
+ bytes: meta.bytes || 0,
50
+ };
51
+
52
+ if (entryPoint) {
53
+ // 1. Exact entry point (e.g. "src/client/main.js")
54
+ manifest[entryPoint] = record;
55
+
56
+ // 2. Base entry name (e.g. "main.js")
57
+ const entryBase = path.basename(entryPoint);
58
+ if (!manifest[entryBase]) {
59
+ manifest[entryBase] = record;
60
+ }
61
+
62
+ // 3. Normalized without src/ (e.g. "client/main.js")
63
+ const trimmedSrc = entryPoint.replace(/^src\//, '');
64
+ if (!manifest[trimmedSrc]) {
65
+ manifest[trimmedSrc] = record;
66
+ }
67
+
68
+ // 4. Normalized relative to client root (e.g. "pages/home.js")
69
+ let clientRel = '';
70
+ const srcClientIdx = entryPoint.indexOf('src/client/');
71
+ if (srcClientIdx !== -1) {
72
+ clientRel = entryPoint.slice(srcClientIdx + 'src/client/'.length);
73
+ } else {
74
+ const clientIdx = entryPoint.indexOf('client/');
75
+ if (clientIdx !== -1) {
76
+ clientRel = entryPoint.slice(clientIdx + 'client/'.length);
77
+ } else {
78
+ clientRel = entryPoint.replace(/^src\//, '');
79
+ }
80
+ }
81
+
82
+ if (clientRel && !manifest[clientRel]) {
83
+ manifest[clientRel] = record;
84
+ }
85
+
86
+ // 5. Conventional js/ prefix (e.g. "js/main.js" or "js/pages/home.js")
87
+ const jsPrefixed = 'js/' + clientRel;
88
+ if (!manifest[jsPrefixed]) {
89
+ manifest[jsPrefixed] = record;
90
+ }
91
+ const jsBasePrefixed = 'js/' + entryBase;
92
+ if (!manifest[jsBasePrefixed]) {
93
+ manifest[jsBasePrefixed] = record;
94
+ }
95
+
96
+ // 6. Conventional css/ prefix for CSS inputs
97
+ if (entryBase.endsWith('.css')) {
98
+ const cssPrefixed = 'css/' + clientRel;
99
+ if (!manifest[cssPrefixed]) {
100
+ manifest[cssPrefixed] = record;
101
+ }
102
+ const cssBasePrefixed = 'css/' + entryBase;
103
+ if (!manifest[cssBasePrefixed]) {
104
+ manifest[cssBasePrefixed] = record;
105
+ }
106
+ }
107
+
108
+ // 7. Key without file extension (e.g. "pages/home", "home")
109
+ const nameWithoutExt = clientRel.replace(/\.[a-z0-9]+$/i, '');
110
+ if (!manifest[nameWithoutExt]) {
111
+ manifest[nameWithoutExt] = record;
112
+ }
113
+ const jsNameWithoutExt = 'js/' + nameWithoutExt;
114
+ if (!manifest[jsNameWithoutExt]) {
115
+ manifest[jsNameWithoutExt] = record;
116
+ }
117
+ } else {
118
+ manifest[baseOutName] = record;
119
+ }
120
+ }
121
+
122
+ return manifest;
123
+ }
124
+
125
+ /**
126
+ * Creates an esbuild plugin that writes out a production manifest.json upon build completion
127
+ *
128
+ * @param {object} [options={}]
129
+ * @param {string} [options.manifestPath]
130
+ * @param {string} [options.outdir='public/dist']
131
+ * @param {string} [options.publicDir='public']
132
+ * @param {object} [options.logger=console]
133
+ * @returns {object} esbuild plugin
134
+ */
135
+ function createManifestPlugin(options = {}) {
136
+ const manifestPath = options.manifestPath || path.join(options.outdir || 'public/dist', 'manifest.json');
137
+ const logger = options.logger !== undefined ? options.logger : console;
138
+
139
+ return {
140
+ name: 'webspresso-manifest',
141
+ setup(build) {
142
+ build.onEnd((result) => {
143
+ if (!result.errors || result.errors.length === 0) {
144
+ if (result.metafile) {
145
+ const manifest = generateManifest(result.metafile, options);
146
+ const fullManifestPath = path.resolve(manifestPath);
147
+ const dir = path.dirname(fullManifestPath);
148
+ if (!fs.existsSync(dir)) {
149
+ fs.mkdirSync(dir, { recursive: true });
150
+ }
151
+ fs.writeFileSync(fullManifestPath, JSON.stringify(manifest, null, 2) + '\n');
152
+ if (logger && logger.log) {
153
+ logger.log(`📦 [Webspresso] Generated asset manifest: ${manifestPath}`);
154
+ }
155
+ }
156
+ }
157
+ });
158
+ },
159
+ };
160
+ }
161
+
162
+ module.exports = {
163
+ generateManifest,
164
+ createManifestPlugin,
165
+ };