@webjsdev/cli 0.10.13 → 0.10.15

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 CHANGED
@@ -42,10 +42,12 @@ webjs create <name> # scaffold a full-stack app (default)
42
42
  webjs create <name> --template api # backend-only API app
43
43
  webjs create <name> --template saas # auth + dashboard + Prisma User model
44
44
 
45
- webjs dev # dev server with live reload
45
+ webjs dev # dev server with live reload (prefer `npm run dev`, which runs the predev prisma generate hook)
46
46
  webjs start # production server (no build step, serves source directly)
47
- webjs check # validate project conventions
47
+ webjs check # validate source-code conventions (CI gate)
48
+ webjs doctor # verify the project/toolchain setup (local onboarding, not CI)
48
49
  webjs test # run server + browser tests
50
+ webjs vendor pin [--download] # pin client deps to a committable importmap (offline/reproducible)
49
51
  webjs db <prisma-subcommand> # prisma passthrough (saas template)
50
52
 
51
53
  webjs ui init # initialise @webjsdev/ui in this project
package/bin/webjs.js CHANGED
@@ -3,6 +3,7 @@ import { resolve, join, dirname } from 'node:path';
3
3
  import { spawn } from 'node:child_process';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import { checkNodeInline, nodeInlineMessage } from '../lib/node-preflight.js';
6
+ import { loadAppEnv, resolvePort } from '../lib/port.js';
6
7
 
7
8
  const __dirname = dirname(fileURLToPath(import.meta.url));
8
9
  const [cmd, ...rest] = process.argv.slice(2);
@@ -43,7 +44,7 @@ const USAGE = `webjs commands:
43
44
  webjs test [--server|--browser] Run server + browser tests
44
45
  webjs check [--json] Run correctness checks on the app (--json emits structured violations)
45
46
  webjs mcp Start the read-only MCP server (routes / actions / components / check)
46
- webjs doctor Verify project health (Node, tsconfig, env, vendor pins, @webjsdev versions, git hook)
47
+ webjs doctor Verify project health (Node, tsconfig, env, vendor pins, importmap coherence, @webjsdev versions, git hook)
47
48
  webjs types Generate .webjs/routes.d.ts (typed Route union + per-route params)
48
49
  webjs typecheck [tsc args...] Type-check the app with the project's tsc --noEmit (non-zero on errors)
49
50
  webjs create <name> [--template full-stack|api|saas] [--no-install] Scaffold a new webjs app
@@ -85,11 +86,24 @@ async function main() {
85
86
  // If we're already inside the --watch child, start the server directly.
86
87
  if (process.env.__WEBJS_DEV_CHILD === '1') {
87
88
  const { startServer } = await import('@webjsdev/server');
88
- const port = Number(flag(rest, '--port', process.env.PORT || 8080));
89
+ // Load `.env` BEFORE resolving the port so a `PORT` set there is in
90
+ // process.env at resolution time (#447). The server loads `.env`
91
+ // too, but that runs too late to affect the port the CLI computes.
92
+ loadAppEnv(process.cwd());
93
+ const port = resolvePort(flag(rest, '--port'));
89
94
  await startServer({ appDir: process.cwd(), port, dev: true });
90
95
  break;
91
96
  }
92
97
 
98
+ // A bare `webjs dev` (not `npm run dev`) skips the `predev` hook, so a
99
+ // Prisma app boots against an ungenerated client and crashes with no
100
+ // hint (#452). Detect that here, in the PARENT only, so the message
101
+ // prints once rather than on every watch restart. Scoped to Prisma apps;
102
+ // a non-Prisma app sees nothing. A hint, not an auto-run.
103
+ const { prismaDevHint } = await import('../lib/prisma-preflight.js');
104
+ const hint = prismaDevHint(process.cwd());
105
+ if (hint) console.error(hint);
106
+
93
107
  // Otherwise, spawn ourselves as a child with node --watch.
94
108
  // This restarts the process on file changes, guaranteeing a fresh
95
109
  // Node ESM module cache. Without this, edits to transitively-imported
@@ -125,7 +139,10 @@ async function main() {
125
139
  }
126
140
  case 'start': {
127
141
  const { startServer } = await import('@webjsdev/server');
128
- const port = Number(flag(rest, '--port', process.env.PORT || 8080));
142
+ // Load `.env` BEFORE resolving the port so a `PORT` set there wins over
143
+ // the 8080 default (#447), same as for `dev`.
144
+ loadAppEnv(process.cwd());
145
+ const port = resolvePort(flag(rest, '--port'));
129
146
  await startServer({ appDir: process.cwd(), port, dev: false });
130
147
  break;
131
148
  }
@@ -417,7 +434,7 @@ Full docs: https://docs.webjs.com`);
417
434
  const sub = rest[0];
418
435
  const args = rest.slice(1);
419
436
  const appDir = process.cwd();
420
- const { pinAll, unpinPackage, listPinned, auditPinned, findOutdated, updatePinned, readPinFile, SUPPORTED_PROVIDERS } = await import('@webjsdev/server');
437
+ const { pinAll, unpinPackage, listPinned, auditPinned, findOutdated, updatePinned, readPinFile, ensureVendorCommittable, SUPPORTED_PROVIDERS } = await import('@webjsdev/server');
421
438
 
422
439
  // Parse `--from <provider>` once at the top so subcommands share it.
423
440
  // Mirrors importmap-rails's `bin/importmap pin foo --from jsdelivr`.
@@ -498,6 +515,30 @@ Full docs: https://docs.webjs.com`);
498
515
  (downloaded ? ` + ${downloaded} bundle${downloaded === 1 ? '' : 's'}` : '') + '.';
499
516
  const pruneMsg = pruned.length ? ` Pruned ${pruned.length} orphan${pruned.length === 1 ? '' : 's'}.` : '';
500
517
  console.log(pinMsg + pruneMsg);
518
+
519
+ // Make the pins committable. Vendoring is opt-in, so the pins the
520
+ // user just wrote are meant for source control; a `.gitignore`
521
+ // that excludes `.webjs/` would silently swallow them. Fresh
522
+ // scaffolds already carry the `!.webjs/vendor/` exception, so for
523
+ // them this is a no-op. If the output IS ignored, self-heal the
524
+ // app's own `.gitignore`; if there is no `.gitignore` to patch (the
525
+ // ignore comes from a parent repo or `.git/info/exclude`), print a
526
+ // notice so the pins do not vanish from `git status` unexplained.
527
+ const committable = await ensureVendorCommittable(appDir);
528
+ if (committable.patched) {
529
+ console.log(
530
+ `Added the \`.webjs/vendor/\` exception to .gitignore so these pins commit. ` +
531
+ `Run \`git add .gitignore .webjs/vendor\`.`,
532
+ );
533
+ } else if (committable.ignored) {
534
+ console.warn(
535
+ `[webjs] .webjs/vendor/importmap.json is gitignored, so these pins will NOT ` +
536
+ `commit. The ignore is not in this app's .gitignore (a parent repo's .gitignore ` +
537
+ `or .git/info/exclude). Un-ignore it by adding \`!**/.webjs/vendor/\` and ` +
538
+ `\`!**/.webjs/vendor/**\` where the \`.webjs\` exclusion lives, then ` +
539
+ `\`git add .webjs/vendor\`. Verify with \`git check-ignore -q .webjs/vendor/importmap.json\`.`,
540
+ );
541
+ }
501
542
  break;
