@celsian/vura-cli 0.7.0 → 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
@@ -17,6 +17,49 @@ import { existsSync, readFileSync } from 'node:fs';
17
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
@@ -188,7 +231,7 @@ export async function buildCommand(_args) {
188
231
  // Shared esbuild helpers
189
232
  const { build: esbuild } = await import('esbuild');
190
233
  const { join, resolve } = await import('node:path');
191
- const { mkdir, readFile, rename } = await import('node:fs/promises');
234
+ const { mkdir, readFile, rename, rm, readdir } = await import('node:fs/promises');
192
235
  const { existsSync } = await import('node:fs');
193
236
  const { createHash } = await import('node:crypto');
194
237
  const cliRequire = createRequire(import.meta.url);
@@ -285,75 +328,21 @@ export async function buildCommand(_args) {
285
328
  },
286
329
  });
287
330
  /** Externals every server-side bundle shares: one framework copy per process. */
288
- const serverRuntimeExternals = ['what-framework', 'what-framework/*', 'what-core', 'what-core/*'];
289
331
  /** Browser bundles: what-framework is inlined, because a browser has no resolver. */
290
332
  const browserEsmResolvePlugin = makeEsmResolvePlugin({ inlineWhatFramework: true });
291
333
  /** Server bundles: what-framework stays external, so the process holds one copy. */
292
334
  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
- }
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.
357
346
  // 3c. Bundle browser entries for client and hybrid pages.
358
347
  // Each bundle is a generated wrapper (generateClientPageEntry) that imports
359
348
  // the page module and calls mount() (client) / hydrate() (hybrid) — bundling
@@ -365,6 +354,10 @@ export async function buildCommand(_args) {
365
354
  console.log(` Bundling ${browserPages.length} browser page entries...`);
366
355
  const clientPagesDir = join(root, 'dist', 'static', '_then', 'pages');
367
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();
368
361
  const { dirname, basename } = await import('node:path');
369
362
  for (const page of browserPages) {
370
363
  const absPath = resolve(root, page.filePath);
@@ -409,10 +402,23 @@ export async function buildCommand(_args) {
409
402
  .slice(0, 12);
410
403
  const hashedOutFile = outFile.replace(/\.js$/, `.${bundleHash}.js`);
411
404
  await rename(outPath, join(clientPagesDir, hashedOutFile));
405
+ emittedBundles.add(join(clientPagesDir, hashedOutFile));
412
406
  const scriptPath = `/_then/pages/${hashedOutFile.replace(/\\/g, '/')}`;
413
407
  clientScripts[page.filePath] = scriptPath;
414
408
  console.log(` ◇ ${page.urlPattern} → dist/static${scriptPath}`);
415
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
+ }
416
422
  }
417
423
  // 4. Build API routes + task entries
418
424
  console.log(' Building...');
@@ -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.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.7.0",
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.7.0",
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.7.0",
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": {