@webjsdev/cli 0.10.4 → 0.10.6

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/bin/webjs.js CHANGED
@@ -2,10 +2,36 @@
2
2
  import { resolve, join, dirname } from 'node:path';
3
3
  import { spawn } from 'node:child_process';
4
4
  import { fileURLToPath } from 'node:url';
5
+ import { checkNodeInline, nodeInlineMessage } from '../lib/node-preflight.js';
5
6
 
6
7
  const __dirname = dirname(fileURLToPath(import.meta.url));
7
8
  const [cmd, ...rest] = process.argv.slice(2);
8
9
 
10
+ // Node-version preflight (issue #238), INLINE and dependency-free.
11
+ // This MUST run before any `import @webjsdev/server`: importing the server
12
+ // package links `src/dev.js`, which references Node 24+ builtins, so on an old
13
+ // Node that import would LINK-fail before any preflight inside the server
14
+ // package could run. The primary guard is therefore `checkNodeInline` (from
15
+ // `../lib/node-preflight.js`, which imports nothing), depending only on
16
+ // `process.versions.node`. The richer `assertNodeVersion` import inside main()
17
+ // stays as belt-and-suspenders for the link-ok (>= 22.13) cases.
18
+ // `help` / no-arg is exempt so a user on an old Node can still read usage.
19
+ if (cmd !== 'help' && cmd !== undefined) {
20
+ let engines = '>=24.0.0';
21
+ try {
22
+ const { readFileSync } = await import('node:fs');
23
+ const pkg = JSON.parse(
24
+ readFileSync(join(__dirname, '..', 'package.json'), 'utf8'),
25
+ );
26
+ engines = pkg?.engines?.node || engines;
27
+ } catch {}
28
+ const r = checkNodeInline(process.versions.node, engines);
29
+ if (!r.ok) {
30
+ console.error(nodeInlineMessage(r));
31
+ process.exit(1);
32
+ }
33
+ }
34
+
9
35
  // Exactly three scaffolds exist. Keep this list as the single source of
10
36
  // truth. AI-agent docs in README.md / AGENTS.md / .cursorrules /
11
37
  // .agents/rules/workflow.md / .github/copilot-instructions.md mirror it.
