@celsian/vura-cli 0.6.1 → 0.8.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.
@@ -23,6 +23,22 @@ export declare function isAllowedAdminRequest(headers: {
23
23
  host?: string | string[];
24
24
  origin?: string | string[];
25
25
  }, bindHost: string, port: number): boolean;
26
+ /**
27
+ * Start the admin server and return once it is listening.
28
+ *
29
+ * Split out of `adminCommand` so tests can drive a real server: the command
30
+ * itself never returns, and the bug this exists to pin lived in the wiring
31
+ * rather than in any function a unit test could reach. `vura admin --port 0`
32
+ * built its same-origin allowlist from the *requested* port, so the allowlist
33
+ * held `localhost:0` while the browser sent the real one and every API request
34
+ * was refused as cross-origin with a valid token in hand.
35
+ */
36
+ export declare function startAdminServer(opts: AdminOptions): Promise<{
37
+ server: import('node:http').Server;
38
+ port: number;
39
+ token: string;
40
+ close: () => Promise<void>;
41
+ }>;
26
42
  export declare function adminCommand(args: string[]): Promise<void>;
27
43
  export declare function renderDashboardHtml(adminToken: string): string;
28
44
  export {};
@@ -60,8 +60,17 @@ export function isAllowedAdminRequest(headers, bindHost, port) {
60
60
  return false;
61
61
  }
62
62
  }
