Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .agents/skills/webjs/references/built-ins.md
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,24 @@ An over-limit body responds `413` without buffering the whole payload.

`before` runs to completion first (a non-zero exit aborts the boot). `parallel` (dev only) runs long-lived watchers alongside the server and tears them down on exit. `watch` (dev only) adds extra live-reload directories outside the app tree.

### Bring your own ORM (`webjs.db`)

Drizzle is the scaffold DEFAULT, not lock-in. The runtime never imports it, `db/connection.server.ts` is the app's own file, and `webjs db` is adapter-driven: a `db` block maps each verb to the shell command `webjs db <verb>` runs instead of the drizzle-kit default (node_modules/.bin on PATH like a `before` step, extra CLI args appended).

```jsonc
{ "webjs": {
"db": {
"generate": "prisma migrate dev --create-only",
"migrate": "prisma migrate deploy",
"push": "prisma db push",
"studio": "prisma studio",
"reset": "prisma migrate reset --force"
}
} }
```

Any key is a verb (`reset` above adds `webjs db reset`). A verb the block does not name keeps its default (drizzle-kit for `generate` / `migrate` / `push` / `studio`, `db/seed.server.ts` for `seed`), so an app with no block is unchanged and the scaffold emits none. The payoff is that `webjs db migrate` stays one spelling across ORMs, so the scaffolded `dev.before` / `start.before`, the Dockerfile, CI, and the deploy docs all keep working after a swap. Write the bare binary (`prisma migrate deploy`), not `npx prisma ...`, since a pure Bun image has no `npx`. The swap itself is the app's own files: replace `db/connection.server.ts` with the new client, drop `drizzle.config.ts` / `db/columns.server.ts`, and keep server-only imports behind `.server.ts` as before.

### Doctor severity gate

