@noego/app 0.0.7 → 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.
@@ -0,0 +1,246 @@
1
+ #!/usr/bin/env node
2
+ import { spawn } from 'node:child_process';
3
+ import fs from 'node:fs/promises';
4
+ import path from 'node:path';
5
+ import chokidar from 'chokidar';
6
+ import picomatch from 'picomatch';
7
+
8
+ const PLAYGROUND_DIR = path.join(process.cwd(), '.watch-harness');
9
+ const sharedPatterns = ['index.ts', 'hammer.config.yml'];
10
+ const backendPatterns = [
11
+ 'server/**/*.ts',
12
+ 'middleware/**/*.ts',
13
+ 'server/stitch.yaml',
14
+ 'server/openapi/**/*.yaml',
15
+ 'server/repo/**/*.sql'
16
+ ];
17
+ const frontendPatterns = [
18
+ 'ui/**/*.ts',
19
+ 'ui/stitch.yaml',
20
+ 'ui/openapi/**/*.yaml'
21
+ ];
22
+ const allPatterns = [...sharedPatterns, ...backendPatterns, ...frontendPatterns];
23
+
24
+ const seedFiles = {
25
+ 'index.ts': '// root entry\n',
26
+ 'hammer.config.yml': '# config\n',
27
+ 'server/server.ts': '// server bootstrap\n',
28
+ 'server/services/admin_service.ts': '// admin service seed\n',
29
+ 'middleware/logger.ts': '// middleware seed\n',
30
+ 'server/stitch.yaml': 'openapi: 3.0.0\n',
31
+ 'server/openapi/routes.yaml': 'paths: {}\n',
32
+ 'server/repo/example/query.sql': 'select 1;\n',
33
+ 'ui/frontend.ts': '// frontend entry\n',
34
+ 'ui/stitch.yaml': 'paths: {}\n',
35
+ 'ui/openapi/page.yaml': 'paths: {}\n',
36
+ 'ui/pages/home.ts': 'export const home = true;\n'
37
+ };
38
+
39
+ const changeSequence = [
40
+ { label: 'backend', file: 'server/services/admin_service.ts' },
41
+ { label: 'frontend', file: 'ui/pages/home.ts' },
42
+ { label: 'shared', file: 'hammer.config.yml' }
43
+ ];
44
+
45
+ const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
46
+
47
+ let backendProc = null;
48
+ let frontendProc = null;
49
+ let watcher = null;
50
+ let shuttingDown = false;
51
+ let pendingRestart = false;
52
+ let backendRestartCount = 0;
53
+ let frontendRestartCount = 0;
54
+ let readyHandled = false;
55
+
56
+ async function preparePlayground() {
57
+ await fs.rm(PLAYGROUND_DIR, { recursive: true, force: true }).catch(() => {});
58
+ await fs.mkdir(PLAYGROUND_DIR, { recursive: true });
59
+ await Promise.all(
60
+ Object.entries(seedFiles).map(async ([relative, contents]) => {
61
+ const absolute = path.join(PLAYGROUND_DIR, relative);
62
+ await fs.mkdir(path.dirname(absolute), { recursive: true });
63
+ await fs.writeFile(absolute, contents);
64
+ })
65
+ );
66
+ }
67
+
68
+ function spawnChild(label) {
69
+ const child = spawn(process.execPath, ['-e', `
70
+ console.log('[${label}] booting (pid', process.pid, ')');
71
+ let counter = 0;
72
+ setInterval(() => {
73
+ counter += 1;
74
+ console.log('[${label}] heartbeat #' + counter);
75
+ }, 750);
76
+ `], {
77
+ stdio: ['ignore', 'inherit', 'inherit']
78
+ });
79
+ child.on('exit', (code, signal) => {
80
+ console.log(`[${label}] exited`, { code, signal });
81
+ });
82
+ return child;
83
+ }
84
+
85
+ function startBackend() {
86
+ backendRestartCount += 1;
87
+ console.log(`🚀 [backend restart #${backendRestartCount}] starting mock backend`);
88
+ backendProc = spawnChild('backend');
89
+ }
90
+
91
+ function startFrontend() {
92
+ frontendRestartCount += 1;
93
+ console.log(`🚀 [frontend restart #${frontendRestartCount}] starting mock frontend`);
94
+ frontendProc = spawnChild('frontend');
95
+ }
96
+
97
+ function stopProcess(proc, label) {
98
+ return new Promise((resolve) => {
99
+ if (!proc || proc.killed) {
100
+ return resolve();
101
+ }
102
+ const timeout = setTimeout(() => {
103
+ console.warn(`[${label}] did not exit in time, forcing SIGKILL`);
104
+ try {
105
+ proc.kill('SIGKILL');
106
+ } catch {}
107
+ resolve();
108
+ }, 2000);
109
+ proc.once('exit', () => {
110
+ clearTimeout(timeout);
111
+ resolve();
112
+ });
113
+ try {
114
+ proc.kill('SIGTERM');
115
+ } catch {
116
+ clearTimeout(timeout);
117
+ resolve();
118
+ }
119
+ });
120
+ }
121
+
122
+ async function shutdown(exitCode = 0) {
123
+ if (shuttingDown) return;
124
+ shuttingDown = true;
125
+ console.log('[harness] shutting down...');
126
+ try {
127
+ if (watcher) {
128
+ await watcher.close();
129
+ }
130
+ } catch (error) {
131
+ console.error('[harness] watcher close error', error);
132
+ }
133
+ await stopProcess(backendProc, 'backend');
134
+ await stopProcess(frontendProc, 'frontend');
135
+ await fs.rm(PLAYGROUND_DIR, { recursive: true, force: true }).catch(() => {});
136
+ process.exit(exitCode);
137
+ }
138
+
139
+ async function handleFileChange(reason, relativePath, matchers) {
140
+ if (pendingRestart) return;
141
+ pendingRestart = true;
142
+ const normalized = relativePath.replace(/\\/g, '/');
143
+ const { sharedMatcher, backendMatcher, frontendMatcher } = matchers;
144
+ let restartBackend = false;
145
+ let restartFrontend = false;
146
+
147
+ if (sharedMatcher && sharedMatcher(normalized)) {
148
+ console.log(`\n${'='.repeat(60)}`);
149
+ console.log(`🔄 change detected (${reason}): ${normalized}`);
150
+ console.log(' type: SHARED -> restarting backend + frontend');
151
+ console.log(`${'='.repeat(60)}\n`);
152
+ restartBackend = true;
153
+ restartFrontend = true;
154
+ } else if (backendMatcher && backendMatcher(normalized)) {
155
+ console.log(`\n${'='.repeat(60)}`);
156
+ console.log(`🔄 change detected (${reason}): ${normalized}`);
157
+ console.log(' type: BACKEND -> restarting backend only');
158
+ console.log(`${'='.repeat(60)}\n`);
159
+ restartBackend = true;
160
+ } else if (frontendMatcher && frontendMatcher(normalized)) {
161
+ console.log(`\n${'='.repeat(60)}`);
162
+ console.log(`🔄 change detected (${reason}): ${normalized}`);
163
+ console.log(' type: FRONTEND -> restarting frontend only');
164
+ console.log(`${'='.repeat(60)}\n`);
165
+ restartFrontend = true;
166
+ } else {
167
+ console.log(`[harness] change ignored (no matcher): ${normalized}`);
168
+ }
169
+
170
+ if (restartBackend) {
171
+ await stopProcess(backendProc, 'backend');
172
+ startBackend();
173
+ }
174
+ if (restartFrontend) {
175
+ await stopProcess(frontendProc, 'frontend');
176
+ startFrontend();
177
+ }
178
+ if (restartBackend || restartFrontend) {
179
+ console.log('✅ restart sequence complete\n');
180
+ }
181
+ pendingRestart = false;
182
+ }
183
+
184
+ async function simulateChanges() {
185
+ for (const step of changeSequence) {
186
+ await delay(500);
187
+ const target = path.join(PLAYGROUND_DIR, step.file);
188
+ console.log(`[harness] simulating ${step.label} change: ${step.file}`);
189
+ await fs.appendFile(target, `\n// touched ${Date.now()}`);
190
+ }
191
+ console.log('[harness] change simulation complete');
192
+ }
193
+
194
+ async function runHarness() {
195
+ await preparePlayground();
196
+
197
+ const normalizePattern = (pattern) => pattern;
198
+ const sharedMatcher = picomatch(sharedPatterns.map(normalizePattern));
199
+ const backendMatcher = picomatch(backendPatterns.map(normalizePattern));
200
+ const frontendMatcher = picomatch(frontendPatterns.map(normalizePattern));
201
+
202
+ watcher = chokidar.watch(allPatterns, {
203
+ cwd: PLAYGROUND_DIR,
204
+ ignoreInitial: true
205
+ });
206
+
207
+ const autoTimeout = setTimeout(() => {
208
+ console.error('[harness] timeout waiting for restarts');
209
+ shutdown(1);
210
+ }, 15000);
211
+
212
+ watcher.on('ready', async () => {
213
+ if (readyHandled) {
214
+ return;
215
+ }
216
+ readyHandled = true;
217
+ console.log('[harness] watcher ready');
218
+ startBackend();
219
+ startFrontend();
220
+ await simulateChanges();
221
+ setTimeout(async () => {
222
+ clearTimeout(autoTimeout);
223
+ await shutdown(0);
224
+ }, 1500);
225
+ });
226
+
227
+ watcher.on('all', (event, file) => {
228
+ if (!['add', 'change', 'unlink'].includes(event)) {
229
+ return;
230
+ }
231
+ handleFileChange(event, file, { sharedMatcher, backendMatcher, frontendMatcher });
232
+ });
233
+
234
+ watcher.on('error', async (error) => {
235
+ console.error('[harness] watcher error', error);
236
+ await shutdown(1);
237
+ });
238
+ }
239
+
240
+ process.on('SIGINT', () => shutdown(0));
241
+ process.on('SIGTERM', () => shutdown(0));
242
+
243
+ runHarness().catch((error) => {
244
+ console.error('[harness] fatal error', error);
245
+ shutdown(1);
246
+ });
package/src/args.js CHANGED
@@ -25,12 +25,13 @@ const FLAG_MAP = new Map([
25
25
  ['split-serve', 'splitServe'],
26
26
  ['frontend-cmd', 'frontendCmd'],
27
27
  ['mode', 'mode'],
28
+ ['verbose', 'verbose'],
28
29
  ['help', 'help'],
29
30
  ['version', 'version']
30
31
  ]);
