@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 +1 -3
- package/dist/commands/admin.d.ts +16 -0
- package/dist/commands/admin.js +61 -19
- package/dist/commands/build.d.ts +36 -8
- package/dist/commands/build.js +83 -87
- package/dist/commands/dev.js +30 -2
- package/package.json +7 -7
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 — /
|
|
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
|
package/dist/commands/admin.d.ts
CHANGED
|
@@ -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 {};
|
package/dist/commands/admin.js
CHANGED
|
@@ -60,8 +60,17 @@ export function isAllowedAdminRequest(headers, bindHost, port) {
|
|
|
60
60
|
return false;
|
|
61
61
|
}
|
|
62
62
|
}
|
|
63
|
-
|
|
64
|
-
|
|
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,
|
|
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
|
-
|
|
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}]:${
|
|
318
|
-
: `http://${displayHost}:${
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
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 ───
|
package/dist/commands/build.d.ts
CHANGED
|
@@ -2,14 +2,42 @@
|
|
|
2
2
|
* `vura build` — Build the project for deployment.
|
|
3
3
|
*
|
|
4
4
|
* 1. Scan routes → build manifest
|
|
5
|
-
* 2.
|
|
6
|
-
* 3.
|
|
7
|
-
* 4.
|
|
8
|
-
* 5.
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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
|
package/dist/commands/build.js
CHANGED
|
@@ -2,21 +2,40 @@
|
|
|
2
2
|
* `vura build` — Build the project for deployment.
|
|
3
3
|
*
|
|
4
4
|
* 1. Scan routes → build manifest
|
|
5
|
-
* 2.
|
|
6
|
-
* 3.
|
|
7
|
-
* 4.
|
|
8
|
-
* 5.
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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
|
-
//
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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');
|
package/dist/commands/dev.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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.
|
|
19
|
-
"esbuild": "^0.28.
|
|
20
|
-
"what-framework": "^0.13.
|
|
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.
|
|
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.
|
|
35
|
+
"@celsian/vura-adapter-vura": "0.8.1",
|
|
36
36
|
"@types/ws": "^8.18.1",
|
|
37
|
-
"ws": "^8.21.
|
|
37
|
+
"ws": "^8.21.3"
|
|
38
38
|
},
|
|
39
39
|
"license": "MIT",
|
|
40
40
|
"publishConfig": {
|