@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/.claude/settings.local.json +11 -4
- package/docs/asset-serving-fix.md +381 -0
- package/package.json +3 -2
- package/src/args.js +3 -1
- package/src/build/bootstrap.js +107 -5
- package/src/build/html.js +4 -2
- package/src/build/runtime-manifest.js +1 -1
- package/src/build/server.js +14 -4
- package/src/build/ssr.js +130 -13
- package/src/build/ui-common.js +19 -2
- package/src/client.js +14 -2
- package/src/config.js +10 -0
- package/src/runtime/runtime.js +49 -6
- package/test/asset-mounting.test.js +211 -0
- package/test/config-pipeline.test.js +353 -0
- package/test/path-resolution.test.js +164 -0
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
|
-
//
|
|
61
|
-
//
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
|
|
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
|
|
138
|
-
const entryPath = path.join(
|
|
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(
|
|
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;
|
package/src/build/ui-common.js
CHANGED
|
@@ -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
|
|
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(
|
|
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
|
-
|
|
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,
|
package/src/runtime/runtime.js
CHANGED
|
@@ -412,8 +412,11 @@ async function runFrontendService(config) {
|
|
|
412
412
|
}
|
|
413
413
|
}
|
|
414
414
|
}
|
|
415
|
-
|
|
416
|
-
|
|
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
|
-
|
|
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
|
-
|
|
450
|
-
|
|
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
|
-
|
|
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
|
+
});
|