502
543
  }
503
544
 
package/lib/doctor.js CHANGED
@@ -326,6 +326,84 @@ async function checkVendorPin(appDir, opts) {
326
326
  };
327
327
  }
328
328
 
329
+ /**
330
+ * CHECK: the `.gitignore` does not swallow the committed vendor pin. The pattern
331
+ * for `.webjs/vendor/` is subtle: a bare `.webjs/` line excludes the directory
332
+ * entirely and git cannot re-include children of an excluded parent, so a
333
+ * `!.webjs/vendor/` exception silently does nothing and `webjs vendor pin`
334
+ * output never gets committed. The correct pattern is the depth-robust
335
+ * contents-glob form (see the fix text below / VENDOR_GITIGNORE_LINES in
336
+ * vendor.js): a globstar-prefixed `.webjs/*` plus the matching vendor
337
+ * negations, which ignores transient `.webjs` output at any depth while
338
+ * keeping the committed vendor pin tracked.
339
+ *
340
+ * This was a `webjs check` rule, but inspecting `.gitignore` is a project-config
341
+ * concern (like `tsconfig-erasable`), not source-code correctness, and vendoring
342
+ * is optional, so a doctor WARN fits the domain and severity better than a CI
343
+ * hard-fail (#461). It lives next to `vendor-pin` (same family).
344
+ *
345
+ * PASS/skip when the dir is not a git repo or has no `.gitignore` (the user has
346
+ * not opted into version control yet). Probes two representative paths via
347
+ * `git check-ignore` with the inherited GIT_* env stripped so `cwd` is the sole
348
+ * authority on which repo + .gitignore stack is consulted (a pre-commit hook
349
+ * from a linked worktree exports GIT_WORK_TREE, which would otherwise override
350
+ * cwd-based discovery).
351
+ *
352
+ * @param {string} appDir
353
+ * @returns {Promise<DoctorResult>}
354
+ */
355
+ async function checkVendorGitignore(appDir) {
356
+ const hasGit = existsSync(join(appDir, '.git'));
357
+ const hasGitignore = existsSync(join(appDir, '.gitignore'));
358
+ if (!hasGit || !hasGitignore) {
359
+ return {
360
+ name: 'vendor-gitignore',
361
+ status: 'pass',
362
+ message: 'Not a git checkout with a .gitignore; nothing to verify.',
363
+ };
364
+ }
365
+ const { spawnSync } = await import('node:child_process');
366
+ const {
367
+ GIT_DIR: _gd, GIT_WORK_TREE: _gwt, GIT_INDEX_FILE: _gif, GIT_PREFIX: _gp,
368
+ ...gitEnv
369
+ } = process.env;
370
+ // Check two representative paths: the pin manifest AND a sample downloaded
371
+ // bundle. A `.gitignore` that allows the manifest but blocks bundles (e.g.
372
+ // `*.js` higher up) would still break `webjs vendor pin --download`.
373
+ // `git check-ignore -q` exits 0 when the path is ignored, 1 when not.
374
+ const probes = [
375
+ '.webjs/vendor/importmap.json',
376
+ '.webjs/vendor/sample-pkg@1.0.0.js',
377
+ ];
378
+ for (const probe of probes) {
379
+ const result = spawnSync('git', ['check-ignore', '-q', probe], {
380
+ cwd: appDir,
381
+ stdio: 'pipe',
382
+ env: gitEnv,
383
+ });
384
+ if (result.status === 0) {
385
+ return {
386
+ name: 'vendor-gitignore',
387
+ status: 'warn',
388
+ message:
389
+ `${probe} is gitignored, but \`webjs vendor pin\` writes files under .webjs/vendor/ that MUST be committed for a production deploy to use the pin (instead of calling api.jspm.io on every cold start). The most common cause: a \`.webjs/\` line that excludes the parent directory before the \`!.webjs/vendor/\` exception can take effect (git semantics: a parent exclusion blocks child negations). A second cause is a broader rule (e.g. \`*.js\` at root) hiding bundle files added by \`webjs vendor pin --download\`.`,
390
+ fix:
391
+ 'Replace `.webjs/` in your .gitignore with this three-line pattern:\n' +
392
+ ' **/.webjs/*\n' +
393
+ ' !**/.webjs/vendor/\n' +
394
+ ' !**/.webjs/vendor/**\n' +
395
+ 'The `**/` prefix ignores `.webjs/` at any depth (so a nested / monorepo app does not leak its generated `.webjs/routes.d.ts`) while still re-including the committed vendor pin. ' +
396
+ 'Verify with `git check-ignore -q .webjs/vendor/importmap.json` (exit 1 means correctly un-ignored).',
397
+ };
398
+ }
399
+ }
400
+ return {
401
+ name: 'vendor-gitignore',
402
+ status: 'pass',
403
+ message: 'The .gitignore keeps .webjs/vendor/ committable.',
404
+ };
405
+ }
406
+
329
407
  /**
330
408
  * Compare an installed version against a semver range PRAGMATICALLY (no semver
331
409
  * dependency). Supports the common scaffold shapes: `latest` / `*` / `workspace:*`
@@ -367,6 +445,200 @@ function satisfiesRange(installed, range) {
367
445
  return null;
368
446
  }
369
447
 
448
+ /**
449
+ * Read the declared dependency ranges of an INSTALLED package from
450
+ * `node_modules/<pkg>/package.json`, for the importmap-coherence check. This
451
+ * is the "already-resolved metadata, no network" path the issue calls for: the
452
+ * package is on disk (it was installed for the importmap to pin it), so its
453
+ * manifest is a local read. Returns null on any failure (not installed,
454
+ * unreadable, unparseable), which the coherence check treats as "could not
455
+ * verify" rather than a conflict.
456
+ *
457
+ * @param {string} appDir
458
+ * @returns {(pkg: string) => Promise<{ dependencies?: Record<string,string>, peerDependencies?: Record<string,string> } | null>}
459
+ */
460
+ function makeInstalledManifestReader(appDir) {
461
+ return async (pkg) => {
462
+ const manifestPath = join(appDir, 'node_modules', pkg, 'package.json');
463
+ if (!existsSync(manifestPath)) return null;
464
+ try {
465
+ const parsed = JSON.parse(await readFile(manifestPath, 'utf8'));
466
+ return {
467
+ dependencies: parsed.dependencies || {},
468
+ peerDependencies: parsed.peerDependencies || {},
469
+ };
470
+ } catch {
471
+ return null;
472
+ }
473
+ };
474
+ }
475
+
476
+ /**
477
+ * Format a coherence conflict list into a single human-readable warning line
478
+ * naming each conflicting pair, the required range, and the pinned version.
479
+ * @param {Array<{ pkg: string, version: string, dependsOn: string, kind: string, requiredRange: string, pinnedVersion: string }>} conflicts
480
+ * @returns {string}
481
+ */
482
+ function formatConflicts(conflicts) {
483
+ return conflicts
484
+ .map(
485
+ (c) =>
486
+ `${c.pkg}@${c.version} needs ${c.dependsOn} ${c.kind === 'peerDependency' ? '(peer) ' : ''}${c.requiredRange} but the importmap pins ${c.dependsOn}@${c.pinnedVersion}`,
487
+ )
488
+ .join('; ');
489
+ }
490
+
491
+ /**
492
+ * CHECK 7, importmap coherence (issue #450). Defense-in-depth that catches an
493
+ * INCOHERENT client dependency graph in the produced importmap, regardless of
494
+ * how the incoherence arose (a hand-edited pin file, a partial vendor pin, or
495
+ * the #446 resolution skew). For each resolved package, it checks that the
496
+ * version actually pinned for every OTHER resolved package it depends on
497
+ * satisfies the declared range; a miss warns naming both packages, the range,
498
+ * and the pinned version.
499
+ *
500
+ * Runs the SAME check over BOTH inputs and produces the same verdict for the
501
+ * same dep set (the parity invariant): the live importmap (resolved the way the
502
+ * server resolves it at runtime) AND the vendored `.webjs/vendor/importmap.json`.
503
+ * A vendored importmap is a freeze of the runtime-resolved graph, so a coherent
504
+ * runtime graph that gets vendored stays coherent.
505
+ *
506
+ * WARN-only and BEST-EFFORT: it never hard-fails (a runtime incoherence is the
507
+ * app's concern, not a broken toolchain), and it degrades to a soft
508
+ * "could not verify" whenever metadata or a live resolve is unavailable rather
509
+ * than failing closed. Dependency metadata is read from the already-installed
510
+ * `node_modules` manifests, no network call of its own; the only network touch
511
+ * is the live importmap resolve, which is wrapped so any failure degrades.
512
+ *
513
+ * The vendor functions + manifest reader are injectable via `opts.coherence`
514
+ * so a test can drive every branch without a network call.
515
+ *
516
+ * @param {string} appDir
517
+ * @param {{ coherence?: {
518
+ * liveImports?: () => Promise<Record<string,string> | null>,
519
+ * vendoredImports?: () => Promise<Record<string,string> | null>,
520
+ * getManifest?: (pkg: string, version: string) => Promise<any>,
521
+ * check?: (imports: Record<string,string>, o: { getManifest: any }) => Promise<{ conflicts: any[], unverified: any[], checked: number }>,
522
+ * } }} opts
523
+ * @returns {Promise<DoctorResult>}
524
+ */
525
+ async function checkImportmapCoherence(appDir, opts) {
526
+ let inj = opts.coherence;
527
+ // Resolve the real vendor toolchain unless a test injected stubs. Both the
528
+ // importmap sources and the coherence-check function come from
529
+ // @webjsdev/server, so a missing install degrades to a WARN, never a throw.
530
+ if (!inj || !inj.check || !inj.liveImports || !inj.vendoredImports || !inj.getManifest) {
531
+ let mod;
532
+ try {
533
+ mod = await import('@webjsdev/server');
534
+ } catch {
535
+ return {
536
+ name: 'importmap-coherence',
537
+ status: 'warn',
538
+ message: 'Could not load the vendor toolchain to check importmap coherence.',
539
+ fix: 'Run `npm install` so @webjsdev/server is available, then re-run `webjs doctor`.',
540
+ };
541
+ }
542
+ const real = {
543
+ check: mod.checkImportmapCoherence,
544
+ // Hoist-aware manifest read from the already-installed node_modules (no
545
+ // network of its own), so a monorepo-hoisted dep still resolves. Falls
546
+ // back to the local app/node_modules read if the server build predates
547
+ // getPackageManifest.
548
+ getManifest: typeof mod.getPackageManifest === 'function'
549
+ ? (pkg) => mod.getPackageManifest(pkg, appDir)
550
+ : makeInstalledManifestReader(appDir),
551
+ // Live importmap: resolve vendor imports the way the server does on the
552
+ // first request (prefers the pin file, else a live jspm.io resolve).
553
+ liveImports: async () => {
554
+ try {
555
+ const resolved = await mod.resolveVendorImports(appDir, () => mod.scanBareImports(appDir));
556
+ return resolved && resolved.imports ? resolved.imports : {};
557
+ } catch {
558
+ return null;
559
+ }
560
+ },
561
+ // Vendored importmap: the committed pin file, no network.
562
+ vendoredImports: async () => {
563
+ try {
564
+ const pin = await mod.readPinFile(appDir);
565
+ return pin && pin.imports ? pin.imports : null;
566
+ } catch {
567
+ return null;
568
+ }
569
+ },
570
+ };
571
+ inj = { ...real, ...(inj || {}) };
572
+ }
573
+
574
+ // Gather both importmaps. Either may be absent (no pin file, or a live
575
+ // resolve that failed / found no vendor imports); the check runs over
576
+ // whichever exist, identically.
577
+ let live = null;
578
+ let vendored = null;
579
+ try { live = await inj.liveImports(); } catch { live = null; }
580
+ try { vendored = await inj.vendoredImports(); } catch { vendored = null; }
581
+
582
+ const liveHas = live && Object.keys(live).length > 0;
583
+ const vendoredHas = vendored && Object.keys(vendored).length > 0;
584
+ if (!liveHas && !vendoredHas) {
585
+ return {
586
+ name: 'importmap-coherence',
587
+ status: 'pass',
588
+ message: 'No vendor importmap to check (the app imports no npm packages on the client).',
589
+ };
590
+ }
591
+
592
+ // Run the IDENTICAL check over each available importmap. The function is
593
+ // pure in (imports, getManifest), so the same pinned dep set produces the
594
+ // same verdict whichever input it came from (the runtime-vs-vendored parity
595
+ // invariant). Aggregate the conflicts; dedupe identical ones so a package
596
+ // pinned the same way in both maps is reported once.
597
+ /** @type {Map<string, any>} */
598
+ const conflictsByKey = new Map();
599
+ let anyChecked = 0;
600
+ let anyUnverified = 0;
601
+ for (const imports of [liveHas ? live : null, vendoredHas ? vendored : null]) {
602
+ if (!imports) continue;
603
+ let report;
604
+ try {
605
+ report = await inj.check(imports, { getManifest: inj.getManifest });
606
+ } catch {
607
+ // A check that threw is a "could not verify", never a doctor crash.
608
+ anyUnverified++;
609
+ continue;
610
+ }
611
+ anyChecked += report.checked || 0;
612
+ anyUnverified += (report.unverified || []).length;
613
+ for (const c of report.conflicts || []) {
614
+ conflictsByKey.set(`${c.pkg}@${c.version}->${c.dependsOn}@${c.pinnedVersion}`, c);
615
+ }
616
+ }
617
+
618
+ const conflicts = [...conflictsByKey.values()];
619
+ if (conflicts.length > 0) {
620
+ return {
621
+ name: 'importmap-coherence',
622
+ status: 'warn',
623
+ message: `Incoherent client dependency graph in the importmap: ${formatConflicts(conflicts)}.`,
624
+ fix: 'Align the pinned versions: re-run `webjs vendor pin` to re-resolve a coherent set, or bump the lagging package in package.json and reinstall so the importmap pins a version satisfying every dependent.',
625
+ };
626
+ }
627
+ if (anyChecked === 0 && anyUnverified > 0) {
628
+ return {
629
+ name: 'importmap-coherence',
630
+ status: 'warn',
631
+ message: 'Could not verify importmap coherence (dependency metadata for the pinned packages was unavailable).',
632
+ fix: 'Run `npm install` so the pinned packages are present in node_modules, then re-run `webjs doctor`.',
633
+ };
634
+ }
635
+ return {
636
+ name: 'importmap-coherence',
637
+ status: 'pass',
638
+ message: 'The importmap dependency graph is coherent (every pinned package satisfies its dependents\' declared ranges).',
639
+ };
640
+ }
641
+
370
642
  /**
371
643
  * CHECK 5, @webjsdev/* version coherence. WARN-level only (a version drift is
372
644
  * not a crash). Reads the app package.json `@webjsdev/*` ranges across
@@ -518,6 +790,9 @@ function checkGitHook(appDir) {
518
790
  * required major (defaults to THIS module's package);
519
791
  * - `vendor`: inject the `{ hasVendorPin, findOutdated }` pair so the pin check
520
792
  * runs against a stub instead of a real network call.
793
+ * - `coherence`: inject `{ liveImports, vendoredImports, getManifest, check }`
794
+ * so the importmap-coherence check runs against stub importmaps + metadata
795
+ * instead of a real live resolve / node_modules read.
521
796
  * @returns {Promise<DoctorResult[]>}
522
797
  */
