@celsian/vura-cli 0.5.14 → 0.6.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.
@@ -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 } 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 } 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,23 +62,32 @@ 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
- * Emit Dockerfile and fly.toml into dist/ when the project has hot routes.
66
- * Also writes/merges dist/package.json so that `npm install --omit=dev` in the
67
- * Docker build pulls the right runtime deps (ws when WS routes are present).
66
+ * Write dist/package.json.
67
+ *
68
+ * `npm install --omit=dev` inside the Docker build resolves against this file,
69
+ * so it has to list every bare specifier the emitted bundles still import at
70
+ * runtime. Two do:
71
+ *
72
+ * - `what-framework`, because API and page route modules are bundled with it
73
+ * kept external (see bundleRouteModule's `keepWhatFwExternal`). Without the
74
+ * dependency the container starts and dies on the first request to any API
75
+ * route with `ERR_MODULE_NOT_FOUND: what-framework/server`. It was only ever
76
+ * absent from this file, never from the imports.
77
+ * - `ws`, when the project has WebSocket routes.
78
+ *
79
+ * It is pinned to the version the project actually resolved, so the container
80
+ * runs the What the app was built and tested against rather than whatever
81
+ * `latest` is on deploy day.
82
+ *
83
+ * This runs for EVERY build. It used to run only for projects with hot routes,
84
+ * which is unrelated to whether the bundles import anything.
68
85
  */
69
- async function emitHotDeployTemplates(distDir, appName, hasWsRoutes) {
86
+ async function emitDeployPackageJson(distDir, projectRoot, hasWsRoutes) {
70
87
  const { writeFile, readFile, mkdir } = await import('node:fs/promises');
71
88
  const { existsSync } = await import('node:fs');
72
89
  const { join } = await import('node:path');
73
90
  await mkdir(distDir, { recursive: true });
74
- // Dockerfile
75
- await writeFile(join(distDir, 'Dockerfile'), DOCKERFILE_HOT, 'utf8');
76
- // fly.toml — replace {{APP_NAME}} placeholder
77
- const flyToml = FLY_TOML_TMPL.replace('{{APP_NAME}}', appName);
78
- await writeFile(join(distDir, 'fly.toml'), flyToml, 'utf8');
79
- // dist/package.json — create or merge
80
91
  const pkgPath = join(distDir, 'package.json');
81
92
  let existing = {};
82
93
  if (existsSync(pkgPath)) {
@@ -87,13 +98,66 @@ async function emitHotDeployTemplates(distDir, appName, hasWsRoutes) {
87
98
  console.warn(' Warning: dist/package.json is malformed JSON — regenerating from scratch.');
88
99
  }
89
100
  }
90
- const merged = { ...existing, type: 'module' };
91
- if (hasWsRoutes) {
92
- const existingDeps = existing.dependencies ?? {};
93
- merged.dependencies = { ...existingDeps, ws: '8.18.0' };
101
+ const deps = { ...(existing.dependencies ?? {}) };
102
+ const whatVersion = resolveWhatFrameworkVersion(projectRoot);
103
+ if (whatVersion) {
104
+ deps['what-framework'] = whatVersion;
94
105
  }
106
+ else {
107
+ console.warn(' Warning: could not resolve what-framework in this project, so dist/package.json ' +
108
+ 'does not declare it. A container build will fail to resolve `what-framework/server` ' +
109
+ 'from the emitted API route bundles.');
110
+ }
111
+ if (hasWsRoutes)
112
+ deps.ws = '8.18.0';
113
+ const merged = { ...existing, type: 'module' };
114
+ if (Object.keys(deps).length > 0)
115
+ merged.dependencies = deps;
95
116
  await writeFile(pkgPath, JSON.stringify(merged, null, 2) + '\n', 'utf8');
96
117
  }
118
+ /** The exact what-framework version installed in the project, or null. */
119
+ function resolveWhatFrameworkVersion(projectRoot) {
120
+ // Read the installed package.json off disk instead of resolving the
121
+ // specifier. `require('what-framework/package.json')` looks like the obvious
122
+ // way to do this and cannot work: what-framework's `exports` map lists `.`,
123
+ // `./server`, `./router` and friends, and Node refuses any subpath an
124
+ // exports map does not name — including './package.json'. Every real install
125
+ // therefore threw ERR_PACKAGE_PATH_NOT_EXPORTED, the version came back null,
126
+ // and dist/package.json shipped without the dependency the container needs.
127
+ let dir = projectRoot;
128
+ for (let depth = 0; depth < 10; depth++) {
129
+ const candidate = pathJoin(dir, 'node_modules', 'what-framework', 'package.json');
130
+ if (existsSync(candidate)) {
131
+ try {
132
+ const manifest = JSON.parse(readFileSync(candidate, 'utf8'));
133
+ if (typeof manifest.version === 'string')
134
+ return manifest.version;
135
+ }
136
+ catch {
137
+ return null;
138
+ }
139
+ return null;
140
+ }
141
+ const parent = pathResolve(dir, '..');
142
+ if (parent === dir)
143
+ break;
144
+ dir = parent;
145
+ }
146
+ return null;
147
+ }
148
+ /**
149
+ * Emit Dockerfile and fly.toml into dist/ when the project has hot routes.
150
+ */
151
+ async function emitHotDeployTemplates(distDir, appName) {
152
+ const { writeFile, mkdir } = await import('node:fs/promises');
153
+ const { join } = await import('node:path');
154
+ await mkdir(distDir, { recursive: true });
155
+ // Dockerfile
156
+ await writeFile(join(distDir, 'Dockerfile'), DOCKERFILE_HOT, 'utf8');
157
+ // fly.toml — replace {{APP_NAME}} placeholder
158
+ const flyToml = FLY_TOML_TMPL.replace('{{APP_NAME}}', appName);
159
+ await writeFile(join(distDir, 'fly.toml'), flyToml, 'utf8');
160
+ }
97
161
  export async function buildCommand(_args) {
98
162
  const startTime = Date.now();
99
163
  const projectRoot = process.cwd();
@@ -113,8 +177,9 @@ export async function buildCommand(_args) {
113
177
  // Shared esbuild helpers
114
178
  const { build: esbuild } = await import('esbuild');
115
179
  const { join, resolve } = await import('node:path');
116
- const { mkdir } = await import('node:fs/promises');
180
+ const { mkdir, readFile, rename } = await import('node:fs/promises');
117
181
  const { existsSync } = await import('node:fs');
182
+ const { createHash } = await import('node:crypto');
118
183
  const cliRequire = createRequire(import.meta.url);
119
184
  const projectRequire = createRequire(join(root, 'package.json'));
120
185
  // Determine JSX import source from the user's project, not from the CLI's own
@@ -282,10 +347,19 @@ export async function buildCommand(_args) {
282
347
  outfile: outPath,
283
348
  jsx: 'automatic',
284
349
  jsxImportSource,
285
- plugins: [esmResolvePlugin],
350
+ // Browser-resolve first: a page that imports `@celsian/vura-core` for
351
+ // useLoaderData must get the pure client module here, not the package
352
+ // root, which reaches node:fs and cannot be bundled for a browser.
353
+ plugins: [vuraBrowserResolvePlugin(), esmResolvePlugin],
286
354
  external: [],
287
355
  });
288
- const scriptPath = `/_then/pages/${outFile.replace(/\\/g, '/')}`;
356
+ const bundleHash = createHash('sha256')
357
+ .update(await readFile(outPath))
358
+ .digest('hex')
359
+ .slice(0, 12);
360
+ const hashedOutFile = outFile.replace(/\.js$/, `.${bundleHash}.js`);
361
+ await rename(outPath, join(clientPagesDir, hashedOutFile));
362
+ const scriptPath = `/_then/pages/${hashedOutFile.replace(/\\/g, '/')}`;
289
363
  clientScripts[page.filePath] = scriptPath;
290
364
  console.log(` ◇ ${page.urlPattern} → dist/static${scriptPath}`);
291
365
  }
@@ -304,23 +378,44 @@ export async function buildCommand(_args) {
304
378
  console.log(` Rendering ${staticPages.length} build-time pages...`);
305
379
  const tmpDir = join(root, 'dist', '.page-tmp');
306
380
  await mkdir(tmpDir, { recursive: true });
381
+ // A build-time page is imported into THIS process and rendered by core's
382
+ // own `renderToString`. Two rules follow, and breaking either one produces
383
+ // a failure that looks like a framework bug rather than a bundling one:
384
+ //
385
+ // 1. `what-framework` and `@celsian/vura-core` stay external. Inlining
386
+ // them gives the page a second copy of the framework, with its own
387
+ // "currently rendering component" and its own context registry — so
388
+ // every hook the page calls, `useLoaderData` included, reads a
389
+ // registry that the renderer never wrote to and reports being called
390
+ // outside a render.
391
+ // 2. The bundle is written to a real file rather than imported as a
392
+ // `data:` URL. A data: module has no parent path, so it cannot resolve
393
+ // the bare specifiers rule 1 just created, and anything it does inline
394
+ // that calls `fileURLToPath(import.meta.url)` at module scope throws
395
+ // "The URL must be of scheme file".
396
+ const sharedRuntimeExternals = [
397
+ 'what-framework',
398
+ 'what-framework/*',
399
+ '@celsian/vura-core',
400
+ '@celsian/vura-core/*',
401
+ ];
402
+ let pageModuleSeq = 0;
307
403
  const loadModule = async (filePath) => {
308
404
  const absPath = resolve(root, filePath);
309
- const result = await esbuild({
405
+ const tmpFile = join(tmpDir, `page-${pageModuleSeq++}.mjs`);
406
+ await esbuild({
310
407
  entryPoints: [absPath],
311
408
  bundle: true,
312
409
  format: 'esm',
313
410
  target: 'es2022',
314
411
  platform: 'node',
315
- write: false,
316
- outfile: 'page.mjs',
412
+ outfile: tmpFile,
317
413
  jsx: 'automatic',
318
414
  jsxImportSource,
319
415
  plugins: [esmResolvePlugin],
320
- external: [],
416
+ external: sharedRuntimeExternals,
321
417
  });
322
- const bundledSource = result.outputFiles[0].text;
323
- return nativeImport(moduleSourceToDataUrl(bundledSource));
418
+ return nativeImport(pathToFileURL(tmpFile).href);
324
419
  };
325
420
  const outDir = join(root, 'dist');
326
421
  const rendered = await renderStaticPages(staticPages, loadModule, outDir, { clientScripts });
@@ -353,15 +448,18 @@ export async function buildCommand(_args) {
353
448
  }
354
449
  // 9. Emit hot deploy templates when the project has hot routes
355
450
  const hotRoutes = manifest.api.filter(r => r.kind === 'hot');
451
+ const hasWsRoutes = hotRoutes.some(r => r.hasWebsocket === true);
452
+ const distDir = join(root, 'dist');
453
+ // Always: the emitted bundles import `what-framework` at runtime whether or
454
+ // not the project has hot routes.
455
+ await emitDeployPackageJson(distDir, root, hasWsRoutes);
356
456
  if (hotRoutes.length > 0) {
357
457
  const { basename } = await import('node:path');
358
458
  const rawName = basename(root);
359
459
  // sanitize to lowercase [a-z0-9-], truncate to Fly's ~30-char DNS label limit,
360
460
  // then strip any trailing dashes introduced by truncation
361
461
  const appName = rawName.toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 30).replace(/-+$/, '') || 'vura-app';
362
- const hasWsRoutes = hotRoutes.some(r => r.hasWebsocket === true);
363
- const distDir = join(root, 'dist');
364
- await emitHotDeployTemplates(distDir, appName, hasWsRoutes);
462
+ await emitHotDeployTemplates(distDir, appName);
365
463
  console.log(` Emitted dist/Dockerfile, dist/fly.toml, dist/package.json (app: ${appName}${hasWsRoutes ? ', ws: true' : ''})`);
366
464
  }
367
465
  const elapsed = Date.now() - startTime;
@@ -14,7 +14,7 @@
14
14
  */
15
15
  import { renderToString as builtinRenderToString } from 'what-framework/server';
16
16
  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';
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';
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');
@@ -276,6 +276,9 @@ export async function startStandaloneServer(manifest, opts) {
276
276
  outfile: 'page.js',
277
277
  jsx: 'automatic',
278
278
  jsxImportSource,
279
+ // Same redirect the production build applies: `@celsian/vura-core` in a
280
+ // browser bundle resolves to the pure client module.
281
+ plugins: [vuraBrowserResolvePlugin()],
279
282
  nodePaths: [join(opts.projectRoot, 'node_modules')],
280
283
  });
281
284
  const text = result.outputFiles[0].text;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celsian/vura-cli",
3
- "version": "0.5.14",
3
+ "version": "0.6.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.5.14",
18
+ "@celsian/vura-core": "0.6.1",
19
19
  "esbuild": "^0.28.1",
20
- "what-framework": "^0.11.1"
20
+ "what-framework": "^0.13.2"
21
21
  },
22
22
  "peerDependencies": {
23
- "@celsian/vura-adapter-vura": "0.5.14",
23
+ "@celsian/vura-adapter-vura": "0.6.1",
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.5.14",
35
+ "@celsian/vura-adapter-vura": "0.6.1",
36
36
  "@types/ws": "^8.18.1",
37
37
  "ws": "^8.21.0"
38
38
  },