@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.
- package/.claude/settings.local.json +11 -4
- package/DEVELOPING.md +73 -0
- package/docs/asset-serving-fix.md +381 -0
- package/package.json +6 -2
- package/scripts/watch-harness.mjs +246 -0
- 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/commands/dev.js +239 -40
- 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
|
@@ -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
|
|
package/src/build/bootstrap.js
CHANGED
|
@@ -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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
|
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
|
-
?
|
|
30
|
+
? `${config.client?.component_base_path || '/assets'}/${toPosix(artifacts.client.entryGraph.entry.file)}`
|
|
31
31
|
: null
|
|
32
32
|
} : null,
|
|
33
33
|
ssr: artifacts.ssr ? {
|
package/src/build/server.js
CHANGED
|
@@ -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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
//
|
|
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);
|