webspresso 0.0.108 → 0.0.110

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 (184) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +3 -3
  3. package/bin/commands/add-esbuild.js +7 -3
  4. package/bin/commands/add-tailwind.js +9 -5
  5. package/bin/commands/build.js +20 -4
  6. package/bin/commands/dev.js +57 -7
  7. package/bin/commands/doctor.js +42 -1
  8. package/bin/commands/favicon-generate.js +10 -3
  9. package/bin/commands/new.js +8 -3
  10. package/bin/commands/start.js +3 -1
  11. package/bin/utils/add-command.js +16 -0
  12. package/core/app.js +45 -3
  13. package/core/auth/authenticate.js +3 -3
  14. package/core/auth/hash.js +167 -43
  15. package/core/auth/index.js +13 -1
  16. package/core/auth/jwt.js +21 -2
  17. package/core/auth/manager.js +63 -14
  18. package/core/auth/middleware.js +4 -2
  19. package/core/auth/oauth.js +5 -1
  20. package/core/auth/tokens.js +7 -2
  21. package/core/auth/totp.js +42 -6
  22. package/core/auth/user-agent.js +2 -0
  23. package/core/client/adapter/datastar.js +1401 -0
  24. package/core/client/api.js +1337 -0
  25. package/core/client/controller.js +1706 -0
  26. package/core/client/debug.js +367 -0
  27. package/core/client/devtool.js +1134 -0
  28. package/core/client/forms.js +1164 -0
  29. package/core/client/fragments.js +894 -0
  30. package/core/client/index.d.ts +59 -0
  31. package/core/client/index.js +391 -0
  32. package/core/client/lite.js +175 -0
  33. package/core/client/server-fragments.js +868 -0
  34. package/core/client/storage.js +339 -0
  35. package/core/client/utils.js +281 -0
  36. package/core/compression/middleware.js +277 -26
  37. package/core/context/app-context.js +25 -3
  38. package/core/context/request.js +32 -4
  39. package/core/errors/middleware.js +14 -0
  40. package/core/events/dispatcher.js +33 -0
  41. package/core/forms/flash.js +136 -102
  42. package/core/health/middleware.js +36 -6
  43. package/core/http/pipeline.js +45 -6
  44. package/core/index.js +25 -7
  45. package/core/kernel/view.js +41 -10
  46. package/core/metrics/middleware.js +30 -6
  47. package/core/metrics/registry.js +13 -5
  48. package/core/orm/cache/fingerprint.js +32 -11
  49. package/core/orm/cache/layer.js +19 -2
  50. package/core/orm/hidden.js +145 -0
  51. package/core/orm/model.js +48 -2
  52. package/core/orm/query-builder.js +343 -6
  53. package/core/orm/query-filters.js +14 -2
  54. package/core/orm/repository.js +64 -13
  55. package/core/orm/schema-helpers.js +9 -6
  56. package/core/orm/transaction.js +50 -6
  57. package/core/orm/types.js +4 -0
  58. package/core/queue/adapters/database.js +184 -2
  59. package/core/queue/adapters/memory.js +153 -5
  60. package/core/queue/adapters/redis.js +59 -4
  61. package/core/queue/job.js +14 -1
  62. package/core/queue/manager.js +115 -0
  63. package/core/resilience/circuit-breaker.js +27 -8
  64. package/core/services/builtins/exchange.js +174 -7
  65. package/core/services/builtins/file-manager.js +36 -0
  66. package/core/services/builtins/mail.js +27 -0
  67. package/core/services/builtins/queue.js +144 -0
  68. package/core/services/builtins/security.js +118 -0
  69. package/core/services/builtins/webhook.js +110 -0
  70. package/core/services/executor.js +38 -16
  71. package/core/services/index.js +4 -1
  72. package/core/sse/stream.js +28 -5
  73. package/core/utils/timeout.js +8 -1
  74. package/core/validation/compiler.js +10 -7
  75. package/index.d.ts +1320 -74
  76. package/index.js +128 -8
  77. package/package.json +75 -27
  78. package/plugins/admin-panel/client/parts/08-record-list.js +717 -70
  79. package/plugins/admin-panel/index.js +19 -8
  80. package/plugins/csrf/index.js +3 -3
  81. package/plugins/data-exchange/export.js +214 -0
  82. package/plugins/data-exchange/import.js +48 -5
  83. package/plugins/data-exchange/index.js +70 -28
  84. package/plugins/data-exchange/preview.js +266 -0
  85. package/plugins/data-exchange/template.js +242 -0
  86. package/plugins/email/admin-component.js +783 -141
  87. package/plugins/email/api-handlers.js +51 -1
  88. package/plugins/email/index.js +5 -0
  89. package/plugins/email/service.js +48 -0
  90. package/plugins/file-manager/admin-component.js +1546 -70
  91. package/plugins/file-manager/api-handlers.js +166 -1
  92. package/plugins/file-manager/core.js +598 -8
  93. package/plugins/file-manager/index.js +8 -0
  94. package/plugins/index.js +7 -0
  95. package/plugins/mcp/auth.js +3 -3
  96. package/plugins/oauth/index.js +36 -3
  97. package/plugins/queue/admin-component.js +1092 -0
  98. package/plugins/queue/api-handlers.js +248 -0
  99. package/plugins/queue/index.js +69 -0
  100. package/plugins/rest-resources/index.js +7 -2
  101. package/plugins/security/admin-component.js +689 -0
  102. package/plugins/security/api-handlers.js +169 -0
  103. package/plugins/security/audit.js +153 -0
  104. package/plugins/security/bruteforce.js +244 -0
  105. package/plugins/security/firewall.js +459 -0
  106. package/plugins/security/headers.js +214 -0
  107. package/plugins/security/honeypot.js +234 -0
  108. package/plugins/security/incident-recorder.js +137 -0
  109. package/plugins/security/index.js +211 -0
  110. package/plugins/seo-checker/analyzer.js +20 -7
  111. package/plugins/upload/index.js +36 -0
  112. package/plugins/webhooks/admin-component.js +702 -175
  113. package/plugins/webhooks/api-handlers.js +75 -1
  114. package/plugins/webhooks/dispatcher.js +290 -11
  115. package/plugins/webhooks/index.js +37 -2
  116. package/plugins/webhooks/sender.js +56 -0
  117. package/plugins/xlsx/engine.js +19 -4
  118. package/src/api/api-loader.js +19 -1
  119. package/src/bundle/index.js +443 -17
  120. package/src/client-runtime/bootstrap-swup.js +9 -0
  121. package/src/client-runtime/mount.js +15 -20
  122. package/src/client-runtime/resolve.js +3 -2
  123. package/src/dev/api-typegen.js +1300 -0
  124. package/src/dev/banner.js +10 -1
  125. package/src/dev/colors.js +1 -1
  126. package/src/dev/index.js +7 -1
  127. package/src/dev/typegen.js +173 -17
  128. package/src/dev-reload/index.js +1 -1
  129. package/src/discovery/file-route-parser.js +17 -3
  130. package/src/discovery/scan-routes.js +4 -1
  131. package/src/file-router.js +253 -69
  132. package/src/helpers.js +46 -26
  133. package/src/njk-frontmatter.js +30 -4
  134. package/src/routing/cascading.js +26 -8
  135. package/src/routing/mount-discovered-routes.js +76 -0
  136. package/src/server.js +231 -70
  137. package/views/partials/webspresso-client-runtime.njk +1 -9
  138. package/.agents/ADMIN.md +0 -426
  139. package/.agents/AGENTS.md +0 -96
  140. package/.agents/ARCHITECTURE.md +0 -163
  141. package/.agents/AUTH.md +0 -581
  142. package/.agents/CACHE.md +0 -148
  143. package/.agents/CLI.md +0 -649
  144. package/.agents/COMPRESSION.md +0 -59
  145. package/.agents/CONTEXT.md +0 -152
  146. package/.agents/CONVENTIONS.md +0 -50
  147. package/.agents/EARLY_HINTS.md +0 -120
  148. package/.agents/EMAIL.md +0 -223
  149. package/.agents/ERRORS.md +0 -260
  150. package/.agents/EVENTS.md +0 -179
  151. package/.agents/FLAGS.md +0 -118
  152. package/.agents/HEALTH.md +0 -118
  153. package/.agents/LIMITS.md +0 -109
  154. package/.agents/LOGGING.md +0 -93
  155. package/.agents/MCP.md +0 -103
  156. package/.agents/METRICS.md +0 -127
  157. package/.agents/MIGRATION_JOBS.md +0 -95
  158. package/.agents/ORM.md +0 -700
  159. package/.agents/PLUGINS.md +0 -729
  160. package/.agents/QUEUE.md +0 -290
  161. package/.agents/RATE_LIMIT.md +0 -147
  162. package/.agents/REALTIME.md +0 -213
  163. package/.agents/RESILIENCE.md +0 -235
  164. package/.agents/ROUTING.md +0 -605
  165. package/.agents/SCALE_OUT.md +0 -109
  166. package/.agents/SECRETS.md +0 -117
  167. package/.agents/SERVICES.md +0 -341
  168. package/.agents/SHUTDOWN.md +0 -74
  169. package/.agents/STREAMING.md +0 -108
  170. package/.agents/WORKFLOW.md +0 -27
  171. package/.agents/internal/DOCUMENTATION_REVIEW.md +0 -485
  172. package/.agents/internal/TECHNICAL_REVIEW.md +0 -428
  173. package/.agents/skills/webspresso-admin-and-plugins/SKILL.md +0 -122
  174. package/.agents/skills/webspresso-framework/SKILL.md +0 -89
  175. package/.agents/skills/webspresso-orm-and-database/SKILL.md +0 -127
  176. package/.agents/skills/webspresso-routing-and-api/SKILL.md +0 -130
  177. package/.agents/skills/webspresso-services/SKILL.md +0 -134
  178. package/.cursorrules +0 -14
  179. package/AGENTS.md +0 -203
  180. package/CLAUDE.md +0 -54
  181. package/core/kernel/run-demo.js +0 -58
  182. package/plugins/admin-panel/styles.js +0 -9
  183. package/plugins/data-exchange/export-xlsx.js +0 -119
  184. package/src/client-runtime/bootstrap-alpine-swup.js +0 -34
