@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.
@@ -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 { Buffer } from 'node:buffer';
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(createRequire(join(projectRoot, 'package.json')));
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
- function resolveWhatFrameworkVersion(projectRequire) {
120
- try {
121
- const manifest = projectRequire('what-framework/package.json');
122
- return typeof manifest.version === 'string' ? manifest.version : null;
123
- }
124
- catch {
125
- return null;
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
- const esmResolvePlugin = {
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: [esmResolvePlugin],
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: [esmResolvePlugin],
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
- plugins: [esmResolvePlugin],
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 result = await esbuild({
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
- write: false,
367
- outfile: 'page.mjs',
462
+ outfile: tmpFile,
368
463
  jsx: 'automatic',
369
464
  jsxImportSource,
370
- plugins: [esmResolvePlugin],
371
- external: [],
465
+ plugins: [serverEsmResolvePlugin],
466
+ external: sharedRuntimeExternals,
372
467
  });
373
- const bundledSource = result.outputFiles[0].text;
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 });
@@ -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 { buildManifest, compilePageRoutes, matchPageRoute, compileRoutes, matchApiPath, getLogger, wrapDocument, escapeHtml, parseNodeBody, reportError, getMimeType, runTaskOnce, buildTaskEnvelope, createApiApp, createWsUpgradeHandler, createNoServerWebSocketServer, GLOBAL_HOOKS_FILENAMES, nodeToWebRequest, writeWebResponse, generateClientPageEntry, } from '@celsian/vura-core';
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 { app: createApiApp({ routes, globalHooks: mergedHooks }), compiledApiRoutes };
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, { dev: true }),
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
- let serverData = {};
537
- if (typeof mod.getServerData === 'function') {
538
- serverData = await mod.getServerData({
539
- params: pageMatch.params,
540
- url: url.pathname,
541
- query: Object.fromEntries(url.searchParams.entries()),
542
- });
543
- }
544
- let vnode = Component({ ...serverData, params: pageMatch.params });
545
- // Wrap in layout chain if layouts are defined (outermost first)
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 bodyHtml = builtinRenderToString(vnode);
557
- const html = wrapDocument(bodyHtml, {
558
- title: pageConfig.title ?? 'Vura App',
559
- meta: pageConfig.meta ?? [],
560
- styles: pageConfig.styles ?? [],
561
- // Hybrid pages also load their browser bundle so hydrate() runs
562
- // against the SSR'd DOM — same contract as the production build.
563
- scripts: [
564
- ...(pageConfig.scripts ?? []),
565
- ...(pageMatch.page.mode === 'hybrid' ? [browserScriptPath(pageMatch.page)] : []),
566
- ],
567
- head: pageConfig.head ?? '',
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
- res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
570
- res.end(html);
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 watchDirs = [apiDir, pagesDir];
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();
@@ -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.6.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.6.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.6.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.6.0",
35
+ "@celsian/vura-adapter-vura": "0.7.0",
36
36
  "@types/ws": "^8.18.1",
37
37
  "ws": "^8.21.0"
38
38
  },