@webjsdev/cli 0.10.43 → 0.10.45

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +2 -3
  2. package/bin/webjs.js +18 -18
  3. package/lib/api-gallery.js +1 -1
  4. package/lib/create.js +52 -108
  5. package/package.json +1 -1
  6. package/templates/.agents/skills/webjs/references/client-router-and-streaming.md +11 -1
  7. package/templates/.agents/skills/webjs/references/components.md +19 -4
  8. package/templates/.agents/skills/webjs/references/muscle-memory-gotchas.md +5 -1
  9. package/templates/.agents/skills/webjs/references/runtime.md +1 -1
  10. package/templates/.agents/skills/webjs/references/service-worker.md +1 -1
  11. package/templates/gallery/app/api/auth/[...path]/route.ts +7 -0
  12. package/templates/gallery/app/examples/layout.ts +11 -0
  13. package/templates/gallery/app/features/auth/dashboard/layout.ts +20 -0
  14. package/templates/gallery/app/features/auth/dashboard/middleware.ts +14 -0
  15. package/templates/gallery/app/features/auth/dashboard/page.ts +18 -0
  16. package/templates/gallery/app/features/auth/dashboard/settings/page.ts +21 -0
  17. package/templates/gallery/app/features/auth/login/page.ts +40 -0
  18. package/templates/gallery/app/features/auth/page.ts +33 -0
  19. package/templates/gallery/app/features/auth/signup/page.ts +58 -0
  20. package/templates/gallery/app/features/frames/page.ts +79 -0
  21. package/templates/gallery/app/features/layout.ts +12 -0
  22. package/templates/gallery/app/features/server-actions/page.ts +10 -0
  23. package/templates/gallery/app/features/stream/page.ts +45 -0
  24. package/templates/gallery/app/features/streaming/page.ts +31 -0
  25. package/templates/gallery/app/features/suspense/page.ts +34 -0
  26. package/templates/gallery/app/features/view-transitions/page.ts +41 -0
  27. package/templates/gallery/app/features/view-transitions/second/page.ts +28 -0
  28. package/templates/gallery/modules/auth/actions/signup.server.ts +19 -0
  29. package/templates/gallery/modules/auth/auth.server.ts +53 -0
  30. package/templates/gallery/modules/auth/password.server.ts +20 -0
  31. package/templates/gallery/modules/auth/queries/current-user.server.ts +12 -0
  32. package/templates/gallery/modules/auth/types.ts +9 -0
  33. package/templates/gallery/modules/frames/utils/tasks.ts +27 -0
  34. package/templates/gallery/modules/server-actions/actions/greet.server.ts +40 -9
  35. package/templates/gallery/modules/server-actions/actions/greet.test.ts +47 -11
  36. package/templates/gallery/modules/server-actions/components/greeter.ts +7 -1
  37. package/templates/gallery/modules/server-actions/middleware/require-auth.server.ts +47 -0
  38. package/templates/gallery/modules/stream/components/stream-demo.ts +76 -0
  39. package/templates/gallery/modules/streaming/actions/stream-tokens.server.ts +17 -0
  40. package/templates/gallery/modules/streaming/components/token-stream.ts +46 -0
  41. package/templates/gallery/modules/suspense/components/slow-fact.ts +19 -0
  42. package/templates/gallery/test/auth/auth.test.ts +81 -0
  43. package/templates/scripts/clear-api-gallery.mjs +55 -0
  44. package/templates/scripts/clear-gallery.mjs +24 -12
  45. package/lib/lean-copy.js +0 -43
  46. package/lib/saas-template.js +0 -568
package/README.md CHANGED
@@ -38,9 +38,8 @@ Both `webjs create` and `create-webjs-app` auto-install dependencies in the new
38
38
  ## Commands
39
39
 
40
40
  ```sh
41
- webjs create <name> # scaffold a full-stack app (default)
42
- webjs create <name> --template api # backend-only API app
43
- webjs create <name> --template saas # auth + dashboard + Drizzle User model
41
+ webjs create <name> # scaffold a full-stack app (default; auth ships as a gallery card)
42
+ webjs create <name> --template api # backend-only API app (routes + modules + Drizzle)
44
43
 
45
44
  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)
46
45
  webjs start # production server (no build step, serves source directly)
package/bin/webjs.js CHANGED
@@ -43,10 +43,10 @@ if (cmd !== 'help' && cmd !== undefined && !wantsHelp && !wantsVersion) {
43
43
  }
44
44
  }
45
45
 
46
- // Exactly three scaffolds exist. Keep this list as the single source of
46
+ // Exactly two scaffolds exist. Keep this list as the single source of
47
47
  // truth. AI-agent docs in README.md / AGENTS.md / .cursorrules /
48
48
  // .agents/rules/workflow.md / .github/copilot-instructions.md mirror it.
49
- const TEMPLATES = ['full-stack', 'api', 'saas'];
49
+ const TEMPLATES = ['full-stack', 'api'];
50
50
 
51
51
  const USAGE = `webjs commands:
52
52
  webjs dev [--port 8080] [--no-hot] Start dev server with live reload
