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