31
32
 
32
33
  const MULTI_VALUE_FLAGS = new Set(['sqlGlob', 'assets', 'clientExclude', 'watchPath']);
33
- const BOOLEAN_FLAGS = new Set(['watch', 'splitServe']);
34
+ const BOOLEAN_FLAGS = new Set(['watch', 'splitServe', 'verbose']);
34
35
 
35
36
  export function parseCliArgs(argv) {
36
37
  const result = {
@@ -158,6 +159,7 @@ Options (shared):
158
159
  --split-serve Run frontend dev server in a separate process (watch mode only)
159
160
  --frontend-cmd <name> Frontend command: currently supports 'vite' (default when split-serve)
160
161
  --mode <value> Build mode forwarded to Vite (default: production)
162
+ --verbose Enable verbose debug logging
161
163
  --help Show this message
162
164
  --version Show App version
163
165
 
@@ -1,6 +1,104 @@
1
1
  import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
 
4
+ /**
5
+ * Calculate all component paths once based on project config.
6
+ * This is the SINGLE SOURCE OF TRUTH for path calculations.
7
+ *
8
+ * @param {object} config - Project config with mode, client.main_abs, client.componentDir_abs
9
+ * @returns {object} All calculated paths
10
+ */
11
+ export async function calculateComponentPaths(config) {
12
+ const uiRootAbs = config.client?.main_abs ? path.dirname(config.client.main_abs) : config.rootDir || config.root;
13
+ const componentDirAbs = config.client?.componentDir_abs || uiRootAbs;
14
+ const relativeFromUiRoot = toPosix(path.relative(uiRootAbs, componentDirAbs) || '');
15
+ const uiRelFromRoot = toPosix(path.relative(config.rootDir || config.root, uiRootAbs) || '');
16
+ const distAbs = config.outDir_abs || config.outDir || path.resolve(config.rootDir || config.root, 'dist');
17
+
18
+ // Compute client filesystem base candidates
19
+ const clientCandidates = [
20
+ path.join(distAbs, '.app', 'assets'),
21
+ path.join(distAbs, 'client'),
22
+ uiRelFromRoot ? path.join(distAbs, uiRelFromRoot) : null
23
+ ].filter(Boolean);
24
+
25
+ const clientFsBase = await firstExistingDir(clientCandidates) || path.join(distAbs, '.app', 'assets');
26
+
27
+ // Determine client public base
28
+ let client_base_path = '/assets';
29
+ if (endsWithDir(clientFsBase, path.join(distAbs, uiRelFromRoot))) {
30
+ client_base_path = `/${uiRelFromRoot}`;
31
+ }
32
+ client_base_path = toPosix(client_base_path);
33
+
34
+ // Compute client component public base (append component suffix if present)
35
+ const component_suffix = relativeFromUiRoot || null;
36
+ const component_dir_client = component_suffix
37
+ ? toPosix(path.posix.join(client_base_path, component_suffix))
38
+ : client_base_path;
39
+
40
+ // Compute SSR filesystem base candidates
41
+ const ssrCandidates = [
42
+ path.join(distAbs, '.app', 'ssr', relativeFromUiRoot),
43
+ path.join(distAbs, '.app', 'ssr'),
44
+ path.join(distAbs, 'ssr', relativeFromUiRoot),
45
+ path.join(distAbs, 'ssr'),
46
+ uiRelFromRoot ? path.join(distAbs, uiRelFromRoot, relativeFromUiRoot) : null,
47
+ uiRelFromRoot ? path.join(distAbs, 'server', uiRelFromRoot, relativeFromUiRoot) : null
48
+ ].filter(Boolean);
49
+
50
+ const ssrFsBase = await firstExistingDir(ssrCandidates) || path.join(distAbs, '.app', 'ssr', relativeFromUiRoot || '');
51
+
52
+ // Resolve values relative to project root for serialization; generate relative-to-dist for Forge
53
+ const distRel = (abs) => toPosix(path.relative(distAbs, abs));
54
+ const rootRel = (abs) => toPosix(path.relative(config.rootDir || config.root, abs));
55
+
56
+ // Directories for runtime mounting and Forge consumption
57
+ const assets_build_root_rel = distRel(clientFsBase) || '.app/assets';
58
+ const component_dir_ssr = distRel(ssrFsBase) || '.app/ssr';
59
+
60
+ return {
61
+ // Forge SSR base directory (relative to dist root)
62
+ component_dir_ssr,
63
+ // Browser public base for component imports
64
+ component_dir_client,
65
+ // Browser public base for all assets (CSR root)
66
+ component_base_path: client_base_path,
67
+ // Filesystem directory (relative to dist root) that holds client bundle
68
+ assets_build_dir: assets_build_root_rel,
69
+ // Extra: absolute paths (resolved at runtime by bootstrap template)
70
+ assets_build_abs: rootRel(path.join(distAbs, assets_build_root_rel)),
71
+ ssr_build_abs: rootRel(path.join(distAbs, component_dir_ssr)),
72
+ component_suffix,
73
+ // Legacy aliases for backwards compatibility
74
+ component_dir: component_dir_ssr,
75
+ componentDir_abs: component_dir_ssr
76
+ };
77
+ }
78
+
79
+ async function firstExistingDir(candidates) {
80
+ for (const p of candidates) {
81
+ try {
82
+ const stat = await fs.stat(p);
83
+ if (stat.isDirectory()) return p;
84
+ } catch {}
85
+ }
86
+ return null;
87
+ }
88
+
89
+ function endsWithDir(child, parent) {
90
+ try {
91
+ const rel = path.relative(parent, child);
92
+ return rel && !rel.startsWith('..') && !path.isAbsolute(rel);
93
+ } catch {
94
+ return false;
95
+ }
96
+ }
97
+
98
+ function toPosix(value) {
99
+ return String(value || '').split(path.sep).join('/');
100
+ }
101
+
4
102
  /**
5
103
  * Generates a bootstrap wrapper (no_ego.js) that sets up the runtime environment
6
104
  * before importing the compiled application entry.
@@ -47,15 +145,19 @@ export async function generateBootstrap(context) {
47
145
  openapi: config.client?.openapi,
48
146
  openapi_abs: config.client?.openapi_abs ? './' + path.relative(config.rootDir, config.client.openapi_abs) : null,
49
147
  openapi_path: config.client?.openapi,
50
- component_dir: config.client?.component_dir,
51
- componentDir_abs: config.client?.componentDir_abs ? './' + path.relative(config.rootDir, config.client.componentDir_abs) : null,
52
- assetDirs: config.client?.assetDirs || []
148
+ // Calculate ALL component paths ONCE - used by SSR, client, and Vite
149
+ // Philosophy: Calculate once in bootstrap, use everywhere without recalculation
150
+ ...(await calculateComponentPaths(config)),
151
+ assetDirs: config.client?.assetDirs ? config.client.assetDirs.map(assetDir => ({
152
+ mountPath: assetDir.mountPath,
153
+ // Convert absolute path to relative path from project root
154
+ absolutePath: './' + path.relative(config.rootDir, assetDir.absolutePath)
155
+ })) : []
53
156
  } : undefined,
54
157
  dev: {
55
158
  port: config.dev?.port || 3000,
56
159
  backendPort: config.dev?.backendPort || 3001
57
- },
58
- assets: []
160
+ }
59
161
  };
60
162
 
61
163
  // Compute relative path from outDir to compiled entry
package/src/build/html.js CHANGED
@@ -19,8 +19,8 @@ export async function rewriteHtmlTemplate(context, clientArtifacts) {
19
19
  const pattern = new RegExp(`\\s*${escaped}\\s*`, 'g');
20
20
  html = html.replace(pattern, '');
21
21
  }
22
- // Client bundle public base served at '/assets'
23
- const clientBase = '/assets';
22
+ // Client bundle public base served at configured base
23
+ const clientBase = config.client?.component_base_path || '/assets';
24
24
  html = html.replace(/(href|src)=(['"])\/assets\//g, `$1=$2${clientBase}/`);
25
25
  // Remove inline dev loader blocks ( import('<whatever>/client.ts') ) and related comments
26
26
  html = html.replace(/\s*<!--[^>]*Development[^>]*-->\s*/gi, '');
@@ -56,6 +56,8 @@ export async function rewriteHtmlTemplate(context, clientArtifacts) {
56
56
  const scriptTag = `<script type="module" src="${makeClientPublicPath(entryFile)}"></script>`;
57
57
  html = injectBeforeClose(html, '</body>', scriptTag);
58
58
 
59
+ // Do not inject window.__COMPONENT_DIR__ here; Forge injects it at response time.
60
+
59
61
  await fs.mkdir(path.dirname(targetPath), { recursive: true });
60
62
  await fs.writeFile(targetPath, html, 'utf8');
61
63
 
@@ -27,7 +27,7 @@ export async function writeRuntimeManifest(context, artifacts) {
27
27
  client: artifacts.client ? {
28
28
  manifest: artifacts.client.manifestPath ? relativeToOut(config, artifacts.client.manifestPath) : null,
29
29
  entry: artifacts.client.entryGraph?.entry?.file
30
- ? `/assets/${toPosix(artifacts.client.entryGraph.entry.file)}`
30
+ ? `${config.client?.component_base_path || '/assets'}/${toPosix(artifacts.client.entryGraph.entry.file)}`
31
31
  : null
32
32
  } : null,
33
33
  ssr: artifacts.ssr ? {
@@ -81,10 +81,12 @@ async function reorganizeServerOutputs(context) {
81
81
 
82
82
  // Mirror middleware directory into dist root for Dinner runtime lookup
83
83
  await fs.rm(middlewareOutDir, { recursive: true, force: true });
84
- const middlewareRel = path.relative(config.rootDir, config.server.middlewareDir);
85
- if (!middlewareRel.startsWith('..')) {
86
- const compiledMiddleware = path.join(serverOutDir, middlewareRel);
87
- await copyTree(compiledMiddleware, middlewareOutDir);
84
+ if (config.server.middlewareDir) {
85
+ const middlewareRel = path.relative(config.rootDir, config.server.middlewareDir);
86
+ if (!middlewareRel.startsWith('..')) {
87
+ const compiledMiddleware = path.join(serverOutDir, middlewareRel);
88
+ await copyTree(compiledMiddleware, middlewareOutDir);
89
+ }
88
90
  }
89
91
 
90
92
  logger.info('Server TypeScript output staged');
@@ -162,6 +164,14 @@ async function mirrorUiModules(context) {
162
164
  await fs.rm(destinationDir, { recursive: true, force: true }).catch(() => {});
163
165
 
164
166
  // Overlay compiled UI outputs from tsc emit so .ts becomes .js under mirrored UI root
167
+ if (config.verbose) {
168
+ console.log('[server] overlayUi called:', {
169
+ 'config.ui': config.ui,
170
+ 'config.ui.rootDir': config.ui?.rootDir,
171
+ 'config.rootDir': config.rootDir
172
+ });
173
+ }
174
+
165
175
  const compiledUiDir = path.join(
166
176
  config.layout.serverOutDir,
167
177
  path.relative(config.rootDir, config.ui.rootDir)
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);