@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 +4 -2
- package/bin/webjs.js +45 -4
- package/lib/doctor.js +277 -0
- package/lib/port.js +60 -0
- package/lib/prisma-preflight.js +168 -0
- package/package.json +1 -1
- package/templates/.agents/rules/workflow.md +7 -3
- package/templates/.cursorrules +1 -1
- package/templates/.github/copilot-instructions.md +1 -0
- package/templates/AGENTS.md +71 -5
- package/templates/CONVENTIONS.md +27 -13
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
@@ -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
|
|
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)
|
|
154
|
-
in
|
|
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>`
|
package/templates/.cursorrules
CHANGED
|
@@ -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.
|
package/templates/AGENTS.md
CHANGED
|
@@ -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,
|
|
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 `
|
|
478
|
-
|
|
479
|
-
|
|
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>`:
|
package/templates/CONVENTIONS.md
CHANGED
|
@@ -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,
|
|
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
|
|
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
|
-
##
|
|
911
|
+
## REST endpoints from server actions (route.ts)
|
|
908
912
|
|
|
909
913
|
<!-- OVERRIDE -->
|
|
910
|
-
|
|
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
|
-
|
|
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
|
-
|
|
922
|
-
|
|
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:** `
|
|
927
|
-
via bearer tokens, API keys, or
|
|
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
|
|