@swissjs/swite 0.4.1 → 0.4.2

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 (36) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/__tests__/import-rewriter-bug.test.ts +122 -122
  3. package/__tests__/security-r001-r002.test.ts +190 -190
  4. package/dist/cli.js +0 -0
  5. package/dist/dev-engine/hmr/hmr-client-template.js +111 -111
  6. package/dist/dev-engine/middleware/middleware-setup.js +4 -3
  7. package/docs/architecture/build-pipeline.md +97 -97
  8. package/docs/architecture/dev-server.md +87 -87
  9. package/docs/architecture/hmr.md +78 -78
  10. package/docs/architecture/import-rewriting.md +101 -101
  11. package/docs/architecture/index.md +16 -16
  12. package/docs/architecture/python-integration.md +93 -93
  13. package/docs/architecture/resolution.md +92 -92
  14. package/docs/cli/build.md +78 -78
  15. package/docs/cli/dev.md +90 -90
  16. package/docs/cli/index.md +15 -15
  17. package/docs/cli/start.md +45 -45
  18. package/docs/development/contributing.md +74 -74
  19. package/docs/development/index.md +12 -12
  20. package/docs/development/internals.md +101 -101
  21. package/docs/guide/configuration.md +89 -89
  22. package/docs/guide/index.md +13 -13
  23. package/docs/guide/project-structure.md +75 -75
  24. package/docs/guide/quickstart.md +113 -113
  25. package/docs/index.md +16 -16
  26. package/package.json +10 -9
  27. package/src/config/env.ts +98 -98
  28. package/src/dev-engine/handlers/ui-handler.ts +30 -30
  29. package/src/dev-engine/handlers/uix-handler.ts +21 -21
  30. package/src/dev-engine/hmr/hmr-client-template.ts +122 -122
  31. package/src/dev-engine/middleware/middleware-setup.ts +354 -354
  32. package/src/dev-engine/middleware/static-files.ts +813 -813
  33. package/src/resolution/cdn/cdn-fallback.ts +40 -40
  34. package/src/resolution/path/path-fixup.ts +27 -27
  35. package/src/resolution/rewriting/import-rewriter.ts +237 -237
  36. package/src/resolution/symlink-registry.ts +114 -114
