@celsian/vura-cli 0.6.0 → 0.7.0
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/dist/commands/build.js +121 -27
- package/dist/commands/dev.js +157 -41
- package/dist/commands/shared.js +19 -0
- package/package.json +4 -4
package/dist/commands/build.js
CHANGED
|
@@ -11,9 +11,11 @@
|
|
|
11
11
|
* 8. Write manifest.json
|
|
12
12
|
* 9. Emit hot deploy templates (Dockerfile, fly.toml, package.json) when hot routes present
|
|
13
13
|
*/
|
|
14
|
-
import { buildManifest, build, renderStaticPages, generateClientPageEntry } from '@celsian/vura-core';
|
|
14
|
+
import { buildManifest, build, renderStaticPages, generateClientPageEntry, vuraBrowserResolvePlugin, vuraActionsStubPlugin } from '@celsian/vura-core';
|
|
15
15
|
import { createRequire } from 'node:module';
|
|
16
|
-
import {
|
|
16
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
17
|
+
import { join as pathJoin, resolve as pathResolve, relative } from 'node:path';
|
|
18
|
+
import { pathToFileURL } from 'node:url';
|
|
17
19
|
import { loadConfig } from '../config-loader.js';
|
|
18
20
|
// ---------------------------------------------------------------------------
|
|
19
21
|
// Deploy template strings — inlined so they survive tsc compilation (tsc does
|
|
@@ -60,7 +62,6 @@ kill_timeout = "30s"
|
|
|
60
62
|
min_machines_running = 1
|
|
61
63
|
`;
|
|
62
64
|
const nativeImport = (specifier) => import(/* @vite-ignore */ specifier);
|
|
63
|
-
const moduleSourceToDataUrl = (source) => `data:text/javascript;base64,${Buffer.from(source).toString('base64')}`;
|
|
64
65
|
/**
|
|
65
66
|
* Write dist/package.json.
|
|
66
67
|
*
|
|
@@ -86,7 +87,6 @@ async function emitDeployPackageJson(distDir, projectRoot, hasWsRoutes) {
|
|
|
86
87
|
const { writeFile, readFile, mkdir } = await import('node:fs/promises');
|
|
87
88
|
const { existsSync } = await import('node:fs');
|
|
88
89
|
const { join } = await import('node:path');
|
|
89
|
-
const { createRequire } = await import('node:module');
|
|
90
90
|
await mkdir(distDir, { recursive: true });
|
|
91
91
|
const pkgPath = join(distDir, 'package.json');
|
|
92
92
|
let existing = {};
|
|
@@ -99,7 +99,7 @@ async function emitDeployPackageJson(distDir, projectRoot, hasWsRoutes) {
|
|
|
99
99
|
}
|
|
100
100
|
}
|
|
101
101
|
const deps = { ...(existing.dependencies ?? {}) };
|
|
102
|
-
const whatVersion = resolveWhatFrameworkVersion(
|
|
102
|
+
const whatVersion = resolveWhatFrameworkVersion(projectRoot);
|
|
103
103
|
if (whatVersion) {
|
|
104
104
|
deps['what-framework'] = whatVersion;
|
|
105
105
|
}
|
|
@@ -116,14 +116,45 @@ async function emitDeployPackageJson(distDir, projectRoot, hasWsRoutes) {
|
|
|
116
116
|
await writeFile(pkgPath, JSON.stringify(merged, null, 2) + '\n', 'utf8');
|
|
117
117
|
}
|
|
118
118
|
/** The exact what-framework version installed in the project, or null. */
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
119
|
+
/**
|
|
120
|
+
* Import specifiers for a page's layout chain, relative to the page's own
|
|
121
|
+
* directory (the resolveDir the generated browser entry is bundled with).
|
|
122
|
+
*/
|
|
123
|
+
function layoutSpecifiersFor(page, projectRoot) {
|
|
124
|
+
const pageDir = pathResolve(projectRoot, page.filePath, '..');
|
|
125
|
+
return (page.layouts ?? []).map((layoutPath) => {
|
|
126
|
+
const rel = relative(pageDir, pathResolve(projectRoot, layoutPath)).replace(/\\/g, '/');
|
|
127
|
+
return rel.startsWith('.') ? rel : `./${rel}`;
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
function resolveWhatFrameworkVersion(projectRoot) {
|
|
131
|
+
// Read the installed package.json off disk instead of resolving the
|
|
132
|
+
// specifier. `require('what-framework/package.json')` looks like the obvious
|
|
133
|
+
// way to do this and cannot work: what-framework's `exports` map lists `.`,
|
|
134
|
+
// `./server`, `./router` and friends, and Node refuses any subpath an
|
|
135
|
+
// exports map does not name — including './package.json'. Every real install
|
|
136
|
+
// therefore threw ERR_PACKAGE_PATH_NOT_EXPORTED, the version came back null,
|
|
137
|
+
// and dist/package.json shipped without the dependency the container needs.
|
|
138
|
+
let dir = projectRoot;
|
|
139
|
+
for (let depth = 0; depth < 10; depth++) {
|
|
140
|
+
const candidate = pathJoin(dir, 'node_modules', 'what-framework', 'package.json');
|
|
141
|
+
if (existsSync(candidate)) {
|
|
142
|
+
try {
|
|
143
|
+
const manifest = JSON.parse(readFileSync(candidate, 'utf8'));
|
|
144
|
+
if (typeof manifest.version === 'string')
|
|
145
|
+
return manifest.version;
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
const parent = pathResolve(dir, '..');
|
|
153
|
+
if (parent === dir)
|
|
154
|
+
break;
|
|
155
|
+
dir = parent;
|
|
126
156
|
}
|
|
157
|
+
return null;
|
|
127
158
|
}
|
|
128
159
|
/**
|
|
129
160
|
* Emit Dockerfile and fly.toml into dist/ when the project has hot routes.
|
|
@@ -195,7 +226,24 @@ export async function buildCommand(_args) {
|
|
|
195
226
|
}
|
|
196
227
|
return join(root, 'node_modules', pkg);
|
|
197
228
|
}
|
|
198
|
-
|
|
229
|
+
/**
|
|
230
|
+
* Resolve the bare specifiers esbuild cannot.
|
|
231
|
+
*
|
|
232
|
+
* `inlineWhatFramework` decides what happens to `what-framework` and
|
|
233
|
+
* `what-core`. A browser bundle needs them inlined — there is no module
|
|
234
|
+
* resolution in a browser, and their exports maps are import-condition-only,
|
|
235
|
+
* so `require.resolve` cannot find them. A **server** bundle must keep them
|
|
236
|
+
* external, and this is the switch that says which.
|
|
237
|
+
*
|
|
238
|
+
* It exists because an `onResolve` that returns a path *beats* esbuild's
|
|
239
|
+
* `external` list. Setting both, as the build-time page loader did, silently
|
|
240
|
+
* loses: the page module inlined its own copy of what-core while
|
|
241
|
+
* `renderToString` ran from the installed one, the two disagreed about which
|
|
242
|
+
* component was rendering, and `useSignal()` threw "can only be called
|
|
243
|
+
* inside a component function" in every `static` and `hybrid` page. Loaders
|
|
244
|
+
* escaped it only because `@celsian/vura-core` is not intercepted here.
|
|
245
|
+
*/
|
|
246
|
+
const makeEsmResolvePlugin = ({ inlineWhatFramework }) => ({
|
|
199
247
|
name: 'esm-resolve',
|
|
200
248
|
setup(build) {
|
|
201
249
|
build.onResolve({ filter: /^@celsian\/vura-core\/(jsx-runtime|jsx-dev-runtime)$/ }, (args) => {
|
|
@@ -206,6 +254,8 @@ export async function buildCommand(_args) {
|
|
|
206
254
|
return { path: cliRequire.resolve(args.path) };
|
|
207
255
|
}
|
|
208
256
|
});
|
|
257
|
+
if (!inlineWhatFramework)
|
|
258
|
+
return;
|
|
209
259
|
// Bare what-framework/what-core imports (the generated client entry
|
|
210
260
|
// imports { h, mount, hydrate } from 'what-framework'). The exports map
|
|
211
261
|
// is import-condition-only, so require.resolve can't find it — resolve
|
|
@@ -233,7 +283,13 @@ export async function buildCommand(_args) {
|
|
|
233
283
|
return null;
|
|
234
284
|
});
|
|
235
285
|
},
|
|
236
|
-
};
|
|
286
|
+
});
|
|
287
|
+
/** Externals every server-side bundle shares: one framework copy per process. */
|
|
288
|
+
const serverRuntimeExternals = ['what-framework', 'what-framework/*', 'what-core', 'what-core/*'];
|
|
289
|
+
/** Browser bundles: what-framework is inlined, because a browser has no resolver. */
|
|
290
|
+
const browserEsmResolvePlugin = makeEsmResolvePlugin({ inlineWhatFramework: true });
|
|
291
|
+
/** Server bundles: what-framework stays external, so the process holds one copy. */
|
|
292
|
+
const serverEsmResolvePlugin = makeEsmResolvePlugin({ inlineWhatFramework: false });
|
|
237
293
|
// 3. Bundle server-mode pages
|
|
238
294
|
const serverPages = manifest.pages.filter(p => p.mode === 'server' || p.mode === 'hybrid');
|
|
239
295
|
if (serverPages.length > 0) {
|
|
@@ -254,8 +310,10 @@ export async function buildCommand(_args) {
|
|
|
254
310
|
outfile: outPath,
|
|
255
311
|
jsx: 'automatic',
|
|
256
312
|
jsxImportSource,
|
|
257
|
-
plugins: [
|
|
258
|
-
external
|
|
313
|
+
plugins: [serverEsmResolvePlugin],
|
|
314
|
+
// Server bundles keep the framework external so the process holds one
|
|
315
|
+
// copy; core's builder rewrites these files with the same externals.
|
|
316
|
+
external: serverRuntimeExternals,
|
|
259
317
|
});
|
|
260
318
|
console.log(` ◈ ${page.urlPattern} → dist/server/pages/${outFile}`);
|
|
261
319
|
}
|
|
@@ -289,8 +347,8 @@ export async function buildCommand(_args) {
|
|
|
289
347
|
outfile: outPath,
|
|
290
348
|
jsx: 'automatic',
|
|
291
349
|
jsxImportSource,
|
|
292
|
-
plugins: [
|
|
293
|
-
external:
|
|
350
|
+
plugins: [serverEsmResolvePlugin],
|
|
351
|
+
external: serverRuntimeExternals,
|
|
294
352
|
});
|
|
295
353
|
console.log(` ⊟ layout ${layout.dirPattern || '(root)'} → dist/server/pages/${outFile}`);
|
|
296
354
|
}
|
|
@@ -315,7 +373,11 @@ export async function buildCommand(_args) {
|
|
|
315
373
|
await mkdir(join(outPath, '..'), { recursive: true });
|
|
316
374
|
await esbuild({
|
|
317
375
|
stdin: {
|
|
318
|
-
contents: generateClientPageEntry(`./${basename(absPath)}`, page.mode
|
|
376
|
+
contents: generateClientPageEntry(`./${basename(absPath)}`, page.mode,
|
|
377
|
+
// The server rendered this page inside its layouts, so the browser
|
|
378
|
+
// has to hydrate the same tree. Specifiers are relative to the
|
|
379
|
+
// page's own directory, which is the entry's resolveDir.
|
|
380
|
+
{ layoutImportSpecifiers: layoutSpecifiersFor(page, root) }),
|
|
319
381
|
resolveDir: dirname(absPath),
|
|
320
382
|
sourcefile: '__vura-client-entry__.js',
|
|
321
383
|
loader: 'js',
|
|
@@ -327,7 +389,18 @@ export async function buildCommand(_args) {
|
|
|
327
389
|
outfile: outPath,
|
|
328
390
|
jsx: 'automatic',
|
|
329
391
|
jsxImportSource,
|
|
330
|
-
|
|
392
|
+
// Browser-resolve first: a page that imports `@celsian/vura-core` for
|
|
393
|
+
// useLoaderData must get the pure client module here, not the package
|
|
394
|
+
// root, which reaches node:fs and cannot be bundled for a browser.
|
|
395
|
+
//
|
|
396
|
+
// The actions stub plugin is the security boundary for `src/actions/`:
|
|
397
|
+
// it answers onResolve, so esbuild never opens an action file for this
|
|
398
|
+
// bundle and nothing inside one can reach the browser.
|
|
399
|
+
plugins: [
|
|
400
|
+
vuraBrowserResolvePlugin(),
|
|
401
|
+
vuraActionsStubPlugin({ projectRoot: root }),
|
|
402
|
+
browserEsmResolvePlugin,
|
|
403
|
+
],
|
|
331
404
|
external: [],
|
|
332
405
|
});
|
|
333
406
|
const bundleHash = createHash('sha256')
|
|
@@ -355,23 +428,44 @@ export async function buildCommand(_args) {
|
|
|
355
428
|
console.log(` Rendering ${staticPages.length} build-time pages...`);
|
|
356
429
|
const tmpDir = join(root, 'dist', '.page-tmp');
|
|
357
430
|
await mkdir(tmpDir, { recursive: true });
|
|
431
|
+
// A build-time page is imported into THIS process and rendered by core's
|
|
432
|
+
// own `renderToString`. Two rules follow, and breaking either one produces
|
|
433
|
+
// a failure that looks like a framework bug rather than a bundling one:
|
|
434
|
+
//
|
|
435
|
+
// 1. `what-framework` and `@celsian/vura-core` stay external. Inlining
|
|
436
|
+
// them gives the page a second copy of the framework, with its own
|
|
437
|
+
// "currently rendering component" and its own context registry — so
|
|
438
|
+
// every hook the page calls, `useLoaderData` included, reads a
|
|
439
|
+
// registry that the renderer never wrote to and reports being called
|
|
440
|
+
// outside a render.
|
|
441
|
+
// 2. The bundle is written to a real file rather than imported as a
|
|
442
|
+
// `data:` URL. A data: module has no parent path, so it cannot resolve
|
|
443
|
+
// the bare specifiers rule 1 just created, and anything it does inline
|
|
444
|
+
// that calls `fileURLToPath(import.meta.url)` at module scope throws
|
|
445
|
+
// "The URL must be of scheme file".
|
|
446
|
+
const sharedRuntimeExternals = [
|
|
447
|
+
'what-framework',
|
|
448
|
+
'what-framework/*',
|
|
449
|
+
'@celsian/vura-core',
|
|
450
|
+
'@celsian/vura-core/*',
|
|
451
|
+
];
|
|
452
|
+
let pageModuleSeq = 0;
|
|
358
453
|
const loadModule = async (filePath) => {
|
|
359
454
|
const absPath = resolve(root, filePath);
|
|
360
|
-
const
|
|
455
|
+
const tmpFile = join(tmpDir, `page-${pageModuleSeq++}.mjs`);
|
|
456
|
+
await esbuild({
|
|
361
457
|
entryPoints: [absPath],
|
|
362
458
|
bundle: true,
|
|
363
459
|
format: 'esm',
|
|
364
460
|
target: 'es2022',
|
|
365
461
|
platform: 'node',
|
|
366
|
-
|
|
367
|
-
outfile: 'page.mjs',
|
|
462
|
+
outfile: tmpFile,
|
|
368
463
|
jsx: 'automatic',
|
|
369
464
|
jsxImportSource,
|
|
370
|
-
plugins: [
|
|
371
|
-
external:
|
|
465
|
+
plugins: [serverEsmResolvePlugin],
|
|
466
|
+
external: sharedRuntimeExternals,
|
|
372
467
|
});
|
|
373
|
-
|
|
374
|
-
return nativeImport(moduleSourceToDataUrl(bundledSource));
|
|
468
|
+
return nativeImport(pathToFileURL(tmpFile).href);
|
|
375
469
|
};
|
|
376
470
|
const outDir = join(root, 'dist');
|
|
377
471
|
const rendered = await renderStaticPages(staticPages, loadModule, outDir, { clientScripts });
|
package/dist/commands/dev.js
CHANGED
|
@@ -12,9 +12,9 @@
|
|
|
12
12
|
* vura dev — Start dev server on port 3000
|
|
13
13
|
* vura dev --port 8080 — Start on custom port
|
|
14
14
|
*/
|
|
15
|
-
import { renderToString as builtinRenderToString } from 'what-framework/server';
|
|
16
15
|
import { importRouteModule } from './shared.js';
|
|
17
|
-
import {
|
|
16
|
+
import { resolve as nodeResolve, relative as nodeRelative } from 'node:path';
|
|
17
|
+
import { buildManifest, compilePageRoutes, matchPageRoute, compileRoutes, matchApiPath, getLogger, wrapDocument, escapeHtml, parseNodeBody, reportError, getMimeType, runTaskOnce, buildTaskEnvelope, createApiApp, createWsUpgradeHandler, createNoServerWebSocketServer, GLOBAL_HOOKS_FILENAMES, nodeToWebRequest, writeWebResponse, generateClientPageEntry, vuraBrowserResolvePlugin, vuraActionsStubPlugin, registerActionModules, ACTION_ENDPOINT, createMiddlewareRunner, createVuraRenderRoute, } from '@celsian/vura-core';
|
|
18
18
|
export function parseDevOptions(args, projectRoot = process.cwd()) {
|
|
19
19
|
const portArg = args.find((_, i) => args[i - 1] === '--port');
|
|
20
20
|
const hostArg = args.find((_, i) => args[i - 1] === '--host');
|
|
@@ -141,6 +141,15 @@ function printRouteTable(manifest) {
|
|
|
141
141
|
console.log(` ${icon} ${page.mode.padEnd(18)} ${page.urlPattern}`);
|
|
142
142
|
}
|
|
143
143
|
}
|
|
144
|
+
const actions = manifest.actions ?? [];
|
|
145
|
+
if (actions.length > 0) {
|
|
146
|
+
console.log(' Actions:');
|
|
147
|
+
for (const mod of actions) {
|
|
148
|
+
for (const name of mod.exports) {
|
|
149
|
+
console.log(` ⚡ ${'server action'.padEnd(18)} ${mod.moduleId}#${name}`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
144
153
|
console.log();
|
|
145
154
|
}
|
|
146
155
|
/**
|
|
@@ -181,7 +190,45 @@ export async function startStandaloneServer(manifest, opts) {
|
|
|
181
190
|
return mod;
|
|
182
191
|
}
|
|
183
192
|
const logger = getLogger();
|
|
193
|
+
/**
|
|
194
|
+
* The render callback for one page request.
|
|
195
|
+
*
|
|
196
|
+
* A hybrid page's browser bundle lives at a dev-server path that does not
|
|
197
|
+
* exist at build time, so it is injected here rather than read off the page.
|
|
198
|
+
*
|
|
199
|
+
* Built per request, closing over the matched page, because the RuntimePage
|
|
200
|
+
* handed to the renderer has its `mode` forced to `'server'` (dev SSRs every
|
|
201
|
+
* non-client page per request, with no build output and no ISR in front of
|
|
202
|
+
* it). A predicate reading the mode off *that* object can never see
|
|
203
|
+
* `'hybrid'`, so it silently stopped injecting the bundle and every hybrid
|
|
204
|
+
* page in dev rendered its markup and then never hydrated. The real mode
|
|
205
|
+
* lives on the matched page, so that is what decides.
|
|
206
|
+
*/
|
|
207
|
+
const devRenderRouteFor = (page) => createVuraRenderRoute({
|
|
208
|
+
extraScripts: () => (page.mode === 'hybrid' ? [browserScriptPath(page)] : []),
|
|
209
|
+
});
|
|
184
210
|
const { existsSync } = await import('node:fs');
|
|
211
|
+
// ── Middleware ──
|
|
212
|
+
// Loaded through the same cached loader as routes so an edit is picked up on
|
|
213
|
+
// the next request, and so module-level state in a middleware behaves the way
|
|
214
|
+
// it does in a route. `manifest.middleware` is refreshed by the fs-watcher
|
|
215
|
+
// rescan, so adding or deleting the file mid-session is picked up too.
|
|
216
|
+
async function currentMiddlewareRunner(forManifest) {
|
|
217
|
+
if (!forManifest.middleware)
|
|
218
|
+
return createMiddlewareRunner(null);
|
|
219
|
+
try {
|
|
220
|
+
const mod = await loadHandlerCached(forManifest.middleware);
|
|
221
|
+
return createMiddlewareRunner(mod);
|
|
222
|
+
}
|
|
223
|
+
catch (err) {
|
|
224
|
+
// A syntax error in middleware must not take the whole dev server down,
|
|
225
|
+
// and it must not silently disable the auth guard the developer is
|
|
226
|
+
// relying on either. Say so, loudly, on every request until it is fixed.
|
|
227
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
228
|
+
logger.error(`[vura] middleware failed to load: ${error.message}`);
|
|
229
|
+
return createMiddlewareRunner(null);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
185
232
|
function findGlobalHooksFile() {
|
|
186
233
|
for (const filename of GLOBAL_HOOKS_FILENAMES) {
|
|
187
234
|
if (existsSync(join(opts.projectRoot, filename)))
|
|
@@ -233,12 +280,29 @@ export async function startStandaloneServer(manifest, opts) {
|
|
|
233
280
|
onError: [...(globalHooks?.onError ?? []), devErrorHook],
|
|
234
281
|
onResponse: globalHooks?.onResponse ?? [],
|
|
235
282
|
};
|
|
283
|
+
// Server actions. Loaded and registered on every rebuild, so editing an
|
|
284
|
+
// action file takes effect on the next request the way editing a route
|
|
285
|
+
// does. Registration is keyed by id, so a re-register replaces rather than
|
|
286
|
+
// duplicates; an action deleted from a file stops being callable only once
|
|
287
|
+
// the process restarts, which is the same limitation dev has for a deleted
|
|
288
|
+
// route module and is not worth a registry generation counter.
|
|
289
|
+
const actionModules = {};
|
|
290
|
+
for (const mod of forManifest.actions ?? []) {
|
|
291
|
+
actionModules[mod.moduleId] = await loadHandler(mod.filePath);
|
|
292
|
+
}
|
|
293
|
+
const hasActions = Object.keys(actionModules).length > 0;
|
|
294
|
+
if (hasActions)
|
|
295
|
+
registerActionModules(actionModules);
|
|
236
296
|
// Compile route regexes for the path-existence pre-check (method-agnostic).
|
|
237
297
|
const compiledApiRoutes = compileRoutes(routes);
|
|
238
|
-
return {
|
|
298
|
+
return {
|
|
299
|
+
app: createApiApp({ routes, globalHooks: mergedHooks, enableActions: hasActions }),
|
|
300
|
+
compiledApiRoutes,
|
|
301
|
+
internalPaths: hasActions ? [ACTION_ENDPOINT] : [],
|
|
302
|
+
};
|
|
239
303
|
}
|
|
240
304
|
// Build initial CelsianApp and page route table
|
|
241
|
-
let { app: apiApp, compiledApiRoutes } = await buildStandaloneApiApp(manifest);
|
|
305
|
+
let { app: apiApp, compiledApiRoutes, internalPaths } = await buildStandaloneApiApp(manifest);
|
|
242
306
|
// In dev mode, compile ALL page routes — not just server/hybrid.
|
|
243
307
|
// Static and server pages are SSR'd on the fly; client pages are served as
|
|
244
308
|
// a shell + on-demand browser bundle (SSR'ing them would run hooks like
|
|
@@ -263,7 +327,10 @@ export async function startStandaloneServer(manifest, opts) {
|
|
|
263
327
|
catch { /* not installed — keep default */ }
|
|
264
328
|
const result = await esbuild({
|
|
265
329
|
stdin: {
|
|
266
|
-
contents: generateClientPageEntry(`./${basename(absPath)}`, page.mode,
|
|
330
|
+
contents: generateClientPageEntry(`./${basename(absPath)}`, page.mode,
|
|
331
|
+
// Same layout chain the dev renderer wraps the page in, so hydration
|
|
332
|
+
// walks the tree that is actually in the document.
|
|
333
|
+
{ dev: true, layoutImportSpecifiers: devLayoutSpecifiers(page) }),
|
|
267
334
|
resolveDir: dirname(absPath),
|
|
268
335
|
sourcefile: '__vura-client-entry__.js',
|
|
269
336
|
loader: 'js',
|
|
@@ -276,12 +343,30 @@ export async function startStandaloneServer(manifest, opts) {
|
|
|
276
343
|
outfile: 'page.js',
|
|
277
344
|
jsx: 'automatic',
|
|
278
345
|
jsxImportSource,
|
|
346
|
+
// Same redirect the production build applies: `@celsian/vura-core` in a
|
|
347
|
+
// browser bundle resolves to the pure client module.
|
|
348
|
+
// The actions stub plugin is what keeps `src/actions/` source out of a
|
|
349
|
+
// browser bundle in dev as well as in a build. Without it `vura dev`
|
|
350
|
+
// would happily bundle a database client into the page and only the
|
|
351
|
+
// production build would catch it.
|
|
352
|
+
plugins: [vuraBrowserResolvePlugin(), vuraActionsStubPlugin({ projectRoot: opts.projectRoot })],
|
|
279
353
|
nodePaths: [join(opts.projectRoot, 'node_modules')],
|
|
280
354
|
});
|
|
281
355
|
const text = result.outputFiles[0].text;
|
|
282
356
|
browserBundleCache.set(page.filePath, text);
|
|
283
357
|
return text;
|
|
284
358
|
}
|
|
359
|
+
/**
|
|
360
|
+
* Import specifiers for a page's layouts, relative to the page's directory,
|
|
361
|
+
* which is the resolveDir the browser entry is bundled with.
|
|
362
|
+
*/
|
|
363
|
+
function devLayoutSpecifiers(page) {
|
|
364
|
+
const pageDir = nodeResolve(opts.projectRoot, page.filePath, '..');
|
|
365
|
+
return (page.layouts ?? []).map((layoutPath) => {
|
|
366
|
+
const rel = nodeRelative(pageDir, nodeResolve(opts.projectRoot, layoutPath)).replace(/\\/g, '/');
|
|
367
|
+
return rel.startsWith('.') ? rel : `./${rel}`;
|
|
368
|
+
});
|
|
369
|
+
}
|
|
285
370
|
const server = createServer(async (req, res) => {
|
|
286
371
|
const url = new URL(req.url ?? '/', `http://${req.headers.host}`);
|
|
287
372
|
const method = (req.method ?? 'GET').toUpperCase();
|
|
@@ -305,6 +390,34 @@ export async function startStandaloneServer(manifest, opts) {
|
|
|
305
390
|
res.end();
|
|
306
391
|
return;
|
|
307
392
|
}
|
|
393
|
+
// ── Middleware ──
|
|
394
|
+
// Before static serving, before routes: an auth guard has to be able to
|
|
395
|
+
// keep a visitor away from a page, and a page may be a prerendered file.
|
|
396
|
+
let middlewareHeaders;
|
|
397
|
+
{
|
|
398
|
+
const runner = await currentMiddlewareRunner(manifest);
|
|
399
|
+
if (runner.enabled) {
|
|
400
|
+
const webReq = new Request(url.toString(), {
|
|
401
|
+
method,
|
|
402
|
+
headers: req.headers,
|
|
403
|
+
});
|
|
404
|
+
const outcome = await runner.run(webReq, url);
|
|
405
|
+
if (outcome.response) {
|
|
406
|
+
const headers = {};
|
|
407
|
+
outcome.response.headers.forEach((v, k) => { headers[k] = v; });
|
|
408
|
+
res.writeHead(outcome.response.status, headers);
|
|
409
|
+
res.end(outcome.response.body ? await outcome.response.text() : '');
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
middlewareHeaders = outcome.headers;
|
|
413
|
+
}
|
|
414
|
+
if (middlewareHeaders) {
|
|
415
|
+
for (const [k, v] of middlewareHeaders) {
|
|
416
|
+
if (!res.hasHeader(k))
|
|
417
|
+
res.setHeader(k, v);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
}
|
|
308
421
|
// Static file serving from public/ directory
|
|
309
422
|
if (method === 'GET' || method === 'HEAD') {
|
|
310
423
|
const publicDir = join(opts.projectRoot, 'public');
|
|
@@ -467,7 +580,7 @@ export async function startStandaloneServer(manifest, opts) {
|
|
|
467
580
|
// pattern matches this pathname, skip celsian entirely and fall through to
|
|
468
581
|
// pages/404. This also correctly passes through intentional handler 404s —
|
|
469
582
|
// if the route exists but returns 404, that response is delivered as-is.
|
|
470
|
-
if (matchApiPath(compiledApiRoutes, url.pathname)) {
|
|
583
|
+
if (matchApiPath(compiledApiRoutes, url.pathname) || internalPaths.includes(url.pathname)) {
|
|
471
584
|
try {
|
|
472
585
|
const webReq = nodeToWebRequest(req, url);
|
|
473
586
|
const webRes = await apiApp.handle(webReq);
|
|
@@ -533,41 +646,43 @@ export async function startStandaloneServer(manifest, opts) {
|
|
|
533
646
|
return;
|
|
534
647
|
}
|
|
535
648
|
if (typeof Component === 'function') {
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
if (pageMatch.page.layouts && pageMatch.page.layouts.length > 0) {
|
|
547
|
-
// Load layouts innermost-last, wrap from inside out
|
|
548
|
-
for (let li = pageMatch.page.layouts.length - 1; li >= 0; li--) {
|
|
549
|
-
const layoutMod = await loadHandler(pageMatch.page.layouts[li]);
|
|
550
|
-
const LayoutComponent = layoutMod.default;
|
|
551
|
-
if (typeof LayoutComponent === 'function') {
|
|
552
|
-
vnode = LayoutComponent({ children: vnode, params: pageMatch.params });
|
|
553
|
-
}
|
|
554
|
-
}
|
|
649
|
+
// Rendered by the SAME function the production server uses.
|
|
650
|
+
// The dev server used to carry its own copy of this logic, and it
|
|
651
|
+
// drifted: it called the component directly instead of through
|
|
652
|
+
// `h()`, it knew only `getServerData` and not `loader`, and it had
|
|
653
|
+
// no layout data, no payload and no notFound/redirect. So RFC 0001
|
|
654
|
+
// loaders worked in a built app and failed in `vura dev`, which is
|
|
655
|
+
// where a developer meets the feature first.
|
|
656
|
+
const layoutModules = [];
|
|
657
|
+
for (const layoutPath of pageMatch.page.layouts ?? []) {
|
|
658
|
+
layoutModules.push(await loadHandler(layoutPath));
|
|
555
659
|
}
|
|
556
|
-
const
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
660
|
+
const runtimePage = {
|
|
661
|
+
...pageMatch.page,
|
|
662
|
+
// Dev renders every non-client page per request: there is no
|
|
663
|
+
// build output to serve, and no ISR cache in front of it.
|
|
664
|
+
mode: 'server',
|
|
665
|
+
config: { ...(pageMatch.page.config ?? {}), revalidate: undefined },
|
|
666
|
+
module: mod,
|
|
667
|
+
layoutModules,
|
|
668
|
+
};
|
|
669
|
+
const webReq = new Request(url.toString(), {
|
|
670
|
+
method,
|
|
671
|
+
headers: req.headers,
|
|
568
672
|
});
|
|
569
|
-
|
|
570
|
-
|
|
673
|
+
const result = await devRenderRouteFor(pageMatch.page)({
|
|
674
|
+
path: url.pathname,
|
|
675
|
+
query: Object.fromEntries(url.searchParams.entries()),
|
|
676
|
+
config: { mode: 'server' },
|
|
677
|
+
route: { path: url.pathname, page: { mode: 'server' }, vura: runtimePage },
|
|
678
|
+
params: pageMatch.params,
|
|
679
|
+
request: webReq,
|
|
680
|
+
});
|
|
681
|
+
res.writeHead(result.status, {
|
|
682
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
683
|
+
...(result.headers ?? {}),
|
|
684
|
+
});
|
|
685
|
+
res.end(result.html);
|
|
571
686
|
return;
|
|
572
687
|
}
|
|
573
688
|
}
|
|
@@ -624,7 +739,8 @@ export async function startStandaloneServer(manifest, opts) {
|
|
|
624
739
|
// Watch for file changes and re-scan manifest
|
|
625
740
|
const apiDir = join(opts.projectRoot, 'src', 'api');
|
|
626
741
|
const pagesDir = join(opts.projectRoot, 'src', 'pages');
|
|
627
|
-
const
|
|
742
|
+
const actionsDir = join(opts.projectRoot, 'src', 'actions');
|
|
743
|
+
const watchDirs = [apiDir, pagesDir, actionsDir];
|
|
628
744
|
const watchers = [];
|
|
629
745
|
for (const dir of watchDirs) {
|
|
630
746
|
try {
|
|
@@ -649,7 +765,7 @@ export async function startStandaloneServer(manifest, opts) {
|
|
|
649
765
|
// Rebuild against the FRESH manifest (nextManifest) — `manifest`
|
|
650
766
|
// is only swapped after success, so building from the closure
|
|
651
767
|
// variable would use the stale route set (see buildStandaloneApiApp).
|
|
652
|
-
({ app: apiApp, compiledApiRoutes } = await buildStandaloneApiApp(nextManifest));
|
|
768
|
+
({ app: apiApp, compiledApiRoutes, internalPaths } = await buildStandaloneApiApp(nextManifest));
|
|
653
769
|
}
|
|
654
770
|
catch (err) {
|
|
655
771
|
moduleCache.clear();
|
package/dist/commands/shared.js
CHANGED
|
@@ -44,6 +44,25 @@ export async function importRouteModule(projectRoot, filePath) {
|
|
|
44
44
|
platform: 'node',
|
|
45
45
|
write: false,
|
|
46
46
|
outfile: 'handler.mjs',
|
|
47
|
+
// The framework stays external, for the same reason it does in a
|
|
48
|
+
// production build: this module is imported into the dev server's own
|
|
49
|
+
// process and rendered by the dev server's `renderToString`. Inlining
|
|
50
|
+
// what-framework gives the page a second copy with its own "currently
|
|
51
|
+
// rendering component" and its own context registry, so every hook it
|
|
52
|
+
// calls reads a registry the renderer never wrote to. That is what made
|
|
53
|
+
// `useLoaderData()` fail in `vura dev` while working in a built app: the
|
|
54
|
+
// page reported "found no loader data" and What warned that useContext had
|
|
55
|
+
// been called outside a render.
|
|
56
|
+
//
|
|
57
|
+
// Safe to mark external here because the output is written to a real file
|
|
58
|
+
// inside the project, so Node resolves these specifiers from the project's
|
|
59
|
+
// own node_modules.
|
|
60
|
+
external: [
|
|
61
|
+
'what-framework',
|
|
62
|
+
'what-framework/*',
|
|
63
|
+
'@celsian/vura-core',
|
|
64
|
+
'@celsian/vura-core/*',
|
|
65
|
+
],
|
|
47
66
|
...(isPage ? { jsx: 'automatic', jsxImportSource } : {}),
|
|
48
67
|
});
|
|
49
68
|
const tmpDir = join(projectRoot, DEV_CACHE_SUBPATH);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@celsian/vura-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Vura CLI — build and deploy full-stack apps",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -15,12 +15,12 @@
|
|
|
15
15
|
"!dist/**/*.map"
|
|
16
16
|
],
|
|
17
17
|
"dependencies": {
|
|
18
|
-
"@celsian/vura-core": "0.
|
|
18
|
+
"@celsian/vura-core": "0.7.0",
|
|
19
19
|
"esbuild": "^0.28.1",
|
|
20
20
|
"what-framework": "^0.13.2"
|
|
21
21
|
},
|
|
22
22
|
"peerDependencies": {
|
|
23
|
-
"@celsian/vura-adapter-vura": "0.
|
|
23
|
+
"@celsian/vura-adapter-vura": "0.7.0",
|
|
24
24
|
"ws": "^8.0.0"
|
|
25
25
|
},
|
|
26
26
|
"peerDependenciesMeta": {
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
}
|
|
33
33
|
},
|
|
34
34
|
"devDependencies": {
|
|
35
|
-
"@celsian/vura-adapter-vura": "0.
|
|
35
|
+
"@celsian/vura-adapter-vura": "0.7.0",
|
|
36
36
|
"@types/ws": "^8.18.1",
|
|
37
37
|
"ws": "^8.21.0"
|
|
38
38
|
},
|