@@ -60,8 +60,8 @@ const USAGE = `webjs commands:
60
60
  --json emits the structured results (with stable codes). --strict also fails the exit on warnings
61
61
  webjs types Generate .webjs/routes.d.ts (typed Route union + per-route params)
62
62
  webjs typecheck [tsc args...] Type-check the app with the project's tsc --noEmit (non-zero on errors)
63
- webjs create <name> [--template full-stack|api|saas] [--db sqlite|postgres] [--runtime node|bun] [--no-install] Scaffold a new webjs app
64
- (only 3 templates exist. default: full-stack, Drizzle, --db sqlite, --runtime node)
63
+ webjs create <name> [--template full-stack|api] [--db sqlite|postgres] [--runtime node|bun] [--no-install] Scaffold a new webjs app
64
+ (only 2 templates exist. default: full-stack, Drizzle, --db sqlite, --runtime node)
65
65
  --runtime bun emits a Bun-flavored app (bun.lock, bun Dockerfile/CI, bun docs);
66
66
  also auto-detected when run via "bun create webjs".
67
67
  Auto-runs the detected package manager's install in the new dir
@@ -160,10 +160,10 @@ const HELP = {
160
160
  examples: ['webjs typecheck', 'webjs typecheck --watch'],
161
161
  },
162
162
  create: {
163
- usage: 'webjs create <name> [--template full-stack|api|saas] [--db sqlite|postgres] [--runtime node|bun] [--no-install]',
163
+ usage: 'webjs create <name> [--template full-stack|api] [--db sqlite|postgres] [--runtime node|bun] [--no-install]',
164
164
  summary: 'Scaffold a new app. Defaults: full-stack template, Drizzle + SQLite, Node runtime.',
165
165
  options: [
166
- { flag: '--template <t>', description: 'full-stack (default), api, or saas.' },
166
+ { flag: '--template <t>', description: 'full-stack (default) or api (backend-only, no UI).' },
167
167
  { flag: '--db <d>', description: 'sqlite (default) or postgres.' },
168
168
  { flag: '--runtime <r>', description: 'node (default) or bun.' },
169
169
  { flag: '--no-install', description: 'Skip the package-manager install step.' },
@@ -171,7 +171,7 @@ const HELP = {
171
171
  examples: [
172
172
  'webjs create my-app',
173
173
  'webjs create my-api --template api',
174
- 'webjs create my-saas --template saas --db postgres',
174
+ 'webjs create my-api --template api --db postgres',
175
175
  'webjs create my-app --runtime bun',
176
176
  ],
177
177
  },
@@ -846,7 +846,7 @@ async function main() {
846
846
  case 'create': {
847
847
  const name = rest[0];
848
848
  if (!name || name.startsWith('-')) {
849
- console.error('Usage: webjs create <app-name> [--template full-stack|api|saas]');
849
+ console.error('Usage: webjs create <app-name> [--template full-stack|api]');
850
850
  process.exit(1);
851
851
  }
852
852
  const template = flag(rest, '--template', 'full-stack');
@@ -856,16 +856,16 @@ async function main() {
856
856
  // on which scaffold to pick for which kind of app.
857
857
  console.error(`Error: unknown template '${template}'.
858
858
 
859
- Only three scaffolds exist:
860
- full-stack (default): pages + components + API + Drizzle/SQLite.
861
- Pick this for any app the user describes in product terms
862
- (todo app, blog, dashboard, marketplace, social feed, …).
863
- api backend-only: route handlers + modules, no pages/SSR.
864
- Pick this only if the user explicitly asks for an HTTP/JSON
865
- API with no UI.
866
- saas auth + login/signup + protected dashboard + Drizzle User
867
- model. Pick this only if the user explicitly asks for auth
868
- or a SaaS-shaped product.
859
+ Only two scaffolds exist:
860
+ full-stack (default): pages + components + API + Drizzle/SQLite, plus a
861
+ browsable feature gallery. Auth is one of the gallery cards
862
+ (login + session + a protected route), so a full-stack app
863
+ already carries a real auth baseline. Pick this for any app
864
+ the user describes in product terms (todo, blog, dashboard,
865
+ marketplace, social feed, a SaaS with accounts, …).
866
+ api backend-only: route handlers + modules + Drizzle/SQLite, no
867
+ pages/SSR. Pick this only if the user explicitly asks for an
868
+ HTTP/JSON API with no UI.
869
869
 
870
870
  The scaffold is a starting point. Replace the example layout/page/
871
871
  components/schema with the actual app the user requested. Use Drizzle +
@@ -2,7 +2,7 @@
2
2
  * Backend-features showcase for `webjs create --template api`.
3
3
  * A set of JSON/HTTP endpoints under `app/api/features/` that demonstrate the
4
4
  * backend capabilities an API app uses (the api counterpart of the UI gallery).
5
- * Extracted here (like saas-template.js) to keep create.js readable and dodge
5
+ * Extracted here to keep create.js readable and dodge
6
6
  * nested-template-literal escaping: files are built from arrays of
7
7
  * double-quoted strings, so `${...}` and backticks are emitted literally.
8
8
  */
package/lib/create.js CHANGED
@@ -18,7 +18,6 @@ import { existsSync } from 'node:fs';
18
18
  import { createRequire } from 'node:module';
19
19
  import { spawnSync } from 'node:child_process';
20
20
  import { bunifyProse, bunifyDockerfile, bunifyCompose, bunifyCi } from './runtime-rewrite.js';
21
- import { leanComponentSource } from './lean-copy.js';
22
21
 
23
22
  /**
24
23
  * Detect which package manager invoked us. Reads `npm_config_user_agent`,
@@ -106,60 +105,6 @@ function resolveUiRegistryRoot() {
106
105
  }
107
106
  const UI_REGISTRY_ROOT = resolveUiRegistryRoot();
108
107
 
109
- /**
110
- * Read a single @webjsdev/ui registry component, rewrite its relative import
111
- * of `../lib/utils.ts` to the scaffolded app's aliased path so it resolves
112
- * when written to `components/ui/<name>.ts`. The scaffold puts cn() at
113
- * `lib/utils/cn.ts` (folder-grouped with other browser-safe helpers), so the
114
- * alias form is `#lib/utils/cn.ts` (#555/#556).
115
- *
116
- * @param {string} name component name without `.ts` (e.g. 'button')
117
- * @returns {Promise<string>} source with import rewritten
118
- */
119
- async function readUiComponent(name) {
120
- const src = join(UI_REGISTRY_ROOT, 'components', `${name}.ts`);
121
- const raw = await readFile(src, 'utf8');
122
- // The registry component imports cn() via a relative `../lib/utils.ts`; rewrite
123
- // it to the scaffolded app's aliased path (cn lives at lib/utils/cn.ts).
124
- const rewritten = raw
125
- .replaceAll("'../lib/utils.ts'", "'#lib/utils/cn.ts'")
126
- .replaceAll('"../lib/utils.ts"', '"#lib/utils/cn.ts"')
127
- // onBeforeCache lives in its own client-only module so cn() stays pure (#819).
128
- .replaceAll("'../lib/dom.ts'", "'#lib/utils/dom.ts'")
129
- .replaceAll('"../lib/dom.ts"', '"#lib/utils/dom.ts"');
130
- // Strip the worked @example from a Tier-1 helper (same as `webjs ui add`), so
131
- // the scaffolded component is lean and the example is served on demand. The
132
- // shared helper is used by the saas-template copier too, so they cannot drift.
133
- return leanComponentSource(rewritten, name);
134
- }
135
-
136
- /**
137
- * Copy a list of @webjsdev/ui registry components into the scaffolded app
138
- * under `components/ui/`. Throws if any name is missing from the registry,
139
- * since the scaffold's generated pages import these by name and a missing
140
- * file would produce ERR_MODULE_NOT_FOUND at first request. Caller must
141
- * have already invoked assertUiRegistryAvailable().
142
- *
143
- * @param {string} appDir destination app root
144
- * @param {string[]} names list of component file basenames (without `.ts`)
145
- */
146
- async function copyUiComponents(appDir, names) {
147
- const uiDir = join(appDir, 'components', 'ui');
148
- await mkdir(uiDir, { recursive: true });
149
- for (const n of names) {
150
- const src = join(UI_REGISTRY_ROOT, 'components', `${n}.ts`);
151
- if (!existsSync(src)) {
152
- throw new Error(
153
- `@webjsdev/ui registry is missing component '${n}.ts' at ${src}. ` +
154
- `The scaffold's example pages import this component by name. ` +
155
- `Either the registry was published incompletely or the scaffold's ` +
156
- `component list is out of sync with the registry.`,
157
- );
158
- }
159
- await writeFile(join(uiDir, `${n}.ts`), await readUiComponent(n));
160
- }
161
- }
162
-
163
108
  /**
164
109
  * Copy the example gallery (idiomatic, densely-commented working examples) into
165
110
  * the scaffolded app. Merges `templates/gallery/{app,modules}` over the app so
@@ -177,7 +122,9 @@ async function copyUiComponents(appDir, names) {
177
122
  */