523
798
  export async function runDoctorChecks(appDir, opts = {}) {
@@ -527,7 +802,9 @@ export async function runDoctorChecks(appDir, opts = {}) {
527
802
  checkTsconfig(appDir),
528
803
  checkEnv(appDir),
529
804
  checkVendorPin(appDir, opts),
805
+ checkVendorGitignore(appDir),
530
806
  checkWebjsVersions(appDir),
807
+ checkImportmapCoherence(appDir, opts),
531
808
  Promise.resolve(checkGitHook(appDir)),
532
809
  ]);
533
810
  return results;
package/lib/port.js ADDED
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Port resolution for `webjs dev` / `webjs start` (issue #447).
3
+ *
4
+ * The bug this fixes: the CLI read `process.env.PORT || 8080` BEFORE the
5
+ * server's bootstrap ran `process.loadEnvFile('.env')`, so a `PORT` set in
6
+ * the project's `.env` never reached the port comparison and the server
7
+ * always came up on 8080. Every OTHER `.env` var worked, because the server
8
+ * loads `.env` early enough for everything IT reads; only the port, computed
9
+ * one layer up in the CLI, missed the load.
10
+ *
11
+ * The fix loads `.env` into `process.env` here, in the CLI, before the port
12
+ * is computed. Both functions live in this module so `dev` and `start` share
13
+ * one implementation and the logic is unit-testable without spawning a
14
+ * server.
15
+ */
16
+ import { join } from 'node:path';
17
+
18
+ /**
19
+ * Load `<appDir>/.env` into `process.env`, guarded exactly like the server's
20
+ * own `loadAppEnv` (`packages/server/src/dev.js`): only on a Node with the
21
+ * built-in `process.loadEnvFile`, and swallowing a missing or malformed file.
22
+ *
23
+ * Node's `loadEnvFile` does NOT override a var already present in
24
+ * `process.env`, so a real shell-exported `PORT=NNNN npm run dev` still wins
25
+ * over the file. That "shell beats file" precedence is intentional and
26
+ * matches what the server does after its own load.
27
+ *
28
+ * @param {string} appDir
29
+ */
30
+ export function loadAppEnv(appDir) {
31
+ try {
32
+ if (typeof process.loadEnvFile === 'function') {
33
+ process.loadEnvFile(join(appDir, '.env'));
34
+ }
35
+ } catch {
36
+ // No .env, malformed file, or a Node without loadEnvFile. Fall through
37
+ // silently: the app may not need any env vars, or they may be set via
38
+ // the shell.
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Resolve the server port with precedence `--port` flag > `PORT` (shell env
44
+ * or `.env`, whichever landed in `process.env`) > 8080.
45
+ *
46
+ * Kept pure (no `.env` loading, no `process.env` mutation) so it is trivially
47
+ * testable: the caller loads `.env` first via `loadAppEnv`, then passes the
48
+ * resulting `process.env` in. A non-numeric or empty `--port` / `PORT`
49
+ * surfaces as `NaN`, same as the previous inline `Number(...)`, so behaviour
50
+ * for bad input is unchanged.
51
+ *
52
+ * @param {string | undefined} portFlag The `--port` value, or undefined.
53
+ * @param {NodeJS.ProcessEnv} [env] Defaults to `process.env`.
54
+ * @returns {number}
55
+ */
56
+ export function resolvePort(portFlag, env = process.env) {
57
+ if (portFlag !== undefined) return Number(portFlag);
58
+ if (env.PORT) return Number(env.PORT);
59
+ return 8080;
60
+ }
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Prisma-client preflight for `webjs dev` (#452).
3
+ *
4
+ * The scaffold's `dev` npm script is `webjs dev`, and `npm run dev` runs the
5
+ * `predev` hook (`prisma generate`) FIRST. Invoking the `webjs dev` binary
6
+ * directly (easy to do, and tempting for an AI/CLI) skips `predev`, so the dev
7
+ * server boots against an ungenerated `@prisma/client` and crashes with a raw
8
+ * "did not initialize yet" error and no hint that the canonical command is
9
+ * `npm run dev`. This turns that crash into a one-line, actionable message.
10
+ *
11
+ * Scope is deliberately narrow: it only fires for an app that actually uses
12
+ * Prisma (a `prisma/schema.prisma` OR an `@prisma/client` dependency), and it
13
+ * only HINTS. It never auto-runs an arbitrary `predev` script and never shells
14
+ * out to `prisma generate` on its own, keeping the no-build promise intact.
15
+ *
16
+ * Detection (verified against a real Prisma 6 install): the GENERATED
17
+ * `.prisma/client` target is resolved through standard Node resolution from the
18
+ * app (so a hoisted monorepo, where the client lives at a PARENT `node_modules`,
19
+ * resolves correctly), then read. An ABSENT target, or a present-but-stub target
20
+ * (the ungenerated client whose `PrismaClient` constructor throws the init
21
+ * error), is "ungenerated". A real generated target older than the schema is
22
+ * "stale". We do NOT grep the static `@prisma/client` re-export shim: it is
23
+ * present in both states and never carries the init-error string itself.
24
+ */
25
+ import { existsSync, statSync, readFileSync } from 'node:fs';
26
+ import { join, dirname } from 'node:path';
27
+ import { createRequire } from 'node:module';
28
+
29
+ /**
30
+ * Does this app use Prisma? True if a schema is checked in OR `@prisma/client`
31
+ * is a declared dependency. Either alone is enough; a non-Prisma app has
32
+ * neither and gets no warning.
33
+ *
34
+ * @param {string} cwd
35
+ * @returns {boolean}
36
+ */
37
+ export function usesPrisma(cwd) {
38
+ if (existsSync(join(cwd, 'prisma', 'schema.prisma'))) return true;
39
+ try {
40
+ const pkg = JSON.parse(readFileSync(join(cwd, 'package.json'), 'utf8'));
41
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies };
42
+ return Boolean(deps && deps['@prisma/client']);
43
+ } catch {
44
+ return false;
45
+ }
46
+ }
47
+
48
+ // Marker the ungenerated `prisma-client-js` stub embeds in its generated target
49
+ // (`node_modules/.prisma/client/index.js`). Verified against a real Prisma 6
50
+ // install: after `npm i @prisma/client` but before `prisma generate`, the
51
+ // generated `.prisma/client` entry IS present but its `PrismaClient` constructor
52
+ // throws `@prisma/client did not initialize yet. Please run "prisma generate"`.
53
+ // A real `prisma generate` replaces that stub with the generated client, which
54
+ // does NOT contain this string. So the marker, read from the GENERATED target
55
+ // (not the static `@prisma/client` shim), is the reliable ungenerated signal.
56
+ const UNGENERATED_MARKER = 'did not initialize yet';
57
+
58
+ /**
59
+ * Resolve the GENERATED Prisma client entry (`.prisma/client/index.js`) for an
60
+ * app, following standard Node resolution so a hoisted monorepo layout (the
61
+ * generated client at a PARENT `node_modules`, the app under `apps/<x>`) still
62
+ * resolves. Returns a discriminated result so the caller can tell the three
63
+ * cases apart:
64
+ * - `{ kind: 'unresolved' }` - `@prisma/client` itself is not resolvable.
65
+ * - `{ kind: 'no-target' }` - the package resolves but `.prisma/client`
66
+ * does not (a custom `output`, ambiguous).
67
+ * - `{ kind: 'target', path }` - the generated target resolves.
68
+ *
69
+ * @param {string} cwd
70
+ * @returns {{ kind: 'unresolved' } | { kind: 'no-target' } | { kind: 'target', path: string }}
71
+ */
72
+ function resolveGeneratedClient(cwd) {
73
+ let clientDir;
74
+ try {
75
+ // Resolve @prisma/client AS THE APP would (hoisting-aware), then locate its
76
+ // package dir. The shim itself loads `.prisma/client/default` relative to
77
+ // here, so resolving from this dir follows the same (possibly hoisted) path.
78
+ const appRequire = createRequire(join(cwd, 'noop.js'));
79
+ clientDir = dirname(appRequire.resolve('@prisma/client'));
80
+ } catch {
81
+ return { kind: 'unresolved' };
82
+ }
83
+ const shimRequire = createRequire(join(clientDir, 'noop.js'));
84
+ for (const entry of ['.prisma/client/index.js', '.prisma/client/default.js']) {
85
+ try {
86
+ return { kind: 'target', path: shimRequire.resolve(entry) };
87
+ } catch { /* try the next entry */ }
88
+ }
89
+ return { kind: 'no-target' };
90
+ }
91
+
92
+ /**
93
+ * Inspect the generated Prisma client state for a Prisma app.
94
+ *
95
+ * Returns one of:
96
+ * - `{ status: 'ok' }` - client generated and not older than the schema.
97
+ * - `{ status: 'missing' }` - schema/dep present but no generated client.
98
+ * - `{ status: 'stale' }` - client exists but the schema is newer than it.
99
+ *
100
+ * Detection resolves the GENERATED `.prisma/client` target through standard Node
101
+ * resolution (so hoisted monorepos are handled) and reads it: an absent target,
102
+ * or a present-but-stub target (the ungenerated `PrismaClient` that throws on
103
+ * construction), is `missing`. A real generated client that is older than the
104
+ * schema is `stale`. A custom-`output` generator whose target Node cannot
105
+ * resolve falls back to `ok` rather than nag a working app (false positives are
106
+ * worse than a missed hint here).
107
+ *
108
+ * @param {string} cwd
109
+ * @returns {{ status: 'ok' | 'missing' | 'stale' }}
110
+ */
111
+ export function prismaClientState(cwd) {
112
+ const resolved = resolveGeneratedClient(cwd);
113
+
114
+ // @prisma/client not resolvable: the app declared the dep (usesPrisma gated
115
+ // us here) but it is not installed/generated. That is the boot-crash case.
116
+ if (resolved.kind === 'unresolved') return { status: 'missing' };
117
+
118
+ // The package resolves but the default `.prisma/client` target does not: a
119
+ // custom `output` whose location we cannot cheaply verify. Fall back to `ok`
120
+ // rather than nag a working app (false positives are worse than a missed hint).
121
+ if (resolved.kind === 'no-target') return { status: 'ok' };
122
+
123
+ const generatedIndex = resolved.path;
124
+
125
+ // The generated target exists. Is it still the ungenerated stub (its
126
+ // PrismaClient constructor throws the init error)?
127
+ try {
128
+ const body = readFileSync(generatedIndex, 'utf8');
129
+ if (body.includes(UNGENERATED_MARKER)) return { status: 'missing' };
130
+ } catch { /* unreadable: fall through to the stale check, then ok */ }
131
+
132
+ // Generated for real. Is it older than the schema (a stale client)?
133
+ const schema = join(cwd, 'prisma', 'schema.prisma');
134
+ try {
135
+ if (existsSync(schema)) {
136
+ const schemaMtime = statSync(schema).mtimeMs;
137
+ const clientMtime = statSync(generatedIndex).mtimeMs;
138
+ if (schemaMtime > clientMtime) return { status: 'stale' };
139
+ }
140
+ } catch { /* if we can't stat, treat as ok */ }
141
+
142
+ return { status: 'ok' };
143
+ }
144
+
145
+ /**
146
+ * Build the actionable hint for an ungenerated/stale client, or `null` when the
147
+ * app is fine or does not use Prisma. The caller prints it (a warning, not a
148
+ * hard exit) before booting the dev server.
149
+ *
150
+ * @param {string} cwd
151
+ * @returns {string | null}
152
+ */
153
+ export function prismaDevHint(cwd) {
154
+ if (!usesPrisma(cwd)) return null;
155
+ const { status } = prismaClientState(cwd);
156
+ if (status === 'ok') return null;
157
+
158
+ const reason =
159
+ status === 'stale'
160
+ ? 'Your Prisma client looks stale (the schema changed since it was generated).'
161
+ : 'Your Prisma client is not generated yet.';
162
+ return (
163
+ `webjs: ${reason}\n` +
164
+ ` The dev server will crash on an ungenerated client. Fix it with either:\n` +
165
+ ` npm run dev # canonical: runs \`prisma generate\` (predev) first\n` +
166
+ ` webjs db generate # just regenerate the client, then re-run\n`
167
+ );
168
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webjsdev/cli",
3
- "version": "0.10.13",
3
+ "version": "0.10.15",
4
4
  "type": "module",
5
5
  "description": "webjs CLI - dev, start, create, db",
6
6
  "bin": {
@@ -144,14 +144,18 @@ self-review loop.
144
144
  `${cond ? a : b}`) and lifecycle hooks (`this.query('#el')` in
145
145
  `firstUpdated`) instead of Lit's `classMap` / `styleMap` / `ref` / `when` /
146
146
  `choose` / `guard`.
147
- - Use Context for cross-component data, Task for async data in components.
147
+ - Use Context for cross-component data. For async data in a component, prefer
148
+ an `async render()` (`const u = await getUser(this.uid)`, awaited at SSR so
149
+ the data is in the first paint); keep `Task` for genuinely client-only data.
148
150
  - **Progressive enhancement is the default.** Pages AND every web component
149
151
  are SSR'd to real HTML. Write components so the first paint is the right
150
152
  content. Read SSR-meaningful defaults in `constructor()`. `connectedCallback`
151
153
  is never called on the server, so anything there only runs after
152
154
  hydration. Initial data for components comes from the page function
153
- (server-side fetch plus pass as attribute/property), NOT from `fetch` calls
154
- in `connectedCallback`. For write-paths, prefer `<form action=...>` plus
155
+ (server-side fetch plus pass as attribute/property) OR from an `async
156
+ render()` in the component itself (preferred over prop-drilling;
157
+ `renderFallback()` is the optional re-fetch loading state, never first
158
+ paint), NOT from `fetch` calls in `connectedCallback`. For write-paths, prefer `<form action=...>` plus
155
159
  server action over `fetch` plus click handler. The framework upgrades plain
156
160
  forms to partial-swap submissions automatically.
157
161
  - **Client navigation is auto-magic.** Real `<a href>` and `<form action>`
@@ -109,6 +109,6 @@ self-review loop.
109
109
  - Components must call customElements.define('tag', Class)
110
110
  - Server-only code (@prisma/client, node:*, anything that needs Node APIs) goes only in .server.{js,ts} files, route.ts handlers, or middleware.ts. Never in pages, layouts, or components. Wrap the access in a .server.{js,ts} file; the framework rewrites that import into an RPC stub for the browser. lib/ holds both server-only infra (lib/prisma.server.ts) and browser-safe utilities (lib/utils/cn.ts with cn); follow the same rule per file: if a lib/ file needs Node APIs, only import it from server-only files.
111
111
  - Directives are deliberately minimal: only `unsafeHTML`, `live`, and `repeat` ship. Lit's `classMap` / `styleMap` / `ref` / `when` / `choose` / `guard` are NOT exported. Use plain template-literal expressions (`class=${cond ? 'a' : 'b'}`, `${cond ? a : b}`) and lifecycle hooks (`this.query('#el')` in `firstUpdated`) instead.
112
- - **Progressive enhancement is the default.** Pages AND every web component are SSR'd to real HTML. Write components so the first paint is the right content. Read SSR-meaningful defaults in `constructor()`. `connectedCallback` is never called on the server, so anything there only runs after hydration. Initial data for components comes from the page function (server-side fetch plus pass as attribute/property), NOT from `fetch` calls in `connectedCallback`. For write-paths, prefer `<form action=...>` plus server action over `fetch` plus click handler. The framework upgrades plain forms to partial-swap submissions automatically.
112
+ - **Progressive enhancement is the default.** Pages AND every web component are SSR'd to real HTML. Write components so the first paint is the right content. Read SSR-meaningful defaults in `constructor()`. `connectedCallback` is never called on the server, so anything there only runs after hydration. Initial data for components comes from the page function (server-side fetch plus pass as attribute/property) OR from an `async render()` in the component itself (`const u = await getUser(this.uid)`, which SSR awaits so the data is in the first paint), NOT from `fetch` calls in `connectedCallback`. Prefer the co-located `async render()` over prop-drilling; `renderFallback()` is the optional re-fetch loading state (never first paint), and a `Task` is for genuinely client-only data. For write-paths, prefer `<form action=...>` plus server action over `fetch` plus click handler. The framework upgrades plain forms to partial-swap submissions automatically.
113
113
  - **Client navigation is auto-magic.** Real `<a href>` and `<form action>` get partial-swap behavior with no opt-in. Because layouts persist across navigation, put shared chrome (sidenav, header) in `layout.ts` and page-specific content in `page.ts`. For validation errors, return 4xx HTML from a `route.ts` POST handler; the router renders it in place preserving the user's input. For non-layout swap regions, wrap in `<webjs-frame id="...">`. See "Client navigation patterns" in AGENTS.md.
114
114
  - See AGENTS.md for the complete directive decision guide
@@ -102,6 +102,7 @@ each change must include.
102
102
  - Tagged template: html`<div>${value}</div>` with css`...` for styles.
103
103
  - **Tailwind-first styling.** Tailwind utilities are the strong default for pages AND light-DOM components (the default DOM mode): layout, spacing, color (via `@theme` tokens), typography, borders, radius, shadows, interaction states. Light DOM does not scope, so utilities apply directly. The lit reflex to scope CSS (`static styles = css\`...\``) or write an inline `<style>` with semantic class names (`.hero`, `.card`) in a light-DOM component is wrong: the scoped block needs `static shadow = true`, and inline class names leak globally. When a utility bundle repeats, extract a `lib/utils/ui.ts` helper returning an `html` fragment, not a CSS class. Reserve raw CSS for the allowlist (design tokens / `@theme`, `@property` + `@keyframes`, `::-webkit-scrollbar`, `prefers-reduced-motion`, complex `color-mix()` / gradients); when unavoidable in a light-DOM component, prefix every class selector with the component tag. Shadow-DOM components (`static shadow = true`) legitimately author `static styles = css\`...\`` for scoped CSS; don't use inline `style="..."` there.
104
104
  - Components: extend WebComponent, declare `static properties` (and `static styles` for shadow-DOM components), call `Class.register('tag-name')` at the bottom of the file. The tag name is the argument to `.register()`, not a static field.
105
+ - Async data in a component: prefer an `async render()` (`const u = await getUser(this.uid)`), which SSR awaits so the data is in the first paint, over prop-drilling from the page or fetching in `connectedCallback`. `renderFallback()` is the optional re-fetch loading state (never first paint); error isolation is automatic (`renderError()` customizes it); a `Task` is for genuinely client-only data.
105
106
  - Server actions: *.server.ts files with one exported async function each.
106
107
  - Server-only code (@prisma/client, node:*, anything needing Node APIs) goes only in .server.{js,ts} files, route.ts handlers, or middleware.ts. Never in pages, layouts, or components. Wrap in a .server.{js,ts} file; the framework rewrites that import to an RPC stub for the browser. lib/ holds both server-only infra (lib/prisma.server.ts) and browser-safe utilities (lib/utils/cn.ts with cn); apply the same rule per file.
107
108
  - Directives: webjs ships only `unsafeHTML`, `live`, and `repeat`. Lit's `classMap` / `styleMap` / `ref` / `when` / `choose` / `guard` are NOT exported. Use plain template-literal expressions and lifecycle hooks instead.
@@ -86,7 +86,8 @@ node_modules/@webjsdev/
86
86
  auth, sessions, cache, rate-limit, WebSocket
87
87
  src/ssr.js ← how metadata becomes <head> tags
88
88
  src/router.js ← file convention → route table
89
- src/actions.js ← .server.ts scanner, RPC, expose()
89
+ src/actions.js ← .server.ts scanner, RPC stubs, action endpoint
90
+ src/action-route.js ← route() adapter (action over REST via route.ts)
90
91
  src/auth.js, session.js, cache.js, rate-limit.js, csrf.js
91
92
  cli/ webjs CLI (dev / start / build / test / check / create / db)
92
93
  intellisense/ tsserver plugin: go-to-definition + diagnostic suppression
@@ -96,6 +97,52 @@ node_modules/@webjsdev/
96
97
  Reaching straight for the source is the fastest way to resolve "why
97
98
  doesn't X work?" with no documentation guesswork and no stale blog posts.
98
99
 
100
+ ## Use the webjs MCP server (introspection + framework knowledge)
101
+
102
+ This project ships a **read-only Model Context Protocol server** that gives
103
+ you (the AI agent) live, version-accurate facts about THIS app and the
104
+ framework. Prefer it over guessing or recalling webjs from training data,
105
+ which drifts. It mutates nothing.
106
+
107
+ **It is already available, no install needed:** the webjs CLI (a project
108
+ dependency) has it built in as `webjs mcp`. It is an MCP STDIO server (JSON-RPC
109
+ over stdout), so you do not run it in a terminal and read its output. Your MCP
110
+ host (Claude Code, Cursor, etc.) launches it and surfaces its tools, then you
111
+ invoke those tools through the MCP protocol.
112
+
113
+ Claude Code is pre-wired (see `.claude.json`). For another host, register the
114
+ server by pointing it at the CLI (or the equivalent standalone package):
115
+
116
+ ```jsonc
117
+ // Cursor: .cursor/mcp.json (or your host's MCP config)
118
+ { "mcpServers": { "webjs": {
119
+ "command": "npx", "args": ["@webjsdev/cli", "mcp"] // the built-in CLI route
120
+ // equivalent: "command": "npx", "args": ["@webjsdev/mcp"]
121
+ } } }
122
+ ```
123
+
124
+ What it serves:
125
+
126
+ - **Introspection of this app** (read-only, no module load, no DB side
127
+ effects): `list_routes` (the route table), `list_actions` (server actions
128
+ with their `/__webjs/action/<hash>/<fn>` RPC endpoints), `list_components`
129
+ (registered custom-element tags), `check` (the structured `webjs check`
130
+ violations). Use these to learn the real route/action/component surface
131
+ before editing, instead of grepping or assuming.
132
+ - **Framework knowledge**: an `init` primer (the read-first mental model +
133
+ invariants), a `docs` tool (retrieve a topic or search the `agent-docs`
134
+ corpus), MCP `resources` (the docs corpus + this AGENTS.md), recipe
135
+ `prompts` (guided page/route/action/component workflows), and a `source`
136
+ tool that reads the framework's OWN no-build source from
137
+ `node_modules/@webjsdev/*/src` (what actually runs).
138
+
139
+ You have TWO complementary ways to understand the framework, use whichever
140
+ helps (or both): (1) **grep the full framework source** under
141
+ `node_modules/@webjsdev/*/src`, which is the real no-build code that runs (no
142
+ sourcemaps, no guessing), and (2) **the MCP** for live app introspection plus
143
+ the curated `init` / `docs` / `source` knowledge tools. Reach for either before
144
+ guessing from training data or asking the user.
145
+
99
146
  ## Editor TS plugin: `@webjsdev/intellisense`
100
147
 
101
148
  This scaffold's `tsconfig.json` lists a single tsserver plugin. It is
@@ -474,9 +521,10 @@ Production then has no importmap.json and the server falls back to
474
521
  calling api.jspm.io on every cold start. The `**/` prefix matters too:
475
522
  it ignores `.webjs/` at any depth, so an app nested below its repo root
476
523
  (a monorepo package) does not leak its generated `.webjs/routes.d.ts`
477
- into `git status`. The `gitignore-vendor-not-ignored` lint rule
478
- (`webjs check`) verifies the pattern with `git check-ignore` and will
479
- fail CI if it regresses.
524
+ into `git status`. The `vendor-gitignore` check (`webjs doctor`)
525
+ verifies the pattern with `git check-ignore` and warns if it regresses
526
+ (it is a project-config / setup concern, not a source-code-correctness
527
+ CI gate).
480
528
 
481
529
  ## Imports
482
530
 
@@ -630,7 +678,9 @@ Practical consequences for agents writing webjs code.
630
678
  | Lit pattern | What breaks in webjs | Webjs equivalent |
631
679
  |---|---|---|
632
680
  | Fetch in `connectedCallback` / `firstUpdated` | Empty first paint (neither hook runs in SSR) | Fetch in the page function, pass as props |
633
- | `Task` for initial-paint data | SSR ships the pending state, flashes to resolved on hydration | Page function fetch, pass as props (`Task` is fine for client-time async) |
681
+ | `Task` for initial-paint data | SSR ships the pending state, flashes to resolved on hydration | Page function fetch, pass as props, OR an `async render()` in the component (`Task` is fine for client-time async) |
682
+ | Expecting a sync `render()` only | webjs allows `async render() { const d = await getData(); ... }`; SSR bakes the data into the first paint | Use it for request-time server data; `renderFallback()` is the re-fetch loading UI (never first paint); error isolation is automatic |
683
+ | Assuming an `async render()` always ships its module | A bare one (no other client signal) is ELIDED, so it costs zero JS and skips the on-hydration re-fetch, first paint unchanged | Rely on it for a fetch-and-display leaf. `static refresh = true` keeps the on-load refresh, `static shadow = true` always ships |
634
684
  | `window.X` / `document.X` in constructor or `render()` | SSR crash | Move to `connectedCallback` |
635
685
  | Top-level `import` of a browser-only library | SSR crash | Dynamic `import()` inside `connectedCallback` |
636
686
  | Class-field initializer for a reactive property (`student: Student = {...}`) | Silently breaks reactivity (overwrites the framework accessor) | `declare student: Student` plus constructor default |
@@ -765,6 +815,14 @@ here is for the "submit → server processes → render new page" flow.)
765
815
 
766
816
  ### 4. `<webjs-frame id="...">` for non-layout swap regions
767
817
 
818
+ `<webjs-frame>` is webjs's take on **Turbo Frames** (Hotwire Turbo), so
819
+ `<turbo-frame>` muscle memory transfers directly: a lazy, URL-addressable
820
+ region that swaps on its own, driven by a link/form targeting its id. Use it
821
+ for a region that loads or refreshes INDEPENDENTLY of a full navigation
822
+ (a self-refreshing widget, a `loading="lazy"` below-the-fold region, a
823
+ URL-addressable panel); it ships zero component JS. Its route can itself use
824
+ `<webjs-suspense>` so a lazy frame's slow data streams in behind a fallback.
825
+
768
826
  For a widget that should swap on click but isn't a route boundary
769
827
  (e.g. a tab strip inside a page), wrap it:
770
828
 
@@ -833,6 +891,14 @@ over the outcome (e.g. `location.assign(e.detail.url)`).
833
891
 
834
892
  ### 5. Stream actions for surgical element-level updates
835
893
 
894
+ `<webjs-stream>` is webjs's take on **Turbo Streams** (Hotwire Turbo); the
895
+ action set (`append` / `prepend` / `before` / `after` / `replace` / `update` /
896
+ `remove`) mirrors `<turbo-stream>`, so that muscle memory transfers directly.
897
+ It is the ONLY surgical single-element update primitive AND the live-channel
898
+ applier (`connectWS` / `broadcast` -> `renderStream`); a region swap or a
899
+ `<webjs-frame>` reload redraws a whole region, so reach for `<webjs-stream>`
900
+ when only one element changes.
901
+
836
902
  When a region swap is too coarse (append ONE comment, remove ONE row, bump a
837
903
  count, insert a toast), a server response can declare per-element actions as
838
904
  plain HTML, a `<webjs-stream action target>` wrapping one `<template>`:
@@ -66,7 +66,10 @@ even if the user doesn't explicitly ask.**
66
66
  Run `npm run doctor` (which runs `webjs doctor`) once after cloning to assert
67
67
  the project is set up correctly: the Node major (the strip-types floor), the
68
68
  tsconfig `erasableSyntaxOnly` flag, `.env` drift vs `.env.example`, vendor-pin
69
- freshness, `@webjsdev/*` version coherence, and the git pre-commit hook. It
69
+ freshness, the `.gitignore` keeping `.webjs/vendor/` committable
70
+ (`vendor-gitignore`), importmap-coherence (the resolved client deps agree on a
71
+ shared transitive version), `@webjsdev/*` version coherence, and the git
72
+ pre-commit hook. It
70
73
  prints `[pass]` / `[warn]` / `[fail]` per check with an actionable fix line and
71
74
  exits non-zero only on a hard fail (a broken toolchain), so a green run means
72
75
  `npm run dev` will boot. It is a local onboarding/setup-verify tool, not a CI
@@ -348,7 +351,7 @@ variables control infrastructure (no config files needed):
348
351
  | `AUTH_SECRET` | Required for auth JWT signing (32+ random chars) |
349
352
  | `AUTH_GOOGLE_ID` | Google OAuth client ID (optional) |
350
353
  | `AUTH_GITHUB_ID` | GitHub OAuth client ID (optional) |
351
- | `PORT` | Server port (default: 8080) |
354
+ | `PORT` | Server port. Precedence: `--port` flag > `PORT` (a real exported env var or a `PORT` in `.env`) > 8080. |
352
355
  | `WEBJS_PUBLIC_*` | Any env var starting with this prefix is exposed to the browser as `process.env.WEBJS_PUBLIC_X`. Components can read it directly. No build step, no transform. Use for API base URLs, Stripe publishable keys, analytics IDs, anything that is intended to be visible client-side. |
353
356
 
354
357
  **Server-only by default.** Any env var without the `WEBJS_PUBLIC_` prefix never reaches the browser. Reading `process.env.DATABASE_URL` from a component returns `undefined`, the same as a typo. The prefix is fail-closed: secrets cannot accidentally leak.
@@ -384,6 +387,7 @@ modules/
384
387
  - Components must call `Class.register('tag')`
385
388
  - **Server-only code goes in `.server.{js,ts}` files, `route.ts` handlers, or `middleware.ts`. Never in pages, layouts, or components.** Direct imports of `@prisma/client` or `node:*` from pages, layouts, or components crash the browser at module load. Wrap in a `.server.{js,ts}` file; the framework rewrites that import to an RPC stub on the browser side. `lib/` holds both server-only infra (`lib/prisma.server.ts`) and browser-safe utilities (`lib/utils/cn.ts` with `cn`); the convention is "if a `lib/` file needs Node APIs, only import it from server-only files."
386
389
  - Routes (`app/**/page.ts`, `app/**/route.ts`) must be thin: import logic from modules
390
+ - **Fetch server data in the component that needs it, with an `async render()`, not by prop-drilling.** A leaf component can write `const u = await getUser(this.uid)` directly in `render()`; SSR awaits it so the data is in the first paint, and the client uses stale-while-revalidate on a re-fetch. Reach for `renderFallback()` only to show a re-fetch loading state, and `Task` / signals only for genuinely client-only data (a `Task` shows its pending state at SSR, losing first-paint data). Do not put `await getData()` in a page / layout when a leaf component can own it (page fetches run sequentially, a route-level waterfall).
387
391
 
388
392
  ---
389
393
 
@@ -904,27 +908,37 @@ SSR content is visible immediately. Only the JS download is deferred.
904
908
 
905
909
  ---
906
910
 
907
- ## expose(): REST endpoints from server actions
911
+ ## REST endpoints from server actions (route.ts)
908
912
 
909
913
  <!-- OVERRIDE -->
910
- Tag a server action to also be reachable over HTTP. The file MUST be a `.server.{js,ts}` file: `expose()` is server-only and the bare `@webjsdev/core` specifier resolves to the browser entry which excludes it, so importing from a client-bound file silently reads `undefined`.
914
+ A server action is RPC-callable from components. To ALSO reach the same
915
+ function over plain HTTP (mobile apps, webhooks, third parties), put it behind
916
+ a `route.ts` handler. The action stays a normal `'use server'` function; the
917
+ route imports and calls it.
911
918
 
912
919
  ```ts
913
920
  // modules/posts/actions/create-post.server.ts
914
921
  'use server';
915
- import { expose } from '@webjsdev/core';
916
- export const createPost = expose('POST /api/posts', async ({ title, body }) => {
922
+ export async function createPost({ title, body }) {
917
923
  return prisma.post.create({ data: { title, body } });
918
- });
924
+ }
925
+ ```
926
+
927
+ ```ts
928
+ // app/api/posts/route.ts
929
+ import { route } from '@webjsdev/server';
930
+ import { createPost } from '../../../modules/posts/actions/create-post.server.ts';
931
+ // The route() adapter merges query + route params + JSON body into one input
932
+ // object and JSON-responds the result. Pass { validate } to guard the input.
933
+ export const POST = route(createPost);
919
934
  ```
920
935
 
921
- The same function works via RPC (from components) and HTTP (for external
922
- callers). Use `expose()` when mobile apps, webhooks, or third parties need
923
- to call your action. For internal-only actions, plain server actions are
924
- simpler and CSRF-protected.
936
+ A hand-written `route.ts` (a `POST(req)` that reads the body and calls the
937
+ action) is always available for full control (custom headers, streaming).
925
938
 
926
- **Security:** `expose()`d endpoints are NOT CSRF-protected. Authenticate
927
- via bearer tokens, API keys, or auth middleware.
939
+ **Security:** a `route.ts` REST endpoint is NOT CSRF-protected (only the RPC
940
+ path is). Authenticate every mutating endpoint via bearer tokens, API keys, or
941
+ auth middleware.
928
942
 
929
943
  ---
930
944