@@ -1,813 +1,813 @@
1
- /*
2
- * Copyright (c) 2024 Themba Mzumara
3
- * SWITE - SWISS Development Server
4
- * Licensed under the MIT License.
5
- */
6
-
7
- import express from "express";
8
- import type { Express } from "express";
9
- import { promises as fs, realpathSync, existsSync } from "node:fs";
10
- import path from "node:path";
11
- import chalk from "chalk";
12
- import { findWorkspaceRoot } from "../../kernel/workspace.js";
13
-
14
- export interface StaticFilesConfig {
15
- root: string;
16
- publicDir: string;
17
- workspaceRoot?: string | null;
18
- /** Entry file for CSS extraction, relative to root. Defaults to "src/index.ui". */
19
- entry?: string;
20
- }
21
-
22
- /**
23
- * Setup static file serving for public directory, node_modules, and workspace packages
24
- */
25
- export async function setupStaticFiles(
26
- app: Express,
27
- config: StaticFilesConfig,
28
- ): Promise<void> {
29
- const _debug = process.env["SWITE_DEBUG"] === "1";
30
- if (_debug) console.log(chalk.magenta(`[static-files] ⚡ setupStaticFiles called with root: ${config.root}`));
31
-
32
- // Static file serving - ONLY serve public directory
33
- // Do NOT serve dist/ folder - it contains old build artifacts with bare imports
34
- const publicPath = path.join(config.root, config.publicDir);
35
-
36
- // Serve static files from public/ directory
37
- // IMPORTANT: Skip source files (.ui, .uix, .ts, .js) - they should be handled by module transformation middleware
38
- // Wrap express.static to prevent it from serving source files
39
- const publicStaticMiddleware = express.static(publicPath, {
40
- // Exclude dist folder and other build artifacts
41
- dotfiles: "ignore",
42
- index: false, // Don't serve index files from static middleware
43
- setHeaders: (res, filePath) => {
44
- // Add cache-busting headers for all static files in dev mode
45
- res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
46
- res.setHeader("Pragma", "no-cache");
47
- res.setHeader("Expires", "0");
48
- },
49
- });
50
-
51
- app.use((req, res, next) => {
52
- const url = req.url.split("?")[0];
53
- // Skip source files - let module transformation middleware handle them
54
- // DO NOT call express.static for source files - it will serve them with wrong MIME type
55
- if (
56
- url.endsWith(".ui") ||
57
- url.endsWith(".uix") ||
58
- url.endsWith(".ts") ||
59
- (url.endsWith(".js") && !url.includes("node_modules")) ||
60
- url.endsWith(".mjs")
61
- ) {
62
- // Skip express.static for source files - pass to next middleware instead
63
- return next();
64
- }
65
- // For non-source files, use express.static
66
- publicStaticMiddleware(req, res, next);
67
- });
68
-
69
- // NOTE: We do NOT serve /src as static files here anymore
70
- // The module transformation middleware in middleware-setup.ts handles ALL /src requests first
71
- // This ensures .ui, .uix, .ts files are processed correctly before static middleware can interfere
72
- // Only non-source files (CSS, images, etc.) will pass through to be served as static
73
- // But they should be handled by the module transformation middleware's next() call
74
-
75
- // Serve node_modules as static files from multiple locations
76
- // 1. App root node_modules
77
- // Wrap to skip source files
78
- const nodeModulesStatic = express.static(path.join(config.root, "node_modules"));
79
- app.use("/node_modules", (req, res, next) => {
80
- const url = req.url.split("?")[0];
81
- // Skip source files in node_modules - they should be handled by module transformation
82
- if (
83
- url.endsWith(".ui") ||
84
- url.endsWith(".uix") ||
85
- url.endsWith(".ts") ||
86
- (url.endsWith(".js") && !url.includes("/dist/") && !url.includes("/lib/")) ||
87
- url.endsWith(".mjs")
88
- ) {
89
- return next();
90
- }
91
- nodeModulesStatic(req, res, next);
92
- });
93
-
94
- // 2. Workspace root node_modules (if different from app root)
95
- const workspaceRootForNodeModules =
96
- config.workspaceRoot ?? (await findWorkspaceRoot(config.root));
97
- if (
98
- workspaceRootForNodeModules &&
99
- workspaceRootForNodeModules !== config.root
100
- ) {
101
- const workspaceNodeModules = path.join(
102
- workspaceRootForNodeModules,
103
- "node_modules",
104
- );
105
- try {
106
- await fs.access(workspaceNodeModules);
107
- // Serve workspace node_modules with a different path to avoid conflicts
108
- // But also check if package exists in app node_modules first
109
- app.use("/node_modules", (req, res, next) => {
110
- const url = req.url.split("?")[0];
111
- // Skip source files - let module transformation middleware handle them
112
- if (
113
- url.endsWith(".ui") ||
114
- url.endsWith(".uix") ||
115
- url.endsWith(".ts") ||
116
- (url.endsWith(".js") && !url.includes("/dist/") && !url.includes("/lib/")) ||
117
- url.endsWith(".mjs")
118
- ) {
119
- return next();
120
- }
121
-
122
- // Strip leading slash: req.path inside app.use('/node_modules') is already
123
- // relative to that prefix, e.g. '/@swissjs/shell/design-tokens/primitive.css'
124
- const relPath = req.path.replace(/^\/+/, "");
125
- if (!relPath) return next();
126
-
127
- // Resolve pnpm symlinks explicitly (same mechanism UIHandler uses).
128
- // express.static / fs.access can silently fail on pnpm virtual-store symlinks
129
- // in production containers, so we use realpathSync to get the real path first.
130
- const isScoped = relPath.startsWith("@");
131
- const parts = relPath.split("/");
132
- const pkgName = isScoped ? `${parts[0]}/${parts[1]}` : parts[0];
133
- const subPath = isScoped ? parts.slice(2).join("/") : parts.slice(1).join("/");
134
-
135
- const pkgSymLink = path.join(workspaceNodeModules, pkgName);
136
- try {
137
- const pkgReal = realpathSync(pkgSymLink);
138
- const realAbs = path.join(pkgReal, subPath);
139
- if (existsSync(realAbs)) {
140
- res.sendFile(realAbs);
141
- return;
142
- }
143
- } catch {
144
- // symlink missing or broken — fall through to next()
145
- }
146
-
147
- next();
148
- });
149
- if (_debug) {
150
- console.log(
151
- chalk.gray(
152
- ` 📦 Serving workspace node_modules from ${workspaceNodeModules}`,
153
- ),
154
- );
155
- }
156
- } catch {
157
- // Workspace node_modules doesn't exist, skip
158
- }
159
- }
160
-
161
- // Serve workspace packages (lib/, libraries/, packages/, modules/, etc.)
162
- // This allows workspace packages to be served via HTTP
163
- // Reuse workspaceRoot from above if it exists, otherwise find it
164
- const workspaceRoot =
165
- workspaceRootForNodeModules || (await findWorkspaceRoot(config.root));
166
-
167
- if (_debug) {
168
- console.log(
169
- chalk.blue(`[static-files] Workspace root: ${workspaceRoot}`),
170
- );
171
- }
172
- if (_debug) {
173
- console.log(
174
- chalk.blue(`[static-files] App root: ${config.root}`),
175
- );
176
- }
177
-
178
- // Try to serve lib/ directory - check both workspace root and app root parent
179
- let libPath: string | null = null;
180
-
181
- if (_debug) {
182
- console.log(
183
- chalk.blue(`[static-files] Determining lib/ path... workspaceRoot: ${workspaceRoot}, config.root: ${config.root}`),
184
- );
185
- }
186
-
187
- // First, try workspace root
188
- if (workspaceRoot && workspaceRoot !== config.root) {
189
- libPath = path.join(workspaceRoot, "lib");
190
- if (_debug) {
191
- console.log(
192
- chalk.blue(`[static-files] Trying workspace root lib/: ${libPath}`),
193
- );
194
- }
195
- } else {
196
- if (_debug) {
197
- console.log(
198
- chalk.yellow(`[static-files] Workspace root equals app root, trying parent directories...`),
199
- );
200
- }
201
- // If workspace root equals app root, try going up from app root
202
- const parentDir = path.dirname(config.root);
203
- const parentLibPath = path.join(parentDir, "lib");
204
- if (_debug) {
205
- console.log(
206
- chalk.blue(`[static-files] Trying parent lib/: ${parentLibPath}`),
207
- );
208
- }
209
- try {
210
- await fs.access(parentLibPath);
211
- libPath = parentLibPath;
212
- if (_debug) {
213
- console.log(
214
- chalk.blue(`[static-files] Using parent directory lib/: ${libPath}`),
215
- );
216
- }
217
- } catch (error) {
218
- if (_debug) {
219
- console.log(
220
- chalk.yellow(`[static-files] Parent lib/ not found: ${error instanceof Error ? error.message : String(error)}`),
221
- );
222
- }
223
- // Parent lib/ doesn't exist, try grandparent
224
- const grandparentDir = path.dirname(parentDir);
225
- const grandparentLibPath = path.join(grandparentDir, "lib");
226
- if (_debug) {
227
- console.log(
228
- chalk.blue(`[static-files] Trying grandparent lib/: ${grandparentLibPath}`),
229
- );
230
- }
231
- try {
232
- await fs.access(grandparentLibPath);
233
- libPath = grandparentLibPath;
234
- if (_debug) {
235
- console.log(
236
- chalk.blue(`[static-files] Using grandparent directory lib/: ${libPath}`),
237
- );
238
- }
239
- } catch (error2) {
240
- if (_debug) {
241
- console.log(
242
- chalk.yellow(`[static-files] Grandparent lib/ not found: ${error2 instanceof Error ? error2.message : String(error2)}`),
243
- );
244
- }
245
- }
246
- }
247
- }
248
-
249
- // Serve lib/ directory if found
250
- if (_debug) {
251
- console.log(
252
- chalk.blue(`[static-files] Checking for lib/ directory... libPath: ${libPath}`),
253
- );
254
- }
255
-
256
- // ALWAYS try to register /lib/ static serving
257
- // Calculate the lib path - prefer workspace root, fallback to parent of app root
258
- let finalLibPath: string;
259
- if (libPath) {
260
- finalLibPath = libPath;
261
- } else if (workspaceRoot && workspaceRoot !== config.root) {
262
- finalLibPath = path.join(workspaceRoot, "lib");
263
- } else {
264
- // Go up from app root to find lib/
265
- const parentDir = path.dirname(config.root);
266
- finalLibPath = path.join(parentDir, "lib");
267
- }
268
-
269
- if (_debug) {
270
- console.log(
271
- chalk.blue(`[static-files] Final lib path to check: ${finalLibPath}`),
272
- );
273
- }
274
- if (_debug) {
275
- console.log(
276
- chalk.blue(`[static-files] workspaceRoot: ${workspaceRoot}, config.root: ${config.root}`),
277
- );
278
- }
279
-
280
- // Try to access the directory
281
- let libPathExists = false;
282
- try {
283
- await fs.access(finalLibPath);
284
- libPathExists = true;
285
- if (_debug) {
286
- console.log(
287
- chalk.green(`[static-files] ✅ Found lib/ directory at: ${finalLibPath}`),
288
- );
289
- }
290
-
291
- // Verify the CSS file exists
292
- const testCssPath = path.join(finalLibPath, "skltn", "src", "css", "index.css");
293
- try {
294
- await fs.access(testCssPath);
295
- if (_debug) {
296
- console.log(
297
- chalk.green(`[static-files] ✅ Test CSS file exists: ${testCssPath}`),
298
- );
299
- }
300
- } catch (error) {
301
- if (_debug) {
302
- console.error(
303
- chalk.yellow(`[static-files] ⚠️ Test CSS file NOT found: ${testCssPath}`),
304
- );
305
- }
306
- }
307
- } catch (error) {
308
- if (_debug) {
309
- console.error(
310
- chalk.red(`[static-files] ❌ lib/ directory not found at: ${finalLibPath}`),
311
- );
312
- }
313
- if (_debug) {
314
- console.error(
315
- chalk.red(`[static-files] Error: ${error instanceof Error ? error.message : String(error)}`),
316
- );
317
- }
318
- if (_debug) {
319
- console.error(
320
- chalk.red(`[static-files] ⚠️ /lib middleware will NOT be registered - CSS files will 404!`),
321
- );
322
- }
323
- }
324
-
325
- // Register static file middleware ONLY if directory exists
326
- if (libPathExists) {
327
- if (_debug) {
328
- console.log(chalk.green(`[static-files] ✅ Registering /lib middleware with finalLibPath: ${finalLibPath}`));
329
- }
330
-
331
- // CRITICAL: First middleware to block source files BEFORE any express.static can serve them
332
- // This MUST run before express.static to prevent wrong MIME types
333
- app.use("/lib", (req, res, next) => {
334
- const url = req.url.split("?")[0];
335
- const path = req.path.split("?")[0];
336
-
337
- // CRITICAL: Block source files immediately - check both url and path
338
- // When express.static is mounted at /lib, req.path is stripped of /lib prefix
339
- const isSourceFile =
340
- url.endsWith(".ui") || url.endsWith(".uix") || url.endsWith(".ts") ||
341
- (url.endsWith(".js") && !url.includes("node_modules")) || url.endsWith(".mjs") ||
342
- path.endsWith(".ui") || path.endsWith(".uix") || path.endsWith(".ts") ||
343
- (path.endsWith(".js") && !path.includes("node_modules")) || path.endsWith(".mjs");
344
-
345
- if (isSourceFile) {
346
- if (_debug) {
347
- console.log(
348
- chalk.red(
349
- `[static-files] ⚠️ FIRST BLOCK: Skipping source file: url=${url}, path=${path} - should be handled by module middleware`
350
- )
351
- );
352
- }
353
- return next(); // Let module transformation middleware handle it
354
- }
355
-
356
- if (_debug) {
357
- console.log(
358
- chalk.cyan(`[static-files] Request for /lib${path} (static file)`),
359
- );
360
- }
361
- next();
362
- });
363
-
364
- // REMOVED: Logging middleware - it was just adding noise
365
- // The blocking middleware above already handles source files
366
-
367
- // CRITICAL: Add express.static for /lib/ but wrap it to skip source files
368
- // Source files should be handled by module transformation middleware (registered before this)
369
- const libStatic = express.static(finalLibPath, {
370
- setHeaders: (res, filePath) => {
371
- try {
372
- res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
373
- res.setHeader("Pragma", "no-cache");
374
- res.setHeader("Expires", "0");
375
- } catch (error) {
376
- if (_debug) {
377
- console.error(chalk.red(`[static-files] Error setting headers for ${filePath}:`), error);
378
- }
379
- }
380
- },
381
- });
382
-
383
- // CRITICAL: Wrap express.static to prevent serving source files
384
- // Check BOTH req.url (full path) and req.path (stripped path) to catch all cases
385
- app.use("/lib", (req, res, next) => {
386
- const url = req.url.split("?")[0];
387
- const path = req.path.split("?")[0];
388
-
389
- // CRITICAL: Skip source files - they should be handled by module transformation middleware
390
- // Check both url and path because express.static strips the mount path
391
- const isSourceFile =
392
- url.endsWith(".ui") || url.endsWith(".uix") || url.endsWith(".ts") ||
393
- (url.endsWith(".js") && !url.includes("node_modules")) || url.endsWith(".mjs") ||
394
- path.endsWith(".ui") || path.endsWith(".uix") || path.endsWith(".ts") ||
395
- (path.endsWith(".js") && !path.includes("node_modules")) || path.endsWith(".mjs");
396
-
397
- if (isSourceFile) {
398
- if (_debug) {
399
- console.log(
400
- chalk.red(
401
- `[static-files /lib express.static] ⚠️ BLOCKING source file: url=${url}, path=${path} - should be handled by module transformation middleware`
402
- )
403
- );
404
- }
405
- // CRITICAL: Don't call libStatic - return next() to skip it
406
- return next(); // Let module transformation middleware handle it
407
- }
408
- // For static files (CSS, images), use express.static
409
- libStatic(req, res, next);
410
- });
411
- // This was serving source files with wrong MIME type (application/octet-stream)
412
- // Source files should be handled by module transformation middleware (registered before this)
413
- // Only static files (CSS, images) should be served, and they're handled by the custom handler above
414
- // If a file isn't found, let it 404 rather than serving with wrong MIME type
415
- if (_debug) {
416
- console.log(
417
- chalk.gray(` 📦 Serving workspace lib/ from ${finalLibPath}`),
418
- );
419
- }
420
- }
421
-
422
- // Continue with other workspace directories if workspaceRoot is different from app root
423
- if (workspaceRoot && workspaceRoot !== config.root) {
424
-
425
- // Serve libraries/ directory (legacy support)
426
- const librariesPath = path.join(workspaceRoot, "libraries");
427
- try {
428
- await fs.access(librariesPath);
429
- app.use("/libraries", express.static(librariesPath));
430
- if (_debug) {
431
- console.log(
432
- chalk.gray(` 📦 Serving workspace libraries/ from ${librariesPath}`),
433
- );
434
- }
435
- } catch {
436
- // libraries/ doesn't exist, skip
437
- }
438
-
439
- // NOTE: Do NOT serve /packages/ as static files
440
- // Workspace packages contain source files (.ts, .ui, .uix) that need to be processed
441
- // by handlers (TSHandler, UIHandler, etc.) to rewrite imports and compile them
442
- // Static file serving would bypass this processing, causing bare imports to fail
443
- // Only serve /packages/ if they're already compiled assets (handled by handlers)
444
-
445
- // Serve modules/ directory (for CSS, assets, etc.)
446
- // Source files (.ui, .uix, .ts) must NOT be served statically — they need
447
- // to fall through to the compiler middleware (general handler). CG-07.
448
- const modulesPath = path.join(workspaceRoot, "modules");
449
- try {
450
- await fs.access(modulesPath);
451
- app.use("/modules", (req: express.Request, res: express.Response, next: express.NextFunction) => {
452
- const url = req.url.split("?")[0];
453
- const isSourceFile =
454
- url.endsWith(".ui") ||
455
- url.endsWith(".uix") ||
456
- url.endsWith(".ts") ||
457
- (url.endsWith(".js") && !url.includes("node_modules")) ||
458
- url.endsWith(".mjs");
459
- if (isSourceFile) {
460
- return next();
461
- }
462
- express.static(modulesPath)(req, res, next);
463
- });
464
- if (_debug) {
465
- console.log(
466
- chalk.gray(` 📦 Serving workspace modules/ from ${modulesPath}`),
467
- );
468
- }
469
- } catch {
470
- // modules/ doesn't exist, skip
471
- }
472
- }
473
-
474
- // NOTE: SWISS packages are NOT served as static files
475
- // They are processed by the middleware (TS/JS handlers) to rewrite imports
476
- // This ensures all bare imports in SWISS packages are rewritten correctly
477
- // The /swiss-packages/ URLs are handled by the middleware in middleware-setup.ts
478
- }
479
-
480
- /**
481
- * Setup SPA fallback - serves index.html for all unmatched routes
482
- */
483
- export async function setupSPAFallback(
484
- app: Express,
485
- config: StaticFilesConfig,
486
- ): Promise<void> {
487
- const _debug = process.env["SWITE_DEBUG"] === "1";
488
- if (_debug) {
489
- console.log(chalk.magenta(`[SWITE] setupSPAFallback loaded - VERSION 0.3.5 (NO HARDCODED CSS)`));
490
- }
491
- // Use app.all() to catch ALL HTTP methods, but only for non-source files
492
- app.all("*", async (req, res, next) => {
493
- const url = req.url.split("?")[0];
494
- const fullUrl = req.url;
495
- const accept = String(req.headers?.accept || "");
496
-
497
- // DEBUG: Verify handler is being called
498
-
499
- // --- CRITICAL SAFETY CHECK ---
500
- // NEVER serve HTML for /src/* requests - these are source files that must be handled by middleware
501
- // Even if middleware fails, we should return 404, not HTML
502
- if (req.path?.startsWith("/src/") || url.startsWith("/src/")) {
503
- res.status(404).setHeader("Content-Type", "text/plain");
504
- res.send(`File not found: ${url}`);
505
- return;
506
- }
507
-
508
- // --- CRITICAL SAFETY CHECK ---
509
- // NEVER serve HTML for /swiss-packages/* requests - these are SWISS framework packages
510
- // They should be handled by TS/JS handlers to rewrite imports
511
- if (req.path?.startsWith("/swiss-packages/") || url.startsWith("/swiss-packages/")) {
512
- res.status(404).setHeader("Content-Type", "text/plain");
513
- res.send(`File not found: ${url}`);
514
- return;
515
- }
516
-
517
- // --- CRITICAL SAFETY CHECK ---
518
- // NEVER serve HTML for /lib/* requests - these are workspace library files
519
- // They should be handled by static file middleware
520
- if (req.path?.startsWith("/lib/") || url.startsWith("/lib/")) {
521
- res.status(404).setHeader("Content-Type", "text/plain");
522
- res.send(`File not found: ${url}`);
523
- return;
524
- }
525
-
526
- // Log every request that hits the fallback (for diagnostics)
527
-
528
- // Log if SPA fallback is being hit for .ui files (this should NOT happen after /src check)
529
- if (url.endsWith(".ui")) {
530
- }
531
-
532
- // DO NOT serve HTML for source files - they should be handled by handlers
533
- // This prevents the SPA fallback from catching .ui, .uix, .ts, .js, .mjs files
534
- // If we reach here, it means the middleware handlers didn't process it
535
- if (
536
- url.endsWith(".ui") ||
537
- url.endsWith(".uix") ||
538
- url.endsWith(".ts") ||
539
- url.endsWith(".js") ||
540
- url.endsWith(".mjs") ||
541
- url.endsWith(".css") ||
542
- url.endsWith(".json")
543
- ) {
544
- // These should have been handled by middleware handlers
545
- // If we reach here, the file wasn't found, return 404 with proper content type
546
- res.status(404).setHeader("Content-Type", "text/plain");
547
- res.send(`File not found: ${url}`);
548
- return;
549
- }
550
-
551
- // Only serve SPA HTML for real navigation/document requests.
552
- // If a script/style/module fetch hits the fallback, returning HTML causes strict MIME failures.
553
- if (!accept.includes("text/html")) {
554
- res.status(404).setHeader("Content-Type", "text/plain");
555
- res.send(`Not found: ${url}`);
556
- return;
557
- }
558
-
559
- // Add cache-busting headers for HTML files during development
560
- res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
561
- res.setHeader("Pragma", "no-cache");
562
- res.setHeader("Expires", "0");
563
-
564
- // Inject timestamp into script tag to force fresh load
565
- const htmlPath = path.join(config.root, config.publicDir, "index.html");
566
- let html = await fs.readFile(htmlPath, "utf-8");
567
- const timestamp = Date.now();
568
- const random = Math.random().toString(36).substring(7);
569
- // More aggressive cache busting - replace ALL script src attributes
570
- html = html.replace(/src="([^"]*index\.ui[^"]*)"/g, (match, src) => {
571
- // Remove any existing cache-busting params
572
- const cleanSrc = src.split("?")[0].split("&")[0];
573
- return `src="${cleanSrc}?v=dev&t=${timestamp}&r=${random}"`;
574
- });
575
- // Also replace any script tags with type="module" that have src attributes
576
- html = html.replace(
577
- /<script\s+type=["']module["'][^>]*src=["']([^"']*index\.ui[^"']*)["'][^>]*>/g,
578
- (match, src) => {
579
- const cleanSrc = src.split("?")[0].split("&")[0];
580
- return match.replace(
581
- src,
582
- `${cleanSrc}?v=dev&t=${timestamp}&r=${random}`,
583
- );
584
- },
585
- );
586
-
587
- // Add cache-busting meta tags to prevent browser caching
588
- if (!html.includes('<meta http-equiv="Cache-Control"')) {
589
- html = html.replace(
590
- "<head>",
591
- `<head>\n <meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">\n <meta http-equiv="Pragma" content="no-cache">\n <meta http-equiv="Expires" content="0">`,
592
- );
593
- }
594
-
595
- // Extract CSS imports from entry point and inject as <link> tags
596
- // This dynamically discovers CSS files from the app's entry point
597
- // CRITICAL: This MUST run before import map injection
598
- // IMPORTANT: Only inject CSS files that actually exist in the app's directory
599
- if (_debug) {
600
- console.log(chalk.magenta(`[SWITE CSS] ========== CSS EXTRACTION START (VERSION 0.3.5) ==========`));
601
- }
602
- if (_debug) {
603
- console.log(chalk.magenta(`[SWITE CSS] App root: ${config.root}`));
604
- }
605
- try {
606
- const entryFile = config.entry ?? "src/index.ui";
607
- const entryPointPath = path.join(config.root, entryFile);
608
- if (_debug) {
609
- console.log(chalk.blue(`[SWITE CSS] Checking entry point: ${entryPointPath}`));
610
- }
611
- const entryPointContent = await fs.readFile(entryPointPath, "utf-8");
612
-
613
- // Extract CSS imports using regex
614
- const cssImportPattern = /import\s+['"](.*?\.css)['"];?/g;
615
- const cssImports = new Set<string>();
616
- let match;
617
-
618
- // Check entry point
619
- while ((match = cssImportPattern.exec(entryPointContent)) !== null) {
620
- cssImports.add(match[1]);
621
- }
622
-
623
- // Also check imported files (like App.uix) for CSS imports
624
- const importPattern = /import\s+.*?from\s+['"](.*?)['"];?/g;
625
- const importedFiles: string[] = [];
626
- let importMatch;
627
- cssImportPattern.lastIndex = 0; // Reset regex
628
- while ((importMatch = importPattern.exec(entryPointContent)) !== null) {
629
- const importPath = importMatch[1];
630
- // Skip node_modules and absolute imports
631
- if (!importPath.startsWith("@") && !importPath.startsWith("/") && !importPath.startsWith(".")) {
632
- continue;
633
- }
634
- // Resolve relative imports
635
- if (importPath.startsWith(".")) {
636
- importedFiles.push(importPath);
637
- }
638
- }
639
-
640
- // Check imported files for CSS
641
- for (const importedFile of importedFiles) {
642
- try {
643
- const importedFilePath = path.resolve(path.dirname(entryPointPath), importedFile);
644
- // Try different extensions
645
- const extensions = [".uix", ".ui", ".ts", ".js"];
646
- let found = false;
647
- for (const ext of extensions) {
648
- const testPath = importedFilePath.endsWith(ext) ? importedFilePath : importedFilePath + ext;
649
- try {
650
- const importedContent = await fs.readFile(testPath, "utf-8");
651
- found = true;
652
- // Extract CSS imports from this file
653
- cssImportPattern.lastIndex = 0; // Reset regex
654
- let cssMatch2;
655
- while ((cssMatch2 = cssImportPattern.exec(importedContent)) !== null) {
656
- // Resolve relative CSS path
657
- const cssPath = cssMatch2[1];
658
- if (cssPath.startsWith(".")) {
659
- const resolvedCssPath = path.resolve(path.dirname(testPath), cssPath);
660
- const relativeCssPath = path.relative(path.join(config.root, "src"), resolvedCssPath);
661
- const normalizedPath = relativeCssPath.replace(/\\/g, "/");
662
- cssImports.add(normalizedPath);
663
- } else {
664
- cssImports.add(cssPath);
665
- }
666
- }
667
- break;
668
- } catch (err) {
669
- // File doesn't exist with this extension, try next
670
- }
671
- }
672
- } catch (error) {
673
- // Could not read imported file, skip
674
- }
675
- }
676
-
677
- if (_debug) {
678
- console.log(chalk.blue(`[SWITE CSS] Found ${cssImports.size} CSS import(s) in code`));
679
- }
680
- if (cssImports.size > 0) {
681
- const cssArray = Array.from(cssImports);
682
- if (_debug) {
683
- console.log(chalk.blue(`[SWITE CSS] CSS imports found: ${cssArray.join(", ")}`));
684
- }
685
-
686
- // Verify CSS files exist before injecting them
687
- const existingCssFiles: string[] = [];
688
- for (const cssPath of cssArray) {
689
- // Convert to file system path
690
- const url = cssPath.startsWith("/") ? cssPath : `/src/${cssPath}`;
691
- const filePath = url.startsWith("/src/")
692
- ? path.join(config.root, url.substring(1)) // Remove leading /
693
- : path.join(config.root, "src", cssPath);
694
-
695
- if (_debug) {
696
- console.log(chalk.blue(`[SWITE CSS] Checking if CSS file exists: ${filePath} (url: ${url})`));
697
- }
698
- try {
699
- await fs.access(filePath);
700
- if (_debug) {
701
- console.log(chalk.green(`[SWITE CSS] ✅ CSS file exists: ${filePath}`));
702
- }
703
- existingCssFiles.push(url);
704
- } catch {
705
- // CSS file doesn't exist, skip it
706
- // This allows different apps/websites to have different CSS files
707
- if (_debug) {
708
- console.log(chalk.yellow(`[SWITE CSS] ⚠️ CSS file NOT found: ${filePath}, skipping`));
709
- }
710
- }
711
- }
712
-
713
- // Only inject CSS files that actually exist
714
- if (_debug) {
715
- console.log(chalk.blue(`[SWITE CSS] ${existingCssFiles.length} CSS file(s) exist out of ${cssArray.length} found`));
716
- }
717
- if (existingCssFiles.length === 0) {
718
- if (_debug) {
719
- console.log(chalk.yellow(`[SWITE CSS] ⚠️ No CSS files exist, skipping injection`));
720
- }
721
- } else if (existingCssFiles.length > 0) {
722
- const cssLinks = existingCssFiles
723
- .map(url => ` <link rel="stylesheet" href="${url}">`)
724
- .join("\n");
725
-
726
- // Check if CSS links are already in HTML (to avoid duplicates)
727
- const alreadyInjected = existingCssFiles.some(url =>
728
- html.includes(`href="${url}"`) || html.includes(`href='${url}'`)
729
- );
730
-
731
- if (!alreadyInjected) {
732
- // Inject CSS links before </head> - MUST happen before import map injection
733
- const beforeReplace = html;
734
- html = html.replace(/\s*<\/head>/i, `${cssLinks}\n </head>`);
735
- if (html === beforeReplace) {
736
- if (_debug) {
737
- console.warn(chalk.yellow("[SWITE] Failed to inject CSS links - </head> not found"));
738
- }
739
- } else {
740
- if (_debug) {
741
- console.log(chalk.green(`[SWITE] ✅ Injected ${existingCssFiles.length} CSS link(s): ${existingCssFiles.join(", ")}`));
742
- }
743
- }
744
- } else {
745
- if (_debug) {
746
- console.log(chalk.blue(`[SWITE CSS] CSS links already in HTML, skipping injection`));
747
- }
748
- }
749
- }
750
- }
751
- } catch (error) {
752
- // If entry point doesn't exist or can't be read, continue without CSS injection
753
- // Silently continue - CSS injection is optional
754
- if (_debug) {
755
- console.log(chalk.yellow(`[SWITE CSS] Could not extract CSS imports: ${error instanceof Error ? error.message : String(error)}`));
756
- }
757
- }
758
-
759
- // Add/merge import map to help browser resolve bare module specifiers.
760
- // If an importmap already exists in HTML, merge .swite/import-map.json entries
761
- // into it — existing HTML entries take priority (never overwrite manual entries).
762
- const cachedMapPath = path.join(config.root, ".swite", "import-map.json");
763
- let switeImports: Record<string, string> = {};
764
- try {
765
- const raw = await fs.readFile(cachedMapPath, "utf-8");
766
- const parsed = JSON.parse(raw);
767
- if (parsed?.imports && typeof parsed.imports === "object") {
768
- switeImports = parsed.imports;
769
- }
770
- } catch {
771
- // no cached map — nothing to merge
772
- }
773
-
774
- if (!html.includes('type="importmap"')) {
775
- // No importmap at all — inject one from .swite/import-map.json
776
- const importMap = `\n <script type="importmap">\n ${JSON.stringify({ imports: switeImports }, null, 2).replace(/\n/g, "\n ")}\n </script>`;
777
- const beforeReplace = html;
778
- html = html.replace(/\s*<\/head>/i, `${importMap}\n </head>`);
779
- if (html === beforeReplace) {
780
- if (_debug) {
781
- console.warn("[SWITE] Failed to add import map - </head> not found or already replaced");
782
- }
783
- } else {
784
- if (_debug) {
785
- console.log(`[SWITE] Added import map with ${Object.keys(switeImports).length} entries`);
786
- }
787
- }
788
- } else {
789
- // Importmap already in HTML — merge swite entries without overwriting existing ones
790
- if (_debug) {
791
- console.log("[SWITE] Import map already exists in HTML — merging swite entries");
792
- }
793
- if (Object.keys(switeImports).length > 0) {
794
- html = html.replace(
795
- /(<script\s+type=["']importmap["'][^>]*>)\s*([\s\S]*?)(\s*<\/script>)/i,
796
- (_match, open, body, close) => {
797
- try {
798
- const existing = JSON.parse(body.trim());
799
- const existingImports: Record<string, string> = existing?.imports ?? {};
800
- // Swite entries fill gaps; existing HTML entries win
801
- const merged = { ...switeImports, ...existingImports };
802
- return `${open}\n ${JSON.stringify({ imports: merged }, null, 2).replace(/\n/g, "\n ")}${close}`;
803
- } catch {
804
- return _match; // parse failed — leave importmap untouched
805
- }
806
- }
807
- );
808
- }
809
- }
810
-
811
- res.send(html);
812
- });
813
- }
1
+ /*
2
+ * Copyright (c) 2024 Themba Mzumara
3
+ * SWITE - SWISS Development Server
4
+ * Licensed under the MIT License.
5
+ */
6
+
7
+ import express from "express";
8
+ import type { Express } from "express";
9
+ import { promises as fs, realpathSync, existsSync } from "node:fs";
10
+ import path from "node:path";
11
+ import chalk from "chalk";
12
+ import { findWorkspaceRoot } from "../../kernel/workspace.js";
13
+
14
+ export interface StaticFilesConfig {
15
+ root: string;
16
+ publicDir: string;
17
+ workspaceRoot?: string | null;
18
+ /** Entry file for CSS extraction, relative to root. Defaults to "src/index.ui". */
19
+ entry?: string;
20
+ }
21
+
22
+ /**
23
+ * Setup static file serving for public directory, node_modules, and workspace packages
24
+ */
25
+ export async function setupStaticFiles(
26
+ app: Express,
27
+ config: StaticFilesConfig,
28
+ ): Promise<void> {
29
+ const _debug = process.env["SWITE_DEBUG"] === "1";
30
+ if (_debug) console.log(chalk.magenta(`[static-files] ⚡ setupStaticFiles called with root: ${config.root}`));
31
+
32
+ // Static file serving - ONLY serve public directory
33
+ // Do NOT serve dist/ folder - it contains old build artifacts with bare imports
34
+ const publicPath = path.join(config.root, config.publicDir);
35
+
36
+ // Serve static files from public/ directory
37
+ // IMPORTANT: Skip source files (.ui, .uix, .ts, .js) - they should be handled by module transformation middleware
38
+ // Wrap express.static to prevent it from serving source files
39
+ const publicStaticMiddleware = express.static(publicPath, {
40
+ // Exclude dist folder and other build artifacts
41
+ dotfiles: "ignore",
42
+ index: false, // Don't serve index files from static middleware
43
+ setHeaders: (res, filePath) => {
44
+ // Add cache-busting headers for all static files in dev mode
45
+ res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
46
+ res.setHeader("Pragma", "no-cache");
47
+ res.setHeader("Expires", "0");
48
+ },
49
+ });
50
+
51
+ app.use((req, res, next) => {
52
+ const url = req.url.split("?")[0];
53
+ // Skip source files - let module transformation middleware handle them
54
+ // DO NOT call express.static for source files - it will serve them with wrong MIME type
55
+ if (
56
+ url.endsWith(".ui") ||
57
+ url.endsWith(".uix") ||
58
+ url.endsWith(".ts") ||
59
+ (url.endsWith(".js") && !url.includes("node_modules")) ||
60
+ url.endsWith(".mjs")
61
+ ) {
62
+ // Skip express.static for source files - pass to next middleware instead
63
+ return next();
64
+ }
65
+ // For non-source files, use express.static
66
+ publicStaticMiddleware(req, res, next);
67
+ });
68
+
69
+ // NOTE: We do NOT serve /src as static files here anymore
70
+ // The module transformation middleware in middleware-setup.ts handles ALL /src requests first
71
+ // This ensures .ui, .uix, .ts files are processed correctly before static middleware can interfere
72
+ // Only non-source files (CSS, images, etc.) will pass through to be served as static
73
+ // But they should be handled by the module transformation middleware's next() call
74
+
75
+ // Serve node_modules as static files from multiple locations
76
+ // 1. App root node_modules
77
+ // Wrap to skip source files
78
+ const nodeModulesStatic = express.static(path.join(config.root, "node_modules"));
79
+ app.use("/node_modules", (req, res, next) => {
80
+ const url = req.url.split("?")[0];
81
+ // Skip source files in node_modules - they should be handled by module transformation
82
+ if (
83
+ url.endsWith(".ui") ||
84
+ url.endsWith(".uix") ||
85
+ url.endsWith(".ts") ||
86
+ (url.endsWith(".js") && !url.includes("/dist/") && !url.includes("/lib/")) ||
87
+ url.endsWith(".mjs")
88
+ ) {
89
+ return next();
90
+ }
91
+ nodeModulesStatic(req, res, next);
92
+ });
93
+
94
+ // 2. Workspace root node_modules (if different from app root)
95
+ const workspaceRootForNodeModules =
96
+ config.workspaceRoot ?? (await findWorkspaceRoot(config.root));
97
+ if (
98
+ workspaceRootForNodeModules &&
99
+ workspaceRootForNodeModules !== config.root
100
+ ) {
101
+ const workspaceNodeModules = path.join(
102
+ workspaceRootForNodeModules,
103
+ "node_modules",
104
+ );
105
+ try {
106
+ await fs.access(workspaceNodeModules);
107
+ // Serve workspace node_modules with a different path to avoid conflicts
108
+ // But also check if package exists in app node_modules first
109
+ app.use("/node_modules", (req, res, next) => {
110
+ const url = req.url.split("?")[0];
111
+ // Skip source files - let module transformation middleware handle them
112
+ if (
113
+ url.endsWith(".ui") ||
114
+ url.endsWith(".uix") ||
115
+ url.endsWith(".ts") ||
116
+ (url.endsWith(".js") && !url.includes("/dist/") && !url.includes("/lib/")) ||
117
+ url.endsWith(".mjs")
118
+ ) {
119
+ return next();
120
+ }
121
+
122
+ // Strip leading slash: req.path inside app.use('/node_modules') is already
123
+ // relative to that prefix, e.g. '/@swissjs/shell/design-tokens/primitive.css'
124
+ const relPath = req.path.replace(/^\/+/, "");
125
+ if (!relPath) return next();
126
+
127
+ // Resolve pnpm symlinks explicitly (same mechanism UIHandler uses).
128
+ // express.static / fs.access can silently fail on pnpm virtual-store symlinks
129
+ // in production containers, so we use realpathSync to get the real path first.
130
+ const isScoped = relPath.startsWith("@");
131
+ const parts = relPath.split("/");
132
+ const pkgName = isScoped ? `${parts[0]}/${parts[1]}` : parts[0];
133
+ const subPath = isScoped ? parts.slice(2).join("/") : parts.slice(1).join("/");
134
+
135
+ const pkgSymLink = path.join(workspaceNodeModules, pkgName);
136
+ try {
137
+ const pkgReal = realpathSync(pkgSymLink);
138
+ const realAbs = path.join(pkgReal, subPath);
139
+ if (existsSync(realAbs)) {
140
+ res.sendFile(realAbs);
141
+ return;
142
+ }
143
+ } catch {
144
+ // symlink missing or broken — fall through to next()
145
+ }
146
+
147
+ next();
148
+ });
149
+ if (_debug) {
150
+ console.log(
151
+ chalk.gray(
152
+ ` 📦 Serving workspace node_modules from ${workspaceNodeModules}`,
153
+ ),
154
+ );
155
+ }
156
+ } catch {
157
+ // Workspace node_modules doesn't exist, skip
158
+ }
159
+ }
160
+
161
+ // Serve workspace packages (lib/, libraries/, packages/, modules/, etc.)
162
+ // This allows workspace packages to be served via HTTP
163
+ // Reuse workspaceRoot from above if it exists, otherwise find it
164
+ const workspaceRoot =
165
+ workspaceRootForNodeModules || (await findWorkspaceRoot(config.root));
166
+
167
+ if (_debug) {
168
+ console.log(
169
+ chalk.blue(`[static-files] Workspace root: ${workspaceRoot}`),
170
+ );
171
+ }
172
+ if (_debug) {
173
+ console.log(
174
+ chalk.blue(`[static-files] App root: ${config.root}`),
175
+ );
176
+ }
177
+
178
+ // Try to serve lib/ directory - check both workspace root and app root parent
179
+ let libPath: string | null = null;
180
+
181
+ if (_debug) {
182
+ console.log(
183
+ chalk.blue(`[static-files] Determining lib/ path... workspaceRoot: ${workspaceRoot}, config.root: ${config.root}`),
184
+ );
185
+ }
186
+
187
+ // First, try workspace root
188
+ if (workspaceRoot && workspaceRoot !== config.root) {
189
+ libPath = path.join(workspaceRoot, "lib");
190
+ if (_debug) {
191
+ console.log(
192
+ chalk.blue(`[static-files] Trying workspace root lib/: ${libPath}`),
193
+ );
194
+ }
195
+ } else {
196
+ if (_debug) {
197
+ console.log(
198
+ chalk.yellow(`[static-files] Workspace root equals app root, trying parent directories...`),
199
+ );
200
+ }
201
+ // If workspace root equals app root, try going up from app root
202
+ const parentDir = path.dirname(config.root);
203
+ const parentLibPath = path.join(parentDir, "lib");
204
+ if (_debug) {
205
+ console.log(
206
+ chalk.blue(`[static-files] Trying parent lib/: ${parentLibPath}`),
207
+ );
208
+ }
209
+ try {
210
+ await fs.access(parentLibPath);
211
+ libPath = parentLibPath;
212
+ if (_debug) {
213
+ console.log(
214
+ chalk.blue(`[static-files] Using parent directory lib/: ${libPath}`),
215
+ );
216
+ }
217
+ } catch (error) {
218
+ if (_debug) {
219
+ console.log(
220
+ chalk.yellow(`[static-files] Parent lib/ not found: ${error instanceof Error ? error.message : String(error)}`),
221
+ );
222
+ }
223
+ // Parent lib/ doesn't exist, try grandparent
224
+ const grandparentDir = path.dirname(parentDir);
225
+ const grandparentLibPath = path.join(grandparentDir, "lib");
226
+ if (_debug) {
227
+ console.log(
228
+ chalk.blue(`[static-files] Trying grandparent lib/: ${grandparentLibPath}`),
229
+ );
230
+ }
231
+ try {
232
+ await fs.access(grandparentLibPath);
233
+ libPath = grandparentLibPath;
234
+ if (_debug) {
235
+ console.log(
236
+ chalk.blue(`[static-files] Using grandparent directory lib/: ${libPath}`),
237
+ );
238
+ }
239
+ } catch (error2) {
240
+ if (_debug) {
241
+ console.log(
242
+ chalk.yellow(`[static-files] Grandparent lib/ not found: ${error2 instanceof Error ? error2.message : String(error2)}`),
243
+ );
244
+ }
245
+ }
246
+ }
247
+ }
248
+
249
+ // Serve lib/ directory if found
250
+ if (_debug) {
251
+ console.log(
252
+ chalk.blue(`[static-files] Checking for lib/ directory... libPath: ${libPath}`),
253
+ );
254
+ }
255
+
256
+ // ALWAYS try to register /lib/ static serving
257
+ // Calculate the lib path - prefer workspace root, fallback to parent of app root
258
+ let finalLibPath: string;
259
+ if (libPath) {
260
+ finalLibPath = libPath;
261
+ } else if (workspaceRoot && workspaceRoot !== config.root) {
262
+ finalLibPath = path.join(workspaceRoot, "lib");
263
+ } else {
264
+ // Go up from app root to find lib/
265
+ const parentDir = path.dirname(config.root);
266
+ finalLibPath = path.join(parentDir, "lib");
267
+ }
268
+
269
+ if (_debug) {
270
+ console.log(
271
+ chalk.blue(`[static-files] Final lib path to check: ${finalLibPath}`),
272
+ );
273
+ }
274
+ if (_debug) {
275
+ console.log(
276
+ chalk.blue(`[static-files] workspaceRoot: ${workspaceRoot}, config.root: ${config.root}`),
277
+ );
278
+ }
279
+
280
+ // Try to access the directory
281
+ let libPathExists = false;
282
+ try {
283
+ await fs.access(finalLibPath);
284
+ libPathExists = true;
285
+ if (_debug) {
286
+ console.log(
287
+ chalk.green(`[static-files] ✅ Found lib/ directory at: ${finalLibPath}`),
288
+ );
289
+ }
290
+
291
+ // Verify the CSS file exists
292
+ const testCssPath = path.join(finalLibPath, "skltn", "src", "css", "index.css");
293
+ try {
294
+ await fs.access(testCssPath);
295
+ if (_debug) {
296
+ console.log(
297
+ chalk.green(`[static-files] ✅ Test CSS file exists: ${testCssPath}`),
298
+ );
299
+ }
300
+ } catch (error) {
301
+ if (_debug) {
302
+ console.error(
303
+ chalk.yellow(`[static-files] ⚠️ Test CSS file NOT found: ${testCssPath}`),
304
+ );
305
+ }
306
+ }
307
+ } catch (error) {
308
+ if (_debug) {
309
+ console.error(
310
+ chalk.red(`[static-files] ❌ lib/ directory not found at: ${finalLibPath}`),
311
+ );
312
+ }
313
+ if (_debug) {
314
+ console.error(
315
+ chalk.red(`[static-files] Error: ${error instanceof Error ? error.message : String(error)}`),
316
+ );
317
+ }
318
+ if (_debug) {
319
+ console.error(
320
+ chalk.red(`[static-files] ⚠️ /lib middleware will NOT be registered - CSS files will 404!`),
321
+ );
322
+ }
323
+ }
324
+
325
+ // Register static file middleware ONLY if directory exists
326
+ if (libPathExists) {
327
+ if (_debug) {
328
+ console.log(chalk.green(`[static-files] ✅ Registering /lib middleware with finalLibPath: ${finalLibPath}`));
329
+ }
330
+
331
+ // CRITICAL: First middleware to block source files BEFORE any express.static can serve them
332
+ // This MUST run before express.static to prevent wrong MIME types
333
+ app.use("/lib", (req, res, next) => {
334
+ const url = req.url.split("?")[0];
335
+ const path = req.path.split("?")[0];
336
+
337
+ // CRITICAL: Block source files immediately - check both url and path
338
+ // When express.static is mounted at /lib, req.path is stripped of /lib prefix
339
+ const isSourceFile =
340
+ url.endsWith(".ui") || url.endsWith(".uix") || url.endsWith(".ts") ||
341
+ (url.endsWith(".js") && !url.includes("node_modules")) || url.endsWith(".mjs") ||
342
+ path.endsWith(".ui") || path.endsWith(".uix") || path.endsWith(".ts") ||
343
+ (path.endsWith(".js") && !path.includes("node_modules")) || path.endsWith(".mjs");
344
+
345
+ if (isSourceFile) {
346
+ if (_debug) {
347
+ console.log(
348
+ chalk.red(
349
+ `[static-files] ⚠️ FIRST BLOCK: Skipping source file: url=${url}, path=${path} - should be handled by module middleware`
350
+ )
351
+ );
352
+ }
353
+ return next(); // Let module transformation middleware handle it
354
+ }
355
+
356
+ if (_debug) {
357
+ console.log(
358
+ chalk.cyan(`[static-files] Request for /lib${path} (static file)`),
359
+ );
360
+ }
361
+ next();
362
+ });
363
+
364
+ // REMOVED: Logging middleware - it was just adding noise
365
+ // The blocking middleware above already handles source files
366
+
367
+ // CRITICAL: Add express.static for /lib/ but wrap it to skip source files
368
+ // Source files should be handled by module transformation middleware (registered before this)
369
+ const libStatic = express.static(finalLibPath, {
370
+ setHeaders: (res, filePath) => {
371
+ try {
372
+ res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
373
+ res.setHeader("Pragma", "no-cache");
374
+ res.setHeader("Expires", "0");
375
+ } catch (error) {
376
+ if (_debug) {
377
+ console.error(chalk.red(`[static-files] Error setting headers for ${filePath}:`), error);
378
+ }
379
+ }
380
+ },
381
+ });
382
+
383
+ // CRITICAL: Wrap express.static to prevent serving source files
384
+ // Check BOTH req.url (full path) and req.path (stripped path) to catch all cases
385
+ app.use("/lib", (req, res, next) => {
386
+ const url = req.url.split("?")[0];
387
+ const path = req.path.split("?")[0];
388
+
389
+ // CRITICAL: Skip source files - they should be handled by module transformation middleware
390
+ // Check both url and path because express.static strips the mount path
391
+ const isSourceFile =
392
+ url.endsWith(".ui") || url.endsWith(".uix") || url.endsWith(".ts") ||
393
+ (url.endsWith(".js") && !url.includes("node_modules")) || url.endsWith(".mjs") ||
394
+ path.endsWith(".ui") || path.endsWith(".uix") || path.endsWith(".ts") ||
395
+ (path.endsWith(".js") && !path.includes("node_modules")) || path.endsWith(".mjs");
396
+
397
+ if (isSourceFile) {
398
+ if (_debug) {
399
+ console.log(
400
+ chalk.red(
401
+ `[static-files /lib express.static] ⚠️ BLOCKING source file: url=${url}, path=${path} - should be handled by module transformation middleware`
402
+ )
403
+ );
404
+ }
405
+ // CRITICAL: Don't call libStatic - return next() to skip it
406
+ return next(); // Let module transformation middleware handle it
407
+ }
408
+ // For static files (CSS, images), use express.static
409
+ libStatic(req, res, next);
410
+ });
411
+ // This was serving source files with wrong MIME type (application/octet-stream)
412
+ // Source files should be handled by module transformation middleware (registered before this)
413
+ // Only static files (CSS, images) should be served, and they're handled by the custom handler above
414
+ // If a file isn't found, let it 404 rather than serving with wrong MIME type
415
+ if (_debug) {
416
+ console.log(
417
+ chalk.gray(` 📦 Serving workspace lib/ from ${finalLibPath}`),
418
+ );
419
+ }
420
+ }
421
+
422
+ // Continue with other workspace directories if workspaceRoot is different from app root
423
+ if (workspaceRoot && workspaceRoot !== config.root) {
424
+
425
+ // Serve libraries/ directory (legacy support)
426
+ const librariesPath = path.join(workspaceRoot, "libraries");
427
+ try {
428
+ await fs.access(librariesPath);
429
+ app.use("/libraries", express.static(librariesPath));
430
+ if (_debug) {
431
+ console.log(
432
+ chalk.gray(` 📦 Serving workspace libraries/ from ${librariesPath}`),
433
+ );
434
+ }
435
+ } catch {
436
+ // libraries/ doesn't exist, skip
437
+ }
438
+
439
+ // NOTE: Do NOT serve /packages/ as static files
440
+ // Workspace packages contain source files (.ts, .ui, .uix) that need to be processed
441
+ // by handlers (TSHandler, UIHandler, etc.) to rewrite imports and compile them
442
+ // Static file serving would bypass this processing, causing bare imports to fail
443
+ // Only serve /packages/ if they're already compiled assets (handled by handlers)
444
+
445
+ // Serve modules/ directory (for CSS, assets, etc.)
446
+ // Source files (.ui, .uix, .ts) must NOT be served statically — they need
447
+ // to fall through to the compiler middleware (general handler). CG-07.
448
+ const modulesPath = path.join(workspaceRoot, "modules");
449
+ try {
450
+ await fs.access(modulesPath);
451
+ app.use("/modules", (req: express.Request, res: express.Response, next: express.NextFunction) => {
452
+ const url = req.url.split("?")[0];
453
+ const isSourceFile =
454
+ url.endsWith(".ui") ||
455
+ url.endsWith(".uix") ||
456
+ url.endsWith(".ts") ||
457
+ (url.endsWith(".js") && !url.includes("node_modules")) ||
458
+ url.endsWith(".mjs");
459
+ if (isSourceFile) {
460
+ return next();
461
+ }
462
+ express.static(modulesPath)(req, res, next);
463
+ });
464
+ if (_debug) {
465
+ console.log(
466
+ chalk.gray(` 📦 Serving workspace modules/ from ${modulesPath}`),
467
+ );
468
+ }
469
+ } catch {
470
+ // modules/ doesn't exist, skip
471
+ }
472
+ }
473
+
474
+ // NOTE: SWISS packages are NOT served as static files
475
+ // They are processed by the middleware (TS/JS handlers) to rewrite imports
476
+ // This ensures all bare imports in SWISS packages are rewritten correctly
477
+ // The /swiss-packages/ URLs are handled by the middleware in middleware-setup.ts
478
+ }
479
+
480
+ /**
481
+ * Setup SPA fallback - serves index.html for all unmatched routes
482
+ */
483
+ export async function setupSPAFallback(
484
+ app: Express,
485
+ config: StaticFilesConfig,
486
+ ): Promise<void> {
487
+ const _debug = process.env["SWITE_DEBUG"] === "1";
488
+ if (_debug) {
489
+ console.log(chalk.magenta(`[SWITE] setupSPAFallback loaded - VERSION 0.3.5 (NO HARDCODED CSS)`));
490
+ }
491
+ // Use app.all() to catch ALL HTTP methods, but only for non-source files
492
+ app.all("*", async (req, res, next) => {
493
+ const url = req.url.split("?")[0];
494
+ const fullUrl = req.url;
495
+ const accept = String(req.headers?.accept || "");
496
+
497
+ // DEBUG: Verify handler is being called
498
+
499
+ // --- CRITICAL SAFETY CHECK ---
500
+ // NEVER serve HTML for /src/* requests - these are source files that must be handled by middleware
501
+ // Even if middleware fails, we should return 404, not HTML
502
+ if (req.path?.startsWith("/src/") || url.startsWith("/src/")) {
503
+ res.status(404).setHeader("Content-Type", "text/plain");
504
+ res.send(`File not found: ${url}`);
505
+ return;
506
+ }
507
+
508
+ // --- CRITICAL SAFETY CHECK ---
509
+ // NEVER serve HTML for /swiss-packages/* requests - these are SWISS framework packages
510
+ // They should be handled by TS/JS handlers to rewrite imports
511
+ if (req.path?.startsWith("/swiss-packages/") || url.startsWith("/swiss-packages/")) {
512
+ res.status(404).setHeader("Content-Type", "text/plain");
513
+ res.send(`File not found: ${url}`);
514
+ return;
515
+ }
516
+
517
+ // --- CRITICAL SAFETY CHECK ---
518
+ // NEVER serve HTML for /lib/* requests - these are workspace library files
519
+ // They should be handled by static file middleware
520
+ if (req.path?.startsWith("/lib/") || url.startsWith("/lib/")) {
521
+ res.status(404).setHeader("Content-Type", "text/plain");
522
+ res.send(`File not found: ${url}`);
523
+ return;
524
+ }
525
+
526
+ // Log every request that hits the fallback (for diagnostics)
527
+
528
+ // Log if SPA fallback is being hit for .ui files (this should NOT happen after /src check)
529
+ if (url.endsWith(".ui")) {
530
+ }
531
+
532
+ // DO NOT serve HTML for source files - they should be handled by handlers
533
+ // This prevents the SPA fallback from catching .ui, .uix, .ts, .js, .mjs files
534
+ // If we reach here, it means the middleware handlers didn't process it
535
+ if (
536
+ url.endsWith(".ui") ||
537
+ url.endsWith(".uix") ||
538
+ url.endsWith(".ts") ||
539
+ url.endsWith(".js") ||
540
+ url.endsWith(".mjs") ||
541
+ url.endsWith(".css") ||
542
+ url.endsWith(".json")
543
+ ) {
544
+ // These should have been handled by middleware handlers
545
+ // If we reach here, the file wasn't found, return 404 with proper content type
546
+ res.status(404).setHeader("Content-Type", "text/plain");
547
+ res.send(`File not found: ${url}`);
548
+ return;
549
+ }
550
+
551
+ // Only serve SPA HTML for real navigation/document requests.
552
+ // If a script/style/module fetch hits the fallback, returning HTML causes strict MIME failures.
553
+ if (!accept.includes("text/html")) {
554
+ res.status(404).setHeader("Content-Type", "text/plain");
555
+ res.send(`Not found: ${url}`);
556
+ return;
557
+ }
558
+
559
+ // Add cache-busting headers for HTML files during development
560
+ res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
561
+ res.setHeader("Pragma", "no-cache");
562
+ res.setHeader("Expires", "0");
563
+
564
+ // Inject timestamp into script tag to force fresh load
565
+ const htmlPath = path.join(config.root, config.publicDir, "index.html");
566
+ let html = await fs.readFile(htmlPath, "utf-8");
567
+ const timestamp = Date.now();
568
+ const random = Math.random().toString(36).substring(7);
569
+ // More aggressive cache busting - replace ALL script src attributes
570
+ html = html.replace(/src="([^"]*index\.ui[^"]*)"/g, (match, src) => {
571
+ // Remove any existing cache-busting params
572
+ const cleanSrc = src.split("?")[0].split("&")[0];
573
+ return `src="${cleanSrc}?v=dev&t=${timestamp}&r=${random}"`;
574
+ });
575
+ // Also replace any script tags with type="module" that have src attributes
576
+ html = html.replace(
577
+ /<script\s+type=["']module["'][^>]*src=["']([^"']*index\.ui[^"']*)["'][^>]*>/g,
578
+ (match, src) => {
579
+ const cleanSrc = src.split("?")[0].split("&")[0];
580
+ return match.replace(
581
+ src,
582
+ `${cleanSrc}?v=dev&t=${timestamp}&r=${random}`,
583
+ );
584
+ },
585
+ );
586
+
587
+ // Add cache-busting meta tags to prevent browser caching
588
+ if (!html.includes('<meta http-equiv="Cache-Control"')) {
589
+ html = html.replace(
590
+ "<head>",
591
+ `<head>\n <meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">\n <meta http-equiv="Pragma" content="no-cache">\n <meta http-equiv="Expires" content="0">`,
592
+ );
593
+ }
594
+
595
+ // Extract CSS imports from entry point and inject as <link> tags
596
+ // This dynamically discovers CSS files from the app's entry point
597
+ // CRITICAL: This MUST run before import map injection
598
+ // IMPORTANT: Only inject CSS files that actually exist in the app's directory
599
+ if (_debug) {
600
+ console.log(chalk.magenta(`[SWITE CSS] ========== CSS EXTRACTION START (VERSION 0.3.5) ==========`));
601
+ }
602
+ if (_debug) {
603
+ console.log(chalk.magenta(`[SWITE CSS] App root: ${config.root}`));
604
+ }
605
+ try {
606
+ const entryFile = config.entry ?? "src/index.ui";
607
+ const entryPointPath = path.join(config.root, entryFile);
608
+ if (_debug) {
609
+ console.log(chalk.blue(`[SWITE CSS] Checking entry point: ${entryPointPath}`));
610
+ }
611
+ const entryPointContent = await fs.readFile(entryPointPath, "utf-8");
612
+
613
+ // Extract CSS imports using regex
614
+ const cssImportPattern = /import\s+['"](.*?\.css)['"];?/g;
615
+ const cssImports = new Set<string>();
616
+ let match;
617
+
618
+ // Check entry point
619
+ while ((match = cssImportPattern.exec(entryPointContent)) !== null) {
620
+ cssImports.add(match[1]);
621
+ }
622
+
623
+ // Also check imported files (like App.uix) for CSS imports
624
+ const importPattern = /import\s+.*?from\s+['"](.*?)['"];?/g;
625
+ const importedFiles: string[] = [];
626
+ let importMatch;
627
+ cssImportPattern.lastIndex = 0; // Reset regex
628
+ while ((importMatch = importPattern.exec(entryPointContent)) !== null) {
629
+ const importPath = importMatch[1];
630
+ // Skip node_modules and absolute imports
631
+ if (!importPath.startsWith("@") && !importPath.startsWith("/") && !importPath.startsWith(".")) {
632
+ continue;
633
+ }
634
+ // Resolve relative imports
635
+ if (importPath.startsWith(".")) {
636
+ importedFiles.push(importPath);
637
+ }
638
+ }
639
+
640
+ // Check imported files for CSS
641
+ for (const importedFile of importedFiles) {
642
+ try {
643
+ const importedFilePath = path.resolve(path.dirname(entryPointPath), importedFile);
644
+ // Try different extensions
645
+ const extensions = [".uix", ".ui", ".ts", ".js"];
646
+ let found = false;
647
+ for (const ext of extensions) {
648
+ const testPath = importedFilePath.endsWith(ext) ? importedFilePath : importedFilePath + ext;
649
+ try {
650
+ const importedContent = await fs.readFile(testPath, "utf-8");
651
+ found = true;
652
+ // Extract CSS imports from this file
653
+ cssImportPattern.lastIndex = 0; // Reset regex
654
+ let cssMatch2;
655
+ while ((cssMatch2 = cssImportPattern.exec(importedContent)) !== null) {
656
+ // Resolve relative CSS path
657
+ const cssPath = cssMatch2[1];
658
+ if (cssPath.startsWith(".")) {
659
+ const resolvedCssPath = path.resolve(path.dirname(testPath), cssPath);
660
+ const relativeCssPath = path.relative(path.join(config.root, "src"), resolvedCssPath);
661
+ const normalizedPath = relativeCssPath.replace(/\\/g, "/");
662
+ cssImports.add(normalizedPath);
663
+ } else {
664
+ cssImports.add(cssPath);
665
+ }
666
+ }
667
+ break;
668
+ } catch (err) {
669
+ // File doesn't exist with this extension, try next
670
+ }
671
+ }
672
+ } catch (error) {
673
+ // Could not read imported file, skip
674
+ }
675
+ }
676
+
677
+ if (_debug) {
678
+ console.log(chalk.blue(`[SWITE CSS] Found ${cssImports.size} CSS import(s) in code`));
679
+ }
680
+ if (cssImports.size > 0) {
681
+ const cssArray = Array.from(cssImports);
682
+ if (_debug) {
683
+ console.log(chalk.blue(`[SWITE CSS] CSS imports found: ${cssArray.join(", ")}`));
684
+ }
685
+
686
+ // Verify CSS files exist before injecting them
687
+ const existingCssFiles: string[] = [];
688
+ for (const cssPath of cssArray) {
689
+ // Convert to file system path
690
+ const url = cssPath.startsWith("/") ? cssPath : `/src/${cssPath}`;
691
+ const filePath = url.startsWith("/src/")
692
+ ? path.join(config.root, url.substring(1)) // Remove leading /
693
+ : path.join(config.root, "src", cssPath);
694
+
695
+ if (_debug) {
696
+ console.log(chalk.blue(`[SWITE CSS] Checking if CSS file exists: ${filePath} (url: ${url})`));
697
+ }
698
+ try {
699
+ await fs.access(filePath);
700
+ if (_debug) {
701
+ console.log(chalk.green(`[SWITE CSS] ✅ CSS file exists: ${filePath}`));
702
+ }
703
+ existingCssFiles.push(url);
704
+ } catch {
705
+ // CSS file doesn't exist, skip it
706
+ // This allows different apps/websites to have different CSS files
707
+ if (_debug) {
708
+ console.log(chalk.yellow(`[SWITE CSS] ⚠️ CSS file NOT found: ${filePath}, skipping`));
709
+ }
710
+ }
711
+ }
712
+
713
+ // Only inject CSS files that actually exist
714
+ if (_debug) {
715
+ console.log(chalk.blue(`[SWITE CSS] ${existingCssFiles.length} CSS file(s) exist out of ${cssArray.length} found`));
716
+ }
717
+ if (existingCssFiles.length === 0) {
718
+ if (_debug) {
719
+ console.log(chalk.yellow(`[SWITE CSS] ⚠️ No CSS files exist, skipping injection`));
720
+ }
721
+ } else if (existingCssFiles.length > 0) {
722
+ const cssLinks = existingCssFiles
723
+ .map(url => ` <link rel="stylesheet" href="${url}">`)
724
+ .join("\n");
725
+
726
+ // Check if CSS links are already in HTML (to avoid duplicates)
727
+ const alreadyInjected = existingCssFiles.some(url =>
728
+ html.includes(`href="${url}"`) || html.includes(`href='${url}'`)
729
+ );
730
+
731
+ if (!alreadyInjected) {
732
+ // Inject CSS links before </head> - MUST happen before import map injection
733
+ const beforeReplace = html;
734
+ html = html.replace(/\s*<\/head>/i, `${cssLinks}\n </head>`);
735
+ if (html === beforeReplace) {
736
+ if (_debug) {
737
+ console.warn(chalk.yellow("[SWITE] Failed to inject CSS links - </head> not found"));
738
+ }
739
+ } else {
740
+ if (_debug) {
741
+ console.log(chalk.green(`[SWITE] ✅ Injected ${existingCssFiles.length} CSS link(s): ${existingCssFiles.join(", ")}`));
742
+ }
743
+ }
744
+ } else {
745
+ if (_debug) {
746
+ console.log(chalk.blue(`[SWITE CSS] CSS links already in HTML, skipping injection`));
747
+ }
748
+ }
749
+ }
750
+ }
751
+ } catch (error) {
752
+ // If entry point doesn't exist or can't be read, continue without CSS injection
753
+ // Silently continue - CSS injection is optional
754
+ if (_debug) {
755
+ console.log(chalk.yellow(`[SWITE CSS] Could not extract CSS imports: ${error instanceof Error ? error.message : String(error)}`));
756
+ }
757
+ }
758
+
759
+ // Add/merge import map to help browser resolve bare module specifiers.
760
+ // If an importmap already exists in HTML, merge .swite/import-map.json entries
761
+ // into it — existing HTML entries take priority (never overwrite manual entries).
762
+ const cachedMapPath = path.join(config.root, ".swite", "import-map.json");
763
+ let switeImports: Record<string, string> = {};
764
+ try {
765
+ const raw = await fs.readFile(cachedMapPath, "utf-8");
766
+ const parsed = JSON.parse(raw);
767
+ if (parsed?.imports && typeof parsed.imports === "object") {
768
+ switeImports = parsed.imports;
769
+ }
770
+ } catch {
771
+ // no cached map — nothing to merge
772
+ }
773
+
774
+ if (!html.includes('type="importmap"')) {
775
+ // No importmap at all — inject one from .swite/import-map.json
776
+ const importMap = `\n <script type="importmap">\n ${JSON.stringify({ imports: switeImports }, null, 2).replace(/\n/g, "\n ")}\n </script>`;
777
+ const beforeReplace = html;
778
+ html = html.replace(/\s*<\/head>/i, `${importMap}\n </head>`);
779
+ if (html === beforeReplace) {
780
+ if (_debug) {
781
+ console.warn("[SWITE] Failed to add import map - </head> not found or already replaced");
782
+ }
783
+ } else {
784
+ if (_debug) {
785
+ console.log(`[SWITE] Added import map with ${Object.keys(switeImports).length} entries`);
786
+ }
787
+ }
788
+ } else {
789
+ // Importmap already in HTML — merge swite entries without overwriting existing ones
790
+ if (_debug) {
791
+ console.log("[SWITE] Import map already exists in HTML — merging swite entries");
792
+ }
793
+ if (Object.keys(switeImports).length > 0) {
794
+ html = html.replace(
795
+ /(<script\s+type=["']importmap["'][^>]*>)\s*([\s\S]*?)(\s*<\/script>)/i,
796
+ (_match, open, body, close) => {
797
+ try {
798
+ const existing = JSON.parse(body.trim());
799
+ const existingImports: Record<string, string> = existing?.imports ?? {};
800
+ // Swite entries fill gaps; existing HTML entries win
801
+ const merged = { ...switeImports, ...existingImports };
802
+ return `${open}\n ${JSON.stringify({ imports: merged }, null, 2).replace(/\n/g, "\n ")}${close}`;
803
+ } catch {
804
+ return _match; // parse failed — leave importmap untouched
805
+ }
806
+ }
807
+ );
808
+ }
809
+ }
810
+
811
+ res.send(html);
812
+ });
813
+ }