diff --git a/.agents/skills/webjs/references/runtime.md b/.agents/skills/webjs/references/runtime.md index 5230b5b16..e8911d707 100644 --- a/.agents/skills/webjs/references/runtime.md +++ b/.agents/skills/webjs/references/runtime.md @@ -46,7 +46,7 @@ The 103 Early Hints gap costs only a small first-load latency edge where an edge webjs create my-app --runtime bun ``` -`--runtime` is orthogonal to `--template`, so it re-flavors any of full-stack, saas, or api. A Bun scaffold emits a `bun.lock`, a pure `oven/bun:1` Dockerfile plus a bun-install CI, and bun-command agent docs. The test, db, and check tooling still runs on Node. +`--runtime` is orthogonal to `--template`, so it re-flavors either full-stack or api. A Bun scaffold emits a `bun.lock`, a pure `oven/bun:1` Dockerfile plus a bun-install CI, and bun-command agent docs. The test, db, and check tooling still runs on Node. ## Running on Bun diff --git a/.agents/skills/webjs/references/service-worker.md b/.agents/skills/webjs/references/service-worker.md index 36970c82c..15c7d52b7 100644 --- a/.agents/skills/webjs/references/service-worker.md +++ b/.agents/skills/webjs/references/service-worker.md @@ -11,7 +11,7 @@ Read this when you want an offline experience or an asset cache in a WebJs app, ## What ships and why it is safe -WebJs's UI scaffolds (full-stack and saas, not the api template) ship a hand-authored service worker at `public/sw.js` and an offline fallback at `public/offline.html`. Both ship **dormant**: they do nothing until the app registers the worker, and the worker only ever registers from JavaScript. So with JS off no worker exists, and pages, links, and forms behave exactly as before. It is opt-in and adds an offline experience plus an asset cache without changing the no-JS baseline. +WebJs's UI scaffold (full-stack, not the api template) ships a hand-authored service worker at `public/sw.js` and an offline fallback at `public/offline.html`. Both ship **dormant**: they do nothing until the app registers the worker, and the worker only ever registers from JavaScript. So with JS off no worker exists, and pages, links, and forms behave exactly as before. It is opt-in and adds an offline experience plus an asset cache without changing the no-JS baseline. This is a thin, hand-readable worker built directly on the native Service Worker and Cache Storage APIs. There is no Workbox, no precache framework, and no bundler step, matching WebJs's no-build, close-to-web-standards posture. The file is yours to edit, not a framework internal. diff --git a/.claude/skills/webjs-scaffold-sync/SKILL.md b/.claude/skills/webjs-scaffold-sync/SKILL.md index 5a3af38f6..1d6e88ffa 100644 --- a/.claude/skills/webjs-scaffold-sync/SKILL.md +++ b/.claude/skills/webjs-scaffold-sync/SKILL.md @@ -75,10 +75,10 @@ it applies, then update or consciously skip each. 1. **The generators** (the code that writes the app): - `packages/cli/lib/create.js` (the main generator: layout, home page, the theme block, db/schema, the full-stack gallery wiring, the per-template - gates like `isApi` / `isSaas` / `!isApi`). - - `packages/cli/lib/saas-template.js` (the saas-only files: auth, login/signup, - dashboard, the saas schema). - - `packages/cli/lib/api-gallery.js` (the api backend-features showcase). + gates like `isApi` / `isFullStack` / `!isApi`). + - `packages/cli/lib/api-gallery.js` (the api backend-features showcase). Auth + is a full-stack GALLERY card now (`templates/gallery/{app/features/auth, + modules/auth}`), pruned by `gallery:clear`, not a separate template. - Any future `*-template.js` / `*-gallery.js` split out for escaping sanity. 2. **The verbatim template files** copied into every app: - `packages/cli/templates/gallery/**` (the UI feature gallery + example app, @@ -144,7 +144,7 @@ it applies, then update or consciously skip each. affected template, generate an app and prove it: ```sh # generate (files only is enough for structure/typecheck; install to boot) - node -e "import('packages/cli/lib/create.js').then(m => m.scaffoldApp('probe', '/tmp/x', { template: 'saas', install: false }))" + node -e "import('packages/cli/lib/create.js').then(m => m.scaffoldApp('probe', '/tmp/x', { template: 'full-stack', install: false }))" # then in the generated app: webjs check (only no-scaffold-placeholder should # remain), webjs typecheck (clean), and boot it to hit the new route(s). ``` diff --git a/.claude/skills/webjs-start-work/SKILL.md b/.claude/skills/webjs-start-work/SKILL.md index 4fdbce352..ab42f15ce 100644 --- a/.claude/skills/webjs-start-work/SKILL.md +++ b/.claude/skills/webjs-start-work/SKILL.md @@ -144,7 +144,7 @@ Doc drift is the #1 way a framework rots. Documentation MUST stay in sync with c - `CLAUDE.md` (only if a Claude Code rule is specifically added; framework conventions go in AGENTS.md). - `.github/*.md` (issue templates, PR templates, contributing) when a workflow rule shifts. 3. **User-facing docs site** under `docs/app/docs//page.ts` (these are `.ts` files, not markdown, so they're excluded by the markdown query but they're the canonical user-facing reference). If the change is visible to a user reading the docs site, update the matching topic page. Add a new page if the surface is new and there's no obvious home. -4. **Scaffold templates** under `packages/cli/templates/` and the generators `packages/cli/lib/{create,saas-template}.js`. Update if the change affects what `webjs create` generates. The scaffold ships a gallery index home + layout + db wiring, a densely-commented feature gallery (`packages/cli/templates/gallery/**`, demos under `app/features/` plus `app/examples/todo`) and the api showcase (`api-gallery.js`), plus one cross-agent skill at `.agents/skills/webjs/` (SKILL.md + references) that the agent grows in place; there are no per-agent rule files. A feature change that agents should know about lands in the skill; a generated-code change lands in the generators, verified with `generate + boot + webjs check`. +4. **Scaffold templates** under `packages/cli/templates/` and the generators `packages/cli/lib/{create,api-gallery}.js`. Update if the change affects what `webjs create` generates. The scaffold ships a gallery index home + layout + db wiring, a densely-commented feature gallery (`packages/cli/templates/gallery/**`, demos under `app/features/` plus `app/examples/todo`) and the api showcase (`api-gallery.js`), plus one cross-agent skill at `.agents/skills/webjs/` (SKILL.md + references) that the agent grows in place; there are no per-agent rule files. A feature change that agents should know about lands in the skill; a generated-code change lands in the generators, verified with `generate + boot + webjs check`. 5. **The MCP server** (the standalone `@webjsdev/mcp` package, `packages/mcp/src/{mcp,mcp-docs,mcp-source}.js`, extracted from the CLI in #415; `webjs mcp` and `npx @webjsdev/mcp` both run it). The MCP is how AI agents learn and introspect webjs, so it must stay in lockstep with the surfaces it exposes. Update it whenever the change touches what it serves: - **Introspection tools** (`list_routes` / `list_actions` / `list_components` / `check`): if you change the route table shape, the action/RPC-hash scheme, component registration, or a `webjs check` rule, update the matching tool projection so the MCP reports reality. - **Knowledge layer** (resources + `init` + `docs` + prompts): the resources are the skill at `.agents/skills/webjs/` (SKILL.md + references/) + `AGENTS.md`, so a docs change is picked up automatically (it is bundled at `prepack`). But if you add or rename a skill reference file, ADD A NEW INVARIANT, change the execution model, or add an authoring concept an agent should know, also: (a) confirm the `init` primer still pulls the right `AGENTS.md` sections (it sources the Execution-model + Invariants headings, so a heading rename breaks it), and (b) add a guided-workflow PROMPT for any new common recipe (a new page/route/action/component-shaped task). New recipes without a prompt are a silent gap. diff --git a/AGENTS.md b/AGENTS.md index d14787395..1b6671c47 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -73,7 +73,7 @@ Every code change MUST include, automatically: 1. **Tests, every applicable layer (not just unit).** Ship the tests that prove the change across EVERY layer it touches: **unit** (`packages/*/test/**`, `test/**`, including the counterfactual that fails when reverted), **browser** (`*/test/**/browser/*` via `npm run test:browser`, for hydration / DOM / slots / client router / custom-element upgrade), **e2e** (`test/e2e/*.test.mjs` via `WEBJS_E2E=1`, including network probes / navigation / streaming), and **smoke** (`test/examples/*/smoke/*`). A unit test is NECESSARY BUT NOT SUFFICIENT for any client-router / component / browser-facing change (the headline behaviour is a browser/e2e assertion). **Bun parity is part of the task, not an afterthought:** WebJs runs on Node 24+ AND Bun (#508), so a change to a runtime-sensitive surface (the serializer, the node:http vs `Bun.serve` listener + request path, SSR / action / CSRF dispatch, streams, `node:crypto`, the TS stripper, auth / session / cors) MUST be proven on Bun (`node scripts/run-bun-tests.js` + the touched `test/bun/*.mjs` under `bun`) AND ship an added/updated `test/bun/.mjs` cross-runtime assertion. `npm test` does NOT run browser, e2e, or Bun; run them yourself and report the result. Never report work done with failing or missing tests. See `references/testing.md`. Enforced by `.claude/hooks/require-tests-with-src.sh` (the scaffold variant WARNS unless `WEBJS_TEST_GATE=block`) and `.claude/hooks/require-bun-parity-with-runtime-src.sh` (BLOCKS a commit that stages runtime-sensitive source with no `test/bun/**` test; escape hatch `WEBJS_BUN_VERIFIED=1`). 2. **Documentation, part of the definition of done (not optional).** A task is NOT done until EVERY doc surface its change touches is in sync: `AGENTS.md` + the skill at `.agents/skills/webjs/` (SKILL.md + references/) for new API surface, `CONVENTIONS.md` (and per-package `AGENTS.md`) for new conventions, the docs site (`docs/app/docs/`), the marketing `website/`, the scaffold templates (`packages/cli/templates/` per-agent rule files), and `README.md` for a headline capability. Updating `AGENTS.md` alone reproduces the #488 gap (docs site left stale). Invoke the `webjs-doc-sync` skill to sync every applicable surface. Enforced by `.claude/hooks/require-docs-with-src.sh`, which BLOCKS a commit that stages public `packages/*/src` source with no doc surface alongside it (a genuinely internal refactor / CI / release / perf change with no behaviour change bypasses with `WEBJS_NO_DOC_GATE=1`). -3. **Scaffold + skill sync (when a feature changes what apps should do).** The scaffold `webjs create` emits is a gallery index home + a root layout + db wiring, a densely-commented feature gallery (`packages/cli/templates/gallery/**`, single-concept demos under `app/features/` plus the `app/examples/todo` app, shipped in every UI template) and the api backend-features showcase (`packages/cli/lib/api-gallery.js`), plus the one cross-agent skill at `packages/cli/templates/.agents/skills/webjs/` (SKILL.md + references). So when a WebJs feature is added or changed, ask: does the generator (`packages/cli/lib/{create,saas-template,api-gallery}.js`), a gallery demo (`packages/cli/templates/gallery/`), or the agent skill (`.agents/skills/webjs/SKILL.md` + its `references/`) need to move so a freshly scaffolded app and the skill teach the new reality? Verify by generating an app and running `generate + boot + webjs check` (the generators emit strings, so an escaping bug only shows in a freshly generated app). See `framework-dev.md`. +3. **Scaffold + skill sync (when a feature changes what apps should do).** The scaffold `webjs create` emits is a gallery index home + a root layout + db wiring, a densely-commented feature gallery (`packages/cli/templates/gallery/**`, single-concept demos under `app/features/` plus the `app/examples/todo` app, shipped in every UI template) and the api backend-features showcase (`packages/cli/lib/api-gallery.js`), plus the one cross-agent skill at `packages/cli/templates/.agents/skills/webjs/` (SKILL.md + references). So when a WebJs feature is added or changed, ask: does the generator (`packages/cli/lib/{create,api-gallery}.js`), a gallery demo (`packages/cli/templates/gallery/`), or the agent skill (`.agents/skills/webjs/SKILL.md` + its `references/`) need to move so a freshly scaffolded app and the skill teach the new reality? Verify by generating an app and running `generate + boot + webjs check` (the generators emit strings, so an escaping bug only shows in a freshly generated app). See `framework-dev.md`. 4. **Convention validation.** Run `webjs check` and fix violations. ### Git workflow (mandatory) @@ -449,7 +449,7 @@ const result = await optimistic(liked, true, () => likePost(postId)); ## Scaffolding -Three scaffolds exist (do not invent template names): `webjs create ` (full-stack: layout, page, components, modules, Drizzle+SQLite), `webjs create --template api` (backend-only routes + modules + Drizzle, no SSR), `webjs create --template saas` (auth + login/signup + protected dashboard + User model). The `--db sqlite|postgres` flag (default sqlite) picks the dialect; the schema/queries/actions are identical across dialects (see #563). The `--runtime node|bun` flag (default node, #541) is ORTHOGONAL to the template and re-flavors any of the three for Bun (`bun --bun` dev/start scripts so the SERVER runs on Bun, `bun.lock`, a pure `oven/bun:1` Dockerfile (#595; safe since cli@0.10.20's npx-free `webjs db migrate` (#570) needs no Node in the image) + bun-install CI, bun-command agent docs; the test/db/check tooling stays on Node); `bun create webjs ` auto-detects it. Pick from the request: default for any product with UI (todo, blog, dashboard, marketplace, social, e-commerce), `api` for an HTTP/JSON API with no UI, `saas` for accounts/login/signup; default to full-stack when ambiguous. +Two scaffolds exist (do not invent template names): `webjs create ` (full-stack: layout, page, components, modules, Drizzle+SQLite, plus a browsable feature gallery), and `webjs create --template api` (backend-only routes + modules + Drizzle, no SSR). Auth is one of the full-stack gallery cards (login + a signed session + a real protected route), so a full-stack app already carries a promotable auth baseline (this replaced the former `--template saas`). The `--db sqlite|postgres` flag (default sqlite) picks the dialect; the schema/queries/actions are identical across dialects (see #563). The `--runtime node|bun` flag (default node, #541) is ORTHOGONAL to the template and re-flavors either for Bun (`bun --bun` dev/start scripts so the SERVER runs on Bun, `bun.lock`, a pure `oven/bun:1` Dockerfile (#595; safe since cli@0.10.20's npx-free `webjs db migrate` (#570) needs no Node in the image) + bun-install CI, bun-command agent docs; the test/db/check tooling stays on Node); `bun create webjs ` auto-detects it. Pick from the request: default (full-stack) for any product with UI (todo, blog, dashboard, marketplace, social, e-commerce, a SaaS with accounts/login), `api` for an HTTP/JSON API with no UI; default to full-stack when ambiguous. Rules: **always scaffold via `webjs create`** (never hand-roll). **Default to a real database (Drizzle + SQLite); NEVER use JSON files, in-memory arrays, or localStorage for persistence.** Update `db/schema.server.ts` to real models FIRST, then `webjs db generate` + `webjs db migrate`, then build pages/actions/queries. **The scaffold ships a gallery index home, a neutral-palette root layout, db wiring, and a densely-commented feature gallery** (single-concept demos under `app/features/` plus the `app/examples/todo` app, with logic in `modules/`), alongside the one cross-agent skill at `.agents/skills/webjs/` (a routing `SKILL.md` + on-demand `references/`) that teaches how to build. Treat the gallery as browsable reference: read the demos to learn the idioms, then build the app the user asked for by growing the scaffold (add routes under `app/`, components under `components/`, features under `modules//`, keep server-only code behind `.server.ts`) and prune the demos the app does not use (delete the `app/features/` route AND its `modules/`). Give a UI app its own palette by setting the token values in `app/layout.ts`. There is no design gate and no placeholder gate: taste is the agent's job, not the checker's. Docs at https://docs.webjs.dev. @@ -469,7 +469,7 @@ webjs types # generate .webjs/routes.d.ts (typed Route un webjs version # print the installed @webjsdev/cli version (also: webjs --version / -v, #975) 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 --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 [--template api|saas] +webjs create [--template api] webjs db # wraps drizzle-kit (+ runs db/seed.server.ts) webjs ui init | add | list | view webjs vendor pin|unpin|list|audit|outdated|update [--from PROVIDER] # importmap pinning, .webjs/vendor/importmap.json diff --git a/README.md b/README.md index e90d1929a..8d0489c31 100644 --- a/README.md +++ b/README.md @@ -32,18 +32,19 @@ TypeScript with zero build step, real SSR with Declarative Shadow DOM. ## Quickstart -> **AI agents, read this before scaffolding.** Only three templates exist -> (`full-stack` default, `--template api`, `--template saas`), and the CLI -> rejects anything else. Always start from one of them. Never hand-roll -> the directory structure. Default to **full-stack with Drizzle + SQLite**. -> Never store app data in JSON files, in-memory arrays, or localStorage -> as a substitute for the database. The scaffold is **reference only**. +> **AI agents, read this before scaffolding.** Only two templates exist +> (`full-stack` default, `--template api`), and the CLI rejects anything +> else. Always start from one of them. Never hand-roll the directory +> structure. Default to **full-stack with Drizzle + SQLite**. Never store +> app data in JSON files, in-memory arrays, or localStorage as a +> substitute for the database. The scaffold is **reference only**. > Replace the example layout, page, components, and `User` model with > the app the user actually asked for. Picker: > -> - Any product UI (todo, blog, notes, dashboard, marketplace, social, e-commerce…) → **default** (`webjs create `) +> - Any product UI (todo, blog, notes, dashboard, marketplace, social, e-commerce, a SaaS with accounts…) → **default** (`webjs create `) > - Backend-only HTTP/JSON API, no UI → **`--template api`** -> - Auth, login/signup, protected dashboard, SaaS → **`--template saas`** +> +> Auth is one of the full-stack gallery cards (login, a signed session, and a real protected route), so a default app already carries a promotable auth baseline. > > **The scaffold is a starting point, grown in place.** It ships a gallery index home, a neutral-palette root layout, database wiring, and a densely-commented feature gallery (single-concept demos under `app/features/` plus the `app/examples/todo` app, with logic in `modules/`). The framework context ships as one cross-agent skill, `.agents/skills/webjs/SKILL.md` (a routing skill), alongside `AGENTS.md` and `.agents/rules/workflow.md`. Read the skill and browse the gallery, then build the app the user asked for on top of the scaffold, pruning the demos it does not use. > @@ -56,11 +57,11 @@ npm create webjs@latest my-app # full-stack (pages + API + components + Drizzl cd my-app && npm run dev # → http://localhost:8080 -# Backend-only API +# Backend-only API (routes + modules + Drizzle, no UI) npm create webjs@latest my-api -- --template api -# SaaS starter (auth + dashboard + Drizzle) -npm create webjs@latest my-saas -- --template saas +# Auth (login/signup, session, a protected route) ships as a gallery card +# in the default full-stack app. No separate template needed. # Prefer Bun? webjs runs on Node 24+ or Bun. Add --runtime bun to any # template (it is orthogonal to --template), or scaffold through Bun and diff --git a/docs/app/docs/ai-first/page.ts b/docs/app/docs/ai-first/page.ts index 1af5aaae6..bbe086ce0 100644 --- a/docs/app/docs/ai-first/page.ts +++ b/docs/app/docs/ai-first/page.ts @@ -124,7 +124,7 @@ export async function createPost(

12. Scaffold + Persistence Defaults

When a layman user says "create a todo app with webjs", the agent should produce a real full-stack app with a real database, not a JSON-file simulation. WebJs enforces this with three guardrails:

    -
  • Exactly three scaffolds. webjs create <name> (full-stack default), --template api, --template saas. The CLI rejects any other --template value, so an agent can't hallucinate --template todo or --template blog.
  • +
  • Exactly two scaffolds. webjs create <name> (full-stack default) and --template api. The CLI rejects any other --template value, so an agent can't hallucinate --template todo or --template blog. Auth is one of the full-stack gallery cards, not a separate template.
  • Drizzle + SQLite wired up by default. Every scaffold ships db/schema.server.ts, db/columns.server.ts, db/connection.server.ts (exports db), the webjs.dev.before + webjs.start.before steps running webjs db migrate (idempotent, so a db:generate'd migration applies on the next boot), and npm run db:generate / db:migrate / db:studio / db:seed. The agent edits the schema, runs db:generate, and npm run dev applies it, and won't accidentally fall back to JSON files for persistence.
  • Persist with Drizzle, not JSON files. A data/todos.json or db.json used as a database resets on reload and cannot scale. This is a project convention in AGENTS.md and the shipped skill, so an agent reading it takes the database path.
@@ -132,8 +132,8 @@ export async function createPost(
User asks for…                                          Scaffold
 ─────────────────────────────────────────────────────────────────────
 Todo app, blog, notes, dashboard, marketplace, social   default
-HTTP/JSON API only, no UI                                --template api
-Auth / login / signup / SaaS                            --template saas
+App with auth / login / signup / accounts (SaaS) default (auth card) +HTTP/JSON API only, no UI --template api

The scaffold is REFERENCE, not the final product. The agent's job after scaffolding is to replace the example app/page.ts ("Hello from …"), the example User model in db/schema.server.ts, and the example components with the app the user actually requested. The infrastructure (Drizzle wiring, test config, agent rules, route conventions) stays.

When the scaffolded AGENTS.md doesn't cover what you need (an obscure directive, an auth-provider recipe, deployment specifics, edge cases), the full hosted documentation is at docs.webjs.dev. Every API, every recipe, every example lives there. Reach for it before guessing or hand-rolling.

diff --git a/docs/app/docs/auth/page.ts b/docs/app/docs/auth/page.ts index 40cfb27ca..4042fb6e3 100644 --- a/docs/app/docs/auth/page.ts +++ b/docs/app/docs/auth/page.ts @@ -63,7 +63,7 @@ export async function loginWithGoogle() { export async function logout() { return signOut({ redirectTo: '/' }); } -

signOut is also reachable as a route: the mounted handlers serve POST /api/auth/signout, so a plain <form method="POST" action="/api/auth/signout"> logs a user out with no JavaScript. That is how the generated saas scaffold renders its logout button.

+

signOut is also reachable as a route: the mounted handlers serve POST /api/auth/signout, so a plain <form method="POST" action="/api/auth/signout"> logs a user out with no JavaScript. That is how the scaffold's auth gallery card renders its logout button.

Showing a failed sign-in

A failed credentials sign-in redirects to ${pages.error}?error=CredentialsSignin, falling back to the home page when pages.error is unset (which silently swallows the failure). Point the error page at your login route, then read the code and render a message:

@@ -80,7 +80,7 @@ export default function LoginPage({ searchParams }) { <form method="POST" action="/api/auth/signin/credentials">...</form> \`; } -

The generated saas scaffold wires exactly this, so a wrong password shows a message on the login page instead of bouncing to the landing page with no feedback.

+

The scaffold's auth gallery card wires exactly this, so a wrong password shows a message on the login page instead of bouncing to the landing page with no feedback.

Callbacks

Customize the session and JWT with callbacks:

diff --git a/docs/app/docs/cache/page.ts b/docs/app/docs/cache/page.ts index 1066ef839..f3bcc5297 100644 --- a/docs/app/docs/cache/page.ts +++ b/docs/app/docs/cache/page.ts @@ -107,7 +107,7 @@ export default async function Blog() {

Safety. Caching is opt-in and conservative, because a wrongly-cached per-user page is a data leak. Declaring revalidate asserts this page is the same for everyone for N seconds. The cache is keyed by the full URL (path plus search) only, with no per-user keying, so a page that reads cookies(), a session, or any per-user data MUST NOT set revalidate. The framework also refuses to cache any response that is not a 200, is a streamed Suspense body, sets any Set-Cookie, or runs under CSP. SSR responses carry no framework cookie (action CSRF is an Origin / Sec-Fetch-Site check, not a token cookie), so a cacheable page is cookieless and safe to share across visitors.

-

Framework defense, not just the contract. When the render reads per-user state through a framework helper (cookies(), headers(), getSession(), or auth()), the framework auto-marks the request dynamic and refuses to cache it even if you set revalidate, warning you once with the page path. So a wrong revalidate on a cookie-reading or auth()-gated page fails safe (served fresh) instead of leaking. A saas-dashboard page that does const session = await auth() is auto-excluded. The loud caveat is that this only catches reads THROUGH those helpers. A page that varies its body by an inbound auth cookie or Authorization header but reads it raw (not via cookies() / headers() / getSession() / auth()) and sets no new Set-Cookie WILL be cached and served to a logged-out visitor. Read per-user request state through the framework helpers, which auto-exclude the page, or never set revalidate on a per-user page.

+

Framework defense, not just the contract. When the render reads per-user state through a framework helper (cookies(), headers(), getSession(), or auth()), the framework auto-marks the request dynamic and refuses to cache it even if you set revalidate, warning you once with the page path. So a wrong revalidate on a cookie-reading or auth()-gated page fails safe (served fresh) instead of leaking. An auth-gated dashboard page that does const session = await auth() is auto-excluded. The loud caveat is that this only catches reads THROUGH those helpers. A page that varies its body by an inbound auth cookie or Authorization header but reads it raw (not via cookies() / headers() / getSession() / auth()) and sets no new Set-Cookie WILL be cached and served to a logged-out visitor. Read per-user request state through the framework helpers, which auto-exclude the page, or never set revalidate on a per-user page.

Evict on a write with revalidatePath from a server action:

diff --git a/docs/app/docs/getting-started/page.ts b/docs/app/docs/getting-started/page.ts index 6c4a3de36..5638affcf 100644 --- a/docs/app/docs/getting-started/page.ts +++ b/docs/app/docs/getting-started/page.ts @@ -26,25 +26,22 @@ cd my-app && npm run dev

Create a New App

Using the scaffold

-
# full-stack app (pages + API + components + Drizzle/SQLite)
+    
# full-stack app (pages + API + components + Drizzle/SQLite + gallery)
 webjs create my-app
 
 # backend-only API (route handlers + modules, no pages/components/SSR)
-webjs create my-api --template api
+webjs create my-api --template api
-# SaaS starter (auth + dashboard + Drizzle User model + modules) -webjs create my-app --template saas
- -

For AI agents: only those three templates exist, and the CLI rejects any other --template value. Default to full-stack with Drizzle + SQLite for any product app (todo, blog, dashboard, marketplace, social, e-commerce…). Pick --template api only if the user explicitly asks for a backend-only API with no UI. Pick --template saas only if the user explicitly asks for auth / login / accounts. The scaffold is a starting point. Replace the example layout, page, and User model with the app the user actually asked for. Use Drizzle for any persisted data, never JSON files, in-memory arrays, or localStorage. When AGENTS.md doesn't cover what you need, the full hosted docs are at docs.webjs.dev.

+

For AI agents: only those two templates exist, and the CLI rejects any other --template value. Default to full-stack with Drizzle + SQLite for any product app (todo, blog, dashboard, marketplace, social, e-commerce, a SaaS with accounts…). Pick --template api only if the user explicitly asks for a backend-only API with no UI. The scaffold is a starting point. Replace the example layout, page, and User model with the app the user actually asked for. Use Drizzle for any persisted data, never JSON files, in-memory arrays, or localStorage. When AGENTS.md doesn't cover what you need, the full hosted docs are at docs.webjs.dev.

The --template api scaffold generates thin route handlers that wrap typed server actions. Business logic lives in modules/. Routes just import and call the action/query, giving you file-based routing for URL structure plus type-safe server actions for logic.

-

The --template saas scaffold includes login + signup pages, a dashboard with auth middleware guard, settings page, auth API route, createAuth() with Credentials provider, Drizzle User model with password hashing, and a modules architecture (modules/auth/{actions,queries,types.ts}, db/connection.server.ts, lib/{auth,password}.ts).

+

Auth ships as a gallery card. The full-stack app includes an auth card (app/features/auth/ + modules/auth/): login + signup pages, a signed session, an auth API route, createAuth() with a Credentials provider, a Drizzle User model with password hashing, and a genuinely protected route (a segment middleware.ts that redirects an anonymous visitor to login). So a fresh full-stack app already carries a real, promotable auth baseline; gallery:clear strips it (and the rest of the gallery) back to the minimal base.

-

The scaffold is a starting point, grown in place. Every UI scaffold ships a gallery index home, a neutral-palette root layout, database wiring, and a densely-commented feature gallery: single-concept demos under app/features/ plus the app/examples/todo app, with logic in modules/ (the api template ships the backend-features showcase under app/api/features/ instead). The framework context you need to build ships as one cross-agent skill, .agents/skills/webjs/SKILL.md (a routing skill), alongside AGENTS.md and the workflow rules in .agents/rules/workflow.md. Read the skill and browse the gallery, then build the app the user asked for on top of the scaffold, editing the home page, layout, and schema in place and pruning the demo routes it does not use.

+

The scaffold is a starting point, grown in place. Every UI scaffold ships a gallery index home, a neutral-palette root layout, database wiring, and a densely-commented feature gallery: single-concept demos under app/features/ plus the app/examples/todo app, with logic in modules/ (the api template ships the backend-features showcase under app/api/features/ instead). The framework context you need to build ships as one cross-agent skill, .agents/skills/webjs/SKILL.md (a routing skill), alongside AGENTS.md and the workflow rules in .agents/rules/workflow.md. Read the skill and browse the gallery, then build the app the user asked for on top of the scaffold, editing the home page, layout, and schema in place and pruning the demo routes it does not use. Both templates ship a one-command reset, npm run gallery:clear: on the full-stack app it sheds the whole feature gallery (and resets the home + schema to a minimal base), and on the api app it sheds the backend-features showcase under app/api/features/ (keeping the health + users endpoints).

Scaffolding a Bun app

-

WebJs runs on Node 24+ or Bun. To generate a Bun-flavored app, add --runtime bun (a separate axis from --template, so it works with all three). It is auto-detected when you scaffold through Bun, so both forms below produce the same Bun app:

+

WebJs runs on Node 24+ or Bun. To generate a Bun-flavored app, add --runtime bun (a separate axis from --template, so it works with both). It is auto-detected when you scaffold through Bun, so both forms below produce the same Bun app:

# auto-detected: scaffolding through bun implies --runtime bun
 bun create webjs my-app
 # the explicit pin-latest form (bun create maps to bunx create-webjs)
diff --git a/examples/blog/.agents/rules/workflow.md b/examples/blog/.agents/rules/workflow.md
index ec1b9441f..662b60d49 100644
--- a/examples/blog/.agents/rules/workflow.md
+++ b/examples/blog/.agents/rules/workflow.md
@@ -19,13 +19,12 @@ cover what you need, the full hosted docs are at **https://docs.webjs.dev**.
   the example `User` model, the example users module, etc. with the app the
   user actually asked for. Do not ship "Hello from " as the
   deliverable.
-- **Only three templates exist:** `webjs create ` (default full-stack),
-  `--template api`, `--template saas`. The CLI rejects any other `--template`
-  value. Pick:
-  - Any product UI (todo, blog, dashboard, marketplace, social) goes through
-    the default template.
+- **Only two templates exist:** `webjs create ` (default full-stack) and
+  `--template api`. The CLI rejects any other `--template` value. Pick:
+  - Any product UI (todo, blog, dashboard, marketplace, social, a SaaS with
+    accounts) goes through the default template. Auth is one of the full-stack
+    gallery cards (login, session, a protected route), not a separate template.
   - HTTP/JSON API only, no UI, uses `--template api`.
-  - Auth / login / signup / SaaS uses `--template saas`.
 
 ## Before starting ANY work
 
diff --git a/examples/blog/.github/copilot-instructions.md b/examples/blog/.github/copilot-instructions.md
index d6fc98f7e..ce2988b9f 100644
--- a/examples/blog/.github/copilot-instructions.md
+++ b/examples/blog/.github/copilot-instructions.md
@@ -18,12 +18,12 @@ the full hosted docs are at **https://docs.webjs.dev**.
   `app/page.ts`, the example `User` model, the example users module, etc.
   with the app the user actually asked for. Don't ship "Hello from
   " as the deliverable.
-- **Only three templates exist:** `webjs create ` (default
-  full-stack), `--template api`, `--template saas`. The CLI rejects any
-  other `--template` value. Pick:
-  - Any product UI (todo, blog, dashboard, marketplace, social…) → default
+- **Only two templates exist:** `webjs create ` (default
+  full-stack) and `--template api`. The CLI rejects any other
+  `--template` value. Pick:
+  - Any product UI (todo, blog, dashboard, marketplace, social, a SaaS with accounts…) → default
   - HTTP/JSON API only, no UI → `--template api`
-  - Auth / login / signup / SaaS → `--template saas`
+  - Auth (login, session, a protected route) ships as a full-stack gallery card, not a template.
 
 ## Before starting ANY work
 
diff --git a/examples/blog/CONVENTIONS.md b/examples/blog/CONVENTIONS.md
index 7538720e1..38372cbc7 100644
--- a/examples/blog/CONVENTIONS.md
+++ b/examples/blog/CONVENTIONS.md
@@ -874,9 +874,8 @@ no per-project disabling. Run `webjs check` to validate, and
 Create new projects with `webjs create`:
 
 ```sh
-webjs create                   # full-stack (default)
-webjs create  --template api   # backend-only API
-webjs create  --template saas  # auth + dashboard + Drizzle User model
+webjs create                   # full-stack (default; auth ships as a gallery card)
+webjs create  --template api   # backend-only API (routes + modules + Drizzle)
 ```
 
 **Route-wrapping pattern (especially for `--template api` apps):**
diff --git a/packages/cli/AGENTS.md b/packages/cli/AGENTS.md
index 53ca62dda..1e0e44189 100644
--- a/packages/cli/AGENTS.md
+++ b/packages/cli/AGENTS.md
@@ -86,10 +86,11 @@ lib/
                          lockfile + applies the
                          runtime-rewrite transforms to the copied deploy +
                          agent-config files when bun.
-  saas-template.js       Extra files written when --template saas:
-                         auth + login/signup + protected dashboard
-                         + Drizzle User model. `writeSaasFiles(appDir, {runtime})`
-                         bun-ifies the generated auth-test setup comments.
+  api-gallery.js         Backend-features showcase for --template api:
+                         JSON/HTTP endpoints under app/api/features/ (the
+                         api counterpart of the UI gallery). Auth is a
+                         full-stack GALLERY card now (templates/gallery/
+                         {app/features/auth,modules/auth}), not a template.
   runtime-rewrite.js     Pure transforms (#541) that DERIVE the bun-mode
                          variant of each canonical node template:
                          `bunifyProse` (npm->bun command forms in markdown),
diff --git a/packages/cli/README.md b/packages/cli/README.md
index ba8ed80a4..b8fcfa1e6 100644
--- a/packages/cli/README.md
+++ b/packages/cli/README.md
@@ -38,9 +38,8 @@ Both `webjs create` and `create-webjs-app` auto-install dependencies in the new
 ## Commands
 
 ```sh
-webjs create             # scaffold a full-stack app (default)
-webjs create  --template api   # backend-only API app
-webjs create  --template saas  # auth + dashboard + Drizzle User model
+webjs create             # scaffold a full-stack app (default; auth ships as a gallery card)
+webjs create  --template api   # backend-only API app (routes + modules + Drizzle)
 
 webjs dev                      # dev server with live reload (runs webjs.dev.before, e.g. webjs db migrate, then serves; npm run dev is a thin alias)
 webjs start                    # production server (no build step, serves source directly)
diff --git a/packages/cli/bin/webjs.js b/packages/cli/bin/webjs.js
index 81905fd8c..b227aa0db 100755
--- a/packages/cli/bin/webjs.js
+++ b/packages/cli/bin/webjs.js
@@ -43,10 +43,10 @@ if (cmd !== 'help' && cmd !== undefined && !wantsHelp && !wantsVersion) {
   }
 }
 
-// Exactly three scaffolds exist. Keep this list as the single source of
+// Exactly two scaffolds exist. Keep this list as the single source of
 // truth. AI-agent docs in README.md / AGENTS.md / .cursorrules /
 // .agents/rules/workflow.md / .github/copilot-instructions.md mirror it.
-const TEMPLATES = ['full-stack', 'api', 'saas'];
+const TEMPLATES = ['full-stack', 'api'];
 
 const USAGE = `webjs commands:
   webjs dev   [--port 8080] [--no-hot]            Start dev server with live reload
@@ -60,8 +60,8 @@ const USAGE = `webjs commands:
                                                   --json emits the structured results (with stable codes). --strict also fails the exit on warnings
   webjs types                                     Generate .webjs/routes.d.ts (typed Route union + per-route params)
   webjs typecheck [tsc args...]                   Type-check the app with the project's tsc --noEmit (non-zero on errors)
-  webjs create  [--template full-stack|api|saas] [--db sqlite|postgres] [--runtime node|bun] [--no-install]  Scaffold a new webjs app
-                                                  (only 3 templates exist. default: full-stack, Drizzle, --db sqlite, --runtime node)
+  webjs create  [--template full-stack|api] [--db sqlite|postgres] [--runtime node|bun] [--no-install]  Scaffold a new webjs app
+                                                  (only 2 templates exist. default: full-stack, Drizzle, --db sqlite, --runtime node)
                                                   --runtime bun emits a Bun-flavored app (bun.lock, bun Dockerfile/CI, bun docs);
                                                   also auto-detected when run via "bun create webjs".
                                                   Auto-runs the detected package manager's install in the new dir
@@ -160,10 +160,10 @@ const HELP = {
     examples: ['webjs typecheck', 'webjs typecheck --watch'],
   },
   create: {
-    usage: 'webjs create  [--template full-stack|api|saas] [--db sqlite|postgres] [--runtime node|bun] [--no-install]',
+    usage: 'webjs create  [--template full-stack|api] [--db sqlite|postgres] [--runtime node|bun] [--no-install]',
     summary: 'Scaffold a new app. Defaults: full-stack template, Drizzle + SQLite, Node runtime.',
     options: [
-      { flag: '--template ', description: 'full-stack (default), api, or saas.' },
+      { flag: '--template ', description: 'full-stack (default) or api (backend-only, no UI).' },
       { flag: '--db ', description: 'sqlite (default) or postgres.' },
       { flag: '--runtime ', description: 'node (default) or bun.' },
       { flag: '--no-install', description: 'Skip the package-manager install step.' },
@@ -171,7 +171,7 @@ const HELP = {
     examples: [
       'webjs create my-app',
       'webjs create my-api --template api',
-      'webjs create my-saas --template saas --db postgres',
+      'webjs create my-api --template api --db postgres',
       'webjs create my-app --runtime bun',
     ],
   },
@@ -846,7 +846,7 @@ async function main() {
     case 'create': {
       const name = rest[0];
       if (!name || name.startsWith('-')) {
-        console.error('Usage: webjs create  [--template full-stack|api|saas]');
+        console.error('Usage: webjs create  [--template full-stack|api]');
         process.exit(1);
       }
       const template = flag(rest, '--template', 'full-stack');
@@ -856,16 +856,16 @@ async function main() {
         // on which scaffold to pick for which kind of app.
         console.error(`Error: unknown template '${template}'.
 
-Only three scaffolds exist:
-  full-stack   (default): pages + components + API + Drizzle/SQLite.
-                Pick this for any app the user describes in product terms
-                (todo app, blog, dashboard, marketplace, social feed, …).
-  api          backend-only: route handlers + modules, no pages/SSR.
-                Pick this only if the user explicitly asks for an HTTP/JSON
-                API with no UI.
-  saas         auth + login/signup + protected dashboard + Drizzle User
-                model. Pick this only if the user explicitly asks for auth
-                or a SaaS-shaped product.
+Only two scaffolds exist:
+  full-stack   (default): pages + components + API + Drizzle/SQLite, plus a
+                browsable feature gallery. Auth is one of the gallery cards
+                (login + session + a protected route), so a full-stack app
+                already carries a real auth baseline. Pick this for any app
+                the user describes in product terms (todo, blog, dashboard,
+                marketplace, social feed, a SaaS with accounts, …).
+  api          backend-only: route handlers + modules + Drizzle/SQLite, no
+                pages/SSR. Pick this only if the user explicitly asks for an
+                HTTP/JSON API with no UI.
 
 The scaffold is a starting point. Replace the example layout/page/
 components/schema with the actual app the user requested. Use Drizzle +
diff --git a/packages/cli/lib/api-gallery.js b/packages/cli/lib/api-gallery.js
index 214753012..3172c7df2 100644
--- a/packages/cli/lib/api-gallery.js
+++ b/packages/cli/lib/api-gallery.js
@@ -2,7 +2,7 @@
  * Backend-features showcase for `webjs create --template api`.
  * A set of JSON/HTTP endpoints under `app/api/features/` that demonstrate the
  * backend capabilities an API app uses (the api counterpart of the UI gallery).
- * Extracted here (like saas-template.js) to keep create.js readable and dodge
+ * Extracted here to keep create.js readable and dodge
  * nested-template-literal escaping: files are built from arrays of
  * double-quoted strings, so `${...}` and backticks are emitted literally.
  */