178
123
  async function copyGallery(appDir) {
179
124
  const galleryDir = join(TEMPLATES, 'gallery');
180
- for (const sub of ['app', 'modules']) {
125
+ // `test` carries the auth card's real request-pipeline test (test/auth); it
126
+ // ships with the gallery and is pruned by gallery:clear alongside the card.
127
+ for (const sub of ['app', 'modules', 'test']) {
181
128
  await cp(join(galleryDir, sub), join(appDir, sub), { recursive: true });
182
129
  }
183
130
  }
@@ -312,21 +259,19 @@ export async function scaffoldApp(name, cwd, opts = {}) {
312
259
  const shouldInstall = opts.install === true;
313
260
  // Defence in depth. The CLI already validates this, but library
314
261
  // callers (tests, programmatic use) might pass anything.
315
- const VALID_TEMPLATES = ['full-stack', 'api', 'saas'];
262
+ const VALID_TEMPLATES = ['full-stack', 'api'];
316
263
  if (!VALID_TEMPLATES.includes(template)) {
317
264
  throw new Error(
318
265
  `Unknown template '${template}'. Only ${VALID_TEMPLATES.join(' / ')} exist.`,
319
266
  );
320
267
  }
321
268
  const isApi = template === 'api';
322
- const isSaas = template === 'saas';
323
- // The example gallery ships in every UI scaffold (full-stack AND saas). The
324
- // copyGallery gate below is !isApi, since only the api template has no UI. saas
325
- // overwrites db/schema.server.ts with its own schema (which includes the
326
- // gallery's todos table) and renders the gallery below its auth landing.
327
- // isFullStack distinguishes the plain full-stack app from saas for the parts
328
- // that differ (its own home page and the create.js-written schema).
329
- const isFullStack = !isApi && !isSaas;
269
+ // The example gallery ships in the one UI template (not api, which has no UI),
270
+ // so the copyGallery gate below is !isApi. Auth is one of the gallery cards
271
+ // (app/features/auth + modules/auth), so a UI app ships a real, prunable auth
272
+ // baseline. `isFullStack` names the UI template for the parts that differ from
273
+ // api (the todos table + the passwordHash column the auth card needs).
274
+ const isFullStack = !isApi;
330
275
 
331
276
  // Database dialect (#563): sqlite (default) or postgres. Drizzle is the ORM;
332
277
  // the schema/queries/actions are identical across dialects, only db/columns
@@ -420,8 +365,13 @@ export async function scaffoldApp(name, cwd, opts = {}) {
420
365
  // app runs the compiler under Bun (its image has no Node), a Node app runs
421
366
  // it directly.
422
367
  ...(isApi ? {} : { 'css:build': cssBuildCmd }),
423
- // Shed the demo gallery to a clean, buildable base (scripts/clear-gallery.mjs).
424
- ...(isApi ? {} : { 'gallery:clear': isBun ? 'bun scripts/clear-gallery.mjs' : 'node scripts/clear-gallery.mjs' }),
368
+ // Shed the demo gallery / backend-features showcase to a clean, buildable
369
+ // base. The UI template runs clear-gallery.mjs; the api template runs
370
+ // clear-api-gallery.mjs (its showcase is app/api/features, not app/features).
371
+ 'gallery:clear': (() => {
372
+ const script = isApi ? 'clear-api-gallery.mjs' : 'clear-gallery.mjs';
373
+ return isBun ? `bun scripts/${script}` : `node scripts/${script}`;
374
+ })(),
425
375
  dev: isBun ? 'bun --bun webjs dev' : 'webjs dev',
426
376
  start: isBun ? 'bun --bun webjs start' : 'webjs start',
427
377
  test: 'webjs test',
@@ -763,7 +713,10 @@ export const users = table('users', {
763
713
  name: text(),
764
714
  // JSON column: a structured value persisted as JSON, typed via json<T>().
765
715
  // Same helper works on SQLite and Postgres. Delete if you do not need it.
766
- settings: json<{ theme?: string }>(),
716
+ settings: json<{ theme?: string }>(),${isFullStack ? `
717
+ // The auth gallery card (app/features/auth) signs credentials against this.
718
+ // gallery:clear removes this column with the rest of the auth surface.
719
+ passwordHash: text(),` : ''}
767
720
  createdAt: createdAt(),
768
721
  });
769
722
  ${isFullStack ? `
@@ -1040,10 +993,18 @@ export type ActionResult<T> =
1040
993
  // counterpart of the UI gallery. Prune what you skip.
1041
994
  const { writeApiGallery } = await import('./api-gallery.js');
1042
995
  await writeApiGallery(appDir);
996
+
997
+ // The showcase-reset script (wired as `gallery:clear` for the api template).
998
+ // It sheds app/api/features + its modules back to the health + users base.
999
+ const apiClearSrc = join(TEMPLATES, 'scripts', 'clear-api-gallery.mjs');
1000
+ if (existsSync(apiClearSrc)) {
1001
+ await mkdir(join(appDir, 'scripts'), { recursive: true });
1002
+ await cp(apiClearSrc, join(appDir, 'scripts', 'clear-api-gallery.mjs'));
1003
+ }
1043
1004
  }
1044
1005
 
1045
1006
  if (!isApi) {
1046
- // Full-stack and SaaS templates: layout + page + theme toggle + Tailwind
1007
+ // The UI template: layout + page + theme toggle + Tailwind
1047
1008
 
1048
1009
  // The Tailwind stylesheet is compiled from public/input.css (written below)
1049
1010
  // to a STATIC public/tailwind.css by css:build, and lib/utils/ui.ts helpers
@@ -1053,8 +1014,8 @@ export type ActionResult<T> =
1053
1014
  const publicDir = join(appDir, 'public');
1054
1015
  await mkdir(publicDir, { recursive: true });
1055
1016
  // Progressive-enhancement service worker (#271): ship the opt-in offline
1056
- // primitive (the worker + its offline fallback) into the UI scaffolds
1057
- // (full-stack / saas; this block is api-excluded since api has no UI).
1017
+ // primitive (the worker + its offline fallback) into the UI scaffold
1018
+ // (this block is api-excluded since api has no UI).
1058
1019
  // Dormant until the app registers it (see the skill's references/service-worker.md);
1059
1020
  // it never changes the JS-disabled baseline.
1060
1021
  for (const swFile of ['sw.js', 'offline.html']) {
@@ -1087,13 +1048,9 @@ export type ActionResult<T> =
1087
1048
  // styles/globals.css (the @webjsdev/ui theme).
1088
1049
  await writeUiBootstrap(appDir);
1089
1050
 
1090
- // The saas auth pages import a few ui-* primitives. A full-stack app adds
1091
- // any component on demand with `webjs ui add <name>`.
1092
- if (isSaas) {
1093
- await copyUiComponents(appDir, [
1094
- 'button', 'card', 'alert', 'badge', 'separator', 'label', 'input',
1095
- ]);
1096
- }
1051
+ // The gallery cards style with plain Tailwind (the app is pre-initialised for
1052
+ // `webjs ui add <name>` via writeUiBootstrap above, but ships no components
1053
+ // until you add them on demand).
1097
1054
 
1098
1055
  // The @webjsdev/ui theme (`--color-primary`, `--color-card`, the @theme maps,
1099
1056
  // @custom-variant, @keyframes) plus the app @theme mappings are compiled from
@@ -1136,10 +1093,10 @@ ${uiThemeRaw}
1136
1093
 
1137
1094
  // The gallery: idiomatic, densely-commented single-feature demos under
1138
1095
  // app/features/ plus one whole example app under app/examples/, with logic
1139
- // in modules/, all linked from the home page below. Shipped in every UI
1140
- // scaffold (full-stack AND saas) so an agent gains context by browsing real
1141
- // working code; prune per-feature (delete the route + its module) for what
1142
- // the app does not use.
1096
+ // in modules/, all linked from the home page below. Shipped in the UI
1097
+ // scaffold so an agent gains context by browsing real working code; prune
1098
+ // per-feature (delete the route + its module) for what the app does not use,
1099
+ // or shed the whole gallery at once with `gallery:clear`.
1143
1100
  await copyGallery(appDir);
1144
1101
 
1145
1102
  await writeFile(join(appDir, 'app', 'layout.ts'), `import { html, cspNonce } from '@webjsdev/core';
@@ -1322,11 +1279,7 @@ export default function RootLayout({ children }: { children: unknown }) {
1322
1279
  // demo and the example app, and a footer with the docs + source links. Treat it
1323
1280
  // as a starting point: prune the demos you do not use (delete the
1324
1281
  // app/features/<x> route AND its modules/<x>), then reshape this page into the
1325
- // app's real landing page. For the saas template a login/signup CTA row is
1326
- // spliced under the tagline.
1327
- const homeAuthLinks = isSaas
1328
- ? '\n <div class="flex flex-wrap gap-3 items-center justify-center mt-2"><a href="/login" class="inline-flex items-center px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium no-underline hover:opacity-90">Log in</a><a href="/signup" class="inline-flex items-center px-4 py-2 rounded-lg border border-border text-foreground text-sm font-medium no-underline hover:bg-accent">Create an account</a></div>'
1329
- : '';
1282
+ // app's real landing page.
1330
1283
  await writeFile(join(appDir, 'app', 'page.ts'), `import { html } from '@webjsdev/core';
1331
1284
 
1332
1285
  export const metadata = {
@@ -1340,10 +1293,15 @@ export const metadata = {
1340
1293
  const FEATURES = [
1341
1294
  { href: '/features/routing', title: 'Routing', blurb: 'A static route plus a dynamic [id] segment that reads params. The file-based router in miniature.' },
1342
1295
  { href: '/features/boundaries', title: 'Boundaries', blurb: 'The control-flow throws (forbidden / unauthorized / notFound) and the nearest boundary file that catches each.' },
1296
+ { 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.' },
1343
1297
  { href: '/features/components', title: 'Components', blurb: 'The WebComponent factory, reactive props, instance signals, and slot projection in light DOM.' },
1344
1298
  { 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.' },
1345
1299
  { href: '/features/optimistic-ui', title: 'Optimistic UI', blurb: 'The imperative optimistic(signal, value, action) flip: instant update, automatic rollback on failure.' },
1346
1300
  { 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.' },
1301
+ { 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.' },
1302
+ { href: '/features/stream', title: 'Stream updates', blurb: 'The <webjs-stream> element: renderStream() applies surgical append / replace / remove DOM updates by target id, no region redraw.' },
1303
+ { href: '/features/suspense', title: 'Suspense boundary', blurb: 'The <webjs-suspense> element: a first-paint fallback for a SLOW component, with the resolved content streamed in.' },
1304
+ { 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.' },
1347
1305
  { href: '/features/directives', title: 'Directives', blurb: 'The lit-html directive set: repeat for keyed lists, watch(signal) for a fine-grained node swap.' },
1348
1306
  { href: '/features/route-handler', title: 'Route handlers', blurb: 'A server-only route.ts HTTP endpoint returning JSON, the WebJs equivalent of a Next route handler.' },
1349
1307
  { href: '/features/forms', title: 'Forms', blurb: 'A no-JS progressive-enhancement form posting to the page action, with server-side validation errors.' },
@@ -1351,6 +1309,7 @@ const FEATURES = [
1351
1309
  { href: '/features/caching', title: 'Caching', blurb: 'export const revalidate caches the page HTML per URL, with the safety rule for when a shared cache is allowed.' },
1352
1310
  { href: '/features/env', title: 'Env vars', blurb: 'The server-only vs WEBJS_PUBLIC_ boundary, read during SSR so secrets never reach the browser.' },
1353
1311
  { href: '/features/client-router', title: 'Client router', blurb: 'Automatic soft navigation: fragment-only fetches, hover prefetch, scroll restore, and graceful no-JS fallback.' },
1312
+ { href: '/features/frames', title: 'Frames', blurb: 'A webjs-frame region that swaps a filtered sub-list in place from a link, shipping zero component JS, with a no-JS full-nav fallback.' },
1354
1313
  { href: '/features/service-worker', title: 'Service worker', blurb: 'The opt-in offline enhancement, registered from a browser-only lifecycle hook (never a page or layout).' },
1355
1314
  { href: '/features/websockets', title: 'WebSockets', blurb: 'A WS(ws, req) route endpoint plus the connectWS() client, echoing messages over a live socket.' },
1356
1315
  { href: '/features/broadcast', title: 'Broadcast', blurb: 'Fan a message out to every connected client on a WebSocket path, so all open tabs stay in sync.' },
@@ -1375,7 +1334,7 @@ export default function Home() {
1375
1334
  </h1>
1376
1335
  <p class="text-base sm:text-lg text-muted-foreground max-w-lg leading-relaxed m-0">
1377
1336
  AI-first and web-components-first. Server-rendered, progressively enhanced, and buildless.
1378
- </p>${homeAuthLinks}
1337
+ </p>
1379
1338
  </section>
1380
1339
 
1381
1340
  <!-- Gallery: every feature demo + the example app -->
@@ -1504,12 +1463,6 @@ ThemeToggle.register('theme-toggle');
1504
1463
  `);
1505
1464
  } // end if (!isApi)
1506
1465
 
1507
- // --- SaaS template extras: auth, dashboard, drizzle User model ---
1508
- if (isSaas) {
1509
- const { writeSaasFiles } = await import('./saas-template.js');
1510
- await writeSaasFiles(appDir, { runtime });
1511
- }
1512
-
1513
1466
  // AGENTS.md is already in place via the shared `templateFiles` loop
1514
1467
  // earlier in this function, so no framework-root fallback needed.
1515
1468
 
@@ -1530,20 +1483,11 @@ ThemeToggle.register('theme-toggle');
1530
1483
  modules/users/{actions,queries,types.ts} ← routes over server actions
1531
1484
  db/{schema,columns,connection}.server.ts ← Drizzle (User model)
1532
1485
  ${guide}
1533
- `);
1534
- } else if (isSaas) {
1535
- console.log(` ${name}/
1536
- app/{layout,page}.ts, login/, signup/
1537
- app/dashboard/{page,settings,middleware}.ts ← protected
1538
- app/api/auth/[...path]/route.ts ← auth API
1539
- components/ui/*, components/theme-toggle.ts
1540
- modules/auth/*, lib/{auth,password}.server.ts
1541
- db/{schema,columns,connection}.server.ts ← Drizzle (User model)
1542
- ${guide}
1543
1486
  `);
1544
1487
  } else {
1545
1488
  console.log(` ${name}/
1546
- app/{layout,page}.ts ← a minimal home to grow in place
1489
+ app/{layout,page}.ts ← gallery home; gallery:clear grows in place
1490
+ app/features/*, modules/* ← browsable demos (incl. auth: login + protected route)
1547
1491
  components/theme-toggle.ts
1548
1492
  public/input.css ← Tailwind entry (compiles to public/tailwind.css)
1549
1493
  db/{schema,columns,connection}.server.ts ← Drizzle
@@ -1592,9 +1536,9 @@ ThemeToggle.register('theme-toggle');
1592
1536
  // Next-steps banner prints LAST so the actionable command is the
1593
1537
  // final thing on screen, never buried above the AI-agent guidance.
1594
1538
  // Single copy-paste line so the user can move from "scaffold done"
1595
- // to "dev server up" in one command. The full-stack and saas
1596
- // templates ship with @webjsdev/ui already initialised; the api
1597
- // template has no UI but may add one later.
1539
+ // to "dev server up" in one command. The full-stack template ships
1540
+ // with @webjsdev/ui already initialised; the api template has no UI
1541
+ // but may add one later.
1598
1542
  const installSegment = installed ? '' : `${pm} install && `;
1599
1543
  // The shipped schema is applied on the first `run dev` (webjs.*.before runs
1600
1544
  // `db migrate`), but only if a migration FILE exists. When we installed, we
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webjsdev/cli",
3
- "version": "0.10.43",
3
+ "version": "0.10.45",
4
4
  "type": "module",
5
5
  "description": "webjs CLI - dev, start, create, db",
6
6
  "bin": {
@@ -17,6 +17,16 @@ Read this when a task touches client navigation, prefetch, partial-page swaps, s
17
17
 
18
18
  The router auto-enables the moment `@webjsdev/core` loads in the browser, which is any page that ships a component. There is nothing to import or opt into. It intercepts same-origin `<a>` clicks (including inside shadow DOM), fetches the target HTML, and replaces only the inside of the deepest shared layout. Outer header, sidenav, and footer DOM is never re-rendered, so scroll positions, input values, and `<details>` state survive a navigation.
19
19
 
20
+ **The nav parse must preserve comments.** SSR wraps each layout's children AND the page itself in a KEYED boundary comment pair (open `<!--wj:children:<segment>:<route-key>-->`, close `<!--/wj:children:<segment>-->`, #1015). The route-key is the region's resolved concrete path with each substituted param value percent-encoded (so a user-controlled value can never terminate the comment or collide with the `:` delimiter). The router STRICTLY scans both the live and incoming DOM into segment maps: a close must id-match its innermost open, and ANY truncation, mispair, duplicate, or legacy anonymous open poisons the whole scan. The swap decision is two-tier with Next.js remount parity: a CHANGED route-key REPLACES (a fresh remount, permanents regrafted) at the PARENT of the shallowest changed boundary (a layout's boundary wraps only its children, so its own param-derived markup lives in the parent's range; anchoring there remounts the layout chrome too, exactly like Next re-rendering the layout with new params), else MORPH (the keyed state-preserving reconcile) at the deepest shared boundary when it is the leaf on both sides. The X-Webjs-Have header carries `segment:route-key` entries so the server re-renders (and re-ships) a dynamic layout the client holds for other params instead of short-circuiting past it. A poisoned scan or no shared segment degrades to a FULL PAGE LOAD (dev logs the cause), never a guessed recovery, so silent DOM corruption is structurally impossible. Hydration keys off another comment (`<!--webjs-hydrate-->`, which `__isHydrating()` reads as a component's first child). So the router and hydration both ride on comments SURVIVING the parse that turns a navigation response into a Document, which makes that parse a load-bearing correctness boundary rather than an implementation detail.
21
+
22
+ `Document.parseHTMLUnsafe` STRIPS every comment in Chromium 150 (#1007). No other parse API does: `DOMParser`, `setHTMLUnsafe`, `template.innerHTML`, and plain `innerHTML` all preserve them, and so does the document's own navigation parser, which is why a hard refresh always looked correct and only soft nav broke. With the boundaries gone the router degrades to a full page load (correct, just not soft); with `webjs-hydrate` gone a slotted light-DOM component misses the hydration adopt path. `parseHTML` therefore PROBES `parseHTMLUnsafe` once for losslessness instead of sniffing versions, uses it when it is lossless (it is the only single-pass API that also processes Declarative Shadow DOM), and otherwise parses with `DOMParser`, which preserves comments. A fixed browser silently returns to the fast path.
23
+
24
+ On that fallback, Declarative Shadow DOM is left UNPROCESSED (`DOMParser` does not attach it), a deliberate limitation tracked in #1011, because both ways of adding it back are worse than the gap. Re-serializing via `body.setHTMLUnsafe(body.innerHTML)` is not idempotent (Chromium omits the spec's LF-compensation, so a leading newline in `pre` / `textarea` is silently eaten, which in a `textarea` is form-data corruption), and attaching each root by hand yields a NON-declarative root, which makes any element whose constructor unconditionally calls `attachShadow()` throw `NotSupportedError` on upgrade. The gap costs a JS-less DSD-dependent element its shadow content on a full-body-swap nav, on a stripping browser only; a `static shadow = true` component attaches and renders its own root on upgrade, and a soft nav runs JS by definition.
25
+
26
+ Note for anyone testing this: **the Chromium web-test-runner currently resolves (148) is LOSSLESS, so CI cannot observe the bug at all** (and `playwright` is a caret range, so that version moves on any dependency refresh). A test that merely asserts "markers survive" passes there whether or not the fix exists. The guard in `packages/core/test/routing/browser/comment-preserving-parse.test.js` SIMULATES a stripping parser so it is provable on every engine.
27
+
28
+ **There is NO dropped-marker recovery (#1015 replaced #994's).** The pre-#1015 router "recovered" an orphaned open marker by guessing where its children ended (bounded by the other side's trailing-sibling count), which could guess wrong and corrupt silently. Keyed closes make a mispair DETECTABLE instead, and every integrity violation now degrades to a bounded, correct full page load. The historical producers of lost comments (our own comment-stripping parse #1007, mid-parse soft navs #1008) are fixed upstream, so the degradation is a rare backstop, not a common path. Wrapping `${children}` in a container element (the shipped idiom, `<main>${children}</main>` with the footer a sibling outside it) remains a fine layout pattern, though no correctness now depends on it.
29
+
20
30
  **Opting out.** App-wide with config, or per moment at runtime.
21
31
 
22
32
  ```jsonc
@@ -103,7 +113,7 @@ The router can wrap a navigation's DOM mutation in the native View Transitions A
103
113
  <meta name="view-transition" content="same-origin">
104
114
  ```
105
115
 
106
- The accepted value is `same-origin`. When enabled it wraps all three swap paths (the layout-marker swap, the `<webjs-frame>` swap, and the full-body fallback). When `startViewTransition` is unavailable the swap runs synchronously with no flash and no throw. To persist a live element (a playing `<audio>`, an open menu) across a swap by node identity, mark it `data-webjs-permanent` and give it an `id`.
116
+ The accepted value is `same-origin`. When enabled it wraps every swap path (the two-tier boundary swap, the `<webjs-frame>` swap, and the background-revalidation full-body path). When `startViewTransition` is unavailable the swap runs synchronously with no flash and no throw. To persist a live element (a playing `<audio>`, an open menu) across a swap by node identity, mark it `data-webjs-permanent` and give it an `id`.
107
117
 
108
118
  ## `<webjs-stream>` Surgical Updates
109
119
 
@@ -103,7 +103,7 @@ class Panel extends WebComponent({ label: String }) {
103
103
 
104
104
  ## Slots
105
105
 
106
- The full `<slot>` surface works in light DOM with shadow-DOM parity; migrating modes never requires a template rewrite.
106
+ The full `<slot>` surface works in light DOM with shadow-DOM parity; migrating modes never requires a template rewrite. A forwarded slot projects its content everywhere (client, SSR, hydration).
107
107
 
108
108
  ```ts
109
109
  class MyCard extends WebComponent {
@@ -116,7 +116,21 @@ class MyCard extends WebComponent {
116
116
  }
117
117
  ```
118
118
 
119
- Named slots, the default slot (unnamed children, text, comments), fallback content (a slot's inner markup when nothing matches), first-wins resolution, and dynamic `name=${...}` all behave per spec. The DOM API mirrors shadow slots: `assignedNodes` / `assignedElements` (with `{ flatten: true }`), `element.assignedSlot`, and the `slotchange` event. Both modes are SSR'd (light DOM projects into `<slot data-webjs-light data-projection="actual">`, shadow DOM via Declarative Shadow DOM), so slotted content renders with no JS.
119
+ Named slots, the default slot (unnamed children, text, comments), fallback content (a slot's inner markup when nothing matches), and first-wins resolution all behave per spec. The DOM API mirrors shadow slots: `assignedNodes` / `assignedElements` (with `{ flatten: true }`), `element.assignedSlot`, and the `slotchange` event. Both modes are SSR'd (light DOM places children into `<slot data-webjs-light data-projection="actual">`, shadow DOM via Declarative Shadow DOM), so slotted content renders with no JS.
120
+
121
+ **Light-DOM slots ARE the native DOM slot API (#1021, full shadow parity).** There is no WebJs-specific slot API. Post-mount writes are live exactly as in shadow DOM, and moving a component between `static shadow = false` and `true` never needs a rewrite:
122
+
123
+ ```ts
124
+ const card = document.querySelector('my-card');
125
+ card.appendChild(node); // live, projected
126
+ card.querySelector('[slot=old]').slot = 'new'; // flip re-projects
127
+ card.innerHTML = '<p>replaced</p>'; // replaces slotted content
128
+ card.querySelector('slot').assignedNodes(); // read, mirrors shadow
129
+ node.assignedSlot;
130
+ card.querySelector('slot').addEventListener('slotchange', ...); // async + coalesced
131
+ ```
132
+
133
+ Things to internalize. (1) Every native mutation is live: `appendChild` / `insertBefore` / `removeChild` / `el.remove()` / `innerHTML` / `el.slot=` flip / `HTMLSlotElement.assign()`. Reorder-by-append moves a child to the end (native semantics), a fragment expands and drains, and `insertBefore` against a renderer/non-child ref throws `NotFoundError`. One caveat rides `assign()`: the light-DOM version is an EXTENSION (an element-bound overlay while name matching keeps working), and native shadow `assign()` needs `slotAssignment: 'manual'` which WebJs does not set, so `assign()` is the one write that does NOT survive flipping to `static shadow = true`; avoid it in mode-portable components. (2) Four inherent gaps (from light DOM having no shadow boundary). The gaps: structural host reads (`host.children` / `host.childNodes` / `querySelector(':scope > ...')` / the `innerHTML` GETTER read the rendered template, not the authored children, so read slotted content with `assignedNodes()`); `assignedChild.parentNode` is the `<slot>`; `::slotted()` CSS is shadow-only (style slotted content with normal selectors / Tailwind); and initial-projection lifecycle timing (`firstUpdated` sees the `<slot>` element with EMPTY `assignedNodes()`, because the first light-DOM projection lands one microtask after the first render, where shadow DOM projects natively before it; read assigned content from a `slotchange` listener or after a microtask). (3) Conditional-on-slot at render time does not exist in EITHER mode (a shadow template can't branch on light-child presence at render time either); use CSS `:has()` / `slot:empty` or a `slotchange` listener. (4) The name `default` is a reserved alias for the default slot; do not name a slot `default`. (5) A display-only slotted wrapper still elides; a component whose slots are mutated at runtime is already shipped because a consumer references its tag (force a ship with `static interactive = true` only for a dynamically-resolved reference the analyser cannot see). (6) A generic DOM library should operate on the assigned nodes, never on the host element itself; writes into an ACTIVELY ASSIGNED slot container are folded into the record (self-heal), while a fallback-mode slot's content is renderer-owned and out of contract. (7) A FORWARDED slot projects its content everywhere (#1023): a template may forward a slot into a nested component (html`<inner-shell><slot></slot></inner-shell>`), and the outer component's content projects through it on a client-only mount, in the SSR first paint, and across hydration (no flash back to fallback). The renderer stamps each slot with its template owner (carried across SSR as `data-wj-slot-owner`), so a forwarded slot routes to the outer host that rendered it, not the child it nests in. (8) A LAYOUT's named slots stay in sync across soft navigation (#1024): when a layout renders its `${children}` inside a slotted shell and a page emits top-level `slot=`-attributed children, the named-slot slices update on a soft-nav boundary swap just as the default slice does (the swap resyncs every own slot of the enclosing shell from the incoming page).
120
134
 
121
135
  A compound child reads its parent at the first server paint via `closest('ui-tabs')` (only tag-name selectors resolve at SSR, and the compound parent must be light DOM). Genuine live-DOM reads (`querySelector`, `classList`, geometry) still throw at SSR, so keep them in `connectedCallback` / `firstUpdated`.
122
136
 
@@ -153,7 +167,8 @@ A component that does no client-side work renders the same SSR'd HTML with or wi
153
167
  - an overridden lifecycle hook (including `renderFallback` / `renderError`)
154
168
  - an imported `signal` / `computed` / `watch` / `Task` / `ref` / streaming directive, or `addController` / `requestUpdate`
155
169
  - code that runs at module load (a top-level call, non-data `new`, dynamic `import(...)`, top-level `await`); only declarations and `X.register(...)` are allowed
156
- - a rendered `<slot>`, or being rendered by a component that itself ships
170
+ - the dynamic slot READ surface (`slotchange`, `assignedNodes` / `assignedElements` / `assignedSlot`); merely RENDERING a `<slot>` does not ship (the SSR output carries the placed children, so a display-only slotted wrapper is byte-identical without its JS; native-write liveness is consumer-driven and the consumer's tag reference forces the ship)
171
+ - being rendered by a component that itself ships
157
172
 
158
173
  A bare `async render()` (no other signal, light DOM) is elided too: the SSR'd data is the complete first paint. Force shipping with `static interactive = true` when interactivity is invisible to static analysis (a dynamically-built tag string, a `:defined` rule in an external stylesheet). `static shadow = true` always ships (Declarative Shadow DOM re-attaches only during parsing). Turn elision off app-wide with `{ "webjs": { "elide": false } }` or `WEBJS_ELIDE=0`.
159
174
 
@@ -162,6 +177,6 @@ A bare `async render()` (no other signal, light DOM) is elided too: the SSR'd da
162
177
  A `WebComponent` inherits `HTMLElement` (browser) or an `ElementShim` (SSR) plus the framework reactivity base. A reactive prop or method whose NAME collides either fails to compile (`TS2415` for a type-incompatible property, `TS2416` for a method signature) or silently hijacks the native member at runtime. The fix is always to rename.
163
178
 
164
179
  - HTMLElement / Element: `title`, `id`, `slot`, `role`, `hidden`, `dir`, `lang`, `translate`, `draggable`, `tabIndex`, `className`, `dataset`, `remove`, `closest`, `matches`, `focus`, `blur`, `click`, `append` / `prepend`, `before` / `after`. Rename (`postTitle`, `removeItem`, `handleClick`).
165
- - WebComponent base: `render`, `update`, `requestUpdate`, `updated` / `firstUpdated`, `willUpdate` / `shouldUpdate`, `connectedCallback`, `renderError` / `renderFallback`, `addController` / `removeController`, `updateComplete`. Only override one deliberately, with its exact signature; never repurpose the name for app logic.
180
+ - WebComponent base: `render`, `update`, `requestUpdate`, `updated` / `firstUpdated`, `willUpdate` / `shouldUpdate`, `connectedCallback`, `renderError` / `renderFallback`, `addController` / `removeController`, `updateComplete` (#1021: there is no WebJs slot API to override; slots are native). Only override one deliberately, with its exact signature; never repurpose the name for app logic.
166
181
 
167
182
  Framework-private fields are underscore-prefixed (`_renderRoot`, `_connected`, `_changedProperties`, `_updatePromise`, `_isUpdating`); never declare a prop or field that matches one. Safe, non-inherited names: `label`, `open`, `count`, `value`, `name`, `items`, `todos`, `active`, `variant`, `size`, `checked`, `selected`, `heading`, `message`, `status`. When in doubt, grep the base surface in `node_modules/@webjsdev/core/src/component.js`.
@@ -3,7 +3,7 @@
3
3
  ## What This Covers
4
4
 
5
5
  - The Next.js patterns that LOOK right in WebJs but break, because WebJs borrows Next's file-based routing shape but not its execution model (no RSC, no `'use client'` split): `redirect()` in a route handler, `fetch()` in a page, `<Link>`, `NEXT_PUBLIC_`, `await params`.
6
- - The Lit patterns that break WebJs SSR or reactivity, because WebJs is HTML-first (real HTML first paint, JS opt-in per behaviour) not JS-first: `static properties` / the `@property()` decorator, class-field initializers, browser globals in `render()`, fetching in `connectedCallback`, interpolation into `<style>`.
6
+ - The Lit patterns that break WebJs SSR or reactivity, because WebJs is HTML-first (real HTML first paint, JS opt-in per behaviour) not JS-first: `static properties` / the `@property()` decorator, class-field initializers, browser globals in `render()`, fetching in `connectedCallback`, interpolation into `<style>`, reading `assignedNodes()` in `firstUpdated` of a light-DOM component.
7
7
  - The WebJs-shaped fix for each, with short code.
8
8
 
9
9
  Read this when a pattern feels familiar from Next.js or Lit but you are not sure it transfers. For the component runtime see `components.md`; for the routing surface see `routing-and-pages.md`. The one difference underneath everything: pages and layouts render server-only and never hydrate, and the one client boundary is a `WebComponent` custom element.
@@ -149,6 +149,10 @@ The `@property()` decorator is banned by the erasable-TS invariant (decorators a
149
149
 
150
150
  Lit defaults to shadow DOM, so `static styles = css` scopes automatically. WebJs defaults to light DOM. A `static styles` block without `static shadow = true` does nothing useful and any inline `<style>` with bare class names leaks globally. The webjs-shaped fix is Tailwind utilities, which apply directly in light DOM. Reach for `static shadow = true` plus `static styles` only when scoped CSS genuinely belongs in a shadow root, or prefix every selector with the tag name if authoring vanilla light-DOM CSS.
151
151
 
152
+ ### Reading `assignedNodes()` in `firstUpdated` of a light-DOM component
153
+
154
+ In shadow DOM the browser projects slotted content natively before `firstUpdated`, so Lit muscle memory says `this.shadowRoot.querySelector('slot').assignedNodes()` is populated there. In light DOM the first projection lands one microtask AFTER the first render, so `firstUpdated` sees the `<slot>` element with an EMPTY `assignedNodes()`. The webjs-shaped fix: read assigned content from a `slotchange` listener (fires once projection lands, and on every later change), or wait a microtask. Every later read and every mutation-driven update behaves identically in both modes; only the first-render read differs.
155
+
152
156
  ### `:host { display: block }` on a light-DOM component
153
157
 
154
158
  A custom element is `display: inline` by default, so a block container collapses. In Lit you fix this with `:host { display: block }`, which works because Lit is shadow-DOM-first. A light-DOM WebJs component has no shadow root, so there is no `:host` to write. There is nothing to do: the framework already defaults every light-DOM host to `display: block` via a low-priority `@layer webjs-host` rule, overridable by any Tailwind utility (`class="flex"` wins). A shadow-DOM component (`static shadow = true`) still sets `:host { display: block }` in `static styles` itself, exactly like Lit.
@@ -46,7 +46,7 @@ The 103 Early Hints gap costs only a small first-load latency edge where an edge
46
46
  webjs create my-app --runtime bun
47
47
  ```
48
48
 
49
- `--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.
49
+ `--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.
50
50
 
51
51
  ## Running on Bun
52
52
 
@@ -11,7 +11,7 @@ Read this when you want an offline experience or an asset cache in a WebJs app,
11
11
 
12
12
  ## What ships and why it is safe
13
13
 
14
- 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.
14
+ 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.
15
15
 
16
16
  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.
17
17
 
@@ -0,0 +1,7 @@
1
+ // The createAuth HTTP endpoints (signin, signout, OAuth callbacks). This route
2
+ // stays at the app root, NOT under app/features/auth/, because createAuth
3
+ // hardcodes /api/auth/signin/* and /api/auth/callback/* for its form posts and
4
+ // OAuth redirect URIs. The rest of the auth card lives under app/features/auth/.
5
+ import { handlers } from '#modules/auth/auth.server.ts';
6
+ export const GET = handlers.GET;
7
+ export const POST = handlers.POST;
@@ -0,0 +1,11 @@
1
+ import { html } from '@webjsdev/core';
2
+
3
+ // Shared layout for every gallery example app under /examples/*. It adds the same
4
+ // slim "back to the gallery" link the feature demos get, so an example is never a
5
+ // dead end. A non-root layout, so it never writes the document shell.
6
+ export default function ExamplesLayout({ children }: { children: unknown }) {
7
+ return html`
8
+ <a href="/" class="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors no-underline mb-6">&larr; Gallery</a>
9
+ ${children}
10
+ `;
11
+ }
@@ -0,0 +1,20 @@
1
+ import { html } from '@webjsdev/core';
2
+
3
+ // Nested layout for the protected dashboard subtree. Logout is a plain
4
+ // <form method="POST"> posting to the createAuth signout route: it clears the
5
+ // session cookie and 302s home, and works with JS off (progressive-enhancement
6
+ // default). signOut is server-only (modules/auth/auth.server.ts), so we POST to
7
+ // its route rather than import it into a browser-shipping page. After signout the
8
+ // dashboard middleware bounces any later visit to login.
9
+ export default function DashboardLayout({ children }: { children: unknown }) {
10
+ return html`
11
+ <nav class="flex items-center gap-4 mb-6 pb-4 border-b border-border">
12
+ <a href="/features/auth/dashboard" class="text-sm font-medium text-foreground hover:underline">Dashboard</a>
13
+ <a href="/features/auth/dashboard/settings" class="text-sm font-medium text-foreground hover:underline">Settings</a>
14
+ <form method="POST" action="/api/auth/signout" class="ml-auto">
15
+ <button type="submit" class="px-3 py-1.5 rounded-lg border border-border text-sm text-foreground bg-transparent cursor-pointer transition-colors hover:bg-accent">Log out</button>
16
+ </form>
17
+ </nav>
18
+ ${children}
19
+ `;
20
+ }
@@ -0,0 +1,14 @@
1
+ import { auth } from '#modules/auth/auth.server.ts';
2
+
3
+ // The protected-route gate. A per-segment middleware.ts runs for every request
4
+ // under /features/auth/dashboard/*. It reads the signed session off the request
5
+ // with auth(req); with no valid session it 302s to login BEFORE the page renders,
6
+ // so an anonymous visitor never sees the protected content. This needs no DB
7
+ // query (only a cookie read), so the gate is real the moment the app boots.
8
+ export default async function requireAuth(req: Request, next: () => Promise<Response>) {
9
+ const session = await auth(req);
10
+ if (!session?.user) {
11
+ return new Response(null, { status: 302, headers: { location: '/features/auth/login' } });
12
+ }
13
+ return next();
14
+ }