@@ -6,7 +6,19 @@
6
6
  * @module plugins/xlsx/engine
7
7
  */
8
8
 
9
- const ExcelJS = require('exceljs');
9
+ let ExcelJS = null;
10
+ function getExcelJS() {
11
+ if (!ExcelJS) {
12
+ try {
13
+ ExcelJS = require('exceljs');
14
+ } catch {
15
+ throw new Error(
16
+ "The 'exceljs' package is required to use xlsxPlugin / XLSX services. Install it with: npm install exceljs"
17
+ );
18
+ }
19
+ }
20
+ return ExcelJS;
21
+ }
10
22
  const { PassThrough } = require('stream');
11
23
 
12
24
  /**
@@ -125,7 +137,8 @@ function applyHeaderStyle(worksheet, style = {}) {
125
137
  * @returns {Promise<Buffer>}
126
138
  */
127
139
  async function generate(options = {}) {
128
- const workbook = new ExcelJS.Workbook();
140
+ const Excel = getExcelJS();
141
+ const workbook = new Excel.Workbook();
129
142
  workbook.creator = options.creator || 'Webspresso';
130
143
  workbook.created = options.created || new Date();
131
144
  if (options.title) workbook.title = options.title;
@@ -225,7 +238,8 @@ async function generate(options = {}) {
225
238
  * @returns {Promise<Array<Object>|Object.<string, Array<Object>>>}
226
239
  */
227
240
  async function parse(input, options = {}) {
228
- const workbook = new ExcelJS.Workbook();
241
+ const Excel = getExcelJS();
242
+ const workbook = new Excel.Workbook();
229
243
 
230
244
  if (Buffer.isBuffer(input)) {
231
245
  await workbook.xlsx.load(input);
@@ -378,7 +392,8 @@ async function fromModel(repository, filterOrQuery = {}, options = {}) {
378
392
  * @returns {ExcelJS.stream.xlsx.WorkbookWriter}
379
393
  */
380
394
  function createStreamWriter(outputStream, options = {}) {
381
- return new ExcelJS.stream.xlsx.WorkbookWriter({
395
+ const Excel = getExcelJS();
396
+ return new Excel.stream.xlsx.WorkbookWriter({
382
397
  stream: outputStream,
383
398
  useStyles: options.useStyles !== false,
384
399
  useSharedStrings: options.useSharedStrings || false,
@@ -18,6 +18,22 @@ const { executeApiPipeline, enrichRouteError } = require('../../core/http');
18
18
  * @param {Object|Function} schemaDef
19
19
  * @returns {Object|null}
20
20
  */
21
+
22
+ const warnedNonRouteModules = new Set();
23
+
24
+ /**
25
+ * Warn once per file that an API-directory module exports no handler.
26
+ * @param {string} source - Project-relative file path
27
+ */
28
+ function warnNonRouteModule(source) {
29
+ if (warnedNonRouteModules.has(source) || process.env.NODE_ENV === 'production') return;
30
+ warnedNonRouteModules.add(source);
31
+ console.warn(
32
+ `[webspresso] ${source} is in an API directory but exports no handler; responding 404. ` +
33
+ 'Prefix helper files with "_" (e.g. _client.js) to keep them out of routing.'
34
+ );
35
+ }
36
+
21
37
  function compileApiSchema(schemaDef) {
22
38
  if (!schemaDef) return null;
23
39
  if (typeof schemaDef === 'function') {
@@ -125,7 +141,9 @@ function createApiHandler(descriptor, context) {
125
141
  handlerFn = apiDef.handler || (typeof apiModule === 'function' ? apiModule : null);
126
142
 
127
143
  if (typeof handlerFn !== 'function') {
128
- throw new Error(`API file "${descriptor.source}" does not export a valid handler function`);
144
+ // A helper module living in an API directory is not a route: answer 404 instead of a 500
145
+ warnNonRouteModule(descriptor.source);
146
+ return next();
129
147
  }
130
148
 
131
149
  compiledSchema = compileApiSchema(apiDef.schema);
@@ -8,6 +8,52 @@
8
8
 
9
9
  const fs = require('fs');
10
10
  const path = require('path');
11
+ const { deriveControllerNameFromPath } = require('../../core/client/controller');
12
+
13
+ const SERVER_ONLY_BUILTINS = new Set([
14
+ 'fs',
15
+ 'fs/promises',
16
+ 'net',
17
+ 'tls',
18
+ 'child_process',
19
+ 'cluster',
20
+ 'dgram',
21
+ 'dns',
22
+ 'http2',
23
+ 'worker_threads',
24
+ 'v8',
25
+ 'vm',
26
+ 'async_hooks',
27
+ 'inspector',
28
+ ]);
29
+
30
+ const SERVER_ONLY_PACKAGES = new Set([
31
+ 'knex',
32
+ 'better-sqlite3',
33
+ 'pg',
34
+ 'pg-native',
35
+ 'mysql',
36
+ 'mysql2',
37
+ 'sqlite3',
38
+ 'oracledb',
39
+ 'tedious',
40
+ 'mssql',
41
+ 'ioredis',
42
+ 'redis',
43
+ 'express',
44
+ 'helmet',
45
+ 'compression',
46
+ 'cookie-parser',
47
+ 'express-session',
48
+ 'nodemailer',
49
+ 'mjml',
50
+ 'bcrypt',
51
+ 'bcryptjs',
52
+ 'argon2',
53
+ ]);
54
+
55
+ const SERVER_ONLY_INTERNAL_PATTERN =
56
+ /(?:^|\/)(?:core\/(?:orm|services|auth|secrets|queue|mail|http|metrics|health|rate-limit)|src\/(?:server|file-router|api|pages)|models|services|jobs|migrations|pages\/api)(?:\/|$)/;
11
57
 
12
58
  const DEFAULT_ENTRYPOINTS = [
13
59
  'src/client/main.js',
@@ -78,11 +124,13 @@ function findPageEntryPoints(cwd = process.cwd()) {
78
124
  }
79
125
  }
80
126
 
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) {
127
+ // 2. Co-located page client files: pages/**/*.client.{js,ts,jsx,tsx} and src/pages/**/*.client.{js,ts,jsx,tsx}
128
+ const pageDirs = [path.resolve(cwd, 'pages'), path.resolve(cwd, 'src/pages')];
129
+ const validExts = new Set(['.js', '.mjs', '.ts', '.jsx', '.tsx']);
130
+
131
+ for (const dirToScan of pageDirs) {
132
+ if (!fs.existsSync(dirToScan)) continue;
133
+ function walkPages(currentDir) {
86
134
  let list;
87
135
  try {
88
136
  list = fs.readdirSync(currentDir, { withFileTypes: true });
@@ -93,11 +141,11 @@ function findPageEntryPoints(cwd = process.cwd()) {
93
141
  if (item.name.startsWith('.')) continue;
94
142
  const fullPath = path.join(currentDir, item.name);
95
143
  if (item.isDirectory()) {
96
- walkSrcPages(fullPath);
144
+ walkPages(fullPath);
97
145
  } else if (item.isFile()) {
98
146
  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, '/');
147
+ if (validExts.has(ext) && item.name.includes('.client.') && !item.name.endsWith('.d.ts')) {
148
+ const rel = path.relative(dirToScan, fullPath).replace(/\\/g, '/');
101
149
  const cleanName = rel.replace(/\.client\.[a-z0-9]+$/i, '');
102
150
  const entryName = 'pages/' + cleanName;
103
151
  if (!pageEntries[entryName]) {
@@ -107,12 +155,293 @@ function findPageEntryPoints(cwd = process.cwd()) {
107
155
  }
108
156
  }
109
157
  }
110
- walkSrcPages(srcPagesDir);
158
+ walkPages(dirToScan);
159
+ }
160
+
161
+ // 3. Co-located component/view client files: views/**/*.client.{js,ts,jsx,tsx} and src/views/**/*.client.{js,ts,jsx,tsx}
162
+ const viewDirs = [path.resolve(cwd, 'views'), path.resolve(cwd, 'src/views')];
163
+ for (const dirToScan of viewDirs) {
164
+ if (!fs.existsSync(dirToScan)) continue;
165
+ function walkViews(currentDir) {
166
+ let list;
167
+ try {
168
+ list = fs.readdirSync(currentDir, { withFileTypes: true });
169
+ } catch (_) {
170
+ return;
171
+ }
172
+ for (const item of list) {
173
+ if (item.name.startsWith('.')) continue;
174
+ const fullPath = path.join(currentDir, item.name);
175
+ if (item.isDirectory()) {
176
+ walkViews(fullPath);
177
+ } else if (item.isFile()) {
178
+ const ext = path.extname(item.name).toLowerCase();
179
+ if (validExts.has(ext) && item.name.includes('.client.') && !item.name.endsWith('.d.ts')) {
180
+ const rel = path.relative(dirToScan, fullPath).replace(/\\/g, '/');
181
+ const cleanName = rel.replace(/\.client\.[a-z0-9]+$/i, '');
182
+ const entryName = cleanName.startsWith('components/') ? cleanName : `views/${cleanName}`;
183
+ if (!pageEntries[entryName]) {
184
+ pageEntries[entryName] = fullPath;
185
+ }
186
+ }
187
+ }
188
+ }
189
+ }
190
+ walkViews(dirToScan);
111
191
  }
112
192
 
113
193
  return pageEntries;
114
194
  }
115
195
 
196
+ /**
197
+ * Discovers all `.client.{js,ts,mjs,jsx,tsx}` controller files across `pages/` and `views/`
198
+ * and validates deterministic controller names for collisions.
199
+ *
200
+ * @param {string} [cwd=process.cwd()]
201
+ * @returns {Array<{ controllerName: string, entryName: string, filePath: string, relativePath: string }>}
202
+ */
203
+ function discoverClientControllers(cwd = process.cwd()) {
204
+ const pageEntries = findPageEntryPoints(cwd);
205
+ const discovered = [];
206
+ const byControllerName = new Map();
207
+
208
+ for (const [entryName, filePath] of Object.entries(pageEntries)) {
209
+ const relativePath = path.relative(cwd, filePath).replace(/\\/g, '/');
210
+ const controllerName = deriveControllerNameFromPath(relativePath);
211
+ if (!controllerName) continue;
212
+
213
+ if (byControllerName.has(controllerName)) {
214
+ const existing = byControllerName.get(controllerName);
215
+ if (existing.filePath !== filePath) {
216
+ const err = new Error(
217
+ `Controller name collision: "${controllerName}" is defined by both "${existing.relativePath}" and "${relativePath}".`
218
+ );
219
+ err.code = 'CONTROLLER_NAME_COLLISION';
220
+ err.controllerName = controllerName;
221
+ err.files = [existing.relativePath, relativePath];
222
+ throw err;
223
+ }
224
+ }
225
+
226
+ const record = {
227
+ controllerName,
228
+ entryName,
229
+ filePath,
230
+ relativePath,
231
+ };
232
+ byControllerName.set(controllerName, record);
233
+ discovered.push(record);
234
+ }
235
+
236
+ return discovered;
237
+ }
238
+
239
+ /**
240
+ * Checks whether an import specifier or resolved path crosses into a server-only module boundary.
241
+ *
242
+ * @param {string} specifier - Raw import/require string
243
+ * @param {string} importerPath - Absolute path of the importing client file
244
+ * @param {string} [cwd=process.cwd()] - Project root directory
245
+ * @returns {{ forbidden: boolean, reason?: string }}
246
+ */
247
+ function checkServerImportViolation(specifier, importerPath, cwd = process.cwd()) {
248
+ if (!specifier || typeof specifier !== 'string') return { forbidden: false };
249
+ const raw = specifier.trim();
250
+
251
+ // 1. Node built-in modules (`node:fs`, `fs`, `child_process`, etc.)
252
+ if (raw.startsWith('node:')) {
253
+ const modName = raw.slice(5);
254
+ if (SERVER_ONLY_BUILTINS.has(modName) || modName.startsWith('fs') || modName.startsWith('child_process')) {
255
+ return {
256
+ forbidden: true,
257
+ reason: `Node.js server built-in module "${raw}" cannot be imported into browser client bundle ("${path.relative(cwd, importerPath)}").`,
258
+ };
259
+ }
260
+ }
261
+ if (SERVER_ONLY_BUILTINS.has(raw)) {
262
+ return {
263
+ forbidden: true,
264
+ reason: `Node.js server built-in module "${raw}" cannot be imported into browser client bundle ("${path.relative(cwd, importerPath)}").`,
265
+ };
266
+ }
267
+
268
+ // 2. Server-only packages (`knex`, `express`, `better-sqlite3`, `pg`, etc.)
269
+ const pkgRoot = raw.startsWith('@')
270
+ ? raw.split('/').slice(0, 2).join('/')
271
+ : raw.split('/')[0];
272
+ if (!raw.startsWith('.') && !raw.startsWith('/') && SERVER_ONLY_PACKAGES.has(pkgRoot)) {
273
+ return {
274
+ forbidden: true,
275
+ reason: `Server-only package "${raw}" cannot be imported into browser client bundle ("${path.relative(cwd, importerPath)}").`,
276
+ };
277
+ }
278
+
279
+ // 3. Webspresso server subpath imports (`webspresso/core/orm`, `webspresso/core/services`, etc.)
280
+ if (!raw.startsWith('.') && SERVER_ONLY_INTERNAL_PATTERN.test(raw)) {
281
+ return {
282
+ forbidden: true,
283
+ reason: `Server-only Webspresso module "${raw}" cannot be imported into browser client bundle ("${path.relative(cwd, importerPath)}").`,
284
+ };
285
+ }
286
+
287
+ // 4. Relative or absolute path imports into server files (`pages/api/*`, `models/*`, `services/*`, server page loaders)
288
+ if (raw.startsWith('.') || raw.startsWith('/')) {
289
+ const resolved = path.resolve(path.dirname(importerPath), raw);
290
+ const relToRoot = path.relative(cwd, resolved).replace(/\\/g, '/');
291
+
292
+ if (SERVER_ONLY_INTERNAL_PATTERN.test(relToRoot)) {
293
+ return {
294
+ forbidden: true,
295
+ reason: `Server module "${relToRoot}" (imported as "${raw}" in "${path.relative(cwd, importerPath)}") cannot be bundled into browser code.`,
296
+ };
297
+ }
298
+
299
+ // Disallow importing a server page loader (`pages/notes.js`) from `pages/notes.client.ts`
300
+ if (
301
+ (relToRoot.startsWith('pages/') || relToRoot.startsWith('src/pages/')) &&
302
+ !relToRoot.includes('.client')
303
+ ) {
304
+ // Check if it resolves to a non-client .js/.ts file in pages/
305
+ const candidates = [resolved, `${resolved}.js`, `${resolved}.ts`];
306
+ for (const cand of candidates) {
307
+ if (fs.existsSync(cand) && fs.statSync(cand).isFile() && !cand.includes('.client.')) {
308
+ return {
309
+ forbidden: true,
310
+ reason: `Server page loader "${path.relative(cwd, cand)}" cannot be imported into browser client bundle ("${path.relative(cwd, importerPath)}").`,
311
+ };
312
+ }
313
+ }
314
+ }
315
+ }
316
+
317
+ return { forbidden: false };
318
+ }
319
+
320
+ /**
321
+ * Statically scans client entry files and their local relative dependency graph
322
+ * to reject any runtime import/require of server-only modules (ignores `import type`).
323
+ *
324
+ * @param {string[]|Record<string, string>} entryPoints
325
+ * @param {string} [cwd=process.cwd()]
326
+ */
327
+ function validateClientBundleBoundaries(entryPoints, cwd = process.cwd()) {
328
+ const fileList = Array.isArray(entryPoints)
329
+ ? entryPoints
330
+ : entryPoints && typeof entryPoints === 'object'
331
+ ? Object.values(entryPoints)
332
+ : [];
333
+
334
+ const visited = new Set();
335
+
336
+ function scanFile(filePath) {
337
+ const absPath = path.resolve(cwd, filePath);
338
+ if (visited.has(absPath) || !fs.existsSync(absPath)) return;
339
+ visited.add(absPath);
340
+
341
+ let source;
342
+ try {
343
+ source = fs.readFileSync(absPath, 'utf8');
344
+ } catch (_) {
345
+ return;
346
+ }
347
+
348
+ // Strip block and line comments so commented-out imports don't trigger false positives
349
+ const cleanSource = source
350
+ .replace(/\/\*[\s\S]*?\*\//g, '')
351
+ .replace(/^\s*\/\/.*$/gm, '');
352
+
353
+ const specs = [];
354
+
355
+ // Match runtime ESM imports (excluding `import type ...` and `export type ...`)
356
+ const esmImportRegex = /\b(?:import|export)\s+(?!type\b)(?:[\s\S]*?\bfrom\s+)?['"]([^'"]+)['"]/g;
357
+ let m;
358
+ while ((m = esmImportRegex.exec(cleanSource)) !== null) {
359
+ const fullMatch = m[0];
360
+ // Skip `import { type Foo } from '...'` if ALL imported bindings are `type`
361
+ const braceMatch = fullMatch.match(/\{([^}]+)\}/);
362
+ if (braceMatch && !/^[^{]+\{/.test(fullMatch.replace(/^(?:import|export)\s+/, ''))) {
363
+ const items = braceMatch[1]
364
+ .split(',')
365
+ .map((s) => s.trim())
366
+ .filter(Boolean);
367
+ if (items.length > 0 && items.every((item) => /^type\s+/.test(item))) {
368
+ continue;
369
+ }
370
+ }
371
+ specs.push(m[1]);
372
+ }
373
+
374
+ // Match CommonJS require('...') and dynamic import('...')
375
+ const callImportRegex = /\b(?:require|import)\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
376
+ while ((m = callImportRegex.exec(cleanSource)) !== null) {
377
+ specs.push(m[1]);
378
+ }
379
+
380
+ for (const spec of specs) {
381
+ const check = checkServerImportViolation(spec, absPath, cwd);
382
+ if (check.forbidden) {
383
+ const err = new Error(check.reason);
384
+ err.code = 'SERVER_DEPENDENCY_IN_CLIENT';
385
+ err.importer = absPath;
386
+ err.specifier = spec;
387
+ throw err;
388
+ }
389
+
390
+ // Follow relative client imports recursively
391
+ if (spec.startsWith('.')) {
392
+ const baseResolved = path.resolve(path.dirname(absPath), spec);
393
+ const candidates = [
394
+ baseResolved,
395
+ `${baseResolved}.ts`,
396
+ `${baseResolved}.js`,
397
+ `${baseResolved}.mjs`,
398
+ `${baseResolved}.tsx`,
399
+ `${baseResolved}.jsx`,
400
+ path.join(baseResolved, 'index.ts'),
401
+ path.join(baseResolved, 'index.js'),
402
+ ];
403
+ for (const cand of candidates) {
404
+ if (fs.existsSync(cand) && fs.statSync(cand).isFile()) {
405
+ scanFile(cand);
406
+ break;
407
+ }
408
+ }
409
+ }
410
+ }
411
+ }
412
+
413
+ for (const entry of fileList) {
414
+ scanFile(entry);
415
+ }
416
+ }
417
+
418
+ /**
419
+ * Creates an esbuild plugin that blocks server-only modules from entering browser bundles.
420
+ *
421
+ * @param {string} [cwd=process.cwd()]
422
+ */
423
+ function createServerBoundaryPlugin(cwd = process.cwd()) {
424
+ return {
425
+ name: 'webspresso-server-boundary',
426
+ setup(build) {
427
+ build.onResolve({ filter: /.*/ }, (args) => {
428
+ if (!args.importer) return null;
429
+ const check = checkServerImportViolation(args.path, args.importer, cwd);
430
+ if (check.forbidden) {
431
+ return {
432
+ errors: [
433
+ {
434
+ text: check.reason,
435
+ },
436
+ ],
437
+ };
438
+ }
439
+ return null;
440
+ });
441
+ },
442
+ };
443
+ }
444
+
116
445
  /**
117
446
  * Simple glob expansion for client entrypoints (supports single and double wildcards)
118
447
  * @param {string} pattern - Glob pattern (e.g. 'src/client/pages/*.js')
@@ -367,6 +696,9 @@ function createDevBundler(options = {}, devReloadHub = null) {
367
696
  );
368
697
  }
369
698
 
699
+ discoverClientControllers(cwd);
700
+ validateClientBundleBoundaries(entryPoints, cwd);
701
+
370
702
  const outbase = options.outbase || (fs.existsSync(path.resolve(cwd, 'src/client')) ? path.resolve(cwd, 'src/client') : undefined);
371
703
  const esbuildConfig = {
372
704
  entryPoints,
@@ -378,7 +710,7 @@ function createDevBundler(options = {}, devReloadHub = null) {
378
710
  format,
379
711
  metafile,
380
712
  outbase,
381
- plugins: [devReloadPlugin, ...userPlugins],
713
+ plugins: [createServerBoundaryPlugin(cwd), devReloadPlugin, ...userPlugins],
382
714
  ...(options.esbuildOptions || {}),
383
715
  };
384
716
 
@@ -443,12 +775,11 @@ const { generateManifest, createManifestPlugin } = require('./manifest');
443
775
  */
444
776
  async function buildBundle(options = {}) {
445
777
  const cwd = options.cwd || process.cwd();
446
- const esbuild = resolveEsbuild(options, cwd);
447
- if (!esbuild) {
448
- throw new Error("esbuild is not installed. Run 'npm install --save-dev esbuild' to build assets.");
449
- }
450
-
451
778
  const entryPoints = resolveEntryPoints(options.entryPoints, cwd);
779
+
780
+ discoverClientControllers(cwd);
781
+ validateClientBundleBoundaries(entryPoints, cwd);
782
+
452
783
  const hasEntryPoints = Array.isArray(entryPoints)
453
784
  ? entryPoints.length > 0
454
785
  : Boolean(entryPoints && Object.keys(entryPoints).length > 0);
@@ -459,6 +790,23 @@ async function buildBundle(options = {}) {
459
790
 
460
791
  const outdir = options.outdir ? path.resolve(cwd, options.outdir) : path.resolve(cwd, 'public/dist');
461
792
  const publicDir = options.publicDir ? path.resolve(cwd, options.publicDir) : path.resolve(cwd, 'public');
793
+ const manifestPath = options.manifestPath || path.join(outdir, 'manifest.json');
794
+
795
+ const esbuild = resolveEsbuild(options, cwd);
796
+ if (!esbuild) {
797
+ if (options.esbuild === null) {
798
+ throw new Error("esbuild is not installed. Run 'npm install --save-dev esbuild' to build assets.");
799
+ }
800
+ return buildBundleFallback({
801
+ cwd,
802
+ entryPoints,
803
+ outdir,
804
+ publicDir,
805
+ manifestPath,
806
+ hash: options.hash !== false,
807
+ });
808
+ }
809
+
462
810
  const bundle = options.bundle !== false;
463
811
  const minify = options.minify !== undefined ? options.minify : true;
464
812
  const sourcemap = options.sourcemap !== undefined ? options.sourcemap : true;
@@ -466,7 +814,6 @@ async function buildBundle(options = {}) {
466
814
  const format = options.format || 'esm';
467
815
  const outbase = options.outbase || (fs.existsSync(path.resolve(cwd, 'src/client')) ? path.resolve(cwd, 'src/client') : undefined);
468
816
  const entryNames = options.entryNames || (options.hash !== false ? '[dir]/[name].[hash]' : '[dir]/[name]');
469
- const manifestPath = options.manifestPath || path.join(outdir, 'manifest.json');
470
817
  const userPlugins = Array.isArray(options.plugins) ? options.plugins : [];
471
818
 
472
819
  const manifestPlugin = createManifestPlugin({
@@ -481,13 +828,20 @@ async function buildBundle(options = {}) {
481
828
  outdir,
482
829
  bundle,
483
830
  minify,
831
+ treeShaking: true,
832
+ legalComments: options.legalComments || 'none',
833
+ define: {
834
+ 'process.env.NODE_ENV': '"production"',
835
+ ...(options.define || {}),
836
+ },
837
+ drop: options.drop !== undefined ? options.drop : ['debugger'],
484
838
  sourcemap,
485
839
  target,
486
840
  format,
487
841
  entryNames,
488
842
  outbase,
489
843
  metafile: true,
490
- plugins: [manifestPlugin, ...userPlugins],
844
+ plugins: [createServerBoundaryPlugin(cwd), manifestPlugin, ...userPlugins],
491
845
  ...(options.esbuildOptions || {}),
492
846
  };
493
847
 
@@ -504,6 +858,74 @@ async function buildBundle(options = {}) {
504
858
  };
505
859
  }
506
860
 
861
+ /**
862
+ * Built-in fallback asset builder when esbuild is not installed in node_modules.
863
+ * Compiles `.ts` / `.js` client entrypoints, hashes filenames for cache-busting,
864
+ * and writes `manifest.json`.
865
+ */
866
+ function buildBundleFallback({ cwd, entryPoints, outdir, publicDir, manifestPath, hash = true }) {
867
+ const crypto = require('crypto');
868
+ let ts = null;
869
+ try {
870
+ ts = require('typescript');
871
+ } catch (_) {}
872
+
873
+ const entriesMap = Array.isArray(entryPoints)
874
+ ? Object.fromEntries(
875
+ entryPoints.map((fp) => {
876
+ const base = path.basename(fp, path.extname(fp)).replace(/\.client$/i, '');
877
+ return [base, fp];
878
+ })
879
+ )
880
+ : entryPoints;
881
+
882
+ const metafile = { inputs: {}, outputs: {} };
883
+
884
+ for (const [entryKey, rawFilePath] of Object.entries(entriesMap)) {
885
+ const absIn = path.resolve(cwd, rawFilePath);
886
+ if (!fs.existsSync(absIn)) continue;
887
+ const rawCode = fs.readFileSync(absIn, 'utf8');
888
+ let outCode = rawCode;
889
+
890
+ if ((absIn.endsWith('.ts') || absIn.endsWith('.tsx')) && ts) {
891
+ const transpiled = ts.transpileModule(rawCode, {
892
+ compilerOptions: {
893
+ module: ts.ModuleKind.ESNext,
894
+ target: ts.ScriptTarget.ES2020,
895
+ },
896
+ fileName: absIn,
897
+ });
898
+ outCode = transpiled.outputText;
899
+ }
900
+
901
+ const digest = hash
902
+ ? '.' + crypto.createHash('sha256').update(outCode).digest('hex').slice(0, 8).toUpperCase()
903
+ : '';
904
+ const relOutFile = `${entryKey}${digest}.js`;
905
+ const absOut = path.join(outdir, relOutFile);
906
+ fs.mkdirSync(path.dirname(absOut), { recursive: true });
907
+ fs.writeFileSync(absOut, outCode, 'utf8');
908
+
909
+ const relInput = path.relative(cwd, absIn).replace(/\\/g, '/');
910
+ const relOutput = path.relative(cwd, absOut).replace(/\\/g, '/');
911
+ metafile.inputs[relInput] = { bytes: Buffer.byteLength(rawCode, 'utf8') };
912
+ metafile.outputs[relOutput] = {
913
+ entryPoint: relInput,
914
+ bytes: Buffer.byteLength(outCode, 'utf8'),
915
+ };
916
+ }
917
+
918
+ const manifest = generateManifest(metafile, { outdir, publicDir });
919
+ fs.mkdirSync(path.dirname(manifestPath), { recursive: true });
920
+ fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), 'utf8');
921
+
922
+ return {
923
+ result: { errors: [], warnings: [], metafile },
924
+ manifest,
925
+ manifestPath,
926
+ };
927
+ }
928
+
507
929
  module.exports = {
508
930
  createDevBundler,
509
931
  buildBundle,
@@ -511,6 +933,10 @@ module.exports = {
511
933
  createManifestPlugin,
512
934
  findDefaultEntryPoints,
513
935
  findPageEntryPoints,
936
+ discoverClientControllers,
937
+ checkServerImportViolation,
938
+ validateClientBundleBoundaries,
939
+ createServerBoundaryPlugin,
514
940
  scanClientPages,
515
941
  expandGlob,
516
942
  resolveEntryPoints,
@@ -22,5 +22,14 @@
22
22
  plugins: [new SwupHeadPlugin(), new SwupScriptsPlugin()],
23
23
  ignoreVisit: ignoreVisit,
24
24
  });
25
+
26
+ // Re-initialize Alpine components after swup replaces page content if Alpine is loaded on the page
27
+ swup.hooks.on('content:replace', function () {
28
+ var root = document.querySelector('#swup');
29
+ if (root && typeof window !== 'undefined' && window.Alpine && typeof window.Alpine.initTree === 'function') {
30
+ window.Alpine.initTree(root);
31
+ }
32
+ });
33
+
25
34
  window.__webspressoSwup = swup;
26
35
  })();