diff --git a/packages/cli/lib/create.js b/packages/cli/lib/create.js
index 336e42bce..4dfb96d93 100644
--- a/packages/cli/lib/create.js
+++ b/packages/cli/lib/create.js
@@ -18,7 +18,6 @@ import { existsSync } from 'node:fs';
 import { createRequire } from 'node:module';
 import { spawnSync } from 'node:child_process';
 import { bunifyProse, bunifyDockerfile, bunifyCompose, bunifyCi } from './runtime-rewrite.js';
-import { leanComponentSource } from './lean-copy.js';
 
 /**
  * Detect which package manager invoked us. Reads `npm_config_user_agent`,
@@ -106,60 +105,6 @@ function resolveUiRegistryRoot() {
 }
 const UI_REGISTRY_ROOT = resolveUiRegistryRoot();
 
-/**
- * Read a single @webjsdev/ui registry component, rewrite its relative import
- * of `../lib/utils.ts` to the scaffolded app's aliased path so it resolves
- * when written to `components/ui/.ts`. The scaffold puts cn() at
- * `lib/utils/cn.ts` (folder-grouped with other browser-safe helpers), so the
- * alias form is `#lib/utils/cn.ts` (#555/#556).
- *
- * @param {string} name  component name without `.ts` (e.g. 'button')
- * @returns {Promise} source with import rewritten
- */
-async function readUiComponent(name) {
-  const src = join(UI_REGISTRY_ROOT, 'components', `${name}.ts`);
-  const raw = await readFile(src, 'utf8');
-  // The registry component imports cn() via a relative `../lib/utils.ts`; rewrite
-  // it to the scaffolded app's aliased path (cn lives at lib/utils/cn.ts).
-  const rewritten = raw
-    .replaceAll("'../lib/utils.ts'", "'#lib/utils/cn.ts'")
-    .replaceAll('"../lib/utils.ts"', '"#lib/utils/cn.ts"')
-    // onBeforeCache lives in its own client-only module so cn() stays pure (#819).
-    .replaceAll("'../lib/dom.ts'", "'#lib/utils/dom.ts'")
-    .replaceAll('"../lib/dom.ts"', '"#lib/utils/dom.ts"');
-  // Strip the worked @example from a Tier-1 helper (same as `webjs ui add`), so
-  // the scaffolded component is lean and the example is served on demand. The
-  // shared helper is used by the saas-template copier too, so they cannot drift.
-  return leanComponentSource(rewritten, name);
-}
-
-/**
- * Copy a list of @webjsdev/ui registry components into the scaffolded app
- * under `components/ui/`. Throws if any name is missing from the registry,
- * since the scaffold's generated pages import these by name and a missing
- * file would produce ERR_MODULE_NOT_FOUND at first request. Caller must
- * have already invoked assertUiRegistryAvailable().
- *
- * @param {string} appDir  destination app root
- * @param {string[]} names list of component file basenames (without `.ts`)
- */
-async function copyUiComponents(appDir, names) {
-  const uiDir = join(appDir, 'components', 'ui');
-  await mkdir(uiDir, { recursive: true });
-  for (const n of names) {
-    const src = join(UI_REGISTRY_ROOT, 'components', `${n}.ts`);
-    if (!existsSync(src)) {
-      throw new Error(
-        `@webjsdev/ui registry is missing component '${n}.ts' at ${src}. ` +
-        `The scaffold's example pages import this component by name. ` +
-        `Either the registry was published incompletely or the scaffold's ` +
-        `component list is out of sync with the registry.`,
-      );
-    }
-    await writeFile(join(uiDir, `${n}.ts`), await readUiComponent(n));
-  }
-}
-
 /**
  * Copy the example gallery (idiomatic, densely-commented working examples) into
  * the scaffolded app. Merges `templates/gallery/{app,modules}` over the app so
@@ -177,7 +122,9 @@ async function copyUiComponents(appDir, names) {
  */
 async function copyGallery(appDir) {
   const galleryDir = join(TEMPLATES, 'gallery');
-  for (const sub of ['app', 'modules']) {
+  // `test` carries the auth card's real request-pipeline test (test/auth); it
+  // ships with the gallery and is pruned by gallery:clear alongside the card.
+  for (const sub of ['app', 'modules', 'test']) {
     await cp(join(galleryDir, sub), join(appDir, sub), { recursive: true });
   }
 }
@@ -312,21 +259,19 @@ export async function scaffoldApp(name, cwd, opts = {}) {
   const shouldInstall = opts.install === true;
   // Defence in depth. The CLI already validates this, but library
   // callers (tests, programmatic use) might pass anything.
-  const VALID_TEMPLATES = ['full-stack', 'api', 'saas'];
+  const VALID_TEMPLATES = ['full-stack', 'api'];
   if (!VALID_TEMPLATES.includes(template)) {
     throw new Error(
       `Unknown template '${template}'. Only ${VALID_TEMPLATES.join(' / ')} exist.`,
     );
   }
   const isApi = template === 'api';
-  const isSaas = template === 'saas';
-  // The example gallery ships in every UI scaffold (full-stack AND saas). The
-  // copyGallery gate below is !isApi, since only the api template has no UI. saas
-  // overwrites db/schema.server.ts with its own schema (which includes the
-  // gallery's todos table) and renders the gallery below its auth landing.
-  // isFullStack distinguishes the plain full-stack app from saas for the parts
-  // that differ (its own home page and the create.js-written schema).
-  const isFullStack = !isApi && !isSaas;
+  // The example gallery ships in the one UI template (not api, which has no UI),
+  // so the copyGallery gate below is !isApi. Auth is one of the gallery cards
+  // (app/features/auth + modules/auth), so a UI app ships a real, prunable auth
+  // baseline. `isFullStack` names the UI template for the parts that differ from
+  // api (the todos table + the passwordHash column the auth card needs).
+  const isFullStack = !isApi;
 
   // Database dialect (#563): sqlite (default) or postgres. Drizzle is the ORM;
   // the schema/queries/actions are identical across dialects, only db/columns
@@ -420,8 +365,13 @@ export async function scaffoldApp(name, cwd, opts = {}) {
       // app runs the compiler under Bun (its image has no Node), a Node app runs
       // it directly.
       ...(isApi ? {} : { 'css:build': cssBuildCmd }),
-      // Shed the demo gallery to a clean, buildable base (scripts/clear-gallery.mjs).
-      ...(isApi ? {} : { 'gallery:clear': isBun ? 'bun scripts/clear-gallery.mjs' : 'node scripts/clear-gallery.mjs' }),
+      // Shed the demo gallery / backend-features showcase to a clean, buildable
+      // base. The UI template runs clear-gallery.mjs; the api template runs
+      // clear-api-gallery.mjs (its showcase is app/api/features, not app/features).
+      'gallery:clear': (() => {
+        const script = isApi ? 'clear-api-gallery.mjs' : 'clear-gallery.mjs';
+        return isBun ? `bun scripts/${script}` : `node scripts/${script}`;
+      })(),
       dev: isBun ? 'bun --bun webjs dev' : 'webjs dev',
       start: isBun ? 'bun --bun webjs start' : 'webjs start',
       test: 'webjs test',
@@ -763,7 +713,10 @@ export const users = table('users', {
   name: text(),
   // JSON column: a structured value persisted as JSON, typed via json().
   // Same helper works on SQLite and Postgres. Delete if you do not need it.
-  settings: json<{ theme?: string }>(),
+  settings: json<{ theme?: string }>(),${isFullStack ? `
+  // The auth gallery card (app/features/auth) signs credentials against this.
+  // gallery:clear removes this column with the rest of the auth surface.
+  passwordHash: text(),` : ''}
   createdAt: createdAt(),
 });
 ${isFullStack ? `
@@ -1040,10 +993,18 @@ export type ActionResult =
     // counterpart of the UI gallery. Prune what you skip.
     const { writeApiGallery } = await import('./api-gallery.js');
     await writeApiGallery(appDir);
+
+    // The showcase-reset script (wired as `gallery:clear` for the api template).
+    // It sheds app/api/features + its modules back to the health + users base.
+    const apiClearSrc = join(TEMPLATES, 'scripts', 'clear-api-gallery.mjs');
+    if (existsSync(apiClearSrc)) {
+      await mkdir(join(appDir, 'scripts'), { recursive: true });
+      await cp(apiClearSrc, join(appDir, 'scripts', 'clear-api-gallery.mjs'));
+    }
   }
 
   if (!isApi) {
-    // Full-stack and SaaS templates: layout + page + theme toggle + Tailwind
+    // The UI template: layout + page + theme toggle + Tailwind
 
     // The Tailwind stylesheet is compiled from public/input.css (written below)
     // to a STATIC public/tailwind.css by css:build, and lib/utils/ui.ts helpers
@@ -1053,8 +1014,8 @@ export type ActionResult =
     const publicDir = join(appDir, 'public');
     await mkdir(publicDir, { recursive: true });
     // Progressive-enhancement service worker (#271): ship the opt-in offline
-    // primitive (the worker + its offline fallback) into the UI scaffolds
-    // (full-stack / saas; this block is api-excluded since api has no UI).
+    // primitive (the worker + its offline fallback) into the UI scaffold
+    // (this block is api-excluded since api has no UI).
     // Dormant until the app registers it (see the skill's references/service-worker.md);
     // it never changes the JS-disabled baseline.
     for (const swFile of ['sw.js', 'offline.html']) {
@@ -1087,13 +1048,9 @@ export type ActionResult =
     // styles/globals.css (the @webjsdev/ui theme).
     await writeUiBootstrap(appDir);
 
-    // The saas auth pages import a few ui-* primitives. A full-stack app adds
-    // any component on demand with `webjs ui add `.
-    if (isSaas) {
-      await copyUiComponents(appDir, [
-        'button', 'card', 'alert', 'badge', 'separator', 'label', 'input',
-      ]);
-    }
+    // The gallery cards style with plain Tailwind (the app is pre-initialised for
+    // `webjs ui add ` via writeUiBootstrap above, but ships no components
+    // until you add them on demand).
 
     // The @webjsdev/ui theme (`--color-primary`, `--color-card`, the @theme maps,
     // @custom-variant, @keyframes) plus the app @theme mappings are compiled from
@@ -1136,10 +1093,10 @@ ${uiThemeRaw}
 
     // The gallery: idiomatic, densely-commented single-feature demos under
     // app/features/ plus one whole example app under app/examples/, with logic
-    // in modules/, all linked from the home page below. Shipped in every UI
-    // scaffold (full-stack AND saas) so an agent gains context by browsing real
-    // working code; prune per-feature (delete the route + its module) for what
-    // the app does not use.
+    // in modules/, all linked from the home page below. Shipped in the UI
+    // scaffold so an agent gains context by browsing real working code; prune
+    // per-feature (delete the route + its module) for what the app does not use,
+    // or shed the whole gallery at once with `gallery:clear`.
     await copyGallery(appDir);
 
   await writeFile(join(appDir, 'app', 'layout.ts'), `import { html, cspNonce } from '@webjsdev/core';
@@ -1322,11 +1279,7 @@ export default function RootLayout({ children }: { children: unknown }) {
   // demo and the example app, and a footer with the docs + source links. Treat it
   // as a starting point: prune the demos you do not use (delete the
   // app/features/ route AND its modules/), then reshape this page into the
-  // app's real landing page. For the saas template a login/signup CTA row is
-  // spliced under the tagline.
-  const homeAuthLinks = isSaas
-    ? '\n          '
-    : '';
+  // app's real landing page.
   await writeFile(join(appDir, 'app', 'page.ts'), `import { html } from '@webjsdev/core';
 
 export const metadata = {
@@ -1340,11 +1293,13 @@ export const metadata = {
 const FEATURES = [
   { href: '/features/routing', title: 'Routing', blurb: 'A static route plus a dynamic [id] segment that reads params. The file-based router in miniature.' },
   { href: '/features/boundaries', title: 'Boundaries', blurb: 'The control-flow throws (forbidden / unauthorized / notFound) and the nearest boundary file that catches each.' },
+  { href: '/features/auth', title: 'Auth', blurb: 'Password login on createAuth, a signed session cookie, and a real protected route that redirects anonymous visitors to login.' },
   { href: '/features/components', title: 'Components', blurb: 'The WebComponent factory, reactive props, instance signals, and slot projection in light DOM.' },
   { href: '/features/server-actions', title: 'Server actions', blurb: 'A use-server RPC action next to a server-only .server.ts utility, and why the boundary matters.' },
   { href: '/features/optimistic-ui', title: 'Optimistic UI', blurb: 'The imperative optimistic(signal, value, action) flip: instant update, automatic rollback on failure.' },
   { href: '/features/async-render', title: 'Async render', blurb: 'A component that awaits server data in async render(), so the resolved value is in the first paint.' },
   { href: '/features/streaming', title: 'Streaming actions', blurb: 'A use-server action that returns an async generator, streamed to the call site token by token with for await.' },
+  { href: '/features/stream', title: 'Stream updates', blurb: 'The  element: renderStream() applies surgical append / replace / remove DOM updates by target id, no region redraw.' },
   { href: '/features/suspense', title: 'Suspense boundary', blurb: 'The  element: a first-paint fallback for a SLOW component, with the resolved content streamed in.' },
   { href: '/features/view-transitions', title: 'View transitions', blurb: 'The opt-in view-transition meta cross-fades a soft navigation, with a data-webjs-permanent element persisted across the swap.' },
   { href: '/features/directives', title: 'Directives', blurb: 'The lit-html directive set: repeat for keyed lists, watch(signal) for a fine-grained node swap.' },
@@ -1379,7 +1334,7 @@ export default function Home() {
         
         

AI-first and web-components-first. Server-rendered, progressively enhanced, and buildless. -

${homeAuthLinks} +

@@ -1508,12 +1463,6 @@ ThemeToggle.register('theme-toggle'); `); } // end if (!isApi) - // --- SaaS template extras: auth, dashboard, drizzle User model --- - if (isSaas) { - const { writeSaasFiles } = await import('./saas-template.js'); - await writeSaasFiles(appDir, { runtime }); - } - // AGENTS.md is already in place via the shared `templateFiles` loop // earlier in this function, so no framework-root fallback needed. @@ -1534,20 +1483,11 @@ ThemeToggle.register('theme-toggle'); modules/users/{actions,queries,types.ts} ← routes over server actions db/{schema,columns,connection}.server.ts ← Drizzle (User model) ${guide} -`); - } else if (isSaas) { - console.log(` ${name}/ - app/{layout,page}.ts, login/, signup/ - app/dashboard/{page,settings,middleware}.ts ← protected - app/api/auth/[...path]/route.ts ← auth API - components/ui/*, components/theme-toggle.ts - modules/auth/*, lib/{auth,password}.server.ts - db/{schema,columns,connection}.server.ts ← Drizzle (User model) - ${guide} `); } else { console.log(` ${name}/ - app/{layout,page}.ts ← a minimal home to grow in place + app/{layout,page}.ts ← gallery home; gallery:clear grows in place + app/features/*, modules/* ← browsable demos (incl. auth: login + protected route) components/theme-toggle.ts public/input.css ← Tailwind entry (compiles to public/tailwind.css) db/{schema,columns,connection}.server.ts ← Drizzle @@ -1596,9 +1536,9 @@ ThemeToggle.register('theme-toggle'); // Next-steps banner prints LAST so the actionable command is the // final thing on screen, never buried above the AI-agent guidance. // Single copy-paste line so the user can move from "scaffold done" - // to "dev server up" in one command. The full-stack and saas - // templates ship with @webjsdev/ui already initialised; the api - // template has no UI but may add one later. + // to "dev server up" in one command. The full-stack template ships + // with @webjsdev/ui already initialised; the api template has no UI + // but may add one later. const installSegment = installed ? '' : `${pm} install && `; // The shipped schema is applied on the first `run dev` (webjs.*.before runs // `db migrate`), but only if a migration FILE exists. When we installed, we diff --git a/packages/cli/lib/lean-copy.js b/packages/cli/lib/lean-copy.js deleted file mode 100644 index fe260d8d7..000000000 --- a/packages/cli/lib/lean-copy.js +++ /dev/null @@ -1,43 +0,0 @@ -/** - * The scaffold's lean-copy of a ui component (#983). - * - * `webjs create` copies a few `@webjsdev/ui` registry components into a - * generated app. To match what `webjs ui add` writes, a Tier-1 helper's worked - * `@example` is stripped (the example is served on demand by `webjs ui view` / - * the MCP `ui` tool), while a Tier-2 element file is kept whole. Both scaffold - * copiers (`create.js` and `saas-template.js`) go through THIS one helper so - * they cannot drift. - * - * The strip primitives live in `@webjsdev/ui/registry/extract`; if that subpath - * cannot be resolved, this degrades to a no-op (keep the example) so the strip - * is never a reason `webjs create` fails. - * - * @module lean-copy - */ - -let _mod = null; - -async function loadPrimitives() { - if (_mod) return _mod; - try { - const m = await import('@webjsdev/ui/registry/extract'); - _mod = { stripExample: m.stripExample, isCustomElementSource: m.isCustomElementSource }; - } catch { - _mod = { stripExample: (s) => s, isCustomElementSource: () => true }; - } - return _mod; -} - -/** - * Return the component source as `webjs ui add` would write it: a Tier-1 helper - * has its worked `@example` stripped and a pointer left; a Tier-2 element is - * returned unchanged. - * - * @param {string} source the component source (imports already rewritten) - * @param {string} name the component name (for the pointer) - * @returns {Promise} - */ -export async function leanComponentSource(source, name) { - const { stripExample, isCustomElementSource } = await loadPrimitives(); - return isCustomElementSource(source) ? source : stripExample(source, name); -} diff --git a/packages/cli/lib/saas-template.js b/packages/cli/lib/saas-template.js deleted file mode 100644 index 441d6115c..000000000 --- a/packages/cli/lib/saas-template.js +++ /dev/null @@ -1,568 +0,0 @@ -/** - * SaaS template files for `webjs create --template saas`. - * Extracted to avoid nested template literal escaping issues. - */ - -import { mkdir, writeFile, readFile } from 'node:fs/promises'; -import { bunifyProse } from './runtime-rewrite.js'; -import { leanComponentSource } from './lean-copy.js'; -import { existsSync } from 'node:fs'; -import { join, resolve, dirname } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const UI_REGISTRY_ROOT = resolve( - __dirname, '..', '..', 'ui', 'packages', 'registry', -); - -/** - * Read a registry component and rewrite its `'#lib/utils.ts'` import for - * the scaffolded app's `components/ui/.ts` layout (the `#lib/utils/cn.ts` - * alias). Mirrors the helper in `create.js`, kept private here to avoid coupling. - */ -async function readUiComponent(name) { - const src = join(UI_REGISTRY_ROOT, 'components', `${name}.ts`); - if (!existsSync(src)) return null; - const raw = await readFile(src, 'utf8'); - // The registry component imports cn() via a relative `../lib/utils.ts`; rewrite - // it to the scaffolded app's aliased path (cn lives at lib/utils/cn.ts). - const rewritten = raw - .replaceAll("'../lib/utils.ts'", "'#lib/utils/cn.ts'") - .replaceAll('"../lib/utils.ts"', '"#lib/utils/cn.ts"') - // onBeforeCache lives in its own client-only module so cn() stays pure (#819). - // Without this rewrite dialog.ts keeps the registry-relative `../lib/dom.ts` - // (which resolves to a nonexistent components/lib/dom.ts) and fails typecheck. - .replaceAll("'../lib/dom.ts'", "'#lib/utils/dom.ts'") - .replaceAll('"../lib/dom.ts"', '"#lib/utils/dom.ts"'); - // Strip a Tier-1 helper's worked @example (same as create.js + `webjs ui add`) - // so switch / checkbox are lean, not just the full-stack base set (#983). - return leanComponentSource(rewritten, name); -} - -/** Copy named registry components into `/components/ui/`. */ -async function copyUiComponents(appDir, names) { - const uiDir = join(appDir, 'components', 'ui'); - await mkdir(uiDir, { recursive: true }); - for (const n of names) { - const content = await readUiComponent(n); - if (content == null) continue; - await writeFile(join(uiDir, `${n}.ts`), content); - } -} - -/** - * @param {string} appDir - * @param {{ runtime?: 'node'|'bun' }} [opts] - */ -export async function writeSaasFiles(appDir, opts = {}) { - const isBun = opts.runtime === 'bun'; - // SaaS pages use auth forms, so copy the extra ui-* components on top of - // the standard set the full-stack scaffold already wrote. Pre-importing - // them in login/signup/dashboard pages below means the dev server will - // SSR these elements with full styling on first paint. - // `form` and `field` are deferred to v2 (see packages/ui/AGENTS.md) - - // the saas auth pages use raw
+ label/input class helpers instead. - await copyUiComponents(appDir, ['dialog', 'switch', 'checkbox']); - - // The db/ layer (columns/connection) is written by the full-stack scaffold - // already; this template overwrites db/schema.server.ts below to add the - // User.passwordHash column auth needs. - await mkdir(join(appDir, 'lib'), { recursive: true }); - - // lib/password.server.ts - await writeFile(join(appDir, 'lib', 'password.server.ts'), [ - "import { scrypt, randomBytes, timingSafeEqual } from 'node:crypto';", - "import { promisify } from 'node:util';", - "", - "const scryptAsync = promisify(scrypt);", - "", - "export async function hash(password: string): Promise {", - " const salt = randomBytes(16).toString('hex');", - " const buf = (await scryptAsync(password, salt, 64)) as Buffer;", - " return salt + ':' + buf.toString('hex');", - "}", - "", - "export async function compare(password: string, stored: string): Promise {", - " const [salt, key] = stored.split(':');", - " const buf = (await scryptAsync(password, salt, 64)) as Buffer;", - " return timingSafeEqual(buf, Buffer.from(key, 'hex'));", - "}", - "", - ].join('\n')); - - // lib/auth.server.ts - await writeFile(join(appDir, 'lib', 'auth.server.ts'), [ - "import { createAuth, Credentials, GitHub, Google } from '@webjsdev/server';", - "import { db } from '#db/connection.server.ts';", - "import { compare } from './password.server.ts';", - "", - "// AUTH_SECRET signs session tokens. Set a strong value in .env for any real", - "// deployment. The dev fallback keeps a fresh scaffold booting, but is NOT", - "// safe for production, so we fail fast if it is missing or blank there.", - "const trimmedSecret = process.env.AUTH_SECRET?.trim();", - "if (process.env.NODE_ENV === 'production' && !trimmedSecret) {", - " throw new Error('AUTH_SECRET must be set in production');", - "}", - "const authSecret = trimmedSecret || 'dev-insecure-secret-change-me';", - "", - "export const { auth, signIn, signOut, handlers } = createAuth({", - " providers: [", - " Credentials({", - " async authorize(credentials: { email: string; password: string }) {", - " const user = await db.query.users.findFirst({ where: { email: credentials.email } });", - " if (!user || !await compare(credentials.password, user.passwordHash)) return null;", - " return { id: String(user.id), name: user.name, email: user.email };", - " },", - " }),", - " // OAuth providers: add GitHub / Google sign-in by setting the matching", - " // env vars. Each preset (GitHub(), Google()) reads AUTH__ID /", - " // _SECRET, so they only activate once configured and a fresh scaffold", - " // still boots with just Credentials.", - " ...(process.env.AUTH_GITHUB_ID ? [GitHub({ clientId: process.env.AUTH_GITHUB_ID, clientSecret: process.env.AUTH_GITHUB_SECRET })] : []),", - " ...(process.env.AUTH_GOOGLE_ID ? [Google({ clientId: process.env.AUTH_GOOGLE_ID, clientSecret: process.env.AUTH_GOOGLE_SECRET })] : []),", - " ],", - " secret: authSecret,", - " // A failed credentials sign-in 302s to `${pages.error}?error=CredentialsSignin`.", - " // Point it at /login so app/login/page.ts reads searchParams.error and shows a", - " // message, instead of the createAuth default (the home page) swallowing the error.", - " pages: { error: '/login' },", - "});", - "", - ].join('\n')); - - // db/schema.server.ts: overwrite the full-stack scaffold's example User to - // add passwordHash (the column auth needs). Drizzle, dialect-agnostic. - await writeFile(join(appDir, 'db', 'schema.server.ts'), [ - "import { defineRelations } from 'drizzle-orm';", - "import { table, pk, uuidPk, text, bool, createdAt } from './columns.server.ts';", - "", - "export const users = table('users', {", - " id: pk(),", - " email: text().notNull().unique(),", - " name: text(),", - " passwordHash: text().notNull(),", - " createdAt: createdAt(),", - "});", - "", - "// Backs the example-gallery /examples/todo route (modules/todo). Delete it", - "// with the gallery when you prune the examples you do not use.", - "export const todos = table('todos', {", - " id: uuidPk(),", - " title: text().notNull(),", - " completed: bool().notNull().default(false),", - " createdAt: createdAt(),", - "});", - "", - "export const relations = defineRelations({ users, todos }, () => ({}));", - "", - "export type User = typeof users.$inferSelect;", - "", - ].join('\n')); - - // modules/auth/actions/signup.server.ts - await mkdir(join(appDir, 'modules', 'auth', 'actions'), { recursive: true }); - await mkdir(join(appDir, 'modules', 'auth', 'queries'), { recursive: true }); - - await writeFile(join(appDir, 'modules', 'auth', 'actions', 'signup.server.ts'), [ - "'use server';", - "", - "import { db } from '#db/connection.server.ts';", - "import { users } from '#db/schema.server.ts';", - "import { hash } from '#lib/password.server.ts';", - "import { signIn } from '#lib/auth.server.ts';", - "", - "// Creates the account, then signs the new user in and lands on the", - "// dashboard. signIn returns a 302 Response carrying the session cookie; the", - "// signup page action returns that Response as-is (a page action may return a", - "// Response). signIn lives in the server-only auth module, imported here", - "// server-to-server, so it never reaches the browser (the signup page only", - "// imports this action's RPC stub).", - "export async function signup(input: { name: string; email: string; password: string }) {", - " const exists = await db.query.users.findFirst({ where: { email: input.email }, columns: { id: true } });", - " if (exists) return { success: false as const, error: 'Email already registered', status: 409 };", - " await db.insert(users).values({ name: input.name, email: input.email, passwordHash: await hash(input.password) });", - " return signIn('credentials', { email: input.email, password: input.password }, { redirectTo: '/dashboard' });", - "}", - "", - ].join('\n')); - - // modules/auth/queries/current-user.server.ts - await writeFile(join(appDir, 'modules', 'auth', 'queries', 'current-user.server.ts'), [ - "'use server';", - "", - "import { auth } from '#lib/auth.server.ts';", - "", - "// This read deliberately stays POST-default (no 'method' export). A GET", - "// server action (#488) is cacheable and SSR-seeded, which is wrong for a", - "// per-session read: the result differs per user and changes on sign-in /", - "// sign-out, so it must never be browser-cached or shared. Reserve GET +", - "// cache + tags for data identical for every visitor (see", - "// modules/users/queries/list-users.server.ts in the full-stack template).", - "export async function currentUser() {", - " const session = await auth();", - " return session?.user ?? null;", - "}", - "", - ].join('\n')); - - // modules/auth/types.ts - await writeFile(join(appDir, 'modules', 'auth', 'types.ts'), [ - "export interface User {", - " id: number;", - " name: string | null;", - " email: string;", - "}", - "", - "export type ActionResult =", - " | { success: true; data: T }", - " | { success: false; error: string; status: number };", - "", - ].join('\n')); - - // test/auth/auth.test.ts: a REAL auth-flow test driven through the framework - // request pipeline with the @webjsdev/server test harness (createRequestHandler - // + the handle() helpers from @webjsdev/server/testing). It lives under the - // documented test// convention (test/auth/), not the old test/unit/ - // path. - // - // Two layers, by DB availability: - // - The protected-route gate (unauthenticated /dashboard -> 302 /login) runs - // ALWAYS once the app modules import: auth() only reads a cookie, no DB - // query. This is the headline security assertion and it is REAL. - // The signup, login, and protected-route flow writes + reads a user, so it - // needs the migrated users table (`npm run db:generate`, then `npm run dev` - // applies it via webjs.dev.before). Until then those flows error, so the - // suite probes readiness and skips with a clear message instead of crashing, - // then runs for real once the db is set up. - await mkdir(join(appDir, 'test', 'auth'), { recursive: true }); - // The generated comments reference `npm run db:*` setup; bun-ify them so a - // bun-flavored saas app reads `bun run db:*` (#541; db is Node tooling, so a - // plain `bun run`, not the --bun server form). The transform is a no-op on Node. - const authTest = [ - "import { test } from 'node:test';", - "import assert from 'node:assert/strict';", - "import { fileURLToPath } from 'node:url';", - "import { dirname, resolve } from 'node:path';", - "", - "import { createRequestHandler } from '@webjsdev/server';", - "import { testRequest, loginAndGetCookies, withSessionCookie } from '@webjsdev/server/testing';", - "", - "const appDir = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');", - "", - "// The auth pages + dashboard middleware query the users table via Drizzle.", - "// Until `npm run db:generate` has authored the migration (then `npm run dev`", - "// applies it via webjs.dev.before, or `npm run db:migrate` directly), a", - "// request hitting those modules 500s; we detect that at the RESPONSE level", - "// (a 5xx on the dashboard) and SKIP with a clear message rather than report", - "// a misleading failure. After the db is set up every assertion runs for real.", - "process.env.DATABASE_URL ||= 'file:./dev.db';", - "process.env.AUTH_SECRET ||= 'test-secret-at-least-32-characters-long!!';", - "", - "function makeHandler() {", - " // createRequestHandler builds lazily, so it succeeds even before the DB", - " // is migrated; the missing table only surfaces when a request reaches a", - " // module that queries it. That is why readiness is probed per-response.", - " return createRequestHandler({ appDir, dev: true });", - "}", - "", - "test('protected route redirects to /login when unauthenticated', async (t) => {", - " const app = await makeHandler();", - " const res = await testRequest(app.handle, '/dashboard');", - " if (res.status >= 500) {", - " t.skip('app deps not ready (run `npm run db:generate` + `npm run db:migrate`)');", - " return;", - " }", - " // The dashboard middleware calls auth(); with no session cookie it 302s to", - " // /login. This needs no DB row, only a cookie read, so it is always real", - " // once the modules import.", - " assert.equal(res.status, 302, 'unauthenticated dashboard is gated');", - " assert.equal(res.headers.get('location'), '/login');", - "});", - "", - "test('signup -> login -> dashboard renders for the authenticated user', async (t) => {", - " const app = await makeHandler();", - " // Probe readiness: a 5xx on the dashboard means deps/DB are not set up.", - " const probe = await testRequest(app.handle, '/dashboard');", - " if (probe.status >= 500) { t.skip('app deps not ready; run `npm run db:generate` + `npm run db:migrate`'); return; }", - "", - " const email = `harness+${Date.now()}@example.com`;", - " const password = 'password123';", - "", - " // Real signup through the page server action (the no-JS form write-path).", - " let canSignup = true;", - " try {", - " const signupRes = await testRequest(app.handle, '/signup', {", - " method: 'POST',", - " headers: { 'content-type': 'application/x-www-form-urlencoded' },", - " body: new URLSearchParams({ name: 'Harness', email, password }).toString(),", - " });", - " // Success auto-logs-in and 302s to /dashboard (carrying the session", - " // cookie); a 422 means validation failed. Either way the action ran.", - " assert.ok([302, 422].includes(signupRes.status), 'signup action ran');", - " if (signupRes.status === 302) assert.equal(signupRes.headers.get('location'), '/dashboard', 'signup lands on the dashboard');", - " if (signupRes.status !== 302) canSignup = false;", - " } catch {", - " // No migrated DB table -> the action throws. Skip the DB-backed assertions.", - " canSignup = false;", - " }", - " if (!canSignup) { t.skip('no migrated DB; run `npm run db:migrate` to enable the full flow'); return; }", - "", - " // Real login captures the genuine signed session cookie.", - " const { cookies } = await loginAndGetCookies(app.handle, { email, password });", - "", - " // With the session cookie the protected route now renders (200).", - " const dash = await testRequest(app.handle, '/dashboard', withSessionCookie({}, cookies));", - " assert.equal(dash.status, 200, 'the session cookie unlocks the dashboard');", - " const body = await dash.text();", - " assert.match(body, /Dashboard/, 'the dashboard content rendered');", - " // The greeting interpolates the real user, so the name renders and the", - " // literal template source never leaks (a counterfactual for the escaping bug).", - " assert.match(body, /Harness/, 'the dashboard greets the signed-in user by name');", - " assert.ok(!body.includes('${user'), 'the greeting interpolation is not a literal string');", - "});", - "", - ].join('\n'); - await writeFile( - join(appDir, 'test', 'auth', 'auth.test.ts'), - isBun ? bunifyProse(authTest) : authTest, - ); - - // app/api/auth/[...path]/route.ts - await mkdir(join(appDir, 'app', 'api', 'auth', '[...path]'), { recursive: true }); - await writeFile(join(appDir, 'app', 'api', 'auth', '[...path]', 'route.ts'), [ - "import { handlers } from '#lib/auth.server.ts';", - "export const GET = handlers.GET;", - "export const POST = handlers.POST;", - "", - ].join('\n')); - - // app/login/page.ts - await mkdir(join(appDir, 'app', 'login'), { recursive: true }); - await writeFile(join(appDir, 'app', 'login', 'page.ts'), [ - "import { html } from '@webjsdev/core';", - "import { cardClass, cardHeaderClass, cardTitleClass, cardDescriptionClass, cardContentClass, cardFooterClass } from '#components/ui/card.ts';", - "import { buttonClass } from '#components/ui/button.ts';", - "import { inputClass } from '#components/ui/input.ts';", - "import { labelClass } from '#components/ui/label.ts';", - "", - "export const metadata = { title: 'Login' };", - "", - "// A failed sign-in 302s back here with ?error=... (createAuth is configured", - "// with pages.error: '/login' in lib/auth.server.ts). Map the code to a plain", - "// message so a bad password gets visible feedback instead of a silent bounce.", - "function errorMessage(code: string | undefined): string | null {", - " if (!code) return null;", - " if (code === 'CredentialsSignin') return 'Invalid email or password.';", - " return 'Could not sign you in. Please try again.';", - "}", - "", - "export default function LoginPage({ searchParams }: { searchParams: { error?: string } }) {", - " const error = errorMessage(searchParams.error);", - " return html`", - "
", - "
", - "
", - "

Sign in

", - "

Welcome back: log in to continue.

", - "
", - "
", - " ${error ? html`

${error}

` : ''}", - " ", - " ", - " ", - "
", - " ", - " ", - "
", - "
", - " ", - " ", - "
", - " ", - " ", - "
", - "
", - "

Don't have an account? Sign up

", - "
", - "
", - "
", - " `;", - "}", - "", - ].join('\n')); - - // app/signup/page.ts - await mkdir(join(appDir, 'app', 'signup'), { recursive: true }); - await writeFile(join(appDir, 'app', 'signup', 'page.ts'), [ - "import { html } from '@webjsdev/core';", - "import { signup } from '#modules/auth/actions/signup.server.ts';", - "import { cardClass, cardHeaderClass, cardTitleClass, cardDescriptionClass, cardContentClass, cardFooterClass } from '#components/ui/card.ts';", - "import { buttonClass } from '#components/ui/button.ts';", - "import { inputClass } from '#components/ui/input.ts';", - "import { labelClass } from '#components/ui/label.ts';", - "", - "export const metadata = { title: 'Sign up' };", - "", - "// Page server action: handles the POST from the form below. With JS", - "// disabled this is a plain
round-trip; with JS the client router", - "// swaps the 422 re-render (errors) or follows the 303 (success) in place.", - "// A validation failure returns fieldErrors + values so the page re-renders", - "// with messages and the user's typed input preserved (#244).", - "export async function action({ formData }: { formData: FormData }) {", - " const name = String(formData.get('name') || '').trim();", - " const email = String(formData.get('email') || '').trim();", - " const password = String(formData.get('password') || '');", - " const values = { name, email };", - " const fieldErrors: Record = {};", - " if (!name) fieldErrors.name = 'Name is required';", - " if (!email.includes('@')) fieldErrors.email = 'Enter a valid email';", - " if (password.length < 8) fieldErrors.password = 'At least 8 characters';", - " if (Object.keys(fieldErrors).length) return { success: false, fieldErrors, values, status: 422 };", - " const result = await signup({ name, email, password });", - " // On success signup returns signIn's 302 Response (auto-login -> /dashboard);", - " // a page action may return a Response, so pass it straight through.", - " if (result instanceof Response) return result;", - " return { success: false, fieldErrors: { email: result.error }, values, status: result.status };", - "}", - "", - "export default function SignupPage({ actionData }: { actionData?: { fieldErrors?: Record; values?: Record } }) {", - " const errors = actionData?.fieldErrors || {};", - " const values = actionData?.values || {};", - " return html`", - "
", - "
", - "
", - "

Create an account

", - "

Get started with your new workspace.

", - "
", - "
", - " ", - "
", - " ", - " ", - " ${errors.name ? html`

${errors.name}

` : ''}", - "
", - "
", - " ", - " ", - " ${errors.email ? html`

${errors.email}

` : ''}", - "
", - "
", - " ", - " ", - " ${errors.password ? html`

${errors.password}

` : ''}", - "
", - " ", - " ", - "
", - "
", - "

Already have an account? Log in

", - "
", - "
", - "
", - " `;", - "}", - "", - ].join('\n')); - - // app/dashboard/middleware.ts - await mkdir(join(appDir, 'app', 'dashboard', 'settings'), { recursive: true }); - await writeFile(join(appDir, 'app', 'dashboard', 'middleware.ts'), [ - "import { auth } from '#lib/auth.server.ts';", - "", - "export default async function requireAuth(req: Request, next: () => Promise) {", - " const session = await auth();", - " if (!session?.user) {", - " return new Response(null, { status: 302, headers: { location: '/login' } });", - " }", - " return next();", - "}", - "", - ].join('\n')); - - // app/dashboard/layout.ts: a thin sub-nav shared by every /dashboard page - // (page.ts and settings/page.ts). It carries the logout control so a signed-in - // user can end the session from anywhere under /dashboard. - await writeFile(join(appDir, 'app', 'dashboard', 'layout.ts'), [ - "import { html } from '@webjsdev/core';", - "import { buttonClass } from '#components/ui/button.ts';", - "", - "// Nested layout for the protected /dashboard subtree. Logout is a plain", - "//
posting to the createAuth signout route: it clears the", - "// session cookie and 302s home, and works with JS off (progressive-enhancement", - "// default). signOut is server-only (lib/auth.server.ts), so we POST to its route", - "// rather than import it into a browser-shipping page. After signout the dashboard", - "// middleware bounces any later /dashboard visit to /login.", - "export default function DashboardLayout({ children }: { children: unknown }) {", - " return html`", - "
", - " ${children}", - " `;", - "}", - "", - ].join('\n')); - - // app/dashboard/page.ts - await writeFile(join(appDir, 'app', 'dashboard', 'page.ts'), [ - "import { html } from '@webjsdev/core';", - "import { currentUser } from '#modules/auth/queries/current-user.server.ts';", - "import { cardClass, cardHeaderClass, cardTitleClass, cardDescriptionClass } from '#components/ui/card.ts';", - "import { badgeClass } from '#components/ui/badge.ts';", - "", - "export const metadata = { title: 'Dashboard' };", - "", - "export default async function Dashboard() {", - " const user = await currentUser();", - " return html`", - "
", - "

Dashboard

", - " Signed in", - "
", - "
", - "
", - "

Welcome, ${user?.name || user?.email}!

", - "

You're authenticated. Replace this scaffold with your real app.

", - "
", - "
", - " `;", - "}", - "", - ].join('\n')); - - // app/dashboard/settings/page.ts - await writeFile(join(appDir, 'app', 'dashboard', 'settings', 'page.ts'), [ - "import { html } from '@webjsdev/core';", - "import { currentUser } from '#modules/auth/queries/current-user.server.ts';", - "import { cardClass, cardHeaderClass, cardTitleClass, cardDescriptionClass, cardContentClass } from '#components/ui/card.ts';", - "", - "export const metadata = { title: 'Settings' };", - "", - "export default async function Settings() {", - " const user = await currentUser();", - " return html`", - "

Settings

", - "
", - "
", - "

Account

", - "

Your basic profile information.

", - "
", - "
", - "
", - "
Email
", - "
${user?.email}
", - "
Name
", - "
${user?.name || 'Not set'}
", - "
", - "
", - "
", - " `;", - "}", - "", - ].join('\n')); -} diff --git a/packages/cli/templates/gallery/app/api/auth/[...path]/route.ts b/packages/cli/templates/gallery/app/api/auth/[...path]/route.ts new file mode 100644 index 000000000..a56e3b52e --- /dev/null +++ b/packages/cli/templates/gallery/app/api/auth/[...path]/route.ts @@ -0,0 +1,7 @@ +// The createAuth HTTP endpoints (signin, signout, OAuth callbacks). This route +// stays at the app root, NOT under app/features/auth/, because createAuth +// hardcodes /api/auth/signin/* and /api/auth/callback/* for its form posts and +// OAuth redirect URIs. The rest of the auth card lives under app/features/auth/. +import { handlers } from '#modules/auth/auth.server.ts'; +export const GET = handlers.GET; +export const POST = handlers.POST; diff --git a/packages/cli/templates/gallery/app/examples/layout.ts b/packages/cli/templates/gallery/app/examples/layout.ts new file mode 100644 index 000000000..4960cad4a --- /dev/null +++ b/packages/cli/templates/gallery/app/examples/layout.ts @@ -0,0 +1,11 @@ +import { html } from '@webjsdev/core'; + +// Shared layout for every gallery example app under /examples/*. It adds the same +// slim "back to the gallery" link the feature demos get, so an example is never a +// dead end. A non-root layout, so it never writes the document shell. +export default function ExamplesLayout({ children }: { children: unknown }) { + return html` + ← Gallery + ${children} + `; +} diff --git a/packages/cli/templates/gallery/app/features/auth/dashboard/layout.ts b/packages/cli/templates/gallery/app/features/auth/dashboard/layout.ts new file mode 100644 index 000000000..7b4263af6 --- /dev/null +++ b/packages/cli/templates/gallery/app/features/auth/dashboard/layout.ts @@ -0,0 +1,20 @@ +import { html } from '@webjsdev/core'; + +// Nested layout for the protected dashboard subtree. Logout is a plain +//
posting to the createAuth signout route: it clears the +// session cookie and 302s home, and works with JS off (progressive-enhancement +// default). signOut is server-only (modules/auth/auth.server.ts), so we POST to +// its route rather than import it into a browser-shipping page. After signout the +// dashboard middleware bounces any later visit to login. +export default function DashboardLayout({ children }: { children: unknown }) { + return html` +
+ ${children} + `; +} diff --git a/packages/cli/templates/gallery/app/features/auth/dashboard/middleware.ts b/packages/cli/templates/gallery/app/features/auth/dashboard/middleware.ts new file mode 100644 index 000000000..2f660628a --- /dev/null +++ b/packages/cli/templates/gallery/app/features/auth/dashboard/middleware.ts @@ -0,0 +1,14 @@ +import { auth } from '#modules/auth/auth.server.ts'; + +// The protected-route gate. A per-segment middleware.ts runs for every request +// under /features/auth/dashboard/*. It reads the signed session off the request +// with auth(req); with no valid session it 302s to login BEFORE the page renders, +// so an anonymous visitor never sees the protected content. This needs no DB +// query (only a cookie read), so the gate is real the moment the app boots. +export default async function requireAuth(req: Request, next: () => Promise) { + const session = await auth(req); + if (!session?.user) { + return new Response(null, { status: 302, headers: { location: '/features/auth/login' } }); + } + return next(); +} diff --git a/packages/cli/templates/gallery/app/features/auth/dashboard/page.ts b/packages/cli/templates/gallery/app/features/auth/dashboard/page.ts new file mode 100644 index 000000000..dd68314e9 --- /dev/null +++ b/packages/cli/templates/gallery/app/features/auth/dashboard/page.ts @@ -0,0 +1,18 @@ +import { html } from '@webjsdev/core'; +import { currentUser } from '#modules/auth/queries/current-user.server.ts'; + +export const metadata = { title: 'Dashboard' }; + +export default async function Dashboard() { + const user = await currentUser(); + return html` +
+

Dashboard

+ Signed in +
+
+

Welcome, ${user?.name || user?.email}!

+

This route is gated by middleware.ts. Promote it into your product, or drop the whole auth card with gallery:clear.

+
+ `; +} diff --git a/packages/cli/templates/gallery/app/features/auth/dashboard/settings/page.ts b/packages/cli/templates/gallery/app/features/auth/dashboard/settings/page.ts new file mode 100644 index 000000000..60b37486e --- /dev/null +++ b/packages/cli/templates/gallery/app/features/auth/dashboard/settings/page.ts @@ -0,0 +1,21 @@ +import { html } from '@webjsdev/core'; +import { currentUser } from '#modules/auth/queries/current-user.server.ts'; + +export const metadata = { title: 'Settings' }; + +export default async function Settings() { + const user = await currentUser(); + return html` +

Settings

+
+

Account

+

Your basic profile information.

+
+
Email
+
${user?.email}
+
Name
+
${user?.name || 'Not set'}
+
+
+ `; +} diff --git a/packages/cli/templates/gallery/app/features/auth/login/page.ts b/packages/cli/templates/gallery/app/features/auth/login/page.ts new file mode 100644 index 000000000..b74ab9ae0 --- /dev/null +++ b/packages/cli/templates/gallery/app/features/auth/login/page.ts @@ -0,0 +1,40 @@ +import { html } from '@webjsdev/core'; + +export const metadata = { title: 'Log in' }; + +const inputCls = 'w-full bg-background border border-border rounded-xl px-3 py-2 text-[15px] text-foreground outline-none transition-colors focus:border-primary placeholder:text-muted-foreground'; + +// A failed sign-in 302s back here with ?error=... (createAuth is configured with +// pages.error: '/features/auth/login' in modules/auth/auth.server.ts). Map the +// code to a plain message so a bad password gets visible feedback instead of a +// silent bounce. +function errorMessage(code: string | undefined): string | null { + if (!code) return null; + if (code === 'CredentialsSignin') return 'Invalid email or password.'; + return 'Could not sign you in. Please try again.'; +} + +export default function LoginPage({ searchParams }: { searchParams: { error?: string } }) { + const error = errorMessage(searchParams.error); + return html` +
+

Sign in

+

Welcome back: log in to continue.

+ ${error ? html`` : ''} +
+ + +
+ + +
+
+ + +
+ +
+

Don't have an account? Sign up

+
+ `; +} diff --git a/packages/cli/templates/gallery/app/features/auth/page.ts b/packages/cli/templates/gallery/app/features/auth/page.ts new file mode 100644 index 000000000..c3b883055 --- /dev/null +++ b/packages/cli/templates/gallery/app/features/auth/page.ts @@ -0,0 +1,33 @@ +// Auth: password login/signup on top of createAuth, a signed session cookie, and +// a genuinely protected route. This card wires a REAL auth baseline (a users +// table with a passwordHash, createAuth in modules/auth/auth.server.ts, and the +// /features/auth/dashboard subtree gated by a middleware.ts), so a fresh app can +// promote it into a product. gallery:clear removes the whole surface (this card, +// modules/auth, app/api/auth, the passwordHash column) back to the minimal base. +// +// This index page is public and reads the current session with currentUser() so +// it can show who is signed in. The read is a 'use server' action, so the same +// line is the real query during SSR and a safe RPC stub on the client. +import { html } from '@webjsdev/core'; +import type { Metadata } from '@webjsdev/core'; +import { currentUser } from '#modules/auth/queries/current-user.server.ts'; + +export const metadata: Metadata = { title: 'Auth (login + protected route) | features' }; + +export default async function AuthExample() { + const user = await currentUser(); + return html` +

Auth

+

Password login on createAuth, a signed session cookie, and a protected /features/auth/dashboard that redirects anonymous visitors to login.

+ + ${user + ? html` +

Signed in as ${user.name || user.email}.

+

Open the protected dashboard

` + : html` +

You are signed out. Visiting the dashboard bounces you to login.

+

Log inCreate an account

`} + +

The gate is app/features/auth/dashboard/middleware.ts calling auth(req); the login form posts to the app/api/auth/[...path] handler; OAuth (GitHub / Google) activates once you set the matching env vars.

+ `; +} diff --git a/packages/cli/templates/gallery/app/features/auth/signup/page.ts b/packages/cli/templates/gallery/app/features/auth/signup/page.ts new file mode 100644 index 000000000..4ad1597db --- /dev/null +++ b/packages/cli/templates/gallery/app/features/auth/signup/page.ts @@ -0,0 +1,58 @@ +import { html } from '@webjsdev/core'; +import { signup } from '#modules/auth/actions/signup.server.ts'; + +export const metadata = { title: 'Sign up' }; + +const inputCls = 'w-full bg-background border border-border rounded-xl px-3 py-2 text-[15px] text-foreground outline-none transition-colors focus:border-primary placeholder:text-muted-foreground'; + +// Page server action: handles the POST from the form below. With JS disabled this +// is a plain
round-trip; with JS the client router swaps the 422 re-render +// (errors) or follows the 302 (success) in place. A validation failure returns +// fieldErrors + values so the page re-renders with messages and the user's typed +// input preserved. +export async function action({ formData }: { formData: FormData }) { + const name = String(formData.get('name') || '').trim(); + const email = String(formData.get('email') || '').trim(); + const password = String(formData.get('password') || ''); + const values = { name, email }; + const fieldErrors: Record = {}; + if (!name) fieldErrors.name = 'Name is required'; + if (!email.includes('@')) fieldErrors.email = 'Enter a valid email'; + if (password.length < 8) fieldErrors.password = 'At least 8 characters'; + if (Object.keys(fieldErrors).length) return { success: false, fieldErrors, values, status: 422 }; + const result = await signup({ name, email, password }); + // On success signup returns signIn's 302 Response (auto-login -> dashboard); a + // page action may return a Response, so pass it straight through. + if (result instanceof Response) return result; + return { success: false, fieldErrors: { email: result.error }, values, status: result.status }; +} + +export default function SignupPage({ actionData }: { actionData?: { fieldErrors?: Record; values?: Record } }) { + const errors = actionData?.fieldErrors || {}; + const values = actionData?.values || {}; + return html` +
+

Create an account

+

Get started with your new workspace.

+ +
+ + + ${errors.name ? html`

${errors.name}

` : ''} +
+
+ + + ${errors.email ? html`

${errors.email}

` : ''} +
+
+ + + ${errors.password ? html`

${errors.password}

` : ''} +
+ + +

Already have an account? Log in

+
+ `; +} diff --git a/packages/cli/templates/gallery/app/features/frames/page.ts b/packages/cli/templates/gallery/app/features/frames/page.ts index 5752ba062..2dba2a802 100644 --- a/packages/cli/templates/gallery/app/features/frames/page.ts +++ b/packages/cli/templates/gallery/app/features/frames/page.ts @@ -2,7 +2,13 @@ // link targeting its id, shipping zero component JS. It is WebJs's take on Turbo // Frames. Unlike the client router (which swaps the whole page's children when // you navigate to a DIFFERENT url), a frame refreshes just ONE sub-region in -// place. The filter links below live INSIDE the frame, so a click walks +// place. It is also NOT a layout: a layout (layout.ts) is server-rendered chrome +// that WRAPS a route subtree via ${children} and re-renders only as part of a +// navigation, so it answers "what structure wraps these routes". A frame answers +// "which region updates itself in place", with no navigation at all. Use a layout +// for shared chrome across routes; use a frame when one region (a filtered list, +// a paginated table, a tab panel) must refresh on its own without navigating. +// The filter links below live INSIDE the frame, so a click walks // closest('webjs-frame'), refetches THIS same page with the new ?status, and the // server returns ONLY the subtree (open the network tab // to see it). The router swaps that subtree in; everything outside the frame, @@ -40,7 +46,9 @@ export default function FramesExample({ searchParams }: { searchParams: Record
diff --git a/packages/cli/templates/gallery/app/features/layout.ts b/packages/cli/templates/gallery/app/features/layout.ts new file mode 100644 index 000000000..1c3fa8fe6 --- /dev/null +++ b/packages/cli/templates/gallery/app/features/layout.ts @@ -0,0 +1,12 @@ +import { html } from '@webjsdev/core'; + +// Shared layout for every gallery feature demo under /features/*. It adds a slim +// "back to the gallery" link above each demo so a card is never a dead end. +// Nested layouts (like the auth dashboard's sub-nav) render inside ${children}. +// A non-root layout, so it never writes the document shell (the framework does). +export default function FeaturesLayout({ children }: { children: unknown }) { + return html` + ← Gallery + ${children} + `; +} diff --git a/packages/cli/templates/gallery/app/features/server-actions/page.ts b/packages/cli/templates/gallery/app/features/server-actions/page.ts index 5795620e6..b58338f40 100644 --- a/packages/cli/templates/gallery/app/features/server-actions/page.ts +++ b/packages/cli/templates/gallery/app/features/server-actions/page.ts @@ -10,12 +10,14 @@ export default function ServerActionsExample() {

A 'use server' action is RPC-callable from the client; a plain .server.ts is a server-only utility you never import into a component.

This action also declares export const middleware: a - chain that runs around it on every boundary. The auth middleware sets the - caller on the request context (read back with actionContext()) + chain that runs around it on every boundary. The auth middleware reads the + real signed session (from the auth card) and + sets the caller on the request context (read back with actionContext()), or 401s before the action runs. The action threads actionSignal(), the request AbortSignal, through its work so a client disconnect or a superseded render stops it early.

+

Signed out, the greeter returns a real 401. Sign in first to see it succeed. (This card depends on the auth card; prune both together.)

`; } diff --git a/packages/cli/templates/gallery/app/features/stream/page.ts b/packages/cli/templates/gallery/app/features/stream/page.ts new file mode 100644 index 000000000..82a0ae58c --- /dev/null +++ b/packages/cli/templates/gallery/app/features/stream/page.ts @@ -0,0 +1,45 @@ +// Stream updates: the element + renderStream() (#248). It is the +// element-level DOM-update grammar (Turbo Streams parity): a self-applying +// element that clones its