@noego/app 0.0.9 → 0.0.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/build/ssr.js CHANGED
@@ -57,20 +57,53 @@ export async function buildSsr(context, discovery) {
57
57
  const manifestPath = path.join(config.layout.ssrOutDir, 'manifest.json');
58
58
  await fs.writeFile(manifestPath, JSON.stringify(manifest, null, 2));
59
59
 
60
- // Overlay SSR compiled modules into the mirrored UI root so runtime imports
61
- // like component_path.replace('.svelte', '.js') resolve without loaders.
62
- try {
63
- await fs.mkdir(config.layout.uiOutDir, { recursive: true });
64
- await fs.cp(config.layout.ssrOutDir, config.layout.uiOutDir, { recursive: true, force: true });
65
- } catch (err) {
66
- // non-fatal; report via console for visibility
67
- console.warn('Failed to overlay SSR output into UI root:', err?.message || err);
60
+ // No overlay needed - Forge loads components directly from .app/ssr/
61
+ // Write entry-ssr.js to where components actually are (preserving full relative path)
62
+ const relativeComponentDir = config.client?.component_dir ? (() => {
63
+ const uiRoot = path.dirname(config.client.main_abs);
64
+ return path.relative(uiRoot, config.client.componentDir_abs);
65
+ })() : null;
66
+
67
+ const ssrComponentsDir = relativeComponentDir
68
+ ? path.join(config.layout.ssrOutDir, relativeComponentDir)
69
+ : config.layout.ssrOutDir;
70
+
71
+ // Copy RecursiveRender.js to components subdirectory if needed
72
+ if (relativeComponentDir && await pathExists(path.join(config.layout.ssrOutDir, 'RecursiveRender.js'))) {
73
+ await fs.mkdir(ssrComponentsDir, { recursive: true });
74
+ await fs.copyFile(
75
+ path.join(config.layout.ssrOutDir, 'RecursiveRender.js'),
76
+ path.join(ssrComponentsDir, 'RecursiveRender.js')
77
+ );
68
78
  }
69
79
 
70
- await writeSsrEntryModule(context, resolution.components).catch((err) => {
80
+ await writeSsrEntryModule(context, resolution.components, ssrComponentsDir).catch((err) => {
71
81
  logger.warn(`Failed to generate SSR entry module: ${err?.message || err}`);
72
82
  });
73
83
 
84
+ // Minimal SSR parity fix: copy compiled loader files (*.load.js) from mirrored UI output
85
+ // into the SSR components directory so Forge can resolve loaders adjacent to SSR modules.
86
+ await copySsrLoaders(context, {
87
+ uiOutDir: config.layout.uiOutDir,
88
+ relativeComponentDir,
89
+ ssrComponentsDir
90
+ }).catch((err) => {
91
+ logger.warn(`Failed to copy SSR loaders: ${err?.message || err}`);
92
+ });
93
+
94
+ // Also copy UI support modules used by loaders via relative paths (e.g., '../../utils').
95
+ // This ensures SSR loader imports like '../../utils/api.js' resolve under SSR base.
96
+ const uiUtilsDir = path.join(config.layout.uiOutDir, 'utils');
97
+ const ssrUtilsDir = path.join(config.layout.ssrOutDir, 'utils');
98
+ if (await pathExists(uiUtilsDir)) {
99
+ try {
100
+ await fs.mkdir(path.dirname(ssrUtilsDir), { recursive: true });
101
+ await fs.cp(uiUtilsDir, ssrUtilsDir, { recursive: true, force: true });
102
+ } catch (e) {
103
+ logger.warn(`Failed to copy SSR utils directory: ${e?.message || e}`);
104
+ }
105
+ }
106
+
74
107
  return {
75
108
  manifestPath,
76
109
  manifest
@@ -132,10 +165,10 @@ async function buildForgeRecursiveRenderer(context) {
132
165
  }
133
166
  }
134
167
 
135
- async function writeSsrEntryModule(context, componentList = []) {
168
+ async function writeSsrEntryModule(context, componentList = [], targetDir = null) {
136
169
  const { config } = context;
137
- const uiOutDir = config.layout.uiOutDir;
138
- const entryPath = path.join(uiOutDir, 'entry-ssr.js');
170
+ const outputDir = targetDir || config.layout.uiOutDir;
171
+ const entryPath = path.join(outputDir, 'entry-ssr.js');
139
172
 
140
173
  const uniqueComponents = Array.from(
141
174
  new Set(
@@ -151,7 +184,7 @@ async function writeSsrEntryModule(context, componentList = []) {
151
184
 
152
185
  for (const component of uniqueComponents) {
153
186
  const jsPathRelative = toPosix(component.replace(/\.svelte$/, '.js'));
154
- const absoluteJsPath = path.join(uiOutDir, jsPathRelative);
187
+ const absoluteJsPath = path.join(outputDir, jsPathRelative);
155
188
  try {
156
189
  await fs.access(absoluteJsPath);
157
190
  } catch {
@@ -184,6 +217,81 @@ function toPosix(value) {
184
217
  return value.split(path.sep).join('/');
185
218
  }
186
219
 
220
+ async function copySsrLoaders(context, { uiOutDir, relativeComponentDir, ssrComponentsDir }) {
221
+ const { requireFromRoot } = context;
222
+ if (!uiOutDir || !ssrComponentsDir) return;
223
+
224
+ const sourceRoot = relativeComponentDir
225
+ ? path.join(uiOutDir, relativeComponentDir)
226
+ : uiOutDir;
227
+
228
+ let glob;
229
+ try {
230
+ const globModule = requireFromRoot('glob');
231
+ glob = typeof globModule === 'function' ? globModule : globModule.glob.bind(globModule);
232
+ } catch {
233
+ glob = null;
234
+ }
235
+
236
+ // If glob is unavailable, do a shallow fallback copy of known locations
237
+ if (!glob) {
238
+ await copyLoaderTree(sourceRoot, ssrComponentsDir);
239
+ return;
240
+ }
241
+
242
+ const patterns = [
243
+ '**/*.load.js'
244
+ ];
245
+
246
+ for (const pattern of patterns) {
247
+ // eslint-disable-next-line no-await-in-loop
248
+ const matches = await glob(pattern, {
249
+ cwd: sourceRoot,
250
+ absolute: false,
251
+ nodir: true,
252
+ dot: true
253
+ });
254
+ for (const rel of matches) {
255
+ const from = path.join(sourceRoot, rel);
256
+ const to = path.join(ssrComponentsDir, rel);
257
+ // eslint-disable-next-line no-await-in-loop
258
+ await fs.mkdir(path.dirname(to), { recursive: true });
259
+ // eslint-disable-next-line no-await-in-loop
260
+ await fs.copyFile(from, to).catch(() => {});
261
+ }
262
+ }
263
+ }
264
+
265
+ async function copyLoaderTree(source, destination) {
266
+ try {
267
+ const entries = await fs.readdir(source, { withFileTypes: true });
268
+ for (const entry of entries) {
269
+ const from = path.join(source, entry.name);
270
+ const to = path.join(destination, entry.name);
271
+ if (entry.isDirectory()) {
272
+ // eslint-disable-next-line no-await-in-loop
273
+ await copyLoaderTree(from, to);
274
+ } else if (entry.isFile() && /\.load\.js$/.test(entry.name)) {
275
+ // eslint-disable-next-line no-await-in-loop
276
+ await fs.mkdir(path.dirname(to), { recursive: true });
277
+ // eslint-disable-next-line no-await-in-loop
278
+ await fs.copyFile(from, to).catch(() => {});
279
+ }
280
+ }
281
+ } catch {
282
+ // ignore
283
+ }
284
+ }
285
+
286
+ async function pathExists(targetPath) {
287
+ try {
288
+ await fs.access(targetPath);
289
+ return true;
290
+ } catch {
291
+ return false;
292
+ }
293
+ }
294
+
187
295
  async function loadSveltePluginModule(context, searchRoots = []) {
188
296
  const attempted = new Set();
189
297
  for (const root of searchRoots.filter(Boolean)) {
@@ -243,6 +351,15 @@ function buildSsrManifest(config, discovery) {
243
351
  }
244
352
 
245
353
  function componentToSsrPath(componentPath, config) {
354
+ if (config.verbose) {
355
+ console.log('[ssr] componentToSsrPath called:', {
356
+ componentPath,
357
+ 'config.ui': config.ui,
358
+ 'config.ui.rootDir': config.ui?.rootDir,
359
+ 'config.rootDir': config.rootDir
360
+ });
361
+ }
362
+
246
363
  const relative = componentPath.startsWith('.')
247
364
  ? path.normalize(componentPath)
248
365
  : componentPath;
@@ -62,9 +62,15 @@ export async function createUiBuildConfig(context, options) {
62
62
  if (entryResolved) inputSet.add(entryResolved);
63
63
 
64
64
  for (const relative of componentRelatives) {
65
- const absolute = path.resolve(config.ui.rootDir, relative);
65
+ const absFromUi = config.ui.rootDir ? path.resolve(config.ui.rootDir, relative) : null;
66
+ const absFromComp = config.client?.componentDir_abs ? path.resolve(config.client.componentDir_abs, relative) : null;
67
+ let absolute = absFromUi;
66
68
  // eslint-disable-next-line no-await-in-loop
67
- if (await fileExists(absolute)) {
69
+ if (absFromComp && await fileExists(absFromComp)) {
70
+ absolute = absFromComp;
71
+ }
72
+ // eslint-disable-next-line no-await-in-loop
73
+ if (absolute && await fileExists(absolute)) {
68
74
  presentComponents.push(relative);
69
75
  inputSet.add(absolute);
70
76
  } else {
@@ -83,6 +89,11 @@ export async function createUiBuildConfig(context, options) {
83
89
  configFile,
84
90
  mode: config.mode,
85
91
  root: config.ui.rootDir,
92
+ // Use precalculated base path from bootstrap config
93
+ // Bootstrap determines this based on project structure
94
+ // SSR: undefined (Node doesn't need base path)
95
+ // Client: configured public base for assets (e.g., '/assets' or '/frontend')
96
+ base: ssr ? undefined : (config.client?.component_base_path || '/assets'),
86
97
  plugins: [
87
98
  clientExcludePlugin(config.ui.clientExclude, { projectRoot: config.rootDir })
88
99
  ],
@@ -166,6 +177,12 @@ function createEntryFileNames(context, entryResolved) {
166
177
  if (!relToUi.startsWith('..')) {
167
178
  return toOutputPath(relToUi);
168
179
  }
180
+ if (config.client?.componentDir_abs) {
181
+ const relToComp = path.relative(config.client.componentDir_abs, facade);
182
+ if (!relToComp.startsWith('..')) {
183
+ return toOutputPath(relToComp);
184
+ }
185
+ }
169
186
  const relToRoot = path.relative(config.rootDir, facade);
170
187
  if (!relToRoot.startsWith('..')) {
171
188
  return toOutputPath(relToRoot);
package/src/client.js CHANGED
@@ -70,6 +70,7 @@ export async function clientBoot() {
70
70
  const root = config.root || process.cwd();
71
71
  const require = createRequire(path.join(root, 'package.json'));
72
72
  const { createServer: createForgeServer } = require('@noego/forge/server');
73
+ const { assets } = require('@noego/forge/assets');
73
74
 
74
75
  // Setup Forge (SSR)
75
76
  // Forge expects paths relative to viteOptions.root
@@ -88,11 +89,23 @@ export async function clientBoot() {
88
89
  }
89
90
  : undefined
90
91
  },
91
- component_dir: config.client?.component_dir,
92
+ // IMPORTANT: Forge uses component_dir for BOTH SSR and client
93
+ // - SSR (server-side): Loads from filesystem using this path directly
94
+ // - Client (browser): Forge injects window.__COMPONENT_DIR__ which browser uses for imports
95
+ // So we pass the SSR path here, and Forge will handle client path injection
96
+ component_dir: config.client?.component_dir_ssr || config.client?.component_dir || '.app/ssr',
92
97
  open_api_path: config.client?.openapi_path,
93
98
  renderer: config.client?.shell_path,
94
99
  middleware_path: config.server?.middleware_path,
95
100
  development: config.mode !== 'production',
101
+ assets: assets((() => {
102
+ if (config.mode === 'production') {
103
+ const route = config.client?.component_base_path || '/assets';
104
+ const dir = config.client?.assets_build_dir || '.app/assets';
105
+ return { [route]: [dir] };
106
+ }
107
+ return {};
108
+ })()),
96
109
  };
97
110
 
98
111
  await createForgeServer(app, forgeOptions);
@@ -138,4 +151,3 @@ export const client = {
138
151
  boot: clientBoot,
139
152
  init: clientInit // Browser-side initialization
140
153
  };
141
-
package/src/config.js CHANGED
@@ -47,6 +47,15 @@ export async function loadBuildConfig(cliOptions = {}, { cwd = process.cwd() } =
47
47
  const uiRootDir = config.client?.main_abs ? path.dirname(config.client.main_abs) : null;
48
48
  const uiRelRoot = uiRootDir ? path.relative(config.root, uiRootDir) : null;
49
49
 
50
+ if (cliOptions.verbose) {
51
+ console.log('[config] uiRootDir calculation:', {
52
+ 'config.client': config.client,
53
+ 'config.client.main_abs': config.client?.main_abs,
54
+ 'uiRootDir': uiRootDir,
55
+ 'uiRelRoot': uiRelRoot
56
+ });
57
+ }
58
+
50
59
  const layout = {
51
60
  outDir,
52
61
  serverOutDir: path.join(outDir, 'server'),
@@ -61,6 +70,7 @@ export async function loadBuildConfig(cliOptions = {}, { cwd = process.cwd() } =
61
70
  const buildConfig = {
62
71
  ...config,
63
72
  rootDir: config.root,
73
+ verbose: cliOptions.verbose || false,
64
74
  layout,
65
75
  server: config.server ? {
66
76
  rootDir: config.server.main_abs ? path.dirname(config.server.main_abs) : config.root,
@@ -412,8 +412,11 @@ async function runFrontendService(config) {
412
412
  }
413
413
  }
414
414
  }
415
-
416
- setContext(frontendApp, config);
415
+
416
+ // Create HTTP server for WebSocket handling (Bug 1 fix)
417
+ const httpServer = http.createServer(frontendApp);
418
+
419
+ setContext(frontendApp, config, httpServer);
417
420
 
418
421
  // Only setup proxy if NOT running as a separate frontend service
419
422
  // In split-serve mode, the router handles proxying
@@ -435,7 +438,7 @@ async function runFrontendService(config) {
435
438
  console.log('[frontend] config.dev.port:', config.dev.port);
436
439
  console.log('[frontend] Using port:', frontendPort);
437
440
 
438
- frontendApp.listen(frontendPort, '0.0.0.0', () => {
441
+ httpServer.listen(frontendPort, '0.0.0.0', () => {
439
442
  console.log(`Frontend server running on http://localhost:${frontendPort}`);
440
443
  });
441
444
 
@@ -446,8 +449,10 @@ async function runFrontendService(config) {
446
449
  * Run router service that proxies to frontend/backend
447
450
  */
448
451
  async function runRouterService(config) {
449
- const appBootModule = await import(toFileUrl(config.app.boot_abs));
450
- const routerApp = appBootModule.default(config);
452
+ // Router runs in main process without TypeScript support.
453
+ // Create a plain Express app instead of importing the user's boot file.
454
+ const express = (await import('express')).default;
455
+ const routerApp = express();
451
456
 
452
457
  attachCookiePolyfill(routerApp);
453
458
 
@@ -626,10 +631,46 @@ async function runRouterService(config) {
626
631
  });
627
632
  });
628
633
 
629
- routerApp.listen(routerPort, '0.0.0.0', () => {
634
+ // Create HTTP server for WebSocket handling (Bug 2 fix)
635
+ const httpServer = http.createServer(routerApp);
636
+
637
+ // Set up WebSocket proxy
638
+ const httpProxy = await import('http-proxy');
639
+ const wsProxy = httpProxy.default.createProxyServer({ changeOrigin: true });
640
+
641
+ // Handle WebSocket upgrade requests
642
+ httpServer.on('upgrade', (req, socket, head) => {
643
+ const url = req.url || '';
644
+
645
+ // Path-based routing for WebSocket connections
646
+ // Vite HMR uses /__vite_hmr by default, but can be configured to /vite-hmr
647
+ const isHmr = url.startsWith('/__vite_hmr') || url.startsWith('/vite-hmr') ||
648
+ String(req.headers['sec-websocket-protocol'] || '').includes('vite-hmr');
649
+
650
+ // Route both HMR and app WebSockets to frontend by default
651
+ // The frontend handles both Vite HMR and app WebSocket connections
652
+ // Only route to backend if explicitly needed (currently none)
653
+ const target = `ws://localhost:${frontendPort}`;
654
+
655
+ console.log(`[router][ws] Routing WebSocket ${url} to ${target}`);
656
+
657
+ wsProxy.ws(req, socket, head, { target }, (err) => {
658
+ console.error('[router][ws] proxy error:', err?.message);
659
+ try { socket.destroy(); } catch {}
660
+ });
661
+ });
662
+
663
+ // Optional: observe proxy-level errors
664
+ wsProxy.on('error', (err, req, socket) => {
665
+ console.error('[router][ws] proxy error (global):', err?.message);
666
+ try { socket?.destroy?.(); } catch {}
667
+ });
668
+
669
+ httpServer.listen(routerPort, '0.0.0.0', () => {
630
670
  console.log(`Router server running on http://localhost:${routerPort}`);
631
671
  console.log(` Proxying to frontend on port ${frontendPort}`);
632
672
  console.log(` Proxying to backend on port ${backendPort}`);
673
+ console.log(` WebSocket support enabled via http-proxy`);
633
674
  });
634
675
 
635
676
  return routerApp;
@@ -660,7 +701,9 @@ export async function runCombinedServices(config, options = {}) {
660
701
  const require = createRequire(path.join(config.root, 'package.json'));
661
702
  const express = require('express');
662
703
  const clientStaticPath = path.join(config.outDir_abs, '.app', 'assets');
704
+ const chunksStaticPath = path.join(config.outDir_abs, '.app', 'ssr', 'chunks');
663
705
  app.use('/client', express.static(clientStaticPath));
706
+ app.use('/chunks', express.static(chunksStaticPath));
664
707
  }
665
708
 
666
709
  const httpServer = http.createServer(app);
@@ -0,0 +1,211 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert';
3
+ import path from 'node:path';
4
+
5
+ /**
6
+ * TEST: Express Asset Mounting
7
+ *
8
+ * Validates that client.js correctly mounts assets based on bootstrap config.
9
+ * This test ensures:
10
+ * 1. We use assets_build_dir from bootstrap (not reconstruct paths)
11
+ * 2. Fallback mount is set up correctly
12
+ * 3. Express receives the correct directory paths
13
+ */
14
+
15
+ /**
16
+ * Mock the assets() function from client.js
17
+ * This simulates what Forge's assets() helper does
18
+ */
19
+ function mockExpressAssets(mountConfig) {
20
+ // Returns what Express would mount
21
+ const mounts = [];
22
+
23
+ for (const [route, dirs] of Object.entries(mountConfig)) {
24
+ if (Array.isArray(dirs)) {
25
+ dirs.forEach(dir => mounts.push({ route, dir }));
26
+ } else {
27
+ mounts.push({ route, dir: dirs });
28
+ }
29
+ }
30
+
31
+ return mounts;
32
+ }
33
+
34
+ /**
35
+ * Mock client.js asset mounting logic
36
+ */
37
+ function calculateAssetMounts(bootstrapConfig, mode) {
38
+ const mountConfig = mode === 'production' && bootstrapConfig.component_suffix
39
+ ? {
40
+ '/assets': [path.join('.app/assets', bootstrapConfig.component_suffix)]
41
+ }
42
+ : {};
43
+
44
+ return mockExpressAssets(mountConfig);
45
+ }
46
+
47
+ /**
48
+ * BETTER: Use assets_build_dir directly from bootstrap
49
+ */
50
+ function calculateAssetMountsBetter(bootstrapConfig, mode) {
51
+ const mountConfig = mode === 'production' && bootstrapConfig.assets_build_dir !== '.app/assets'
52
+ ? {
53
+ '/assets': [bootstrapConfig.assets_build_dir]
54
+ }
55
+ : {};
56
+
57
+ return mockExpressAssets(mountConfig);
58
+ }
59
+
60
+ /**
61
+ * Dual-mount strategy used by the runtime: mount at '/' and '/assets'
62
+ */
63
+ function calculateAssetMountsDual(bootstrapConfig, mode) {
64
+ if (mode !== 'production') return [];
65
+ const dir = bootstrapConfig.assets_build_dir || '.app/assets';
66
+ const mountConfig = {
67
+ '/': [dir],
68
+ '/assets': [dir]
69
+ };
70
+ return mockExpressAssets(mountConfig);
71
+ }
72
+
73
+ test('markdown_view: Express mounts fallback for nested components', () => {
74
+ const bootstrap = {
75
+ component_dir_ssr: '.app/ssr/components',
76
+ component_dir_client: '/assets/components',
77
+ component_base_path: '/assets',
78
+ assets_build_dir: '.app/assets/components',
79
+ component_suffix: 'components'
80
+ };
81
+
82
+ const mounts = calculateAssetMounts(bootstrap, 'production');
83
+
84
+ console.log('Express mounts:', mounts);
85
+
86
+ // Should mount fallback
87
+ assert.strictEqual(mounts.length, 1, 'Should have 1 fallback mount');
88
+ assert.strictEqual(mounts[0].route, '/assets');
89
+ assert.strictEqual(mounts[0].dir, '.app/assets/components');
90
+
91
+ console.log('✅ Express will serve .app/assets/components at /assets');
92
+ console.log(' Browser request: /assets/layout/root.js');
93
+ console.log(' Express serves from: .app/assets/components/layout/root.js');
94
+ });
95
+
96
+ test('liftlog: No fallback mount needed (components at root)', () => {
97
+ const bootstrap = {
98
+ component_dir_ssr: '.app/ssr',
99
+ component_dir_client: '/assets',
100
+ component_base_path: '/assets',
101
+ assets_build_dir: '.app/assets',
102
+ component_suffix: null
103
+ };
104
+
105
+ const mounts = calculateAssetMounts(bootstrap, 'production');
106
+
107
+ console.log('Express mounts:', mounts);
108
+
109
+ // No fallback needed since components at root
110
+ assert.strictEqual(mounts.length, 0, 'Should have no fallback mount');
111
+
112
+ console.log('✅ No fallback needed - components at asset root');
113
+ console.log(' Forge default mount handles everything');
114
+ });
115
+
116
+ test('Better approach: Use assets_build_dir directly', () => {
117
+ const bootstrap = {
118
+ component_dir_ssr: '.app/ssr/components',
119
+ component_dir_client: '/assets/components',
120
+ component_base_path: '/assets',
121
+ assets_build_dir: '.app/assets/components',
122
+ component_suffix: 'components'
123
+ };
124
+
125
+ const mounts = calculateAssetMountsBetter(bootstrap, 'production');
126
+
127
+ console.log('Better Express mounts:', mounts);
128
+
129
+ assert.strictEqual(mounts.length, 1);
130
+ assert.strictEqual(mounts[0].dir, '.app/assets/components',
131
+ 'Should use assets_build_dir directly, not reconstruct from component_suffix');
132
+
133
+ console.log('✅ Using assets_build_dir avoids path reconstruction');
134
+ });
135
+
136
+ test('Dual mount: serve assets at root and /assets', () => {
137
+ const bootstrap = {
138
+ assets_build_dir: '.app/assets/components'
139
+ };
140
+
141
+ const mounts = calculateAssetMountsDual(bootstrap, 'production');
142
+
143
+ // Expect two mounts
144
+ assert.strictEqual(mounts.length, 2);
145
+ const routes = mounts.map(m => m.route).sort();
146
+ assert.deepStrictEqual(routes, ['/', '/assets']);
147
+ mounts.forEach(m => assert.strictEqual(m.dir, '.app/assets/components'));
148
+
149
+ console.log('✅ Dual mount configured: "/" and "/assets" -> .app/assets/components');
150
+ });
151
+
152
+ test('Development mode: No fallback mounts', () => {
153
+ const bootstrap = {
154
+ component_dir_ssr: '.app/ssr/components',
155
+ component_dir_client: '.app/ssr/components',
156
+ component_base_path: '/assets',
157
+ assets_build_dir: '.app/assets/components',
158
+ component_suffix: 'components'
159
+ };
160
+
161
+ const mounts = calculateAssetMounts(bootstrap, 'development');
162
+
163
+ console.log('Dev mode mounts:', mounts);
164
+
165
+ assert.strictEqual(mounts.length, 0, 'Dev mode: no fallback mounts needed');
166
+
167
+ console.log('✅ Dev mode uses Vite dev server - no static mounts');
168
+ });
169
+
170
+ test('Verify client.js uses correct path construction', () => {
171
+ const bootstrap = {
172
+ component_suffix: 'components',
173
+ assets_build_dir: '.app/assets/components'
174
+ };
175
+
176
+ // Current approach (reconstructs path)
177
+ const currentPath = path.join('.app/assets', bootstrap.component_suffix);
178
+
179
+ // Better approach (uses precalculated)
180
+ const betterPath = bootstrap.assets_build_dir;
181
+
182
+ console.log('Current approach:', currentPath);
183
+ console.log('Better approach:', betterPath);
184
+
185
+ // They should produce same result
186
+ assert.strictEqual(currentPath, betterPath,
187
+ 'Both approaches should produce same path');
188
+
189
+ console.log('✅ Current implementation is correct (but could use assets_build_dir directly)');
190
+ console.log(' Recommendation: Change client.js to use config.client.assets_build_dir');
191
+ });
192
+
193
+ test('Edge case: Deeply nested components', () => {
194
+ const bootstrap = {
195
+ component_suffix: 'components/shared',
196
+ assets_build_dir: '.app/assets/components/shared'
197
+ };
198
+
199
+ const currentPath = path.join('.app/assets', bootstrap.component_suffix);
200
+ const betterPath = bootstrap.assets_build_dir;
201
+
202
+ assert.strictEqual(currentPath, betterPath);
203
+
204
+ const mounts = [{
205
+ route: '/assets',
206
+ dir: betterPath
207
+ }];
208
+
209
+ console.log('Deeply nested mount:', mounts[0]);
210
+ console.log('✅ Works correctly for nested paths too');
211
+ });