@@ -16,6 +42,7 @@ const USAGE = `webjs commands:
16
42
  webjs start [--port 8080] Start production server (serves source directly, no build step)
17
43
  webjs test [--server|--browser] Run server + browser tests
18
44
  webjs check Run correctness checks on the app
45
+ webjs types Generate .webjs/routes.d.ts (typed Route union + per-route params)
19
46
  webjs create <name> [--template full-stack|api|saas] [--no-install] Scaffold a new webjs app
20
47
  (only 3 templates exist. default: full-stack with Prisma+SQLite)
21
48
  Auto-runs the detected package manager's install in the new dir
@@ -41,6 +68,15 @@ function flag(args, name, def) {
41
68
  }
42
69
 
43
70
  async function main() {
71
+ // Preflight: webjs needs Node 24+ (built-in TS strip + recursive fs.watch).
72
+ // Run before any subcommand so an older Node fails fast with a clear,
73
+ // actionable message naming the found + required version, exiting non-zero
74
+ // instead of crashing cryptically later. `help` is exempt so a user on an
75
+ // old Node can still read usage.
76
+ if (cmd !== 'help' && cmd !== undefined) {
77
+ const { assertNodeVersion } = await import('@webjsdev/server');
78
+ assertNodeVersion({ onFail: 'exit' });
79
+ }
44
80
  switch (cmd) {
45
81
  case 'dev': {
46
82
  // If we're already inside the --watch child, start the server directly.
@@ -242,6 +278,27 @@ async function main() {
242
278
  }
243
279
  break;
244
280
  }
281
+ case 'types': {
282
+ // Generate `.webjs/routes.d.ts` from the app's `app/` routes (#258),
283
+ // narrowing the @webjsdev/core `Route` href union + per-route `params`.
284
+ // Opt-in codegen: the static types in @webjsdev/core work without it
285
+ // (un-generated apps see `Route = string`).
286
+ const { generateRouteTypes } = await import('@webjsdev/server');
287
+ const { mkdir, writeFile } = await import('node:fs/promises');
288
+ const appDir = process.cwd();
289
+ const text = await generateRouteTypes(appDir);
290
+ const outDir = join(appDir, '.webjs');
291
+ await mkdir(outDir, { recursive: true });
292
+ const outFile = join(outDir, 'routes.d.ts');
293
+ await writeFile(outFile, text);
294
+ // Count the typed routes (each `WebjsRoutes` key is one route literal).
295
+ const count = (text.match(/^\s+".*": true;$/gm) || []).length;
296
+ console.log(
297
+ `webjs types: wrote .webjs/routes.d.ts (${count} route${count === 1 ? '' : 's'} typed). ` +
298
+ `Ensure tsconfig "include" lists ".webjs/routes.d.ts" so tsserver picks it up.`,
299
+ );
300
+ break;
301
+ }
245
302
  case 'create': {
246
303
  const name = rest[0];
247
304
  if (!name || name.startsWith('-')) {
package/lib/create.js CHANGED
@@ -346,6 +346,22 @@ export async function scaffoldApp(name, cwd, opts = {}) {
346
346
  { name: '@webjsdev/ts-plugin' },
347
347
  ],
348
348
  },
349
+ // `.webjs/routes.d.ts` is the OPT-IN generated route-types overlay (#258):
350
+ // run `webjs types` (or `webjs dev`, which emits it) to narrow the
351
+ // @webjsdev/core `Route` href union + per-route `params`. Listed in
352
+ // `include` so tsserver picks it up; it is gitignored (regenerated per
353
+ // machine), so a fresh clone runs `webjs dev` / `webjs types` to recreate
354
+ // it, and the static @webjsdev/core types work even when it is absent.
355
+ include: [
356
+ 'app/**/*',
357
+ 'components/**/*',
358
+ 'modules/**/*',
359
+ 'lib/**/*',
360
+ 'middleware.js',
361
+ 'middleware.ts',
362
+ '.webjs/routes.d.ts',
363
+ ],
364
+ exclude: ['node_modules', '.webjs/vendor', 'prisma/migrations'],
349
365
  }, null, 2) + '\n');
350
366
 
351
367
  // --- Templates (AGENTS.md, CONVENTIONS.md, CLAUDE.md, test files, Claude hooks) ---
@@ -397,6 +413,10 @@ export async function scaffoldApp(name, cwd, opts = {}) {
397
413
  // to main, mirroring the webjs framework's own CI.
398
414
  '.github/workflows/ci.yml',
399
415
  '.editorconfig',
416
+ // VS Code: associate the published webjs-config JSON Schema with the
417
+ // package.json `webjs` block, so an unknown / typo'd key (#259) is
418
+ // flagged natively in the editor instead of silently dropped.
419
+ '.vscode/settings.json',
400
420
  // Production / deploy scaffolding. `docker compose up --build` runs
401
421
  // the app locally with the same Dockerfile production builds from.
402
422
  'Dockerfile',
@@ -494,6 +514,27 @@ if (process.env.NODE_ENV !== 'production') g.__prisma = prisma;
494
514
  if (isApi) {
495
515
  // API-only template: no layout, no page, no components.
496
516
  // Just a health route and an example module with route wrapper.
517
+
518
+ // Root middleware applying CORS to every route. An API consumed by a
519
+ // browser from another origin needs this; the `cors()` primitive
520
+ // handles origin reflection, the OPTIONS preflight, Vary: Origin, and
521
+ // the credentials rule, so route handlers stay focused on data.
522
+ await writeFile(join(appDir, 'middleware.ts'), `import { cors } from '@webjsdev/server';
523
+
524
+ /**
525
+ * App-wide CORS policy. Replace the allow-list with your real frontend
526
+ * origins. With \`credentials: true\` a wildcard origin is invalid per the
527
+ * CORS spec, so list explicit origins (never \`'*'\` + credentials).
528
+ */
529
+ export default cors({
530
+ origin: ['http://localhost:3000', 'https://app.example.com'],
531
+ credentials: true,
532
+ methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
533
+ allowedHeaders: ['content-type', 'authorization'],
534
+ maxAge: 86400,
535
+ });
536
+ `);
537
+
497
538
  await mkdir(join(appDir, 'app', 'api', 'health'), { recursive: true });
498
539
  await mkdir(join(appDir, 'app', 'api', 'users'), { recursive: true });
499
540
  await writeFile(join(appDir, 'app', 'api', 'health', 'route.ts'), `export async function GET() {
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Inline, dependency-free Node-version preflight for the CLI (issue #238).
3
+ *
4
+ * This is deliberately SEPARATE from `@webjsdev/server`'s `node-version.js`:
5
+ * importing the server package links `src/dev.js`, which references Node 24+
6
+ * builtins, so on an old Node that import LINK-fails before any preflight could
7
+ * run. The CLI's primary guard must therefore depend on nothing but
8
+ * `process.versions.node`. `bin/webjs.js` imports `checkNodeInline` from here
9
+ * and runs it before any `import @webjsdev/server`. This module imports nothing.
10
+ */
11
+
12
+ /**
13
+ * Pure Node-major check. Reads the minimum from the passed `engines` range
14
+ * (the CLI's own `engines.node`, so the requirement lives in one place) and
15
+ * compares the running major. Fails open (`ok: true`) on an unparseable running
16
+ * version so an unusual runtime is not blocked; falls back to 24 when the
17
+ * engines range carries no integer.
18
+ * @param {string} current running Node version (e.g. `process.versions.node`)
19
+ * @param {string} engines the CLI package's `engines.node` range
20
+ * @returns {{ ok: boolean, current: string, currentMajor: number, requiredMajor: number }}
21
+ */
22
+ export function checkNodeInline(current, engines) {
23
+ const cm = String(current).match(/^v?(\d+)/);
24
+ const rm = String(engines).match(/(\d+)/);
25
+ const currentMajor = cm ? Number(cm[1]) : NaN;
26
+ const requiredMajor = rm ? Number(rm[1]) : 24;
27
+ const ok = Number.isNaN(currentMajor) || currentMajor >= requiredMajor;
28
+ return { ok, current, currentMajor, requiredMajor };
29
+ }
30
+
31
+ /**
32
+ * Compose the actionable stderr message for an unsupported Node. Names the found
33
+ * and required version and the reason (the built-in TS strip + recursive
34
+ * fs.watch need Node 24+).
35
+ * @param {{ current: string, currentMajor: number, requiredMajor: number }} r
36
+ * @returns {string}
37
+ */
38
+ export function nodeInlineMessage(r) {
39
+ return (
40
+ `webjs requires Node ${r.requiredMajor}+ but found Node ${r.current}. ` +
41
+ `webjs is buildless and relies on Node ${r.requiredMajor}'s built-in ` +
42
+ `TypeScript strip (module.stripTypeScriptTypes) and recursive fs.watch, ` +
43
+ `neither of which exists on Node ${r.currentMajor}. ` +
44
+ `Upgrade to Node ${r.requiredMajor} or newer (see https://nodejs.org).`
45
+ );
46
+ }
@@ -276,6 +276,7 @@ export async function writeSaasFiles(appDir) {
276
276
  await mkdir(join(appDir, 'app', 'signup'), { recursive: true });
277
277
  await writeFile(join(appDir, 'app', 'signup', 'page.ts'), [
278
278
  "import { html } from '@webjsdev/core';",
279
+ "import { signup } from '../../modules/auth/actions/signup.server.ts';",
279
280
  "import { cardClass, cardHeaderClass, cardTitleClass, cardDescriptionClass, cardContentClass, cardFooterClass } from '../../components/ui/card.ts';",
280
281
  "import { buttonClass } from '../../components/ui/button.ts';",
281
282
  "import { inputClass } from '../../components/ui/input.ts';",
@@ -283,7 +284,30 @@ export async function writeSaasFiles(appDir) {
283
284
  "",
284
285
  "export const metadata = { title: 'Sign up' };",
285
286
  "",
286
- "export default function SignupPage() {",
287
+ "// Page server action: handles the POST from the form below. With JS",
288
+ "// disabled this is a plain <form> round-trip; with JS the client router",
289
+ "// swaps the 422 re-render (errors) or follows the 303 (success) in place.",
290
+ "// A validation failure returns fieldErrors + values so the page re-renders",
291
+ "// with messages and the user's typed input preserved (#244).",
292
+ "export async function action({ formData }: { formData: FormData }) {",
293
+ " const name = String(formData.get('name') || '').trim();",
294
+ " const email = String(formData.get('email') || '').trim();",
295
+ " const password = String(formData.get('password') || '');",
296
+ " const values = { name, email };",
297
+ " const fieldErrors: Record<string, string> = {};",
298
+ " if (!name) fieldErrors.name = 'Name is required';",
299
+ " if (!email.includes('@')) fieldErrors.email = 'Enter a valid email';",
300
+ " if (password.length < 8) fieldErrors.password = 'At least 8 characters';",
301
+ " if (Object.keys(fieldErrors).length) return { success: false, fieldErrors, values, status: 422 };",
302
+ " const result = await signup({ name, email, password });",
303
+ " if (!result.success) return { success: false, fieldErrors: { email: result.error }, values, status: result.status };",
304
+ " // Account created. Redirect to login via PRG so a reload will not resubmit.",
305
+ " return { success: true, redirect: '/login' };",
306
+ "}",
307
+ "",
308
+ "export default function SignupPage({ actionData }: { actionData?: { fieldErrors?: Record<string, string>; values?: Record<string, string> } }) {",
309
+ " const errors = actionData?.fieldErrors || {};",
310
+ " const values = actionData?.values || {};",
287
311
  " return html`",
288
312
  " <div class=\"max-w-sm mx-auto mt-12\">",
289
313
  " <div class=${cardClass()}>",
@@ -292,18 +316,21 @@ export async function writeSaasFiles(appDir) {
292
316
  " <p class=${cardDescriptionClass()}>Get started with your new workspace.</p>",
293
317
  " </div>",
294
318
  " <div class=${cardContentClass()}>",
295
- " <form id=\"signup-form\" class=\"flex flex-col gap-4\">",
319
+ " <form method=\"POST\" class=\"flex flex-col gap-4\">",
296
320
  " <div class=\"flex flex-col gap-1.5\">",
297
321
  " <label class=${labelClass()} for=\"name\">Name</label>",
298
- " <input class=${inputClass()} id=\"name\" name=\"name\" type=\"text\" required>",
322
+ " <input class=${inputClass()} id=\"name\" name=\"name\" type=\"text\" value=${values.name || ''} required>",
323
+ " ${errors.name ? html`<p class=\"text-sm text-destructive\">${errors.name}</p>` : ''}",
299
324
  " </div>",
300
325
  " <div class=\"flex flex-col gap-1.5\">",
301
326
  " <label class=${labelClass()} for=\"email\">Email</label>",
302
- " <input class=${inputClass()} id=\"email\" name=\"email\" type=\"email\" required>",
327
+ " <input class=${inputClass()} id=\"email\" name=\"email\" type=\"email\" value=${values.email || ''} required>",
328
+ " ${errors.email ? html`<p class=\"text-sm text-destructive\">${errors.email}</p>` : ''}",
303
329
  " </div>",
304
330
  " <div class=\"flex flex-col gap-1.5\">",
305
331
  " <label class=${labelClass()} for=\"password\">Password</label>",
306
- " <input class=${inputClass()} id=\"password\" name=\"password\" type=\"password\" required>",
332
+ " <input class=${inputClass()} id=\"password\" name=\"password\" type=\"password\" minlength=\"8\" required>",
333
+ " ${errors.password ? html`<p class=\"text-sm text-destructive\">${errors.password}</p>` : ''}",
307
334
  " </div>",
308
335
  " <button class=${buttonClass()} type=\"submit\">Create account</button>",
309
336
  " </form>",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webjsdev/cli",
3
- "version": "0.10.4",
3
+ "version": "0.10.6",
4
4
  "type": "module",
5
5
  "description": "webjs CLI - dev, start, create, db",
6
6
  "bin": {
@@ -0,0 +1,15 @@
1
+ {
2
+ "json.schemas": [
3
+ {
4
+ "fileMatch": ["/package.json"],
5
+ "schema": {
6
+ "type": "object",
7
+ "properties": {
8
+ "webjs": {
9
+ "$ref": "./node_modules/@webjsdev/server/webjs-config.schema.json"
10
+ }
11
+ }
12
+ }
13
+ }
14
+ ]
15
+ }
@@ -112,6 +112,15 @@ layered on top:
112
112
  See [docs.webjs.com → Editor setup](https://docs.webjs.com/docs/editor-setup)
113
113
  for the full walkthrough.
114
114
 
115
+ **Config validation in `package.json`.** The scaffold ships
116
+ `.vscode/settings.json`, which associates the published webjs-config JSON
117
+ Schema (`@webjsdev/server/webjs-config.schema.json`) with the `webjs` block
118
+ of `package.json`. In VS Code an unknown / typo'd `webjs.*` key (`redirect`
119
+ for `redirects`, say) is then flagged inline instead of silently dropped to
120
+ the default. The same shape is typed by the `WebjsConfig` type from
121
+ `@webjsdev/core` (`import type { WebjsConfig } from '@webjsdev/core'`) for a
122
+ typed reference.
123
+
115
124
  ## UI components: Webjs UI (preinstalled)
116
125
 
117
126
  This scaffold ships with the standard Webjs UI component kit
@@ -267,6 +276,28 @@ test/<feature>/ feature-scoped tests, one folder per concern
267
276
  middleware.ts root middleware (optional, outermost)
268
277
  ```
269
278
 
279
+ ### Typed page / layout / route-handler props
280
+
281
+ Type page / layout / route-handler arguments with the exported helpers so a
282
+ param typo is a compile-time error:
283
+
284
+ ```ts
285
+ import type { PageProps, LayoutProps, RouteHandlerContext } from '@webjsdev/core';
286
+
287
+ export default function Post({ params }: PageProps<'/blog/[slug]'>) {
288
+ return html`<h1>${params.slug}</h1>`; // params typed { slug: string }
289
+ }
290
+ export default function RootLayout({ children }: LayoutProps) { /* ... */ }
291
+ export async function GET(req: Request, ctx: RouteHandlerContext) { /* ctx.params */ }
292
+ ```
293
+
294
+ Run `webjs types` once (and ensure `tsconfig.json` `include` lists
295
+ `.webjs/routes.d.ts`, the scaffold already does) to generate the route union:
296
+ `PageProps<'/blog/[slug]'>['params']` then narrows to `{ slug: string }` and
297
+ `navigate()` only accepts real app routes. `webjs dev` regenerates the file on
298
+ startup, so it stays current. Without it, `params` is `Record<string, string>`
299
+ and `navigate()` accepts any string (non-breaking).
300
+
270
301
  ## Database (Prisma + SQLite by default)
271
302
 
272
303
  Every scaffold includes a Prisma setup pointed at a local SQLite file.
@@ -440,7 +471,7 @@ import { createContext } from '@webjsdev/core/context';
440
471
  import { Task } from '@webjsdev/core/task';
441
472
  import { fixture, waitForUpdate } from '@webjsdev/core/testing';
442
473
 
443
- import { rateLimit, cache, createAuth, Credentials, Session } from '@webjsdev/server';
474
+ import { rateLimit, cors, cache, createAuth, Credentials, Session } from '@webjsdev/server';
444
475
  ```
445
476
 
446
477
  ## Environment variables (server vs browser)
@@ -709,8 +740,13 @@ return html`
709
740
  ```
710
741
 
711
742
  The router's `closest('webjs-frame')` detection takes precedence over
712
- layout markers. Only the frame's content swaps. Use this sparingly -
713
- folder-based layouts handle 99% of cases.
743
+ layout markers. Only the frame's content swaps. Use this sparingly,
744
+ folder-based layouts handle 99% of cases. When a frame nav's response
745
+ lacks the matching `<webjs-frame id>` (e.g. an auth redirect), the router
746
+ fires a cancelable, bubbling `webjs:frame-missing` event (detail
747
+ `{ frameId, url, document }`) and leaves the frame unchanged rather than
748
+ silently swapping the whole page; call `preventDefault()` to take over
749
+ the outcome (e.g. `location.assign(e.detail.url)`).
714
750
 
715
751
  ### 5. `loading.ts` for per-segment skeletons
716
752
 
@@ -8,6 +8,14 @@
8
8
  # falls back to esbuild, whose class-declaration transform breaks webjs's SSR
9
9
  # walker for multi-class component files. Do not lower this base image
10
10
  # below 24 (the same version the CI workflow and the framework pin).
11
+ #
12
+ # Security headers are set by the framework, not the proxy. webjs emits
13
+ # X-Content-Type-Options, X-Frame-Options, Referrer-Policy, and
14
+ # Permissions-Policy on every response, plus Strict-Transport-Security in
15
+ # production over HTTPS (detected from X-Forwarded-Proto on the trusted
16
+ # edge). So the baseline needs no reverse-proxy config. Override or extend
17
+ # per path with package.json "webjs": { "headers": [...] }. See the
18
+ # framework AGENTS.md "Secure response headers" section.
11
19
  FROM node:24-alpine
12
20
 
13
21
  # openssl + ca-certificates are required by Prisma's query engine at runtime.