`webjs doctor` reports project health, and by default only a broken toolchain fails the exit. `--strict` makes EVERY warning fatal, which is unusable in CI, because four checks are environment-shaped: `GIT_HOOK` wants a local pre-commit hook a runner has no reason to have, `ENV_DRIFT` compares against a `.env` CI does not carry, `VENDOR_PIN` fetches the network, and `FRAMEWORK_RESOLVE` plus `FRAMEWORK_LINKS` depend on the environment. So per-check severity is CONFIG, keyed by the stable code every result carries.
Expand Down
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -579,7 +579,7 @@ webjs version # print the installed @webjsdev/cli version (
webjs help [command] # full usage banner, or per-command usage + Options + Examples (e.g. webjs help routes, #975). Flag forms: webjs --help / -h (banner), webjs <command> --help / -h (that command). typecheck/db/ui --help forward to their wrapped tool; an unknown topic exits 1
webjs typecheck [tsc args...] # the project's own tsc --noEmit
webjs create <name> [--template api]
webjs db <generate|migrate|push|studio|seed> # wraps drizzle-kit (+ runs db/seed.server.ts)
webjs db <generate|migrate|push|studio|seed|verb> [args] # wraps drizzle-kit by default (+ runs db/seed.server.ts). Bring your own ORM (#1468): a `"webjs": { "db": { "<verb>": "<command>" } }` block in package.json runs that shell command instead (node_modules/.bin on PATH, extra args appended), any key is a verb, an unmapped verb keeps its default, so `webjs db migrate` is one spelling across ORMs and the scaffolded start.before / Dockerfile / CI keep working after a swap
webjs ui init | add <names...> | list | view <name>
webjs vendor pin|unpin|list|audit|outdated|update [--from PROVIDER] # importmap pinning, .webjs/vendor/importmap.json
```
Expand All @@ -591,7 +591,7 @@ webjs vendor pin|unpin|list|audit|outdated|update [--from PROVIDER] # importma
## Environment, server config, caching, observability

- **Env vars.** `process.env.X` reads are server-only; `WEBJS_PUBLIC_`-prefixed names are exposed in the browser via an inline `<script>` (no build); `NODE_ENV` is defined both sides. See `references/built-ins.md`.
- **The `package.json` `"webjs"` block.** Security headers (on by default, per-path `webjs.headers` overrides), CSP (opt-in nonce, `webjs.csp`), declarative `webjs.redirects` (#254), `webjs.trailingSlash` (#255), `webjs.basePath` (#256), ingress caps (`maxBodyBytes` / `maxMultipartBytes` / server timeouts), and dev/start task orchestration (`webjs.dev.before` / `webjs.dev.parallel` / `webjs.dev.regenerate` / `webjs.start.before`, #550 + #967, the orchestration `webjs dev`/`start` run so they match `npm run dev`/`start`; `regenerate` recompiles a stale served build output like `public/tailwind.css` ON REQUEST in dev, replacing a fragile `--watch`), and the doctor severity gate (`webjs.doctor.gate`, #1257, mapping a stable doctor code to `off` / `warn` / `error` so CI fails on a chosen subset of project-health checks instead of on every warning). Type it with `WebjsConfig` + the JSON Schema. See `references/built-ins.md`.
- **The `package.json` `"webjs"` block.** Security headers (on by default, per-path `webjs.headers` overrides), CSP (opt-in nonce, `webjs.csp`), declarative `webjs.redirects` (#254), `webjs.trailingSlash` (#255), `webjs.basePath` (#256), ingress caps (`maxBodyBytes` / `maxMultipartBytes` / server timeouts), and dev/start task orchestration (`webjs.dev.before` / `webjs.dev.parallel` / `webjs.dev.regenerate` / `webjs.start.before`, #550 + #967, the orchestration `webjs dev`/`start` run so they match `npm run dev`/`start`; `regenerate` recompiles a stale served build output like `public/tailwind.css` ON REQUEST in dev, replacing a fragile `--watch`), the doctor severity gate (`webjs.doctor.gate`, #1257, mapping a stable doctor code to `off` / `warn` / `error` so CI fails on a chosen subset of project-health checks instead of on every warning), and the `webjs db` verb map (`webjs.db`, #1468, bring your own ORM: `{ "migrate": "prisma migrate deploy" }` makes `webjs db migrate` run that command instead of drizzle-kit, so the spelling every deploy surface uses is stable across ORMs; Drizzle stays the default by omission and the scaffold emits no block). Type it with `WebjsConfig` + the JSON Schema. See `references/built-ins.md`.
- **Caching + file storage** (`references/built-ins.md`). HTTP `Cache-Control`, the `cache()` query helper + `revalidateTag`, the server HTML response cache (`export const revalidate` + `revalidatePath`, #241), content-hash asset URLs (`?v=`, #243), conditional GET (ETag, #240), and `FileStore` + `diskStore` (streaming, traversal-safe, signed URLs, S3-pluggable, #247).
- **Observability** (`references/built-ins.md`). Access log, `requestId()` + `X-Request-Id`, the `onError` APM hook, `GET /__webjs/version` (#239).

Expand Down
2 changes: 1 addition & 1 deletion packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ webjs check # validate source-code conventions (CI gate)
webjs doctor # verify the project/toolchain setup (per-check severity via webjs.doctor.gate, so CI can gate a subset)
webjs test # run server + browser tests
webjs vendor pin [--download] # pin client deps to a committable importmap (offline/reproducible)
webjs db <generate|migrate|push|studio|seed> # drizzle-kit passthrough (+ seed)
webjs db <generate|migrate|push|studio|seed> # drizzle-kit passthrough (+ seed) by default; a package.json webjs.db block maps any verb to your own ORM's command

webjs ui init # initialise @webjsdev/ui in this project
webjs ui add <names...> # copy components from the registry (https://webjs.dev/ui/registry/<name>.json)
Expand Down
53 changes: 48 additions & 5 deletions packages/cli/bin/webjs.js
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,9 @@ const USAGE = `webjs commands:
webjs db push Push the schema straight to the dev DB (drizzle-kit push)
webjs db studio Open the database browser (drizzle-kit studio)
webjs db seed Run the app's db/seed.server.ts
webjs db <verb> Any verb "webjs": { "db": { "<verb>": "<command>" } } maps in
package.json runs that command instead (bring your own ORM);
an unmapped verb keeps the drizzle-kit default above
webjs ui <subcmd> AI-first component library CLI
(init / add / list / view / diff / info)
Requires @webjsdev/ui installed in the project
Expand Down Expand Up @@ -250,8 +253,15 @@ const HELP = {
],
},
db: {
usage: 'webjs db <generate|migrate|push|studio|seed>',
summary: 'Database tasks (wraps drizzle-kit); seed runs db/seed.server.ts.',
usage: 'webjs db <generate|migrate|push|studio|seed|verb> [args...]',
summary: 'Database tasks (wraps drizzle-kit by default); seed runs db/seed.server.ts.',
notes: [
'A "webjs": { "db": { "<verb>": "<command>" } } block in package.json maps a verb to',
'a shell command (run with node_modules/.bin on PATH, extra args appended), so',
'another ORM keeps the same spelling: { "migrate": "prisma migrate deploy" }.',
'Any key is a verb ("reset" adds `webjs db reset`); an unmapped one keeps its',
'drizzle-kit / seed default, so an app with no block is unchanged.',
],
examples: ['webjs db generate', 'webjs db migrate', 'webjs db studio', 'webjs db seed'],
},
ui: {
Expand Down Expand Up @@ -516,6 +526,31 @@ async function main() {
case 'db': {
const sub = rest[0];
const args = rest.slice(1);
// A verb the app's `webjs.db` block maps (#1468) runs that command
// through the shell, the way a `before` step does (node_modules/.bin on
// PATH, so a bare `prisma migrate deploy` resolves), with the extra args
// appended. This is what makes Drizzle a default rather than lock-in:
// `webjs db migrate` stays the one spelling the scaffolded start.before,
// the Dockerfile, and the deploy docs use, whatever ORM is behind it.
// Checked FIRST so a mapped `seed` overrides the seed-file runner too.
if (!sub) {
console.error('webjs db: missing subcommand.\n' + USAGE);
process.exit(1);
}
const { readDbCommands } = await import('../lib/app-tasks.js');
const dbCommands = readDbCommands(process.cwd());
// Own-property lookup: the map is a plain object, so a bare index would
// answer `webjs db constructor` with an inherited function.
const mapped = Object.hasOwn(dbCommands, sub) ? dbCommands[sub] : undefined;
if (mapped) {
const { runBeforeSteps, shellQuote } = await import('../lib/run-tasks.js');
// Each arg is quoted so it reaches the ORM as one word, unexpanded,
// the way the drizzle-kit default's real argv already does.
const full = [mapped, ...args.map(shellQuote)].join(' ');
console.log(`webjs db ${sub}: running \`${full}\` (from package.json webjs.db)`);
const r = await runBeforeSteps([full], process.cwd());
process.exit(r.ok ? 0 : r.code);
}
// `webjs db seed` runs the app's own seed script directly (not a
// drizzle-kit command); Drizzle has no codegen, so there is no
// `generate`-the-client step, only schema-to-SQL `generate`.
Expand All @@ -535,8 +570,14 @@ async function main() {
// schema sync), studio. All wrap drizzle-kit; the verbose name stays
// hidden behind `webjs db`.
const map = { generate: ['generate'], migrate: ['migrate'], push: ['push'], studio: ['studio'] };
const kitArgs = map[sub];
if (!kitArgs) { console.error('Unknown db subcommand.\n' + USAGE); process.exit(1); }
const kitArgs = Object.hasOwn(map, sub) ? map[sub] : undefined;
if (!kitArgs) {
console.error(
`Unknown db subcommand "${sub}". Map it in package.json to add it: ` +
`"webjs": { "db": { "${sub}": "<command>" } }\n` + USAGE,
Comment thread
vivek7405 marked this conversation as resolved.
);
process.exit(1);
}
// Resolve the app's own drizzle-kit bin and spawn it with the CURRENT
// runtime (process.execPath). This drops the hard `npx` dependency (#570):
// `npx` is absent in a pure oven/bun image, which broke `webjs db migrate`
Expand All @@ -548,7 +589,9 @@ async function main() {
} catch {
console.error(
'webjs db: drizzle-kit is not installed in this project.\n' +
'Install it with `npm install -D drizzle-kit`, then re-run `webjs db ' + sub + '`.',
'Install it with `npm install -D drizzle-kit`, then re-run `webjs db ' + sub + '`.\n' +
'Using another ORM? Map the verb in package.json and the same command runs it:\n' +
' "webjs": { "db": { "' + sub + '": "<your ORM\'s ' + sub + ' command>" } }',
);
process.exit(1);
}
Expand Down
80 changes: 70 additions & 10 deletions packages/cli/lib/app-tasks.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,16 +38,9 @@ import { join } from 'node:path';
* @returns {{ dev: { before: string[], parallel: string[] }, start: { before: string[] } }}
*/
export function readAppTasks(appDir, readFile) {
const read = readFile || ((p) => readFileSync(p, 'utf8'));
let pkg = {};
try {
pkg = JSON.parse(read(join(appDir, 'package.json')));
} catch {
// No package.json, or unparseable: a plain run with no orchestration.
return emptyTasks();
}
const webjs = pkg && typeof pkg === 'object' ? pkg.webjs : null;
if (!webjs || typeof webjs !== 'object') return emptyTasks();
const webjs = readWebjsBlock(appDir, readFile);
// No package.json, unparseable, or no block: a plain run with no orchestration.
if (!webjs) return emptyTasks();

/** Keep only non-empty string entries; drop anything else defensively. */
const cmds = (v) =>
Expand All @@ -62,7 +55,74 @@ export function readAppTasks(appDir, readFile) {
};
}

/**
* The app's `package.json` `"webjs"` block, or `null` when there is no
* package.json, it does not parse, or the block is absent / not an object.
* Shared by every CLI-side reader here so the file is read one way.
*
* @param {string} appDir
* @param {(p: string) => string} [readFile] injectable reader for tests
* @returns {Record<string, unknown> | null}
*/
function readWebjsBlock(appDir, readFile) {
const read = readFile || ((p) => readFileSync(p, 'utf8'));
let pkg;
try {
pkg = JSON.parse(read(join(appDir, 'package.json')));
} catch {
return null;
}
const webjs = pkg && typeof pkg === 'object' ? pkg.webjs : null;
return webjs && typeof webjs === 'object' ? webjs : null;
}

/** @returns {{ dev: { before: string[], parallel: string[] }, start: { before: string[] } }} */
function emptyTasks() {
return { dev: { before: [], parallel: [] }, start: { before: [] } };
}

/**
* Read the `webjs db` verb map from an app's `package.json` `"webjs"` block
* (#1468). Drizzle is the scaffold DEFAULT, never lock-in: the runtime never
* imports it and `db/connection.server.ts` is the app's own file. But without
* this map the `webjs db` verbs contradicted that, since `generate` / `migrate`
* / `push` / `studio` resolved the app's drizzle-kit binary and exited 1 with
* any other ORM installed, while `webjs db migrate` is the spelling baked into
* the scaffolded `dev.before` / `start.before` tasks, the Dockerfile, and the
* deployment docs. Mapping a verb here keeps that spelling stable across ORMs,
* so an ORM swap is one config block plus the app's own `db/` files.
*
* Shape:
* "webjs": {
* "db": {
* "migrate": "prisma migrate deploy",
* "studio": "prisma studio",
* "reset": "prisma migrate reset --force"
* }
* }
*
* Any key is a verb: a mapped verb runs its command through the shell (the
* same way a `before` step does, so a local-only binary resolves), with the
* extra CLI args appended. A verb the map does not name keeps its default (the
* drizzle-kit passthrough for the four kit verbs, `db/seed.server.ts` for
* `seed`), so an app with no block is unchanged. Non-string / blank values are
* dropped defensively, the same posture as `readAppTasks`.
*
* Pure (reads one file, never spawns / prints / exits) so it is unit-testable
* without a process.
*
* @param {string} appDir
* @param {(p: string) => string} [readFile] injectable reader for tests
* @returns {Record<string, string>} verb -> shell command (empty when unset)
*/
export function readDbCommands(appDir, readFile) {
const webjs = readWebjsBlock(appDir, readFile);
const db = webjs ? webjs.db : null;
if (!db || typeof db !== 'object' || Array.isArray(db)) return {};
/** @type {Record<string, string>} */
const out = {};
for (const [verb, cmd] of Object.entries(db)) {
if (typeof cmd === 'string' && cmd.trim().length > 0 && verb.trim().length > 0) out[verb] = cmd;
}
return out;
}
6 changes: 6 additions & 0 deletions packages/cli/lib/create.js
Original file line number Diff line number Diff line change
Expand Up @@ -551,6 +551,12 @@ export async function scaffoldApp(name, cwd, opts = {}) {
}),
},
start: { before: isApi ? ['webjs db migrate'] : ['webjs db migrate', cssBuildCmd] },
// No `db` block on purpose (#1468). `webjs db` defaults to drizzle-kit,
// and Drizzle is the scaffold default by OMISSION: an app that swaps the
// ORM adds `"db": { "migrate": "prisma migrate deploy", ... }` here and
// the `webjs db migrate` spelling in `before` (and the Dockerfile, and
// CI) keeps working. Emitting the Drizzle mapping would only duplicate
// the default into every app.
// Which doctor findings are FATAL is the app's own call (#1257), declared
// here rather than in the CI workflow so `npm run doctor` locally and the
// workflow step agree about what fails. UNMARKED_ASSET_LINKS starts at
Expand Down
22 changes: 21 additions & 1 deletion packages/cli/lib/run-tasks.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,14 +44,34 @@ export async function runBeforeSteps(steps, cwd, opts = {}) {
if (opts.onStep) opts.onStep(step);
const code = await new Promise((res) => {
const c = spawn(step, { shell: true, stdio: 'inherit', cwd, env });
c.on('exit', (code) => res(code ?? 0));
// A child killed by a signal exits with `code` null and `signal` set.
// That is a failure (an OOM-killed `db migrate` must not boot the
// server over a half-applied schema), so it maps to 1, never 0.
c.on('exit', (code, signal) => res(code ?? (signal ? 1 : 0)));
c.on('error', () => res(1));
});
if (code !== 0) return { ok: false, step, code };
}
return { ok: true };
}

/**
* Quote one argv entry for a POSIX shell so it survives `shell: true` as ONE
* word with no expansion. A plain word passes through untouched; anything
* else is single-quoted, with an embedded single quote spliced as `'\''`.
* Used by `webjs db <verb> [args]` (#1468) to append the CLI's args to a
* mapped command string, so `--name "add users"` reaches the ORM as one arg
* and a `$` / `;` / glob is never expanded, matching what the drizzle-kit
* default (a real argv, no shell) already guarantees.
*
* @param {string} arg
* @returns {string}
*/
export function shellQuote(arg) {
if (/^[A-Za-z0-9_\-.\/=:@,+%]+$/.test(arg)) return arg;
return `'${arg.replace(/'/g, `'\\''`)}'`;
}

/**
* Spawn the configured dev `parallel` tasks (#550) as long-lived children and
* return a killer that tears them ALL down (idempotent), so a watcher cannot
Expand Down
Loading
Loading