@celsian/vura-cli 0.7.0 → 0.8.1

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/README.md CHANGED
@@ -52,10 +52,8 @@ and 12 GiB profiles.
52
52
 
53
53
  ## Documentation
54
54
 
55
- _vura.io docs site launches with v0.5 — until then, see the repo README and CHANGELOG._
56
-
57
55
  - [Quick start — /ladder/0-create/](https://vura.io/ladder/0-create/)
58
- - [Task routes — /reference/tasks/](https://vura.io/ladder/5-tasks/)
56
+ - [Task routes — /ladder/5-tasks/](https://vura.io/ladder/5-tasks/)
59
57
  - [Self-host — /self-host/](https://vura.io/self-host/)
60
58
 
61
59
  ## License
@@ -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 ───
@@ -2,14 +2,42 @@
2
2
  * `vura build` — Build the project for deployment.
3
3
  *
4
4
  * 1. Scan routes → build manifest
5
- * 2. Generate server entry (for hot server)
6
- * 3. Generate function entries (for serverless)
7
- * 4. Generate task entries (for task routes)
8
- * 5. Bundle server-mode pages with esbuild
9
- * 6. Render static pages (mode: 'static')
10
- * 7. Run adapter.buildEnd() if configured
11
- * 8. Write manifest.json
12
- * 9. Emit hot deploy templates (Dockerfile, fly.toml, package.json) when hot routes present
5
+ * 2. Bundle browser entries for client and hybrid pages
6
+ * 3. Render build-time pages (static, client shells, hybrid HTML) → dist/static
7
+ * 4. Copy public/ dist/public
8
+ * 5. core build(): server entry, function entries, task entries, manifest.json,
9
+ * then adapter.buildEnd()
10
+ * 6. Emit hot deploy templates (Dockerfile, fly.toml, package.json)
11
+ *
12
+ * The adapter runs last, and the two steps that write dist/static and
13
+ * dist/public run before it, because an adapter that serves prerendered pages
14
+ * has to be able to read them.
15
+ */
16
+ /**
17
+ * Remove hashed page bundles that this build did not emit.
18
+ *
19
+ * Bundle filenames carry a content hash, so every edit to a client or hybrid
20
+ * page produces a new name and orphans the old one. Nothing removed those, so
21
+ * `dist/static/_then/pages` grew with every incremental build and the dead
22
+ * copies shipped to whatever the project deployed to.
23
+ *
24
+ * The sweep itself now lives in core as `pruneStaleOutputs`, because the same
25
+ * accretion happens to `dist/server`, `dist/functions` and each adapter's own
26
+ * output directory, and four copies of a recursive delete is four chances to
27
+ * fix one and not the others. This wrapper is the client-bundle name and the
28
+ * client-bundle reason; the behaviour is shared.
13
29
  */
30
+ export declare function pruneStaleBundles(dir: string, keep: Set<string>, fs: {
31
+ readdir: (p: string, o: {
32
+ withFileTypes: true;
33
+ }) => Promise<Array<{
34
+ name: string;
35
+ isDirectory: () => boolean;
36
+ }>>;
37
+ rm: (p: string, o?: {
38
+ recursive?: boolean;
39
+ force?: boolean;
40
+ }) => Promise<void>;
41
+ }): Promise<number>;
14
42
  export declare function buildCommand(_args: string[]): Promise<void>;
15
43
  //# sourceMappingURL=build.d.ts.map
@@ -2,21 +2,40 @@
2
2
  * `vura build` — Build the project for deployment.
3
3
  *
4
4
  * 1. Scan routes → build manifest
5
- * 2. Generate server entry (for hot server)
6
- * 3. Generate function entries (for serverless)
7
- * 4. Generate task entries (for task routes)
8
- * 5. Bundle server-mode pages with esbuild
9
- * 6. Render static pages (mode: 'static')
10
- * 7. Run adapter.buildEnd() if configured
11
- * 8. Write manifest.json
12
- * 9. Emit hot deploy templates (Dockerfile, fly.toml, package.json) when hot routes present
5
+ * 2. Bundle browser entries for client and hybrid pages
6
+ * 3. Render build-time pages (static, client shells, hybrid HTML) → dist/static
7
+ * 4. Copy public/ dist/public
8
+ * 5. core build(): server entry, function entries, task entries, manifest.json,
9
+ * then adapter.buildEnd()
10
+ * 6. Emit hot deploy templates (Dockerfile, fly.toml, package.json)
11
+ *
12
+ * The adapter runs last, and the two steps that write dist/static and
13
+ * dist/public run before it, because an adapter that serves prerendered pages
14
+ * has to be able to read them.
13
15
  */
14
- import { buildManifest, build, renderStaticPages, generateClientPageEntry, vuraBrowserResolvePlugin, vuraActionsStubPlugin } from '@celsian/vura-core';
16
+ import { buildManifest, build, renderStaticPages, generateClientPageEntry, vuraBrowserResolvePlugin, vuraActionsStubPlugin, pruneStaleOutputs } from '@celsian/vura-core';
15
17
  import { createRequire } from 'node:module';
16
18
  import { existsSync, readFileSync } from 'node:fs';
17
19
  import { join as pathJoin, resolve as pathResolve, relative } from 'node:path';
18
20
  import { pathToFileURL } from 'node:url';
19
21
  import { loadConfig } from '../config-loader.js';
22
+ /**
23
+ * Remove hashed page bundles that this build did not emit.
24
+ *
25
+ * Bundle filenames carry a content hash, so every edit to a client or hybrid
26
+ * page produces a new name and orphans the old one. Nothing removed those, so
27
+ * `dist/static/_then/pages` grew with every incremental build and the dead
28
+ * copies shipped to whatever the project deployed to.
29
+ *
30
+ * The sweep itself now lives in core as `pruneStaleOutputs`, because the same
31
+ * accretion happens to `dist/server`, `dist/functions` and each adapter's own
32
+ * output directory, and four copies of a recursive delete is four chances to
33
+ * fix one and not the others. This wrapper is the client-bundle name and the
34
+ * client-bundle reason; the behaviour is shared.
35
+ */
36
+ export async function pruneStaleBundles(dir, keep, fs) {
37
+ return pruneStaleOutputs(dir, keep, fs);
38
+ }
20
39
  // ---------------------------------------------------------------------------
21
40
  // Deploy template strings — inlined so they survive tsc compilation (tsc does
22
41
  // not copy non-TS assets). Emitted by emitHotDeployTemplates() when hot
@@ -188,7 +207,7 @@ export async function buildCommand(_args) {
188
207
  // Shared esbuild helpers
189
208
  const { build: esbuild } = await import('esbuild');
190
209
  const { join, resolve } = await import('node:path');
191
- const { mkdir, readFile, rename } = await import('node:fs/promises');
210
+ const { mkdir, readFile, rename, rm, readdir } = await import('node:fs/promises');
192
211
  const { existsSync } = await import('node:fs');
193
212
  const { createHash } = await import('node:crypto');
194
213
  const cliRequire = createRequire(import.meta.url);
@@ -285,76 +304,22 @@ export async function buildCommand(_args) {
285
304
  },
286
305
  });
287
306
  /** Externals every server-side bundle shares: one framework copy per process. */
288
- const serverRuntimeExternals = ['what-framework', 'what-framework/*', 'what-core', 'what-core/*'];
289
307
  /** Browser bundles: what-framework is inlined, because a browser has no resolver. */
290
308
  const browserEsmResolvePlugin = makeEsmResolvePlugin({ inlineWhatFramework: true });
291
309
  /** Server bundles: what-framework stays external, so the process holds one copy. */
292
310
  const serverEsmResolvePlugin = makeEsmResolvePlugin({ inlineWhatFramework: false });
293
- // 3. Bundle server-mode pages
294
- const serverPages = manifest.pages.filter(p => p.mode === 'server' || p.mode === 'hybrid');
295
- if (serverPages.length > 0) {
296
- console.log(` Bundling ${serverPages.length} server-mode pages...`);
297
- const serverPagesDir = join(root, 'dist', 'server', 'pages');
298
- await mkdir(serverPagesDir, { recursive: true });
299
- for (const page of serverPages) {
300
- const absPath = resolve(root, page.filePath);
301
- const outFile = page.filePath.replace(/^src\/pages\//, '').replace(/\.tsx?$/, '.js');
302
- const outPath = join(serverPagesDir, outFile);
303
- await mkdir(join(outPath, '..'), { recursive: true });
304
- await esbuild({
305
- entryPoints: [absPath],
306
- bundle: true,
307
- format: 'esm',
308
- target: 'es2022',
309
- platform: 'node',
310
- outfile: outPath,
311
- jsx: 'automatic',
312
- jsxImportSource,
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,
317
- });
318
- console.log(` ◈ ${page.urlPattern} → dist/server/pages/${outFile}`);
319
- }
320
- }
321
- // 3b. Bundle layout files used by server-mode pages
322
- if (manifest.layouts.length > 0 && serverPages.length > 0) {
323
- // Only compile layouts that are actually referenced by server-mode pages
324
- const usedLayoutPaths = new Set();
325
- for (const page of serverPages) {
326
- if (page.layouts) {
327
- for (const lp of page.layouts)
328
- usedLayoutPaths.add(lp);
329
- }
330
- }
331
- const layoutsToCompile = manifest.layouts.filter(l => usedLayoutPaths.has(l.filePath));
332
- if (layoutsToCompile.length > 0) {
333
- console.log(` Bundling ${layoutsToCompile.length} layout files...`);
334
- const serverPagesDir = join(root, 'dist', 'server', 'pages');
335
- await mkdir(serverPagesDir, { recursive: true });
336
- for (const layout of layoutsToCompile) {
337
- const absPath = resolve(root, layout.filePath);
338
- const outFile = layout.filePath.replace(/^src\/pages\//, '').replace(/\.tsx?$/, '.js');
339
- const outPath = join(serverPagesDir, outFile);
340
- await mkdir(join(outPath, '..'), { recursive: true });
341
- await esbuild({
342
- entryPoints: [absPath],
343
- bundle: true,
344
- format: 'esm',
345
- target: 'es2022',
346
- platform: 'node',
347
- outfile: outPath,
348
- jsx: 'automatic',
349
- jsxImportSource,
350
- plugins: [serverEsmResolvePlugin],
351
- external: serverRuntimeExternals,
352
- });
353
- console.log(` ⊟ layout ${layout.dirPattern || '(root)'} → dist/server/pages/${outFile}`);
354
- }
355
- }
356
- }
357
- // 3c. Bundle browser entries for client and hybrid pages.
311
+ // Steps 3a and 3b used to live here: the CLI bundled every server-mode page
312
+ // and every layout into `dist/server/pages/`, and then core's `build()` ran
313
+ // `bundleServerPageModules()` over the same inputs and overwrote every one of
314
+ // those files. The output was byte-identical, so the only thing the CLI's
315
+ // copy produced was a second esbuild pass per page and per layout on every
316
+ // build, plus a second copy of the resolve configuration to keep in sync with
317
+ // core's. The `useSignal` bug shipped through exactly that kind of duplicate:
318
+ // one copy was fixed and the other was not.
319
+ //
320
+ // Page and layout bundling now happens once, in core. `serverEsmResolvePlugin`
321
+ // below is still used for the standalone entry.
322
+ // 2. Bundle browser entries for client and hybrid pages.
358
323
  // Each bundle is a generated wrapper (generateClientPageEntry) that imports
359
324
  // the page module and calls mount() (client) / hydrate() (hybrid) — bundling
360
325
  // the raw page module alone leaves the shell at "Loading..." forever because
@@ -365,6 +330,10 @@ export async function buildCommand(_args) {
365
330
  console.log(` Bundling ${browserPages.length} browser page entries...`);
366
331
  const clientPagesDir = join(root, 'dist', 'static', '_then', 'pages');
367
332
  await mkdir(clientPagesDir, { recursive: true });
333
+ // Every bundle this build writes. Anything else under the directory is a
334
+ // bundle from an earlier build whose content hash has since changed, and
335
+ // nothing references it any more.
336
+ const emittedBundles = new Set();
368
337
  const { dirname, basename } = await import('node:path');
369
338
  for (const page of browserPages) {
370
339
  const absPath = resolve(root, page.filePath);
@@ -389,6 +358,7 @@ export async function buildCommand(_args) {
389
358
  outfile: outPath,
390
359
  jsx: 'automatic',
391
360
  jsxImportSource,
361
+ absWorkingDir: root,
392
362
  // Browser-resolve first: a page that imports `@celsian/vura-core` for
393
363
  // useLoaderData must get the pure client module here, not the package
394
364
  // root, which reaches node:fs and cannot be bundled for a browser.
@@ -409,20 +379,25 @@ export async function buildCommand(_args) {
409
379
  .slice(0, 12);
410
380
  const hashedOutFile = outFile.replace(/\.js$/, `.${bundleHash}.js`);
411
381
  await rename(outPath, join(clientPagesDir, hashedOutFile));
382
+ emittedBundles.add(join(clientPagesDir, hashedOutFile));
412
383
  const scriptPath = `/_then/pages/${hashedOutFile.replace(/\\/g, '/')}`;
413
384
  clientScripts[page.filePath] = scriptPath;
414
385
  console.log(` ◇ ${page.urlPattern} → dist/static${scriptPath}`);
415
386
  }
387
+ // Drop bundles left behind by earlier builds. The filename carries a
388
+ // content hash, so editing a page emits a new name and orphans the old
389
+ // one, which nothing then removes: `dist/` grew with every incremental
390
+ // build and shipped the dead copies to whatever you deployed to.
391
+ //
392
+ // Pruning *after* the writes rather than wiping the directory first is
393
+ // deliberate. A build that fails partway leaves the previous bundles
394
+ // intact instead of leaving nothing to serve.
395
+ const pruned = await pruneStaleBundles(clientPagesDir, emittedBundles, { readdir, rm });
396
+ if (pruned > 0) {
397
+ console.log(` ✓ removed ${pruned} stale bundle${pruned === 1 ? '' : 's'} from earlier builds`);
398
+ }
416
399
  }
417
- // 4. Build API routes + task entries
418
- console.log(' Building...');
419
- const result = await build(manifest, config, root);
420
- console.log(` Server entry: ${result.serverEntry}`);
421
- console.log(` Functions: ${result.functions.length} serverless bundles`);
422
- if (result.taskEntries.length > 0) {
423
- console.log(` Tasks: ${result.taskEntries.length} task entries`);
424
- }
425
- // 5. Render build-time pages (static, client shells, and hybrid prerendered HTML)
400
+ // 3. Render build-time pages (static, client shells, and hybrid prerendered HTML)
426
401
  const staticPages = manifest.pages.filter(p => p.mode !== 'server');
427
402
  if (staticPages.length > 0) {
428
403
  console.log(` Rendering ${staticPages.length} build-time pages...`);
@@ -462,6 +437,12 @@ export async function buildCommand(_args) {
462
437
  outfile: tmpFile,
463
438
  jsx: 'automatic',
464
439
  jsxImportSource,
440
+ // esbuild anchors resolution to the working directory its service
441
+ // captured, which is not necessarily the cwd at call time. Core's
442
+ // bundlers pass this for the same reason; this one did not, and a
443
+ // second build in one process resolved against the first build's
444
+ // directory — which fails outright once that directory is gone.
445
+ absWorkingDir: root,
465
446
  plugins: [serverEsmResolvePlugin],
466
447
  external: sharedRuntimeExternals,
467
448
  });
@@ -476,7 +457,7 @@ export async function buildCommand(_args) {
476
457
  const { rm } = await import('node:fs/promises');
477
458
  await rm(tmpDir, { recursive: true, force: true }).catch(() => { });
478
459
  }
479
- // 6. Copy public assets to dist/public/ for production static serving.
460
+ // 4. Copy public assets to dist/public/ for production static serving.
480
461
  // Prefer the framework-standard root public/ directory, but support the
481
462
  // historical starter/runbook layout src/public/ when root public/ is absent.
482
463
  const publicDir = join(root, 'public');
@@ -493,10 +474,25 @@ export async function buildCommand(_args) {
493
474
  const label = publicSourceDir === publicDir ? 'public/' : 'src/public/';
494
475
  console.log(` Copied ${label} → dist/public/`);
495
476
  }
477
+ // 5. Build API routes, task entries and the adapter artifacts.
478
+ //
479
+ // This runs AFTER the page render and the public copy, not before. The
480
+ // adapter's buildEnd is the last step by design — this file's own header has
481
+ // said so since it was written — and an adapter that reads dist/static, which
482
+ // is what serving prerendered pages on Cloudflare and Lambda requires, saw
483
+ // either nothing on a clean build or the PREVIOUS build's HTML on a dirty
484
+ // one. It ran fourth of nine only because the page render was added later.
485
+ console.log(' Building...');
486
+ const result = await build(manifest, config, root);
487
+ console.log(` Server entry: ${result.serverEntry}`);
488
+ console.log(` Functions: ${result.functions.length} serverless bundles`);
489
+ if (result.taskEntries.length > 0) {
490
+ console.log(` Tasks: ${result.taskEntries.length} task entries`);
491
+ }
496
492
  if (config.adapter) {
497
493
  console.log(` Adapter: ${config.adapter.name}`);
498
494
  }
499
- // 9. Emit hot deploy templates when the project has hot routes
495
+ // 6. Emit hot deploy templates when the project has hot routes
500
496
  const hotRoutes = manifest.api.filter(r => r.kind === 'hot');
501
497
  const hasWsRoutes = hotRoutes.some(r => r.hasWebsocket === true);
502
498
  const distDir = join(root, 'dist');
@@ -14,7 +14,7 @@
14
14
  */
15
15
  import { importRouteModule } from './shared.js';
16
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';
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');
@@ -207,6 +207,13 @@ export async function startStandaloneServer(manifest, opts) {
207
207
  const devRenderRouteFor = (page) => createVuraRenderRoute({
208
208
  extraScripts: () => (page.mode === 'hybrid' ? [browserScriptPath(page)] : []),
209
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
+ });
210
217
  const { existsSync } = await import('node:fs');
211
218
  // ── Middleware ──
212
219
  // Loaded through the same cached loader as routes so an edit is picked up on
@@ -670,6 +677,21 @@ export async function startStandaloneServer(manifest, opts) {
670
677
  method,
671
678
  headers: req.headers,
672
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,
687
+ query: Object.fromEntries(url.searchParams.entries()),
688
+ route: { path: url.pathname, page: { mode: 'server' }, vura: runtimePage },
689
+ params: pageMatch.params,
690
+ request: webReq,
691
+ });
692
+ await writeWebResponse(res, streamed);
693
+ return;
694
+ }
673
695
  const result = await devRenderRouteFor(pageMatch.page)({
674
696
  path: url.pathname,
675
697
  query: Object.fromEntries(url.searchParams.entries()),
@@ -802,7 +824,13 @@ export async function startStandaloneServer(manifest, opts) {
802
824
  process.on('SIGINT', onSigint);
803
825
  await new Promise((resolve) => {
804
826
  server.listen(opts.port, opts.host, () => {
805
- 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`);
806
834
  // Warn once at startup when the user explicitly exposes the dev server beyond loopback.
807
835
  if (isLanDevHost(opts.host)) {
808
836
  console.warn(` [vura] Dev server exposed on ${opts.host}. Only use --host for trusted LAN testing.`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celsian/vura-cli",
3
- "version": "0.7.0",
3
+ "version": "0.8.1",
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.7.0",
19
- "esbuild": "^0.28.1",
20
- "what-framework": "^0.13.2"
18
+ "@celsian/vura-core": "0.8.1",
19
+ "esbuild": "^0.28.2",
20
+ "what-framework": "^0.13.6"
21
21
  },
22
22
  "peerDependencies": {
23
- "@celsian/vura-adapter-vura": "0.7.0",
23
+ "@celsian/vura-adapter-vura": "0.8.1",
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.7.0",
35
+ "@celsian/vura-adapter-vura": "0.8.1",
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": {