63
- export async function adminCommand(args) {
64
- const opts = parseAdminOptions(args);
63
+ /**
64
+ * Start the admin server and return once it is listening.
65
+ *
66
+ * Split out of `adminCommand` so tests can drive a real server: the command
67
+ * itself never returns, and the bug this exists to pin lived in the wiring
68
+ * rather than in any function a unit test could reach. `vura admin --port 0`
69
+ * built its same-origin allowlist from the *requested* port, so the allowlist
70
+ * held `localhost:0` while the browser sent the real one and every API request
71
+ * was refused as cross-origin with a valid token in hand.
72
+ */
73
+ export async function startAdminServer(opts) {
65
74
  assertSafeAdminBindHost(opts.host);
66
75
  const { createServer } = await import('node:http');
67
76
  const { randomBytes } = await import('node:crypto');
@@ -72,11 +81,17 @@ export async function adminCommand(args) {
72
81
  const config = await loadConfig(opts.projectRoot);
73
82
  const projectName = basename(opts.projectRoot);
74
83
  const adminToken = randomBytes(32).toString('base64url');
84
+ // The port the server actually bound. `--port 0` means "pick a free port",
85
+ // and the same-origin allowlist below is built from it: with the *requested*
86
+ // port the allowlist held `localhost:0` while the browser sent the real one,
87
+ // so every request to a `--port 0` dashboard was refused as cross-origin.
88
+ // Assigned in the listen callback, before any request can arrive.
89
+ let boundPort = opts.port;
75
90
  const server = createServer(async (req, res) => {
76
91
  const url = new URL(req.url ?? '/', `http://${req.headers.host}`);
77
92
  const method = (req.method ?? 'GET').toUpperCase();
78
93
  const isAdminApi = url.pathname.startsWith('/__admin/api/');
79
- const sameOrigin = isAllowedAdminRequest(req.headers, opts.host, opts.port);
94
+ const sameOrigin = isAllowedAdminRequest(req.headers, opts.host, boundPort);
80
95
  const apiHeaders = adminApiHeaders();
81
96
  if (method === 'OPTIONS') {
82
97
  res.writeHead(sameOrigin ? 204 : 403, {
@@ -311,25 +326,52 @@ export async function adminCommand(args) {
311
326
  res.writeHead(404, apiHeaders);
312
327
  res.end(JSON.stringify({ error: 'Not found' }));
313
328
  });
314
- server.listen(opts.port, opts.host, () => {
329
+ const onListening = () => {
315
330
  const displayHost = opts.host === '127.0.0.1' ? 'localhost' : opts.host;
331
+ const addr = server.address();
332
+ boundPort = addr && typeof addr === 'object' ? addr.port : opts.port;
316
333
  const displayUrl = displayHost.includes(':')
317
- ? `http://[${displayHost}]:${opts.port}`
318
- : `http://${displayHost}:${opts.port}`;
319
- console.log(`
320
- ┌─────────────────────────────────────────┐
321
- │ │
322
- │ vura admin │
323
- │ │
324
- │ Dashboard: ${displayUrl.padEnd(22)}
325
- │ Token: ${adminToken.slice(0, 8).padEnd(25)}
326
- │ Project: ${projectName.slice(0, 25).padEnd(25)} │
327
- │ │
328
- │ ${manifest.api.length} API routes · ${manifest.pages.length} pages │
329
- │ │
330
- └─────────────────────────────────────────┘
331
- `);
334
+ ? `http://[${displayHost}]:${boundPort}`
335
+ : `http://${displayHost}:${boundPort}`;
336
+ // Every line is padded to one width. They used to be padded to four
337
+ // different ones (36, 39, 39 and 38 inside a 41-wide box), so the box only
338
+ // ever looked square by accident of the default port's length.
339
+ const W = 41;
340
+ const line = (text = '') => ` │${text.padEnd(W).slice(0, W)}│`;
341
+ const rule = (l, r) => ` ${l}${'─'.repeat(W)}${r}`;
342
+ const field = (label, value) => line(` ${label.padEnd(12)}${value}`);
343
+ console.log([
344
+ '',
345
+ rule('┌', '┐'),
346
+ line(),
347
+ line(' vura admin'),
348
+ line(),
349
+ field('Dashboard:', displayUrl),
350
+ field('Token:', `${adminToken.slice(0, 8)}...`),
351
+ field('Project:', projectName),
352
+ line(),
353
+ line(` ${manifest.api.length} API routes · ${manifest.pages.length} pages`),
354
+ line(),
355
+ rule('└', '┘'),
356
+ '',
357
+ ].join('\n'));
358
+ };
359
+ await new Promise((resolve, reject) => {
360
+ server.once('error', reject);
361
+ server.listen(opts.port, opts.host, () => {
362
+ onListening();
363
+ resolve();
364
+ });
332
365
  });
366
+ return {
367
+ server,
368
+ port: boundPort,
369
+ token: adminToken,
370
+ close: () => new Promise((resolve) => server.close(() => resolve())),
371
+ };
372
+ }
373
+ export async function adminCommand(args) {
374
+ await startAdminServer(parseAdminOptions(args));
333
375
  await new Promise(() => { });
334
376
  }
335
377
  // ─── Dashboard HTML ───
@@ -11,5 +11,29 @@
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
+ /**
15
+ * Remove hashed page bundles that this build did not emit.
16
+ *
17
+ * Bundle filenames carry a content hash, so every edit to a client or hybrid
18
+ * page produces a new name and orphans the old one. Nothing removed those, so
19
+ * `dist/static/_then/pages` grew with every incremental build and the dead
20
+ * copies shipped to whatever the project deployed to.
21
+ *
22
+ * Recursive, because pages nest (`loaders/island.js`). Empty directories left
23
+ * behind by a deleted page are removed too. `fs` is injected so this can be
24
+ * tested without touching a disk.
25
+ */
26
+ export declare function pruneStaleBundles(dir: string, keep: Set<string>, fs: {
27
+ readdir: (p: string, o: {
28
+ withFileTypes: true;
29
+ }) => Promise<Array<{
30
+ name: string;
31
+ isDirectory: () => boolean;
32
+ }>>;
33
+ rm: (p: string, o?: {
34
+ recursive?: boolean;
35
+ force?: boolean;
36
+ }) => Promise<void>;
37
+ }): Promise<number>;
14
38
  export declare function buildCommand(_args: string[]): Promise<void>;
15
39
  //# sourceMappingURL=build.d.ts.map
@@ -11,12 +11,55 @@
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, vuraBrowserResolvePlugin } from '@celsian/vura-core';
14
+ import { buildManifest, build, renderStaticPages, generateClientPageEntry, vuraBrowserResolvePlugin, vuraActionsStubPlugin } from '@celsian/vura-core';
15
15
  import { createRequire } from 'node:module';
16
16
  import { existsSync, readFileSync } from 'node:fs';
17
- import { join as pathJoin, resolve as pathResolve } from 'node:path';
17
+ import { join as pathJoin, resolve as pathResolve, relative } from 'node:path';
18
18
  import { pathToFileURL } from 'node:url';
19
19
  import { loadConfig } from '../config-loader.js';
20
+ /**
21
+ * Remove hashed page bundles that this build did not emit.
22
+ *
23
+ * Bundle filenames carry a content hash, so every edit to a client or hybrid
24
+ * page produces a new name and orphans the old one. Nothing removed those, so
25
+ * `dist/static/_then/pages` grew with every incremental build and the dead
26
+ * copies shipped to whatever the project deployed to.
27
+ *
28
+ * Recursive, because pages nest (`loaders/island.js`). Empty directories left
29
+ * behind by a deleted page are removed too. `fs` is injected so this can be
30
+ * tested without touching a disk.
31
+ */
32
+ export async function pruneStaleBundles(dir, keep, fs) {
33
+ let removed = 0;
34
+ let entries;
35
+ try {
36
+ entries = await fs.readdir(dir, { withFileTypes: true });
37
+ }
38
+ catch {
39
+ return 0; // nothing was written here
40
+ }
41
+ for (const entry of entries) {
42
+ const full = pathJoin(dir, entry.name);
43
+ if (entry.isDirectory()) {
44
+ removed += await pruneStaleBundles(full, keep, fs);
45
+ // The page that lived here is gone; do not leave the empty shell.
46
+ try {
47
+ const rest = await fs.readdir(full, { withFileTypes: true });
48
+ if (rest.length === 0)
49
+ await fs.rm(full, { recursive: true, force: true });
50
+ }
51
+ catch {
52
+ /* raced or unreadable: leaving it is harmless */
53
+ }
54
+ continue;
55
+ }
56
+ if (keep.has(full))
57
+ continue;
58
+ await fs.rm(full, { force: true });
59
+ removed++;
60
+ }
61
+ return removed;
62
+ }
20
63
  // ---------------------------------------------------------------------------
21
64
  // Deploy template strings — inlined so they survive tsc compilation (tsc does
22
65
  // not copy non-TS assets). Emitted by emitHotDeployTemplates() when hot
@@ -116,6 +159,17 @@ async function emitDeployPackageJson(distDir, projectRoot, hasWsRoutes) {
116
159
  await writeFile(pkgPath, JSON.stringify(merged, null, 2) + '\n', 'utf8');
117
160
  }
118
161
  /** The exact what-framework version installed in the project, or null. */
162
+ /**
163
+ * Import specifiers for a page's layout chain, relative to the page's own
164
+ * directory (the resolveDir the generated browser entry is bundled with).
165
+ */
166
+ function layoutSpecifiersFor(page, projectRoot) {
167
+ const pageDir = pathResolve(projectRoot, page.filePath, '..');
168
+ return (page.layouts ?? []).map((layoutPath) => {
169
+ const rel = relative(pageDir, pathResolve(projectRoot, layoutPath)).replace(/\\/g, '/');
170
+ return rel.startsWith('.') ? rel : `./${rel}`;
171
+ });
172
+ }
119
173
  function resolveWhatFrameworkVersion(projectRoot) {
120
174
  // Read the installed package.json off disk instead of resolving the
121
175
  // specifier. `require('what-framework/package.json')` looks like the obvious
@@ -177,7 +231,7 @@ export async function buildCommand(_args) {
177
231
  // Shared esbuild helpers
178
232
  const { build: esbuild } = await import('esbuild');
179
233
  const { join, resolve } = await import('node:path');
180
- const { mkdir, readFile, rename } = await import('node:fs/promises');
234
+ const { mkdir, readFile, rename, rm, readdir } = await import('node:fs/promises');
181
235
  const { existsSync } = await import('node:fs');
182
236
  const { createHash } = await import('node:crypto');
183
237
  const cliRequire = createRequire(import.meta.url);
@@ -215,7 +269,24 @@ export async function buildCommand(_args) {
215
269
  }
216
270
  return join(root, 'node_modules', pkg);
217
271
  }
218
- const esmResolvePlugin = {
272
+ /**
273
+ * Resolve the bare specifiers esbuild cannot.
274
+ *
275
+ * `inlineWhatFramework` decides what happens to `what-framework` and
276
+ * `what-core`. A browser bundle needs them inlined — there is no module
277
+ * resolution in a browser, and their exports maps are import-condition-only,
278
+ * so `require.resolve` cannot find them. A **server** bundle must keep them
279
+ * external, and this is the switch that says which.
280
+ *
281
+ * It exists because an `onResolve` that returns a path *beats* esbuild's
282
+ * `external` list. Setting both, as the build-time page loader did, silently
283
+ * loses: the page module inlined its own copy of what-core while
284
+ * `renderToString` ran from the installed one, the two disagreed about which
285
+ * component was rendering, and `useSignal()` threw "can only be called
286
+ * inside a component function" in every `static` and `hybrid` page. Loaders
287
+ * escaped it only because `@celsian/vura-core` is not intercepted here.
288
+ */
289
+ const makeEsmResolvePlugin = ({ inlineWhatFramework }) => ({
219
290
  name: 'esm-resolve',
220
291
  setup(build) {
221
292
  build.onResolve({ filter: /^@celsian\/vura-core\/(jsx-runtime|jsx-dev-runtime)$/ }, (args) => {
@@ -226,6 +297,8 @@ export async function buildCommand(_args) {
226
297
  return { path: cliRequire.resolve(args.path) };
227
298
  }
228
299
  });
300
+ if (!inlineWhatFramework)
301
+ return;
229
302
  // Bare what-framework/what-core imports (the generated client entry
230
303
  // imports { h, mount, hydrate } from 'what-framework'). The exports map
231
304
  // is import-condition-only, so require.resolve can't find it — resolve
@@ -253,69 +326,23 @@ export async function buildCommand(_args) {
253
326
  return null;
254
327
  });
255
328
  },
256
- };
257
- // 3. Bundle server-mode pages
258
- const serverPages = manifest.pages.filter(p => p.mode === 'server' || p.mode === 'hybrid');
259
- if (serverPages.length > 0) {
260
- console.log(` Bundling ${serverPages.length} server-mode pages...`);
261
- const serverPagesDir = join(root, 'dist', 'server', 'pages');
262
- await mkdir(serverPagesDir, { recursive: true });
263
- for (const page of serverPages) {
264
- const absPath = resolve(root, page.filePath);
265
- const outFile = page.filePath.replace(/^src\/pages\//, '').replace(/\.tsx?$/, '.js');
266
- const outPath = join(serverPagesDir, outFile);
267
- await mkdir(join(outPath, '..'), { recursive: true });
268
- await esbuild({
269
- entryPoints: [absPath],
270
- bundle: true,
271
- format: 'esm',
272
- target: 'es2022',
273
- platform: 'node',
274
- outfile: outPath,
275
- jsx: 'automatic',
276
- jsxImportSource,
277
- plugins: [esmResolvePlugin],
278
- external: [],
279
- });
280
- console.log(` ◈ ${page.urlPattern} → dist/server/pages/${outFile}`);
281
- }
282
- }
283
- // 3b. Bundle layout files used by server-mode pages
284
- if (manifest.layouts.length > 0 && serverPages.length > 0) {
285
- // Only compile layouts that are actually referenced by server-mode pages
286
- const usedLayoutPaths = new Set();
287
- for (const page of serverPages) {
288
- if (page.layouts) {
289
- for (const lp of page.layouts)
290
- usedLayoutPaths.add(lp);
291
- }
292
- }
293
- const layoutsToCompile = manifest.layouts.filter(l => usedLayoutPaths.has(l.filePath));
294
- if (layoutsToCompile.length > 0) {
295
- console.log(` Bundling ${layoutsToCompile.length} layout files...`);
296
- const serverPagesDir = join(root, 'dist', 'server', 'pages');
297
- await mkdir(serverPagesDir, { recursive: true });
298
- for (const layout of layoutsToCompile) {
299
- const absPath = resolve(root, layout.filePath);
300
- const outFile = layout.filePath.replace(/^src\/pages\//, '').replace(/\.tsx?$/, '.js');
301
- const outPath = join(serverPagesDir, outFile);
302
- await mkdir(join(outPath, '..'), { recursive: true });
303
- await esbuild({
304
- entryPoints: [absPath],
305
- bundle: true,
306
- format: 'esm',
307
- target: 'es2022',
308
- platform: 'node',
309
- outfile: outPath,
310
- jsx: 'automatic',
311
- jsxImportSource,
312
- plugins: [esmResolvePlugin],
313
- external: [],
314
- });
315
- console.log(` ⊟ layout ${layout.dirPattern || '(root)'} → dist/server/pages/${outFile}`);
316
- }
317
- }
318
- }
329
+ });
330
+ /** Externals every server-side bundle shares: one framework copy per process. */
331
+ /** Browser bundles: what-framework is inlined, because a browser has no resolver. */
332
+ const browserEsmResolvePlugin = makeEsmResolvePlugin({ inlineWhatFramework: true });
333
+ /** Server bundles: what-framework stays external, so the process holds one copy. */
334
+ const serverEsmResolvePlugin = makeEsmResolvePlugin({ inlineWhatFramework: false });
335
+ // Steps 3a and 3b used to live here: the CLI bundled every server-mode page
336
+ // and every layout into `dist/server/pages/`, and then core's `build()` ran
337
+ // `bundleServerPageModules()` over the same inputs and overwrote every one of
338
+ // those files. The output was byte-identical, so the only thing the CLI's
339
+ // copy produced was a second esbuild pass per page and per layout on every
340
+ // build, plus a second copy of the resolve configuration to keep in sync with
341
+ // core's. The `useSignal` bug shipped through exactly that kind of duplicate:
342
+ // one copy was fixed and the other was not.
343
+ //
344
+ // Page and layout bundling now happens once, in core. `serverEsmResolvePlugin`
345
+ // below is still used for the standalone entry.
319
346
  // 3c. Bundle browser entries for client and hybrid pages.
320
347
  // Each bundle is a generated wrapper (generateClientPageEntry) that imports
321
348
  // the page module and calls mount() (client) / hydrate() (hybrid) — bundling
@@ -327,6 +354,10 @@ export async function buildCommand(_args) {
327
354
  console.log(` Bundling ${browserPages.length} browser page entries...`);
328
355
  const clientPagesDir = join(root, 'dist', 'static', '_then', 'pages');
329
356
  await mkdir(clientPagesDir, { recursive: true });
357
+ // Every bundle this build writes. Anything else under the directory is a
358
+ // bundle from an earlier build whose content hash has since changed, and
359
+ // nothing references it any more.
360
+ const emittedBundles = new Set();
330
361
  const { dirname, basename } = await import('node:path');
331
362
  for (const page of browserPages) {
332
363
  const absPath = resolve(root, page.filePath);
@@ -335,7 +366,11 @@ export async function buildCommand(_args) {
335
366
  await mkdir(join(outPath, '..'), { recursive: true });
336
367
  await esbuild({
337
368
  stdin: {
338
- contents: generateClientPageEntry(`./${basename(absPath)}`, page.mode),
369
+ contents: generateClientPageEntry(`./${basename(absPath)}`, page.mode,
370
+ // The server rendered this page inside its layouts, so the browser
371
+ // has to hydrate the same tree. Specifiers are relative to the
372
+ // page's own directory, which is the entry's resolveDir.
373
+ { layoutImportSpecifiers: layoutSpecifiersFor(page, root) }),
339
374
  resolveDir: dirname(absPath),
340
375
  sourcefile: '__vura-client-entry__.js',
341
376
  loader: 'js',
@@ -350,7 +385,15 @@ export async function buildCommand(_args) {
350
385
  // Browser-resolve first: a page that imports `@celsian/vura-core` for
351
386
  // useLoaderData must get the pure client module here, not the package
352
387
  // root, which reaches node:fs and cannot be bundled for a browser.
353
- plugins: [vuraBrowserResolvePlugin(), esmResolvePlugin],
388
+ //
389
+ // The actions stub plugin is the security boundary for `src/actions/`:
390
+ // it answers onResolve, so esbuild never opens an action file for this
391
+ // bundle and nothing inside one can reach the browser.
392
+ plugins: [
393
+ vuraBrowserResolvePlugin(),
394
+ vuraActionsStubPlugin({ projectRoot: root }),
395
+ browserEsmResolvePlugin,
396
+ ],
354
397
  external: [],
355
398
  });
356
399
  const bundleHash = createHash('sha256')
@@ -359,10 +402,23 @@ export async function buildCommand(_args) {
359
402
  .slice(0, 12);
360
403
  const hashedOutFile = outFile.replace(/\.js$/, `.${bundleHash}.js`);
361
404
  await rename(outPath, join(clientPagesDir, hashedOutFile));
405
+ emittedBundles.add(join(clientPagesDir, hashedOutFile));
362
406
  const scriptPath = `/_then/pages/${hashedOutFile.replace(/\\/g, '/')}`;
363
407
  clientScripts[page.filePath] = scriptPath;
364
408
  console.log(` ◇ ${page.urlPattern} → dist/static${scriptPath}`);
365
409
  }
410
+ // Drop bundles left behind by earlier builds. The filename carries a
411
+ // content hash, so editing a page emits a new name and orphans the old
412
+ // one, which nothing then removes: `dist/` grew with every incremental
413
+ // build and shipped the dead copies to whatever you deployed to.
414
+ //
415
+ // Pruning *after* the writes rather than wiping the directory first is
416
+ // deliberate. A build that fails partway leaves the previous bundles
417
+ // intact instead of leaving nothing to serve.
418
+ const pruned = await pruneStaleBundles(clientPagesDir, emittedBundles, { readdir, rm });
419
+ if (pruned > 0) {
420
+ console.log(` ✓ removed ${pruned} stale bundle${pruned === 1 ? '' : 's'} from earlier builds`);
421
+ }
366
422
  }
367
423
  // 4. Build API routes + task entries
368
424
  console.log(' Building...');
@@ -412,7 +468,7 @@ export async function buildCommand(_args) {
412
468
  outfile: tmpFile,
413
469
  jsx: 'automatic',
414
470
  jsxImportSource,
415
- plugins: [esmResolvePlugin],
471
+ plugins: [serverEsmResolvePlugin],
416
472
  external: sharedRuntimeExternals,
417
473
  });
418
474
  return nativeImport(pathToFileURL(tmpFile).href);
@@ -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, vuraBrowserResolvePlugin, } 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, createVuraStreamRoute, isStreamingPage, } 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,52 @@ 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
+ });
210
+ /**
211
+ * The streaming counterpart, built from the same options as the string
212
+ * renderer above so the two cannot disagree about which scripts a page gets.
213
+ */
214
+ const devStreamRouteFor = (page) => createVuraStreamRoute({
215
+ extraScripts: () => (page.mode === 'hybrid' ? [browserScriptPath(page)] : []),
216
+ });
184
217
  const { existsSync } = await import('node:fs');
218
+ // ── Middleware ──
219
+ // Loaded through the same cached loader as routes so an edit is picked up on
220
+ // the next request, and so module-level state in a middleware behaves the way
221
+ // it does in a route. `manifest.middleware` is refreshed by the fs-watcher
222
+ // rescan, so adding or deleting the file mid-session is picked up too.
223
+ async function currentMiddlewareRunner(forManifest) {
224
+ if (!forManifest.middleware)
225
+ return createMiddlewareRunner(null);
226
+ try {
227
+ const mod = await loadHandlerCached(forManifest.middleware);
228
+ return createMiddlewareRunner(mod);
229
+ }
230
+ catch (err) {
231
+ // A syntax error in middleware must not take the whole dev server down,
232
+ // and it must not silently disable the auth guard the developer is
233
+ // relying on either. Say so, loudly, on every request until it is fixed.
234
+ const error = err instanceof Error ? err : new Error(String(err));
235
+ logger.error(`[vura] middleware failed to load: ${error.message}`);
236
+ return createMiddlewareRunner(null);
237
+ }
238
+ }
185
239
  function findGlobalHooksFile() {
186
240
  for (const filename of GLOBAL_HOOKS_FILENAMES) {
187
241
  if (existsSync(join(opts.projectRoot, filename)))
@@ -233,12 +287,29 @@ export async function startStandaloneServer(manifest, opts) {
233
287
  onError: [...(globalHooks?.onError ?? []), devErrorHook],
234
288
  onResponse: globalHooks?.onResponse ?? [],
235
289
  };
290
+ // Server actions. Loaded and registered on every rebuild, so editing an
291
+ // action file takes effect on the next request the way editing a route
292
+ // does. Registration is keyed by id, so a re-register replaces rather than
293
+ // duplicates; an action deleted from a file stops being callable only once
294
+ // the process restarts, which is the same limitation dev has for a deleted
295
+ // route module and is not worth a registry generation counter.
296
+ const actionModules = {};
297
+ for (const mod of forManifest.actions ?? []) {
298
+ actionModules[mod.moduleId] = await loadHandler(mod.filePath);
299
+ }
300
+ const hasActions = Object.keys(actionModules).length > 0;
301
+ if (hasActions)
302
+ registerActionModules(actionModules);
236
303
  // Compile route regexes for the path-existence pre-check (method-agnostic).
237
304
  const compiledApiRoutes = compileRoutes(routes);
238
- return { app: createApiApp({ routes, globalHooks: mergedHooks }), compiledApiRoutes };
305
+ return {
306
+ app: createApiApp({ routes, globalHooks: mergedHooks, enableActions: hasActions }),
307
+ compiledApiRoutes,
308
+ internalPaths: hasActions ? [ACTION_ENDPOINT] : [],
309
+ };
239
310
  }
240
311
  // Build initial CelsianApp and page route table
241
- let { app: apiApp, compiledApiRoutes } = await buildStandaloneApiApp(manifest);
312
+ let { app: apiApp, compiledApiRoutes, internalPaths } = await buildStandaloneApiApp(manifest);
242
313
  // In dev mode, compile ALL page routes — not just server/hybrid.
243
314
  // Static and server pages are SSR'd on the fly; client pages are served as
244
315
  // a shell + on-demand browser bundle (SSR'ing them would run hooks like
@@ -263,7 +334,10 @@ export async function startStandaloneServer(manifest, opts) {
263
334
  catch { /* not installed — keep default */ }
264
335
  const result = await esbuild({
265
336
  stdin: {
266
- contents: generateClientPageEntry(`./${basename(absPath)}`, page.mode, { dev: true }),
337
+ contents: generateClientPageEntry(`./${basename(absPath)}`, page.mode,
338
+ // Same layout chain the dev renderer wraps the page in, so hydration
339
+ // walks the tree that is actually in the document.
340
+ { dev: true, layoutImportSpecifiers: devLayoutSpecifiers(page) }),
267
341
  resolveDir: dirname(absPath),
268
342
  sourcefile: '__vura-client-entry__.js',
269
343
  loader: 'js',
@@ -278,13 +352,28 @@ export async function startStandaloneServer(manifest, opts) {
278
352
  jsxImportSource,
279
353
  // Same redirect the production build applies: `@celsian/vura-core` in a
280
354
  // browser bundle resolves to the pure client module.
281
- plugins: [vuraBrowserResolvePlugin()],
355
+ // The actions stub plugin is what keeps `src/actions/` source out of a
356
+ // browser bundle in dev as well as in a build. Without it `vura dev`
357
+ // would happily bundle a database client into the page and only the
358
+ // production build would catch it.
359
+ plugins: [vuraBrowserResolvePlugin(), vuraActionsStubPlugin({ projectRoot: opts.projectRoot })],
282
360
  nodePaths: [join(opts.projectRoot, 'node_modules')],
283
361
  });
284
362
  const text = result.outputFiles[0].text;
285
363
  browserBundleCache.set(page.filePath, text);
286
364
  return text;
287
365
  }
366
+ /**
367
+ * Import specifiers for a page's layouts, relative to the page's directory,
368
+ * which is the resolveDir the browser entry is bundled with.
369
+ */
370
+ function devLayoutSpecifiers(page) {
371
+ const pageDir = nodeResolve(opts.projectRoot, page.filePath, '..');
372
+ return (page.layouts ?? []).map((layoutPath) => {
373
+ const rel = nodeRelative(pageDir, nodeResolve(opts.projectRoot, layoutPath)).replace(/\\/g, '/');
374
+ return rel.startsWith('.') ? rel : `./${rel}`;
375
+ });
376
+ }
288
377
  const server = createServer(async (req, res) => {
289
378
  const url = new URL(req.url ?? '/', `http://${req.headers.host}`);
290
379
  const method = (req.method ?? 'GET').toUpperCase();
@@ -308,6 +397,34 @@ export async function startStandaloneServer(manifest, opts) {
308
397
  res.end();
309
398
  return;
310
399
  }
400
+ // ── Middleware ──
401
+ // Before static serving, before routes: an auth guard has to be able to
402
+ // keep a visitor away from a page, and a page may be a prerendered file.
403
+ let middlewareHeaders;
404
+ {
405
+ const runner = await currentMiddlewareRunner(manifest);
406
+ if (runner.enabled) {
407
+ const webReq = new Request(url.toString(), {
408
+ method,
409
+ headers: req.headers,
410
+ });
411
+ const outcome = await runner.run(webReq, url);
412
+ if (outcome.response) {
413
+ const headers = {};
414
+ outcome.response.headers.forEach((v, k) => { headers[k] = v; });
415
+ res.writeHead(outcome.response.status, headers);
416
+ res.end(outcome.response.body ? await outcome.response.text() : '');
417
+ return;
418
+ }
419
+ middlewareHeaders = outcome.headers;
420
+ }
421
+ if (middlewareHeaders) {
422
+ for (const [k, v] of middlewareHeaders) {
423
+ if (!res.hasHeader(k))
424
+ res.setHeader(k, v);
425
+ }
426
+ }
427
+ }
311
428
  // Static file serving from public/ directory
312
429
  if (method === 'GET' || method === 'HEAD') {
313
430
  const publicDir = join(opts.projectRoot, 'public');
@@ -470,7 +587,7 @@ export async function startStandaloneServer(manifest, opts) {
470
587
  // pattern matches this pathname, skip celsian entirely and fall through to
471
588
  // pages/404. This also correctly passes through intentional handler 404s —
472
589
  // if the route exists but returns 404, that response is delivered as-is.
473
- if (matchApiPath(compiledApiRoutes, url.pathname)) {
590
+ if (matchApiPath(compiledApiRoutes, url.pathname) || internalPaths.includes(url.pathname)) {
474
591
  try {
475
592
  const webReq = nodeToWebRequest(req, url);
476
593
  const webRes = await apiApp.handle(webReq);
@@ -536,41 +653,58 @@ export async function startStandaloneServer(manifest, opts) {
536
653
  return;
537
654
  }
538
655
  if (typeof Component === 'function') {
539
- let serverData = {};
540
- if (typeof mod.getServerData === 'function') {
541
- serverData = await mod.getServerData({
542
- params: pageMatch.params,
543
- url: url.pathname,
656
+ // Rendered by the SAME function the production server uses.
657
+ // The dev server used to carry its own copy of this logic, and it
658
+ // drifted: it called the component directly instead of through
659
+ // `h()`, it knew only `getServerData` and not `loader`, and it had
660
+ // no layout data, no payload and no notFound/redirect. So RFC 0001
661
+ // loaders worked in a built app and failed in `vura dev`, which is
662
+ // where a developer meets the feature first.
663
+ const layoutModules = [];
664
+ for (const layoutPath of pageMatch.page.layouts ?? []) {
665
+ layoutModules.push(await loadHandler(layoutPath));
666
+ }
667
+ const runtimePage = {
668
+ ...pageMatch.page,
669
+ // Dev renders every non-client page per request: there is no
670
+ // build output to serve, and no ISR cache in front of it.
671
+ mode: 'server',
672
+ config: { ...(pageMatch.page.config ?? {}), revalidate: undefined },
673
+ module: mod,
674
+ layoutModules,
675
+ };
676
+ const webReq = new Request(url.toString(), {
677
+ method,
678
+ headers: req.headers,
679
+ });
680
+ // Streaming pages take the same route in dev as in production,
681
+ // through the same helper and the same response writer. A dev
682
+ // server that renders a streamed page as a string would hide
683
+ // exactly the bugs streaming introduces.
684
+ if (method === 'GET' && isStreamingPage(runtimePage)) {
685
+ const streamed = await devStreamRouteFor(pageMatch.page)({
686
+ path: url.pathname,
544
687
  query: Object.fromEntries(url.searchParams.entries()),
688
+ route: { path: url.pathname, page: { mode: 'server' }, vura: runtimePage },
689
+ params: pageMatch.params,
690
+ request: webReq,
545
691
  });
692
+ await writeWebResponse(res, streamed);
693
+ return;
546
694
  }
547
- let vnode = Component({ ...serverData, params: pageMatch.params });
548
- // Wrap in layout chain if layouts are defined (outermost first)
549
- if (pageMatch.page.layouts && pageMatch.page.layouts.length > 0) {
550
- // Load layouts innermost-last, wrap from inside out
551
- for (let li = pageMatch.page.layouts.length - 1; li >= 0; li--) {
552
- const layoutMod = await loadHandler(pageMatch.page.layouts[li]);
553
- const LayoutComponent = layoutMod.default;
554
- if (typeof LayoutComponent === 'function') {
555
- vnode = LayoutComponent({ children: vnode, params: pageMatch.params });
556
- }
557
- }
558
- }
559
- const bodyHtml = builtinRenderToString(vnode);
560
- const html = wrapDocument(bodyHtml, {
561
- title: pageConfig.title ?? 'Vura App',
562
- meta: pageConfig.meta ?? [],
563
- styles: pageConfig.styles ?? [],
564
- // Hybrid pages also load their browser bundle so hydrate() runs
565
- // against the SSR'd DOM — same contract as the production build.
566
- scripts: [
567
- ...(pageConfig.scripts ?? []),
568
- ...(pageMatch.page.mode === 'hybrid' ? [browserScriptPath(pageMatch.page)] : []),
569
- ],
570
- head: pageConfig.head ?? '',
695
+ const result = await devRenderRouteFor(pageMatch.page)({
696
+ path: url.pathname,
697
+ query: Object.fromEntries(url.searchParams.entries()),
698
+ config: { mode: 'server' },
699
+ route: { path: url.pathname, page: { mode: 'server' }, vura: runtimePage },
700
+ params: pageMatch.params,
701
+ request: webReq,
571
702
  });
572
- res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
573
- res.end(html);
703
+ res.writeHead(result.status, {
704
+ 'Content-Type': 'text/html; charset=utf-8',
705
+ ...(result.headers ?? {}),
706
+ });
707
+ res.end(result.html);
574
708
  return;
575
709
  }
576
710
  }
@@ -627,7 +761,8 @@ export async function startStandaloneServer(manifest, opts) {
627
761
  // Watch for file changes and re-scan manifest
628
762
  const apiDir = join(opts.projectRoot, 'src', 'api');
629
763
  const pagesDir = join(opts.projectRoot, 'src', 'pages');
630
- const watchDirs = [apiDir, pagesDir];
764
+ const actionsDir = join(opts.projectRoot, 'src', 'actions');
765
+ const watchDirs = [apiDir, pagesDir, actionsDir];
631
766
  const watchers = [];
632
767
  for (const dir of watchDirs) {
633
768
  try {
@@ -652,7 +787,7 @@ export async function startStandaloneServer(manifest, opts) {
652
787
  // Rebuild against the FRESH manifest (nextManifest) — `manifest`
653
788
  // is only swapped after success, so building from the closure
654
789
  // variable would use the stale route set (see buildStandaloneApiApp).
655
- ({ app: apiApp, compiledApiRoutes } = await buildStandaloneApiApp(nextManifest));
790
+ ({ app: apiApp, compiledApiRoutes, internalPaths } = await buildStandaloneApiApp(nextManifest));
656
791
  }
657
792
  catch (err) {
658
793
  moduleCache.clear();
@@ -689,7 +824,13 @@ export async function startStandaloneServer(manifest, opts) {
689
824
  process.on('SIGINT', onSigint);
690
825
  await new Promise((resolve) => {
691
826
  server.listen(opts.port, opts.host, () => {
692
- console.log(` Server listening on http://${opts.host}:${opts.port}\n`);
827
+ // The *bound* port, not the requested one. `--port 0` means "pick a free
828
+ // port", and printing the request back gave a URL of `:0`, which cannot
829
+ // be connected to. The address is also what tells you which port you got
830
+ // when the requested one was taken.
831
+ const addr = server.address();
832
+ const boundPort = addr && typeof addr === 'object' ? addr.port : opts.port;
833
+ console.log(` Server listening on http://${opts.host}:${boundPort}\n`);
693
834
  // Warn once at startup when the user explicitly exposes the dev server beyond loopback.
694
835
  if (isLanDevHost(opts.host)) {
695
836
  console.warn(` [vura] Dev server exposed on ${opts.host}. Only use --host for trusted LAN testing.`);
@@ -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.1",
3
+ "version": "0.8.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.1",
19
- "esbuild": "^0.28.1",
20
- "what-framework": "^0.13.2"
18
+ "@celsian/vura-core": "0.8.0",
19
+ "esbuild": "^0.28.2",
20
+ "what-framework": "^0.13.4"
21
21
  },
22
22
  "peerDependencies": {
23
- "@celsian/vura-adapter-vura": "0.6.1",
23
+ "@celsian/vura-adapter-vura": "0.8.0",
24
24
  "ws": "^8.0.0"
25
25
  },
26
26
  "peerDependenciesMeta": {
@@ -32,9 +32,9 @@
32
32
  }
33
33
  },
34
34
  "devDependencies": {
35
- "@celsian/vura-adapter-vura": "0.6.1",
35
+ "@celsian/vura-adapter-vura": "0.8.0",
36
36
  "@types/ws": "^8.18.1",
37
- "ws": "^8.21.0"
37
+ "ws": "^8.21.3"
38
38
  },
39
39
  "license": "MIT",
40
40
  "publishConfig": {