@webjsdev/cli 0.10.19 → 0.10.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/webjs.js +59 -19
- package/lib/create.js +81 -4
- package/lib/resolve-bin.js +42 -0
- package/lib/runtime-rewrite.js +189 -0
- package/lib/saas-template.js +19 -3
- package/package.json +1 -1
- package/templates/.agents/rules/workflow.md +7 -0
- package/templates/.cursorrules +10 -4
- package/templates/.dockerignore +6 -5
- package/templates/.github/copilot-instructions.md +8 -2
- package/templates/.hooks/pre-commit +11 -0
- package/templates/AGENTS.md +37 -12
- package/templates/CONVENTIONS.md +18 -24
package/bin/webjs.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { resolve, join, dirname } from 'node:path';
|
|
3
3
|
import { spawn } from 'node:child_process';
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { resolveBin } from '../lib/resolve-bin.js';
|
|
5
6
|
import { checkNodeInline, nodeInlineMessage } from '../lib/node-preflight.js';
|
|
6
7
|
import { loadAppEnv, resolvePort } from '../lib/port.js';
|
|
7
8
|
import { planDevSupervisor } from '../lib/dev-supervisor.js';
|
|
@@ -49,8 +50,10 @@ const USAGE = `webjs commands:
|
|
|
49
50
|
webjs doctor Verify project health (Node, tsconfig, env, vendor pins, importmap coherence, @webjsdev versions, git hook)
|
|
50
51
|
webjs types Generate .webjs/routes.d.ts (typed Route union + per-route params)
|
|
51
52
|
webjs typecheck [tsc args...] Type-check the app with the project's tsc --noEmit (non-zero on errors)
|
|
52
|
-
webjs create <name> [--template full-stack|api|saas] [--db sqlite|postgres] [--no-install] Scaffold a new webjs app
|
|
53
|
-
(only 3 templates exist. default: full-stack, Drizzle, --db sqlite)
|
|
53
|
+
webjs create <name> [--template full-stack|api|saas] [--db sqlite|postgres] [--runtime node|bun] [--no-install] Scaffold a new webjs app
|
|
54
|
+
(only 3 templates exist. default: full-stack, Drizzle, --db sqlite, --runtime node)
|
|
55
|
+
--runtime bun emits a Bun-flavored app (bun.lock, bun Dockerfile/CI, bun docs);
|
|
56
|
+
also auto-detected when run via "bun create webjs".
|
|
54
57
|
Auto-runs the detected package manager's install in the new dir
|
|
55
58
|
unless --no-install is passed.
|
|
56
59
|
webjs db generate Generate a SQL migration from the schema (drizzle-kit generate)
|
|
@@ -216,7 +219,22 @@ async function main() {
|
|
|
216
219
|
const map = { generate: ['generate'], migrate: ['migrate'], push: ['push'], studio: ['studio'] };
|
|
217
220
|
const kitArgs = map[sub];
|
|
218
221
|
if (!kitArgs) { console.error('Unknown db subcommand.\n' + USAGE); process.exit(1); }
|
|
219
|
-
|
|
222
|
+
// Resolve the app's own drizzle-kit bin and spawn it with the CURRENT
|
|
223
|
+
// runtime (process.execPath). This drops the hard `npx` dependency (#570):
|
|
224
|
+
// `npx` is absent in a pure oven/bun image, which broke `webjs db migrate`
|
|
225
|
+
// at boot. On Node this is `node drizzle-kit`, on Bun `bun drizzle-kit`
|
|
226
|
+
// (drizzle-kit runs under both).
|
|
227
|
+
let dkPath;
|
|
228
|
+
try {
|
|
229
|
+
dkPath = resolveBin(process.cwd(), 'drizzle-kit', 'drizzle-kit');
|
|
230
|
+
} catch {
|
|
231
|
+
console.error(
|
|
232
|
+
'webjs db: drizzle-kit is not installed in this project.\n' +
|
|
233
|
+
'Install it with `npm install -D drizzle-kit`, then re-run `webjs db ' + sub + '`.',
|
|
234
|
+
);
|
|
235
|
+
process.exit(1);
|
|
236
|
+
}
|
|
237
|
+
const child = spawn(process.execPath, [dkPath, ...kitArgs, ...args], { stdio: 'inherit', cwd: process.cwd() });
|
|
220
238
|
child.on('exit', (code) => process.exit(code ?? 0));
|
|
221
239
|
break;
|
|
222
240
|
}
|
|
@@ -296,7 +314,14 @@ async function main() {
|
|
|
296
314
|
|
|
297
315
|
if (testFiles.length > 0) {
|
|
298
316
|
console.log(`webjs test: running ${testFiles.length} server test file(s)…\n`);
|
|
299
|
-
|
|
317
|
+
// Dispatch to the current runtime's test runner (#570). Node uses
|
|
318
|
+
// `node --test <files>`; Bun's runner is the `bun test <files>`
|
|
319
|
+
// subcommand (`bun --test` is invalid). process.execPath is the
|
|
320
|
+
// active runtime, so the args differ but the runner is native to it.
|
|
321
|
+
const testArgs = process.versions.bun
|
|
322
|
+
? ['test', ...testFiles]
|
|
323
|
+
: ['--test', ...testFiles];
|
|
324
|
+
const child = spawn(process.execPath, testArgs, {
|
|
300
325
|
stdio: 'inherit', cwd, env: { ...process.env },
|
|
301
326
|
});
|
|
302
327
|
const code = await new Promise(r => child.on('exit', r));
|
|
@@ -306,25 +331,32 @@ async function main() {
|
|
|
306
331
|
|
|
307
332
|
// --- Browser tests (WTR + Playwright) ---
|
|
308
333
|
if (runBrowser) {
|
|
309
|
-
const
|
|
310
|
-
|
|
334
|
+
const hasConfig = existsSync(join(cwd, 'web-test-runner.config.js'))
|
|
335
|
+
|| existsSync(join(cwd, 'web-test-runner.config.mjs'));
|
|
336
|
+
// Fall back to the test/browser dir only when there is no explicit config.
|
|
337
|
+
const useBrowserDir = !hasConfig && !serverOnly && existsSync(join(cwd, 'test', 'browser'));
|
|
338
|
+
// Only resolve + run when there is actually something to run, so a
|
|
339
|
+
// `webjs test` with no browser tests stays a no-op (not a hard error).
|
|
340
|
+
if (hasConfig || useBrowserDir) {
|
|
341
|
+
// Resolve the app's @web/test-runner bin and spawn it with the current
|
|
342
|
+
// runtime, dropping `npx` (#570; absent in a pure oven/bun image).
|
|
343
|
+
let wtrPath;
|
|
344
|
+
try {
|
|
345
|
+
wtrPath = resolveBin(cwd, '@web/test-runner', 'wtr');
|
|
346
|
+
} catch {
|
|
347
|
+
console.error(
|
|
348
|
+
'\nwebjs test --browser: @web/test-runner is not installed in this project.\n' +
|
|
349
|
+
'Install it with `npm install -D @web/test-runner @web/test-runner-playwright`.',
|
|
350
|
+
);
|
|
351
|
+
process.exit(1);
|
|
352
|
+
}
|
|
311
353
|
console.log(`\nwebjs test: running browser tests (WTR + Playwright)…\n`);
|
|
312
|
-
const
|
|
354
|
+
const wtrArgs = hasConfig ? [wtrPath] : [wtrPath, '--files', 'test/browser/**/*.test.js'];
|
|
355
|
+
const child = spawn(process.execPath, wtrArgs, {
|
|
313
356
|
stdio: 'inherit', cwd, env: { ...process.env },
|
|
314
357
|
});
|
|
315
358
|
const code = await new Promise(r => child.on('exit', r));
|
|
316
359
|
if (code !== 0) process.exit(code ?? 1);
|
|
317
|
-
} else if (!serverOnly) {
|
|
318
|
-
// No WTR config, check for test/browser directory
|
|
319
|
-
const browserDir = join(cwd, 'test', 'browser');
|
|
320
|
-
if (existsSync(browserDir)) {
|
|
321
|
-
console.log(`\nwebjs test: running browser tests (WTR + Playwright)…\n`);
|
|
322
|
-
const child = spawn('npx', ['wtr', '--files', 'test/browser/**/*.test.js'], {
|
|
323
|
-
stdio: 'inherit', cwd, env: { ...process.env },
|
|
324
|
-
});
|
|
325
|
-
const code = await new Promise(r => child.on('exit', r));
|
|
326
|
-
if (code !== 0) process.exit(code ?? 1);
|
|
327
|
-
}
|
|
328
360
|
}
|
|
329
361
|
}
|
|
330
362
|
|
|
@@ -492,8 +524,16 @@ Full docs: https://docs.webjs.com`);
|
|
|
492
524
|
const noInstall = rest.includes('--no-install');
|
|
493
525
|
// --db picks the database dialect: sqlite (default) or postgres.
|
|
494
526
|
const db = flag(rest, '--db', 'sqlite');
|
|
527
|
+
// --runtime picks the target runtime: node (default) or bun. Orthogonal
|
|
528
|
+
// to --template (#541). When omitted, scaffoldApp auto-detects bun from
|
|
529
|
+
// the invoking PM (so `bun create webjs` implies bun).
|
|
530
|
+
const runtime = flag(rest, '--runtime');
|
|
531
|
+
if (runtime && !['node', 'bun'].includes(runtime)) {
|
|
532
|
+
console.error(`Error: unknown --runtime '${runtime}'. Only node / bun are supported.`);
|
|
533
|
+
process.exit(1);
|
|
534
|
+
}
|
|
495
535
|
const { scaffoldApp } = await import('../lib/create.js');
|
|
496
|
-
await scaffoldApp(name, process.cwd(), { template, db, install: !noInstall });
|
|
536
|
+
await scaffoldApp(name, process.cwd(), { template, db, runtime, install: !noInstall });
|
|
497
537
|
break;
|
|
498
538
|
}
|
|
499
539
|
case 'vendor': {
|
package/lib/create.js
CHANGED
|
@@ -17,6 +17,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
17
17
|
import { existsSync } from 'node:fs';
|
|
18
18
|
import { createRequire } from 'node:module';
|
|
19
19
|
import { spawnSync } from 'node:child_process';
|
|
20
|
+
import { bunifyProse, bunifyDockerfile, bunifyCompose, bunifyCi } from './runtime-rewrite.js';
|
|
20
21
|
|
|
21
22
|
/**
|
|
22
23
|
* Detect which package manager invoked us. Reads `npm_config_user_agent`,
|
|
@@ -259,6 +260,19 @@ export async function scaffoldApp(name, cwd, opts = {}) {
|
|
|
259
260
|
if (!VALID_DIALECTS.includes(dialect)) {
|
|
260
261
|
throw new Error(`Unknown --db '${dialect}'. Only ${VALID_DIALECTS.join(' / ')} are supported.`);
|
|
261
262
|
}
|
|
263
|
+
|
|
264
|
+
// Runtime axis (#541), ORTHOGONAL to --template (the exactly-3-templates
|
|
265
|
+
// invariant is untouched). Default node; bun opt-in via `--runtime bun` OR
|
|
266
|
+
// auto-detected when the scaffold is invoked through bun (`bun create webjs`),
|
|
267
|
+
// with the explicit flag winning over detection. A bun-flavored app SERVES on
|
|
268
|
+
// Bun (its dev/start scripts force `--bun`), commits `bun.lock`, sets
|
|
269
|
+
// `trustedDependencies`, and ships a bun Dockerfile / CI / agent docs.
|
|
270
|
+
const runtime = opts.runtime || (detectPackageManager() === 'bun' ? 'bun' : 'node');
|
|
271
|
+
const VALID_RUNTIMES = ['node', 'bun'];
|
|
272
|
+
if (!VALID_RUNTIMES.includes(runtime)) {
|
|
273
|
+
throw new Error(`Unknown --runtime '${runtime}'. Only ${VALID_RUNTIMES.join(' / ')} are supported.`);
|
|
274
|
+
}
|
|
275
|
+
const isBun = runtime === 'bun';
|
|
262
276
|
const appDir = join(cwd, name);
|
|
263
277
|
if (existsSync(appDir)) {
|
|
264
278
|
console.error(`Error: directory '${name}' already exists.`);
|
|
@@ -303,8 +317,19 @@ export async function scaffoldApp(name, cwd, opts = {}) {
|
|
|
303
317
|
// the start orchestration (`webjs db migrate`), run INSIDE `webjs start`,
|
|
304
318
|
// so `npm run start` (a thin alias) behaves identically. Drizzle has no
|
|
305
319
|
// codegen, so there is no dev `before` step.
|
|
306
|
-
|
|
307
|
-
|
|
320
|
+
//
|
|
321
|
+
// Bun runtime (#541): the long-running server scripts (`dev` / `start`)
|
|
322
|
+
// are prefixed `bun --bun` so the app SERVES on Bun. The `--bun` overrides
|
|
323
|
+
// the `webjs` bin's `#!/usr/bin/env node` shebang (without it `bun run dev`
|
|
324
|
+
// would exec webjs under Node, silently running the "bun" app on Node).
|
|
325
|
+
// Baking it into the script body means a plain `bun run dev` (or even
|
|
326
|
+
// `npm run dev`) starts on Bun, so a user never has to remember the flag.
|
|
327
|
+
// The runtime-neutral tooling scripts below (test / db / check / typecheck
|
|
328
|
+
// / doctor) stay plain `webjs ...`: they spawn node tooling (`node --test`,
|
|
329
|
+
// drizzle-kit, tsc) and forcing `--bun` there buys nothing (and `webjs
|
|
330
|
+
// test` shells `node --test`, which a `bun --test` would not be).
|
|
331
|
+
dev: isBun ? 'bun --bun webjs dev' : 'webjs dev',
|
|
332
|
+
start: isBun ? 'bun --bun webjs start' : 'webjs start',
|
|
308
333
|
test: 'webjs test',
|
|
309
334
|
'test:server': 'webjs test --server',
|
|
310
335
|
'test:browser': 'webjs test --browser',
|
|
@@ -371,6 +396,14 @@ export async function scaffoldApp(name, cwd, opts = {}) {
|
|
|
371
396
|
// applies pending migrations at boot via `webjs db migrate` (drizzle-kit).
|
|
372
397
|
start: { before: ['webjs db migrate'] },
|
|
373
398
|
},
|
|
399
|
+
// Bun runtime (#541): Bun does NOT run a dependency's postinstall by default
|
|
400
|
+
// (a security default); a package must be listed here for its install script
|
|
401
|
+
// to run on `bun install`. The sqlite driver `better-sqlite3` fetches its
|
|
402
|
+
// native prebuild in a postinstall, so without this the native binding is
|
|
403
|
+
// missing and the app crashes at first DB access. Postgres `pg` is pure JS
|
|
404
|
+
// (no postinstall), so the list is sqlite-only. Omitted entirely on Node
|
|
405
|
+
// (npm runs postinstalls), keeping the node-mode package.json byte-identical.
|
|
406
|
+
...(isBun && dialect !== 'postgres' ? { trustedDependencies: ['better-sqlite3'] } : {}),
|
|
374
407
|
}, null, 2) + '\n');
|
|
375
408
|
|
|
376
409
|
await writeFile(join(appDir, 'tsconfig.json'), JSON.stringify({
|
|
@@ -484,12 +517,39 @@ export async function scaffoldApp(name, cwd, opts = {}) {
|
|
|
484
517
|
'compose.yaml',
|
|
485
518
|
'.dockerignore',
|
|
486
519
|
];
|
|
520
|
+
// Bun runtime (#541): the agent-config markdown shows bun commands, and the
|
|
521
|
+
// deploy files (Dockerfile / compose / CI) run on Bun. Each is DERIVED from
|
|
522
|
+
// the canonical node template by a pure transform (see runtime-rewrite.js), so
|
|
523
|
+
// there is no parallel bun template to drift. Prose files get the command
|
|
524
|
+
// rewrites; the three infra files get their file-specific transform. On Node,
|
|
525
|
+
// every file is copied byte-identical (the map is empty).
|
|
526
|
+
const PROSE_REWRITE = new Set([
|
|
527
|
+
'AGENTS.md', 'CONVENTIONS.md', '.cursorrules',
|
|
528
|
+
'.agents/rules/workflow.md', '.github/copilot-instructions.md',
|
|
529
|
+
// The starter tests carry header comments with run commands (`npx wtr`,
|
|
530
|
+
// `npm i -D puppeteer-core`); bun-ify those too so a bun app's test files
|
|
531
|
+
// do not tell the user to run npm/npx (#541 review). The transform only
|
|
532
|
+
// touches npm/npx command tokens, so the test code itself is unaffected.
|
|
533
|
+
'test/hello/browser/hello.test.js', 'test/hello/e2e/hello.test.ts',
|
|
534
|
+
]);
|
|
535
|
+
// compose.yaml builds from the (pure oven/bun) Dockerfile and inherits its
|
|
536
|
+
// `bun --bun run start` CMD; only its healthcheck needs switching off node
|
|
537
|
+
// (the pure Bun image has no node), which bunifyCompose does.
|
|
538
|
+
const FILE_REWRITE = {
|
|
539
|
+
'Dockerfile': bunifyDockerfile,
|
|
540
|
+
'compose.yaml': bunifyCompose,
|
|
541
|
+
'.github/workflows/ci.yml': bunifyCi,
|
|
542
|
+
};
|
|
487
543
|
for (const f of templateFiles) {
|
|
488
544
|
const src = join(TEMPLATES, f);
|
|
489
545
|
if (existsSync(src)) {
|
|
490
546
|
await mkdir(dirname(join(appDir, f)), { recursive: true });
|
|
491
547
|
let content = await readFile(src, 'utf8');
|
|
492
548
|
content = content.replace(/\{\{APP_NAME\}\}/g, name);
|
|
549
|
+
if (isBun) {
|
|
550
|
+
if (PROSE_REWRITE.has(f)) content = bunifyProse(content);
|
|
551
|
+
else if (FILE_REWRITE[f]) content = FILE_REWRITE[f](content);
|
|
552
|
+
}
|
|
493
553
|
await writeFile(join(appDir, f), content);
|
|
494
554
|
}
|
|
495
555
|
}
|
|
@@ -717,6 +777,15 @@ export default cors({
|
|
|
717
777
|
|
|
718
778
|
await writeFile(join(appDir, 'modules', 'users', 'queries', 'list-users.server.ts'), `'use server';
|
|
719
779
|
|
|
780
|
+
// A GET server action (#488): a read declares its HTTP semantics via reserved
|
|
781
|
+
// sibling exports the framework reads statically. 'method' makes the call ride
|
|
782
|
+
// the URL (cacheable, ETag/304-aware, SSR-seeded on first paint); 'cache' is the
|
|
783
|
+
// max-age in seconds (private by default, do NOT add { public: true } unless the
|
|
784
|
+
// data is identical for EVERY visitor); 'tags' label the cached entry so a
|
|
785
|
+
// mutation can evict it. One function per file.
|
|
786
|
+
export const method = 'GET';
|
|
787
|
+
export const cache = 30;
|
|
788
|
+
export const tags = () => ['users'];
|
|
720
789
|
export async function listUsers() {
|
|
721
790
|
// TODO: replace with real data source
|
|
722
791
|
return [
|
|
@@ -727,6 +796,11 @@ export async function listUsers() {
|
|
|
727
796
|
`);
|
|
728
797
|
await writeFile(join(appDir, 'modules', 'users', 'actions', 'create-user.server.ts'), `'use server';
|
|
729
798
|
|
|
799
|
+
// A mutation server action (#488). With no 'method' export it defaults to POST
|
|
800
|
+
// (CSRF-protected, rich request body). 'invalidates' lists the cache tags to
|
|
801
|
+
// evict on success, so the next listUsers() read refetches fresh instead of
|
|
802
|
+
// serving a stale browser-cached value. One function per file.
|
|
803
|
+
export const invalidates = () => ['users'];
|
|
730
804
|
export async function createUser(input: { name: string; email: string }) {
|
|
731
805
|
// TODO: validate input, persist to database
|
|
732
806
|
return { success: true, data: { id: Date.now().toString(), ...input } };
|
|
@@ -1173,7 +1247,7 @@ ThemeToggle.register('theme-toggle');
|
|
|
1173
1247
|
// --- SaaS template extras: auth, dashboard, drizzle User model ---
|
|
1174
1248
|
if (isSaas) {
|
|
1175
1249
|
const { writeSaasFiles } = await import('./saas-template.js');
|
|
1176
|
-
await writeSaasFiles(appDir);
|
|
1250
|
+
await writeSaasFiles(appDir, { runtime });
|
|
1177
1251
|
}
|
|
1178
1252
|
|
|
1179
1253
|
// AGENTS.md is already in place via the shared `templateFiles` loop
|
|
@@ -1264,7 +1338,10 @@ For AI agents, read this before editing scaffolded files:
|
|
|
1264
1338
|
// pnpm / yarn / bun users get their own. Pass `--no-install` (or
|
|
1265
1339
|
// `{ install: false }` to scaffoldApp) to opt out, e.g. for CI tests
|
|
1266
1340
|
// that exercise the scaffold without paying the install cost.
|
|
1267
|
-
|
|
1341
|
+
// In bun mode, install with bun regardless of the invoking PM, so the app
|
|
1342
|
+
// commits `bun.lock` (text JSONC, git-diffable) instead of `package-lock.json`
|
|
1343
|
+
// (#541). Otherwise honour the invoking PM (npm / pnpm / yarn / bun).
|
|
1344
|
+
const pm = isBun ? 'bun' : detectPackageManager();
|
|
1268
1345
|
let installed = false;
|
|
1269
1346
|
if (shouldInstall) {
|
|
1270
1347
|
console.log(`Running '${pm} install' in ${name}/ ...\n`);
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve a dependency's executable from an app's node_modules (#570).
|
|
3
|
+
*
|
|
4
|
+
* `webjs db` / `webjs test --browser` used to shell `npx drizzle-kit` / `npx
|
|
5
|
+
* wtr`, but `npx` is absent in a pure `oven/bun` image (and the whole point of
|
|
6
|
+
* runtime-native commands is to run under whatever the current runtime is). So
|
|
7
|
+
* instead resolve the tool's bin from the APP's node_modules and let the caller
|
|
8
|
+
* spawn it with `process.execPath` (Node or Bun), the same pattern `webjs
|
|
9
|
+
* typecheck` uses for the app's `tsc`.
|
|
10
|
+
*
|
|
11
|
+
* The wrinkle: CLIs like drizzle-kit and @web/test-runner do NOT expose
|
|
12
|
+
* `./package.json` or their bin subpath in `exports`, so `require.resolve(
|
|
13
|
+
* 'drizzle-kit/bin.cjs')` throws `ERR_PACKAGE_PATH_NOT_EXPORTED`. The `.` main
|
|
14
|
+
* entry DOES resolve, so resolve that, walk up to the package root (the nearest
|
|
15
|
+
* dir with a package.json), and read the `bin` field, which is version-robust.
|
|
16
|
+
*/
|
|
17
|
+
import { createRequire } from 'node:module';
|
|
18
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
19
|
+
import { join, dirname, resolve } from 'node:path';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @param {string} cwd the app root (a dir containing the app's package.json)
|
|
23
|
+
* @param {string} pkgName the dependency package name (e.g. 'drizzle-kit')
|
|
24
|
+
* @param {string} binName the key in the package's `bin` map (e.g. 'wtr');
|
|
25
|
+
* ignored when `bin` is a plain string
|
|
26
|
+
* @returns {string} absolute path to the bin's JS entry
|
|
27
|
+
* @throws if the package is not installed or has no matching bin
|
|
28
|
+
*/
|
|
29
|
+
export function resolveBin(cwd, pkgName, binName) {
|
|
30
|
+
const req = createRequire(join(cwd, 'package.json'));
|
|
31
|
+
// `.` (the main entry) is exported even when subpaths are not.
|
|
32
|
+
let pkgDir = dirname(req.resolve(pkgName));
|
|
33
|
+
while (!existsSync(join(pkgDir, 'package.json'))) {
|
|
34
|
+
const parent = dirname(pkgDir);
|
|
35
|
+
if (parent === pkgDir) throw new Error(`package.json not found for ${pkgName}`);
|
|
36
|
+
pkgDir = parent;
|
|
37
|
+
}
|
|
38
|
+
const pkg = JSON.parse(readFileSync(join(pkgDir, 'package.json'), 'utf8'));
|
|
39
|
+
const binRel = typeof pkg.bin === 'string' ? pkg.bin : pkg.bin?.[binName];
|
|
40
|
+
if (!binRel) throw new Error(`bin '${binName}' not found in ${pkgName}`);
|
|
41
|
+
return resolve(pkgDir, binRel);
|
|
42
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bun-first scaffold rewrites (#541).
|
|
3
|
+
*
|
|
4
|
+
* The scaffold authors every template in its node/npm form (ONE source of
|
|
5
|
+
* truth, no drift) and DERIVES the bun-mode variant by transform when the app
|
|
6
|
+
* is scaffolded with `--runtime bun` (or through `bun create webjs`). These are
|
|
7
|
+
* pure string transforms so they unit-test without touching the filesystem.
|
|
8
|
+
*
|
|
9
|
+
* Why a transform and not a second set of template files: the agent-config
|
|
10
|
+
* markdown (AGENTS.md / CONVENTIONS.md / .cursorrules / ...) plus the deploy
|
|
11
|
+
* files (Dockerfile / ci.yml) are long and change often; a parallel bun copy
|
|
12
|
+
* would silently drift from the node original. A transform keeps the node
|
|
13
|
+
* template canonical and the bun output a deterministic function of it.
|
|
14
|
+
*
|
|
15
|
+
* The runtime axis is ORTHOGONAL to `--template` (the exactly-3-templates
|
|
16
|
+
* invariant is untouched, this is a separate dimension).
|
|
17
|
+
*
|
|
18
|
+
* WHAT RUNS ON BUN, AND WHAT DOES NOT (the load-bearing design decision):
|
|
19
|
+
* the SERVER (the `dev` / `start` scripts) runs on Bun, because that is the
|
|
20
|
+
* app. The dev/build TOOLING (`webjs test` / `db:*` / `check` / `typecheck`)
|
|
21
|
+
* stays on Node, because `webjs test` spawns `node --test` (Bun has no
|
|
22
|
+
* `--test` flag; its runner is `bun test`). So:
|
|
23
|
+
* - the `dev` / `start` scripts force `bun --bun` (the server is Bun),
|
|
24
|
+
* - every other command stays `bun run` / `webjs ...` (runs on Node via the
|
|
25
|
+
* `webjs` bin's `#!/usr/bin/env node` shebang),
|
|
26
|
+
* - the Dockerfile is a pure `oven/bun:1` base (#595). This is safe as of
|
|
27
|
+
* `@webjsdev/cli@0.10.20` (#570): `webjs db migrate` resolves drizzle-kit
|
|
28
|
+
* and runs it under Bun (no `npx`), so a Node-less image works. (Before
|
|
29
|
+
* #570 shipped as `latest`, this stayed on `node:24-alpine` + a copied Bun
|
|
30
|
+
* binary, since the installed CLI could still shell `npx`.)
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Rewrite command-shaped npm/npx invocations in prose (markdown / agent config /
|
|
35
|
+
* the starter-test header comments) to their bun equivalents.
|
|
36
|
+
*
|
|
37
|
+
* `npm run dev` / `start` become `bun --bun run` (the `--bun` overrides the
|
|
38
|
+
* `webjs` bin's Node shebang so the SERVER runs on Bun). Every OTHER script
|
|
39
|
+
* becomes a plain `bun run` (Node tooling: forcing `--bun` on `webjs test`
|
|
40
|
+
* would spawn the invalid `bun --test`). Bare "npm" as a word (e.g. "a
|
|
41
|
+
* third-party npm package", "the npm registry") is left alone: packages still
|
|
42
|
+
* come from npm, only the COMMAND changes.
|
|
43
|
+
*
|
|
44
|
+
* @param {string} s
|
|
45
|
+
* @returns {string}
|
|
46
|
+
*/
|
|
47
|
+
export function bunifyProse(s) {
|
|
48
|
+
return s
|
|
49
|
+
// Prose claim about the Dockerfile CMD (AGENTS.md dev-start parity section);
|
|
50
|
+
// the bun Dockerfile's CMD becomes `bun --bun run start`.
|
|
51
|
+
.replaceAll('`CMD ["npm", "start"]`', '`CMD ["bun", "--bun", "run", "start"]`')
|
|
52
|
+
// The "Containerized deploy" prose describes the node template's
|
|
53
|
+
// node:24-alpine base; the bun Dockerfile is a pure oven/bun:1 image (#595),
|
|
54
|
+
// so rewrite the base claim to match what the bun app actually ships. (The
|
|
55
|
+
// trailing `npm start` is rewritten to `bun --bun run start` by the generic
|
|
56
|
+
// rule below.)
|
|
57
|
+
.replaceAll(
|
|
58
|
+
'Dockerfile pins `node:24-alpine` (the same Node major CI uses), installs\ndeps (no build step, since Drizzle has no codegen), and starts via',
|
|
59
|
+
'Dockerfile is a pure `oven/bun:1` image (no Node, since `webjs db migrate`\nresolves drizzle-kit and runs under Bun with no `npx`, #570), installs deps\nwith `bun install` (no build step, since Drizzle has no codegen), and starts via',
|
|
60
|
+
)
|
|
61
|
+
// The "Running on Bun" section frames Bun as opt-in ("force it with --bun").
|
|
62
|
+
// In a bun-flavored app the dev/start scripts ALREADY embed --bun, so reframe
|
|
63
|
+
// it as the configured default.
|
|
64
|
+
.replaceAll('### Running on Bun instead of Node', '### Runtime: this app runs on Bun')
|
|
65
|
+
.replaceAll(
|
|
66
|
+
'The same `package.json` scripts work on\neither; to run under Bun, force it with `--bun` so the server executes on Bun\nrather than the `webjs` bin\'s Node shebang:',
|
|
67
|
+
'This app is configured for Bun. Its `dev` / `start` scripts already force\n`--bun` (which overrides the `webjs` bin\'s Node shebang), so a plain `bun run dev`\nserves on Bun. The other scripts (test / db / check) run on Node, the runtime\nthe `webjs` tooling targets:',
|
|
68
|
+
)
|
|
69
|
+
// Invocation styles first, so "npm create webjs@latest" does not get
|
|
70
|
+
// mangled by the generic "npm <x>" rules below.
|
|
71
|
+
.replaceAll('npm create webjs@latest', 'bun create webjs')
|
|
72
|
+
.replaceAll('npm create webjs', 'bun create webjs')
|
|
73
|
+
// dev-dep installs (`npm install -D` and the `npm i -D` shorthand)
|
|
74
|
+
.replace(/npm install -D /g, 'bun add -d ')
|
|
75
|
+
.replace(/npm install --save-dev /g, 'bun add -d ')
|
|
76
|
+
.replace(/npm i -D /g, 'bun add -d ')
|
|
77
|
+
// a package install (followed by a package name) -> `bun add <pkg>`
|
|
78
|
+
.replace(/npm install (?=[A-Za-z@])/g, 'bun add ')
|
|
79
|
+
.replace(/npm i (?=[A-Za-z@])/g, 'bun add ')
|
|
80
|
+
// bare install / ci -> `bun install`
|
|
81
|
+
.replace(/npm install\b/g, 'bun install')
|
|
82
|
+
.replace(/npm ci\b/g, 'bun install')
|
|
83
|
+
// The SERVER scripts run on Bun (force --bun, overriding the Node shebang).
|
|
84
|
+
.replace(/npm run dev\b/g, 'bun --bun run dev')
|
|
85
|
+
.replace(/npm run start\b/g, 'bun --bun run start')
|
|
86
|
+
.replace(/npm start\b/g, 'bun --bun run start')
|
|
87
|
+
// Every other script is Node tooling (webjs test -> node --test, db -> npx
|
|
88
|
+
// drizzle-kit): a plain `bun run` lets the shebang pick Node. NEVER --bun
|
|
89
|
+
// here (it would make `webjs test` spawn the invalid `bun --test`).
|
|
90
|
+
.replace(/npm run /g, 'bun run ')
|
|
91
|
+
.replace(/npm test\b/g, 'bun run test')
|
|
92
|
+
// one-off executors
|
|
93
|
+
.replace(/npx /g, 'bunx ');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Rewrite the scaffolded Dockerfile for Bun.
|
|
98
|
+
*
|
|
99
|
+
* Base decision (acceptance criterion): a pure `oven/bun:1` image (no Node).
|
|
100
|
+
* Safe as of `@webjsdev/cli@0.10.20` (#570): `webjs db` / `webjs test` resolve
|
|
101
|
+
* their tools (drizzle-kit, wtr) and spawn them with the current runtime instead
|
|
102
|
+
* of `npx`, so the boot-time `webjs db migrate` runs under Bun with no Node
|
|
103
|
+
* toolchain. (Before #570 was the published `latest`, this stayed on a
|
|
104
|
+
* `node:24-alpine` base with a copied Bun binary, since the installed CLI could
|
|
105
|
+
* still shell `npx`, which a pure Bun image lacks. #595 flipped it once the
|
|
106
|
+
* npx-free CLI shipped.) `oven/bun:1` is Debian-based: `ca-certificates` ship in
|
|
107
|
+
* the image and better-sqlite3 fetches its glibc prebuild on `bun install`
|
|
108
|
+
* (gated by `trustedDependencies`), so no build toolchain is needed.
|
|
109
|
+
*
|
|
110
|
+
* @param {string} s
|
|
111
|
+
* @returns {string}
|
|
112
|
+
*/
|
|
113
|
+
export function bunifyDockerfile(s) {
|
|
114
|
+
return s
|
|
115
|
+
// Top comment: explain the pure oven/bun base.
|
|
116
|
+
.replace(
|
|
117
|
+
/# webjs serves \.ts directly[\s\S]*?since the built-in stripper and recursive fs\.watch need it\.\n/,
|
|
118
|
+
'# webjs serves .ts directly by stripping types at the runtime layer, so there is\n' +
|
|
119
|
+
'# NO JavaScript build step (webjs is buildless end to end; there is no bundler or\n' +
|
|
120
|
+
'# esbuild fallback). This image runs the app on **Bun**: the type-strip comes from\n' +
|
|
121
|
+
'# `amaro`, the server serves via Bun.serve, and `webjs db migrate` runs under Bun\n' +
|
|
122
|
+
'# (the CLI resolves drizzle-kit without npx, #570), so no Node is needed. webjs also\n' +
|
|
123
|
+
'# runs on Node 24+; for a Node base instead, swap to `node:24-alpine` and start with\n' +
|
|
124
|
+
'# `npm start`.\n',
|
|
125
|
+
)
|
|
126
|
+
.replace('FROM node:24-alpine', 'FROM oven/bun:1')
|
|
127
|
+
// Debian base: ca-certificates already present, no `apk`. Drop the alpine line.
|
|
128
|
+
.replace(
|
|
129
|
+
/# ca-certificates for outbound TLS \(e\.g\. a managed Postgres\)\. better-sqlite3\n# is a prebuilt native module, so no build toolchain is needed here\.\nRUN apk add --no-cache ca-certificates\n\n/,
|
|
130
|
+
'# The Debian-based oven/bun image ships ca-certificates for outbound TLS (e.g. a\n# managed Postgres). better-sqlite3 fetches its glibc prebuild on `bun install`\n# (gated by trustedDependencies), so no build toolchain is needed.\n\n',
|
|
131
|
+
)
|
|
132
|
+
// Lockfile + install (bun.lock, bun install).
|
|
133
|
+
.replace(
|
|
134
|
+
'# package-lock.json is optional (it\'s absent when the app was scaffolded with\n# --no-install); the glob keeps the COPY working with or without it.\nCOPY package.json package-lock.json* ./\nRUN npm install --no-audit --no-fund',
|
|
135
|
+
'# bun.lock is optional (absent when scaffolded with --no-install); the glob keeps\n# the COPY working with or without it. trustedDependencies in package.json lets\n# better-sqlite3\'s native-prebuild postinstall run (bun skips postinstalls).\nCOPY package.json bun.lock* ./\nRUN bun install',
|
|
136
|
+
)
|
|
137
|
+
// Healthcheck: the pure Bun image has no node; use `bun -e`. Keep the
|
|
138
|
+
// dependency-free-probe comment accurate (the probe runs under Bun now).
|
|
139
|
+
.replace("(Node 24's built-in fetch, no curl/wget)", "(the runtime's built-in fetch, no curl/wget)")
|
|
140
|
+
.replace('CMD ["node", "-e", "fetch(', 'CMD ["bun", "-e", "fetch(')
|
|
141
|
+
// Entrypoint: serve on Bun.
|
|
142
|
+
.replace(
|
|
143
|
+
/# `npm start` is a thin alias[\s\S]*?the migrate no longer depends on an npm `prestart` hook\.\nCMD \["npm", "start"\]/,
|
|
144
|
+
'# `bun --bun run start` runs the `start` script on Bun (the server serves via\n' +
|
|
145
|
+
'# Bun.serve). `webjs start` runs the `webjs.start.before` step (`webjs db migrate`,\n' +
|
|
146
|
+
'# which resolves drizzle-kit and runs it under Bun, no npx, #570), idempotent / a\n' +
|
|
147
|
+
'# no-op with no pending migrations, then serves on $PORT.\n' +
|
|
148
|
+
'CMD ["bun", "--bun", "run", "start"]',
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Rewrite compose.yaml for the pure-Bun image (#595): its healthcheck runs in
|
|
154
|
+
* the `oven/bun:1` container (compose's healthcheck overrides the Dockerfile's),
|
|
155
|
+
* which has no `node`, so switch `node -e` to `bun -e`. compose otherwise builds
|
|
156
|
+
* from the Dockerfile and inherits its `bun --bun run start` CMD, so nothing
|
|
157
|
+
* else changes.
|
|
158
|
+
*
|
|
159
|
+
* @param {string} s
|
|
160
|
+
* @returns {string}
|
|
161
|
+
*/
|
|
162
|
+
export function bunifyCompose(s) {
|
|
163
|
+
return s.replace('test: ["CMD", "node", "-e", "fetch(', 'test: ["CMD", "bun", "-e", "fetch(');
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Rewrite the GitHub Actions CI workflow for Bun: ADD `oven-sh/setup-bun`
|
|
168
|
+
* alongside `actions/setup-node` (kept, because the `webjs` test/db/check
|
|
169
|
+
* tooling runs on Node), install with `bun install` (uses bun.lock), and run
|
|
170
|
+
* scripts with a plain `bun run` (the script bodies' `webjs ...` resolve to Node
|
|
171
|
+
* via the shebang, and dev/start are not run in CI). The `node -e` Chromium-path
|
|
172
|
+
* step stays (Node is present from setup-node).
|
|
173
|
+
*
|
|
174
|
+
* @param {string} s
|
|
175
|
+
* @returns {string}
|
|
176
|
+
*/
|
|
177
|
+
export function bunifyCi(s) {
|
|
178
|
+
return s
|
|
179
|
+
// Keep the Node setup (the tooling needs it), add Bun for `bun install`.
|
|
180
|
+
// Drop `cache: npm` (the cache is bun's now; setup-bun caches by default).
|
|
181
|
+
.replaceAll(
|
|
182
|
+
"- uses: actions/setup-node@v6\n with:\n node-version: '24'\n cache: npm",
|
|
183
|
+
"- uses: actions/setup-node@v6\n with:\n node-version: '24'\n - uses: oven-sh/setup-bun@v2\n with:\n bun-version: latest",
|
|
184
|
+
)
|
|
185
|
+
.replaceAll('- run: npm ci', '- run: bun install')
|
|
186
|
+
.replaceAll('npm install --no-save ', 'bun add --no-save ')
|
|
187
|
+
.replaceAll('npm run ', 'bun run ')
|
|
188
|
+
.replaceAll('npx ', 'bunx ');
|
|
189
|
+
}
|
package/lib/saas-template.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { mkdir, writeFile, readFile } from 'node:fs/promises';
|
|
7
|
+
import { bunifyProse } from './runtime-rewrite.js';
|
|
7
8
|
import { existsSync } from 'node:fs';
|
|
8
9
|
import { join, resolve, dirname } from 'node:path';
|
|
9
10
|
import { fileURLToPath } from 'node:url';
|
|
@@ -42,8 +43,10 @@ async function copyUiComponents(appDir, names) {
|
|
|
42
43
|
|
|
43
44
|
/**
|
|
44
45
|
* @param {string} appDir
|
|
46
|
+
* @param {{ runtime?: 'node'|'bun' }} [opts]
|
|
45
47
|
*/
|
|
46
|
-
export async function writeSaasFiles(appDir) {
|
|
48
|
+
export async function writeSaasFiles(appDir, opts = {}) {
|
|
49
|
+
const isBun = opts.runtime === 'bun';
|
|
47
50
|
// SaaS pages use auth forms, so copy the extra ui-* components on top of
|
|
48
51
|
// the standard set the full-stack scaffold already wrote. Pre-importing
|
|
49
52
|
// them in login/signup/dashboard pages below means the dev server will
|
|
@@ -145,6 +148,12 @@ export async function writeSaasFiles(appDir) {
|
|
|
145
148
|
"",
|
|
146
149
|
"import { auth } from '#lib/auth.server.ts';",
|
|
147
150
|
"",
|
|
151
|
+
"// This read deliberately stays POST-default (no 'method' export). A GET",
|
|
152
|
+
"// server action (#488) is cacheable and SSR-seeded, which is wrong for a",
|
|
153
|
+
"// per-session read: the result differs per user and changes on sign-in /",
|
|
154
|
+
"// sign-out, so it must never be browser-cached or shared. Reserve GET +",
|
|
155
|
+
"// cache + tags for data identical for every visitor (see",
|
|
156
|
+
"// modules/users/queries/list-users.server.ts in the full-stack template).",
|
|
148
157
|
"export async function currentUser() {",
|
|
149
158
|
" const session = await auth();",
|
|
150
159
|
" return session?.user ?? null;",
|
|
@@ -181,7 +190,10 @@ export async function writeSaasFiles(appDir) {
|
|
|
181
190
|
// Until the users table exists those flows error, so the suite skips with a
|
|
182
191
|
// clear message instead of crashing. After DB setup it runs for real.
|
|
183
192
|
await mkdir(join(appDir, 'test', 'auth'), { recursive: true });
|
|
184
|
-
|
|
193
|
+
// The generated comments reference `npm run db:*` setup; bun-ify them so a
|
|
194
|
+
// bun-flavored saas app reads `bun run db:*` (#541; db is Node tooling, so a
|
|
195
|
+
// plain `bun run`, not the --bun server form). The transform is a no-op on Node.
|
|
196
|
+
const authTest = [
|
|
185
197
|
"import { test } from 'node:test';",
|
|
186
198
|
"import assert from 'node:assert/strict';",
|
|
187
199
|
"import { fileURLToPath } from 'node:url';",
|
|
@@ -260,7 +272,11 @@ export async function writeSaasFiles(appDir) {
|
|
|
260
272
|
" assert.match(body, /Dashboard/, 'the dashboard content rendered');",
|
|
261
273
|
"});",
|
|
262
274
|
"",
|
|
263
|
-
].join('\n')
|
|
275
|
+
].join('\n');
|
|
276
|
+
await writeFile(
|
|
277
|
+
join(appDir, 'test', 'auth', 'auth.test.ts'),
|
|
278
|
+
isBun ? bunifyProse(authTest) : authTest,
|
|
279
|
+
);
|
|
264
280
|
|
|
265
281
|
// app/api/auth/[...path]/route.ts
|
|
266
282
|
await mkdir(join(appDir, 'app', 'api', 'auth', '[...path]'), { recursive: true });
|
package/package.json
CHANGED
|
@@ -34,6 +34,13 @@ FIRST, before writing any code:
|
|
|
34
34
|
- If on main/master: create a feature branch before editing.
|
|
35
35
|
- If on a feature branch: verify it matches the current task.
|
|
36
36
|
2. Sync: `git fetch origin && git rebase origin/main` if behind.
|
|
37
|
+
3. If more than one agent may work this repo at once, use a DEDICATED git
|
|
38
|
+
worktree per task (`git worktree add -b <branch> ../<repo>-<slug> origin/main`,
|
|
39
|
+
`cd` in, work there, `git worktree remove` after merge), never a shared
|
|
40
|
+
checkout. Two agents in one directory collide: a `git checkout` in one moves
|
|
41
|
+
HEAD under the other, so commits land on the wrong branch. Git enforces
|
|
42
|
+
one-branch-per-worktree, so worktrees prevent it. A lone agent in a clean
|
|
43
|
+
checkout may use a plain branch.
|
|
37
44
|
|
|
38
45
|
## Autonomous mode (sandbox / no-prompt)
|
|
39
46
|
|
package/templates/.cursorrules
CHANGED
|
@@ -7,10 +7,10 @@ cover what you need, the full hosted docs are at **https://docs.webjs.com**.
|
|
|
7
7
|
|
|
8
8
|
## Persistence + scaffold rules (non-negotiable)
|
|
9
9
|
|
|
10
|
-
- **Use
|
|
11
|
-
(`
|
|
10
|
+
- **Use Drizzle + SQLite for data, never JSON files.** It's already wired up
|
|
11
|
+
(`db/schema.server.ts`, `db/connection.server.ts`, `npm run db:generate` + `npm run db:migrate`). For ANY
|
|
12
12
|
data the app stores (todos, posts, messages, products, comments…),
|
|
13
|
-
define a
|
|
13
|
+
define a Drizzle table. NEVER create `data/*.json`, `db.json`, or any
|
|
14
14
|
JSON file as a fake database. NEVER use module-scope arrays / Maps as
|
|
15
15
|
a substitute. NEVER use localStorage for app data. These are project conventions in CONVENTIONS.md (a JSON file used as a
|
|
16
16
|
database resets on reload and cannot scale).
|
|
@@ -35,6 +35,12 @@ FIRST, before writing any code:
|
|
|
35
35
|
2. Sync with parent: `git fetch origin && git log HEAD..origin/main --oneline`
|
|
36
36
|
- If upstream has new commits: `git rebase origin/main` before starting.
|
|
37
37
|
- Resolve any conflicts before proceeding with the task.
|
|
38
|
+
3. If more than one agent may work this repo at once, use a DEDICATED git
|
|
39
|
+
worktree per task, not a shared checkout: `git worktree add -b <branch>
|
|
40
|
+
../<repo>-<slug> origin/main`, `cd` in, work there, `git worktree remove`
|
|
41
|
+
after merge. Two agents in one directory collide (a `git checkout` in one
|
|
42
|
+
moves HEAD under the other, so commits land on the wrong branch). A lone
|
|
43
|
+
agent in a clean checkout may use a plain branch.
|
|
38
44
|
|
|
39
45
|
## Autonomous mode (sandbox / no-prompt)
|
|
40
46
|
|
|
@@ -107,7 +113,7 @@ self-review loop.
|
|
|
107
113
|
- Shadow-DOM components opt in with `static shadow = true` and use `static styles = css` for scoped CSS, not inline styles. That is the right home for scoped CSS.
|
|
108
114
|
- One function per server action file (*.server.ts)
|
|
109
115
|
- Components must call customElements.define('tag', Class)
|
|
110
|
-
- Server-only code (
|
|
116
|
+
- Server-only code (the DB driver `better-sqlite3` / `pg`, 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. The DB lives in db/*.server.ts (db/connection.server.ts); lib/ holds other server-only infra 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
117
|
- 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
118
|
- **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
119
|
- **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.
|
package/templates/.dockerignore
CHANGED
|
@@ -23,11 +23,12 @@ out
|
|
|
23
23
|
.env
|
|
24
24
|
!.env.example
|
|
25
25
|
|
|
26
|
-
#
|
|
27
|
-
dev.db
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
26
|
+
# Local SQLite database (Drizzle, sqlite dialect). Schema + migrations ship;
|
|
27
|
+
# the runtime volume owns the db file. Matches the `db/dev.db*` rule the
|
|
28
|
+
# scaffold appends to `.gitignore` for the sqlite dialect (no-op for postgres).
|
|
29
|
+
db/dev.db
|
|
30
|
+
db/dev.db-journal
|
|
31
|
+
db/dev.db-*
|
|
31
32
|
|
|
32
33
|
# logs
|
|
33
34
|
*.log
|
|
@@ -32,6 +32,12 @@ FIRST, before writing any code:
|
|
|
32
32
|
- If on main/master: create a feature branch before editing.
|
|
33
33
|
- If on a feature branch: verify it matches the task at hand.
|
|
34
34
|
2. Sync: `git fetch origin && git rebase origin/main` if behind.
|
|
35
|
+
3. If more than one agent may work this repo at once, use a DEDICATED git
|
|
36
|
+
worktree per task (`git worktree add -b <branch> ../<repo>-<slug> origin/main`,
|
|
37
|
+
`cd` in, work there, `git worktree remove` after merge), never a shared
|
|
38
|
+
checkout. Two agents in one directory collide: a `git checkout` in one moves
|
|
39
|
+
HEAD under the other, so commits land on the wrong branch. A lone agent in a
|
|
40
|
+
clean checkout may use a plain branch.
|
|
35
41
|
|
|
36
42
|
## Autonomous mode (sandbox / no-prompt)
|
|
37
43
|
|
|
@@ -101,7 +107,7 @@ each change must include.
|
|
|
101
107
|
- **Erasable TypeScript only.** The runtime strips types via `module.stripTypeScriptTypes` (Node's built-in, or `amaro` on Bun) (whitespace replacement, byte-exact position preservation, no sourcemap). The scaffold's tsconfig.json sets `erasableSyntaxOnly: true`, so the TS compiler rejects `enum`, `namespace` with values, constructor parameter properties, legacy decorators with `emitDecoratorMetadata`, and `import = require`. Use erasable equivalents: `const X = { ... } as const` plus a derived union type instead of `enum`; explicit fields plus constructor body assignments instead of parameter properties. If `erasableSyntaxOnly` is disabled and non-erasable syntax is used, the dev server fails at strip time and returns a 500 pointing at the `no-non-erasable-typescript` lint rule. webjs is buildless end-to-end and has no bundler fallback.
|
|
102
108
|
- Tagged template: html`<div>${value}</div>` with css`...` for styles.
|
|
103
109
|
- **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
|
-
- Components: extend WebComponent
|
|
110
|
+
- Components: extend the `WebComponent({ ... })` factory to declare reactive properties (e.g. `extends WebComponent({ count: Number })`; per-prop options via `prop(Number, { reflect: true })`), add `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. A hand-written `static properties` throws at construction (`no-static-properties`); set defaults via the `default` option or the constructor, never a class-field initializer (`reactive-props-no-class-field`).
|
|
105
111
|
- 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.
|
|
106
112
|
- Server actions: *.server.ts files with one exported async function each.
|
|
107
113
|
- Server-only code (a DB driver like better-sqlite3/pg, 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. The DB lives in db/*.server.ts; lib/ holds other server-only infra and browser-safe utilities (lib/utils/cn.ts with cn); apply the same rule per file.
|
|
@@ -109,5 +115,5 @@ each change must include.
|
|
|
109
115
|
- Context: import { createContext, ContextProvider, ContextConsumer } from '@webjsdev/core/context'
|
|
110
116
|
- Task: import { Task, TaskStatus } from '@webjsdev/core/task'
|
|
111
117
|
- Routing: file-based under app/ (page.ts, layout.ts, route.ts, middleware.ts).
|
|
112
|
-
- Component state lives in signals from @webjsdev/core. Module-scope signals share state across components; instance signals (created in the constructor) carry component-local state. Reactive properties (
|
|
118
|
+
- Component state lives in signals from @webjsdev/core. Module-scope signals share state across components; instance signals (created in the constructor) carry component-local state. Reactive properties (declared via the `WebComponent({ ... })` factory) are for HTML attributes and .prop=${...} hydration.
|
|
113
119
|
- Don't skip tests or documentation updates.
|
|
@@ -11,9 +11,20 @@
|
|
|
11
11
|
# here, so a commit stays fast and the test gate cannot be skipped by a
|
|
12
12
|
# local --no-verify. The CI workflow runs `webjs check` + `webjs test`
|
|
13
13
|
# on every push and pull request.
|
|
14
|
+
#
|
|
15
|
+
# Running more than one AI agent on this repo at once? Give each task its own
|
|
16
|
+
# git worktree, not a shared checkout. Two agents in one working directory
|
|
17
|
+
# collide: a `git checkout` in one moves HEAD under the other, so the next
|
|
18
|
+
# commit lands on the wrong branch. Before committing, confirm the branch below
|
|
19
|
+
# is the one you intended; if it moved, you are sharing a checkout. Isolate:
|
|
20
|
+
# git worktree add -b <branch> ../<app>-<task> origin/main && cd ../<app>-<task>
|
|
14
21
|
|
|
15
22
|
BRANCH=$(git symbolic-ref --short HEAD 2>/dev/null)
|
|
16
23
|
|
|
24
|
+
# Surface the branch so a wrong-branch commit (a concurrent-agent HEAD move) is
|
|
25
|
+
# visible in the commit output rather than silent.
|
|
26
|
+
echo "[pre-commit] committing on branch: ${BRANCH:-<detached>}"
|
|
27
|
+
|
|
17
28
|
if [ "$BRANCH" = "main" ] || [ "$BRANCH" = "master" ]; then
|
|
18
29
|
echo ""
|
|
19
30
|
echo "ERROR: Cannot commit directly to '$BRANCH'."
|
package/templates/AGENTS.md
CHANGED
|
@@ -163,8 +163,9 @@ entry, its own template parser. Inside `` html`…` `` templates you get:
|
|
|
163
163
|
- Binding-aware completions: reachable tag names after `<`, and
|
|
164
164
|
prefix-keyed attributes (`.prop` property names, `?bool` / plain
|
|
165
165
|
hyphenated attribute names).
|
|
166
|
-
- Diagnostics: value type-checks against
|
|
167
|
-
`@`/`.`/`?` bindings, and
|
|
166
|
+
- Diagnostics: value type-checks against the reactive props declared in
|
|
167
|
+
`WebComponent({ ... })`, unquoted `@`/`.`/`?` bindings, and
|
|
168
|
+
expressionless `.prop` bindings.
|
|
168
169
|
- Hover showing the component class / declared member type.
|
|
169
170
|
|
|
170
171
|
In VS Code / Cursor / Windsurf, the **`webjs` extension** bundles this
|
|
@@ -597,12 +598,13 @@ const secret = process.env.AUTH_SECRET; // undefined (fail-closed)
|
|
|
597
598
|
```ts
|
|
598
599
|
import { WebComponent, html, css } from '@webjsdev/core';
|
|
599
600
|
|
|
600
|
-
|
|
601
|
-
|
|
601
|
+
// Recommended declare-free base-class factory style
|
|
602
|
+
export class Counter extends WebComponent({
|
|
603
|
+
count: Number
|
|
604
|
+
}) {
|
|
602
605
|
static styles = css`button { padding: 8px 12px; }`; // shadow-DOM only
|
|
603
606
|
// static shadow = true; // opt into shadow DOM (default: light DOM)
|
|
604
607
|
// static lazy = true; // download JS only when scrolled into view
|
|
605
|
-
declare count: number; // TypeScript-only typed accessor
|
|
606
608
|
|
|
607
609
|
constructor() {
|
|
608
610
|
super();
|
|
@@ -629,8 +631,9 @@ the click handler is inert). Two consequences for how you write code:
|
|
|
629
631
|
1. **Defaults for the first paint go in `constructor()`** (after
|
|
630
632
|
`super()`), never as class-field initializers (which break
|
|
631
633
|
reactivity) and never in `connectedCallback` (which the server
|
|
632
|
-
doesn't run). For
|
|
633
|
-
default in the constructor
|
|
634
|
+
doesn't run). For reactive properties declared via the
|
|
635
|
+
`WebComponent({ ... })` factory, set the default in the constructor
|
|
636
|
+
or pass the `default` option (e.g. `prop(Number, { default: 0 })`).
|
|
634
637
|
2. **`connectedCallback` is browser-only.** Use it for
|
|
635
638
|
`localStorage`, viewport size, online status, or anything that
|
|
636
639
|
genuinely can't be known on the server. Read the value, then
|
|
@@ -659,7 +662,8 @@ See [Progressive Enhancement](https://docs.webjs.dev/docs/progressive-enhancemen
|
|
|
659
662
|
## Lit muscle-memory gotchas (read if you have written lit before)
|
|
660
663
|
|
|
661
664
|
Webjs's runtime API matches lit. The `WebComponent` base class,
|
|
662
|
-
|
|
665
|
+
reactive properties (declared via the `WebComponent({ ... })` factory),
|
|
666
|
+
the lifecycle hooks, ReactiveControllers, the
|
|
663
667
|
directive set, `html` / `css` tagged templates. The **rendering
|
|
664
668
|
model**, however, is different. Pure-lit patterns that work fine in a
|
|
665
669
|
client-only lit app break in webjs's SSR pipeline or its reactivity
|
|
@@ -714,11 +718,12 @@ Practical consequences for agents writing webjs code.
|
|
|
714
718
|
| 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 |
|
|
715
719
|
| `window.X` / `document.X` in constructor or `render()` | SSR crash | Move to `connectedCallback` |
|
|
716
720
|
| Top-level `import` of a browser-only library | SSR crash | Dynamic `import()` inside `connectedCallback` |
|
|
717
|
-
| Class-field initializer for a reactive property (`student: Student = {...}`) | Silently breaks reactivity (overwrites the framework accessor) | `
|
|
718
|
-
| `@property()` decorator | Banned by invariant 10 (erasable TS) |
|
|
721
|
+
| Class-field initializer for a reactive property (`student: Student = {...}`) | Silently breaks reactivity (overwrites the framework accessor) | Pass the shape to the base-class factory `WebComponent({ student: Object })` and set the default in the constructor (flagged by `reactive-props-no-class-field`) |
|
|
722
|
+
| `@property()` decorator | Banned by invariant 10 (erasable TS) | Pass the shape to the base-class factory `WebComponent({ ... })` (the only supported form) |
|
|
723
|
+
| Hand-written `static properties = { ... }` | Throws at construction (the factory owns property setup) | Pass the same shape to the base-class factory `WebComponent({ ... })` (flagged by `no-static-properties`) |
|
|
719
724
|
| Scoped `static styles = css` or an inline `<style>` with semantic class names (`.hero`, `.card`) in a light-DOM component | Scoped block does nothing without `static shadow = true`; inline `<style>` class names leak globally | Tailwind utilities (the light-DOM default); or `static shadow = true` for genuinely scoped CSS |
|
|
720
725
|
| `willUpdate` computing SSR-visible derived state | Works (runs at SSR), but overriding it opts the component out of elision | Fine for interactive components; for display-only, derive inline in `render()` |
|
|
721
|
-
| `this.hasAttribute` / `getAttribute` in `render()` | Works (server attribute shim backs the attribute methods at SSR) | Read attributes directly, or
|
|
726
|
+
| `this.hasAttribute` / `getAttribute` in `render()` | Works (server attribute shim backs the attribute methods at SSR) | Read attributes directly, or declare a reactive prop via the base-class factory `WebComponent({ ... })` |
|
|
722
727
|
| `ContextProvider` for server-known data | Default value during SSR, content shift on hydration | Pass via props from the page function |
|
|
723
728
|
|
|
724
729
|
The full annotated catalog with code examples lives in the framework
|
|
@@ -767,6 +772,19 @@ export async function createPost(input: { title: string; body: string }) {
|
|
|
767
772
|
Import it from a client component. The framework rewrites it into a
|
|
768
773
|
type-safe RPC stub automatically.
|
|
769
774
|
|
|
775
|
+
A server action is a POST by default, but reserved sibling exports change its
|
|
776
|
+
HTTP semantics without changing the call site (`await getUser(7)` stays the
|
|
777
|
+
same): `export const method = 'GET'` (a read; rides args in the URL, CSRF-exempt,
|
|
778
|
+
cacheable), `export const cache = 60` + `export const tags` (GET response
|
|
779
|
+
caching), `export const invalidates` (a mutation's tags to evict), `export const
|
|
780
|
+
middleware` (a per-action chain, `actionContext()`), and `export const validate`
|
|
781
|
+
(the boundary validator). One callable function per configured file. An action
|
|
782
|
+
that RETURNS a `ReadableStream` / async generator streams its chunks (consume
|
|
783
|
+
with `for await`); read the request `AbortSignal` via `actionSignal()` to cancel
|
|
784
|
+
on disconnect. **SAFETY:** a `cache` with `public: true` shares one response
|
|
785
|
+
across all users, so use it only for data identical for every visitor. Full
|
|
786
|
+
reference: https://docs.webjs.com/docs/server-actions
|
|
787
|
+
|
|
770
788
|
## Client navigation patterns (auto-magic)
|
|
771
789
|
|
|
772
790
|
The client router enables itself when the scaffolded root layout imports
|
|
@@ -1205,7 +1223,14 @@ composition, so a nested shell ends up dropped by the HTML parser.
|
|
|
1205
1223
|
|
|
1206
1224
|
## Workflow expectations for AI agents
|
|
1207
1225
|
|
|
1208
|
-
1. Branch before editing. Never push to `main` directly.
|
|
1226
|
+
1. Branch before editing. Never push to `main` directly. **If more than one
|
|
1227
|
+
agent may work this repo at once, give each task its own git worktree, not a
|
|
1228
|
+
shared checkout** (`git worktree add -b <branch> ../<repo>-<slug> origin/main`,
|
|
1229
|
+
`cd` in, work there, `git worktree remove` after merge). Two agents in one
|
|
1230
|
+
working directory collide: a `git checkout` in one moves `HEAD` under the
|
|
1231
|
+
other, so the next commit lands on the wrong branch. Git enforces
|
|
1232
|
+
one-branch-per-worktree, so worktrees prevent it; a lone agent in a clean
|
|
1233
|
+
checkout may use a plain branch.
|
|
1209
1234
|
2. Every code change comes with a test, AGENTS.md / docs updates if the
|
|
1210
1235
|
feature surface changed, `webjs check` passing. A unit test is not
|
|
1211
1236
|
always enough: a component, hydration, the client router, or a server
|
package/templates/CONVENTIONS.md
CHANGED
|
@@ -60,6 +60,14 @@ even if the user doesn't explicitly ask.**
|
|
|
60
60
|
3. If on a feature branch → verify it matches the current task
|
|
61
61
|
4. Sync with parent: `git fetch origin && git rebase origin/main` if behind
|
|
62
62
|
5. Don't mix unrelated work on the wrong branch
|
|
63
|
+
6. **If more than one agent may work this repo at once, use a dedicated git
|
|
64
|
+
worktree per task, never a shared checkout.** Two agents in one working
|
|
65
|
+
directory collide: a `git checkout` in one moves `HEAD` under the other, so
|
|
66
|
+
the next commit lands on the wrong branch. Isolate each task:
|
|
67
|
+
`git worktree add -b <branch> ../<repo>-<slug> origin/main`, `cd` in, work
|
|
68
|
+
there, and `git worktree remove` after the PR merges. Git enforces
|
|
69
|
+
one-branch-per-worktree, so this makes the collision impossible. A lone agent
|
|
70
|
+
in a clean checkout may use a plain branch.
|
|
63
71
|
|
|
64
72
|
### After cloning: verify the toolchain
|
|
65
73
|
|
|
@@ -635,10 +643,11 @@ Any stateful behavior with a Tier-2 element uses the element.
|
|
|
635
643
|
```ts
|
|
636
644
|
import { WebComponent, html } from '@webjsdev/core';
|
|
637
645
|
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
646
|
+
// Recommended declare-free base-class factory style
|
|
647
|
+
export class MyWidget extends WebComponent({
|
|
648
|
+
label: String,
|
|
649
|
+
count: Number
|
|
650
|
+
}) {
|
|
642
651
|
// Light DOM is the default; Tailwind utility classes apply directly.
|
|
643
652
|
|
|
644
653
|
constructor() {
|
|
@@ -660,16 +669,7 @@ export class MyWidget extends WebComponent {
|
|
|
660
669
|
MyWidget.register('my-widget');
|
|
661
670
|
```
|
|
662
671
|
|
|
663
|
-
`static properties`
|
|
664
|
-
attribute coercion, reflection). `declare` types the field for
|
|
665
|
-
TypeScript without emitting a class-field initializer that would
|
|
666
|
-
clobber the reactive accessor at construction time. The two
|
|
667
|
-
declarations together give you full intelligence in any tsserver-backed
|
|
668
|
-
editor. See the Editor Setup docs for the standalone `@webjsdev/intellisense`
|
|
669
|
-
(no Lit dependency) that extends this to tag / attribute intelligence
|
|
670
|
-
inside `html\`…\`` templates (go-to-definition, binding-aware completions,
|
|
671
|
-
value/binding diagnostics, hover); in VS Code / Cursor / Windsurf the
|
|
672
|
-
`webjs` extension bundles it automatically.
|
|
672
|
+
Reactive properties are declared one way: pass the properties shape directly to the base-class factory `WebComponent({ ... })` (e.g. `label: String`). The property types flow to `this.<prop>` with no `declare` lines needed, and the factory installs the reactive accessors so a class-field initializer can never clobber them. For per-property options use the `prop()` helper inside the shape (`count: prop(Number, { reflect: true })`, `mode: prop({ state: true })`); narrow a type with `prop<Student>(Object)`. Set defaults via the `default` option (`prop(Number, { default: 0 })`) or by assigning in the constructor after `super()`. A hand-written `static properties = { ... }` THROWS at construction (`no-static-properties`). The factory gives you full intelligence in any tsserver-backed editor. See the Editor Setup docs for the standalone `@webjsdev/intellisense` (no Lit dependency) that extends this to tag / attribute intelligence inside `html\`…\`` templates (go-to-definition, binding-aware completions, value/binding diagnostics, hover); in VS Code / Cursor / Windsurf the `webjs` extension bundles it automatically.
|
|
673
673
|
|
|
674
674
|
**Rules:**
|
|
675
675
|
- One component per file
|
|
@@ -680,15 +680,8 @@ value/binding diagnostics, hover); in VS Code / Cursor / Windsurf the
|
|
|
680
680
|
- `my-widget .body`, `my-widget .title` (descendant selector)
|
|
681
681
|
- Tag name must contain a hyphen (HTML spec)
|
|
682
682
|
- Always call `Class.register('tag')`. That's the standard DOM API.
|
|
683
|
-
- **Reactive props
|
|
684
|
-
- Component state lives in signals. Import `signal` from
|
|
685
|
-
`@webjsdev/core`, read via `signal.get()` inside `render()`, write
|
|
686
|
-
via `signal.set(value)`. Module-scope signals share state across
|
|
687
|
-
components; instance signals (created in the constructor) carry
|
|
688
|
-
component-local state. Reactive properties (`static properties =
|
|
689
|
-
{ foo: { type: ... } }` with a sibling `declare foo: T`) wrap HTML
|
|
690
|
-
attributes, attribute reflection, and `.prop=${value}` SSR
|
|
691
|
-
hydration.
|
|
683
|
+
- **Reactive props are declared via the base-class factory `WebComponent({ ... })`.** A hand-written `static properties = { ... }` throws at construction (`webjs check` flags it via `no-static-properties`). Never write `propName = value` or `propName: Type = value` as a class-field initializer on a reactive prop. It compiles to `Object.defineProperty(this, …)` after `super()` and clobbers the framework's reactive accessor, silently breaking re-renders (`webjs check` flags this via `reactive-props-no-class-field`). Set defaults via the `default` option or in the constructor.
|
|
684
|
+
- Component state lives in signals. Import `signal` from `@webjsdev/core`, read via `signal.get()` inside `render()`, write via `signal.set(value)`. Module-scope signals share state across components; instance signals (created in the constructor) carry component-local state. Reactive properties (declared via the factory) wrap HTML attributes, attribute reflection, and `.prop=${value}` SSR hydration.
|
|
692
685
|
- Use lifecycle hooks (`firstUpdated`, `updated`) only when needed
|
|
693
686
|
|
|
694
687
|
---
|
|
@@ -997,7 +990,8 @@ component, applies its attributes, runs `willUpdate` and controllers'
|
|
|
997
990
|
`firstUpdated`, `updated`, or any other browser-only lifecycle hook.
|
|
998
991
|
Whatever state should appear on first paint MUST be set in the
|
|
999
992
|
constructor (after `super()`), derived in `willUpdate`, or derivable
|
|
1000
|
-
from
|
|
993
|
+
from the factory-declared reactive props + attributes on the rendered
|
|
994
|
+
tag. Reading
|
|
1001
995
|
`this.getAttribute` / `hasAttribute` in `render()` works server-side (a
|
|
1002
996
|
server attribute shim backs the attribute methods), but a `Task`'s
|
|
1003
997
|
fetch still runs only on the client.
|