@webjsdev/cli 0.10.44 → 0.10.46

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 (111) 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 +154 -243
  5. package/package.json +1 -1
  6. package/templates/.agents/rules/workflow.md +7 -3
  7. package/templates/.agents/skills/webjs/SKILL.md +4 -2
  8. package/templates/.agents/skills/webjs/references/auth-and-sessions.md +78 -16
  9. package/templates/.agents/skills/webjs/references/built-ins.md +16 -2
  10. package/templates/.agents/skills/webjs/references/client-router-and-streaming.md +38 -6
  11. package/templates/.agents/skills/webjs/references/components.md +101 -6
  12. package/templates/.agents/skills/webjs/references/data-and-actions.md +19 -2
  13. package/templates/.agents/skills/webjs/references/muscle-memory-gotchas.md +5 -1
  14. package/templates/.agents/skills/webjs/references/optimistic-ui.md +18 -0
  15. package/templates/.agents/skills/webjs/references/routing-and-pages.md +25 -2
  16. package/templates/.agents/skills/webjs/references/runtime.md +1 -1
  17. package/templates/.agents/skills/webjs/references/service-worker.md +1 -1
  18. package/templates/.agents/skills/webjs/references/styling.md +82 -2
  19. package/templates/AGENTS.md +12 -5
  20. package/templates/gallery/app/api/auth/[...path]/route.ts +7 -0
  21. package/templates/gallery/app/apple-icon.ts +2 -5
  22. package/templates/gallery/app/examples/layout.ts +16 -0
  23. package/templates/gallery/app/examples/todo/page.ts +2 -1
  24. package/templates/gallery/app/features/async-render/page.ts +3 -2
  25. package/templates/gallery/app/features/auth/dashboard/layout.ts +21 -0
  26. package/templates/gallery/app/features/auth/dashboard/middleware.ts +14 -0
  27. package/templates/gallery/app/features/auth/dashboard/page.ts +20 -0
  28. package/templates/gallery/app/features/auth/dashboard/settings/page.ts +22 -0
  29. package/templates/gallery/app/features/auth/login/middleware.ts +15 -0
  30. package/templates/gallery/app/features/auth/login/page.ts +43 -0
  31. package/templates/gallery/app/features/auth/page.ts +34 -0
  32. package/templates/gallery/app/features/auth/signup/middleware.ts +11 -0
  33. package/templates/gallery/app/features/auth/signup/page.ts +61 -0
  34. package/templates/gallery/app/features/boundaries/error.ts +5 -4
  35. package/templates/gallery/app/features/boundaries/gated/forbidden.ts +5 -4
  36. package/templates/gallery/app/features/boundaries/not-found.ts +5 -4
  37. package/templates/gallery/app/features/boundaries/page.ts +11 -10
  38. package/templates/gallery/app/features/boundaries/private/unauthorized.ts +5 -4
  39. package/templates/gallery/app/features/broadcast/page.ts +4 -3
  40. package/templates/gallery/app/features/caching/page.ts +5 -4
  41. package/templates/gallery/app/features/client-router/page.ts +7 -5
  42. package/templates/gallery/app/features/client-router/second/page.ts +4 -3
  43. package/templates/gallery/app/features/components/page.ts +3 -2
  44. package/templates/gallery/app/features/directives/page.ts +3 -2
  45. package/templates/gallery/app/features/env/page.ts +4 -3
  46. package/templates/gallery/app/features/file-storage/page.ts +8 -5
  47. package/templates/gallery/app/features/forms/page.ts +10 -6
  48. package/templates/gallery/app/features/frames/page.ts +26 -10
  49. package/templates/gallery/app/features/layout.ts +67 -0
  50. package/templates/gallery/app/features/metadata/page.ts +7 -6
  51. package/templates/gallery/app/features/optimistic-ui/page.ts +3 -2
  52. package/templates/gallery/app/features/rate-limit/page.ts +6 -5
  53. package/templates/gallery/app/features/route-handler/page.ts +4 -3
  54. package/templates/gallery/app/features/routing/[id]/page.ts +7 -6
  55. package/templates/gallery/app/features/routing/page.ts +10 -9
  56. package/templates/gallery/app/features/server-actions/page.ts +7 -4
  57. package/templates/gallery/app/features/service-worker/page.ts +4 -3
  58. package/templates/gallery/app/features/sessions/page.ts +5 -4
  59. package/templates/gallery/app/features/stream/page.ts +46 -0
  60. package/templates/gallery/app/features/streaming/page.ts +32 -0
  61. package/templates/gallery/app/features/suspense/page.ts +35 -0
  62. package/templates/gallery/app/features/view-transitions/page.ts +44 -0
  63. package/templates/gallery/app/features/view-transitions/second/page.ts +30 -0
  64. package/templates/gallery/app/features/websockets/page.ts +4 -3
  65. package/templates/gallery/app/global-error.ts +2 -5
  66. package/templates/gallery/app/global-not-found.ts +4 -6
  67. package/templates/gallery/app/icon.ts +2 -5
  68. package/templates/gallery/app/manifest.ts +1 -4
  69. package/templates/gallery/app/opengraph-image.ts +3 -6
  70. package/templates/gallery/app/robots.ts +0 -3
  71. package/templates/gallery/app/sitemap.ts +0 -3
  72. package/templates/gallery/app/twitter-image.ts +3 -6
  73. package/templates/gallery/components/ui/badge.ts +41 -0
  74. package/templates/gallery/components/ui/button.ts +86 -0
  75. package/templates/gallery/components/ui/card.ts +36 -0
  76. package/templates/gallery/components/ui/input.ts +50 -0
  77. package/templates/gallery/lib/utils/ui.ts +31 -0
  78. package/templates/gallery/modules/auth/actions/signup.server.ts +19 -0
  79. package/templates/gallery/modules/auth/auth.server.ts +53 -0
  80. package/templates/gallery/modules/auth/password.server.ts +20 -0
  81. package/templates/gallery/modules/auth/queries/current-user.server.ts +12 -0
  82. package/templates/gallery/modules/auth/types.ts +9 -0
  83. package/templates/gallery/modules/broadcast/components/broadcast-feed.ts +4 -2
  84. package/templates/gallery/modules/caching/components/cache-buster.ts +2 -1
  85. package/templates/gallery/modules/client-router/components/router-controls.ts +4 -3
  86. package/templates/gallery/modules/components/components/counter-card.ts +4 -2
  87. package/templates/gallery/modules/components/components/reactive-meter.ts +9 -1
  88. package/templates/gallery/modules/components/components/task-loader.ts +3 -2
  89. package/templates/gallery/modules/components/components/theme-context.ts +5 -3
  90. package/templates/gallery/modules/directives/components/directive-demo.ts +17 -10
  91. package/templates/gallery/modules/gallery/components/gallery-nav.ts +54 -0
  92. package/templates/gallery/modules/gallery/nav.ts +79 -0
  93. package/templates/gallery/modules/optimistic-ui/components/like-button.ts +19 -1
  94. package/templates/gallery/modules/rate-limit/components/rate-probe.ts +2 -1
  95. package/templates/gallery/modules/route-handler/components/rich-data.ts +2 -1
  96. package/templates/gallery/modules/server-actions/actions/greet.server.ts +2 -2
  97. package/templates/gallery/modules/server-actions/actions/greet.test.ts +39 -36
  98. package/templates/gallery/modules/server-actions/components/greeter.ts +11 -11
  99. package/templates/gallery/modules/server-actions/middleware/require-auth.server.ts +14 -9
  100. package/templates/gallery/modules/stream/components/stream-demo.ts +81 -0
  101. package/templates/gallery/modules/streaming/actions/stream-tokens.server.ts +17 -0
  102. package/templates/gallery/modules/streaming/components/token-stream.ts +53 -0
  103. package/templates/gallery/modules/suspense/components/slow-fact.ts +20 -0
  104. package/templates/gallery/modules/todo/components/todo-app.ts +8 -4
  105. package/templates/gallery/modules/websockets/components/ws-echo.ts +5 -3
  106. package/templates/gallery/test/auth/auth.test.ts +81 -0
  107. package/templates/public/favicon.svg +10 -3
  108. package/templates/scripts/clear-api-gallery.mjs +55 -0
  109. package/templates/scripts/clear-gallery.mjs +143 -24
  110. package/lib/lean-copy.js +0 -43
  111. package/lib/saas-template.js +0 -568
@@ -0,0 +1,54 @@
1
+ // The gallery's left sidebar: a grouped index of every demo, with the current
2
+ // one highlighted (docs-site style). WHY a component and not plain layout markup:
3
+ // the features layout is PRESERVED across a soft navigation (only the page
4
+ // content swaps), so a server-rendered "active" highlight would go stale. This
5
+ // component listens for the router's `webjs:navigate` event and re-derives the
6
+ // active item from location.pathname, so the highlight follows soft-nav. SSR is
7
+ // still correct: `render()` reads the `current` prop (the pathname the layout
8
+ // passes) for the first paint, and the client takes over from location after.
9
+ import { WebComponent, prop, html, signal } from '@webjsdev/core';
10
+ import { FEATURE_GROUPS } from '#modules/gallery/nav.ts';
11
+
12
+ // Module-scope so the value survives re-renders; set on the client only (SSR
13
+ // reads the `current` prop instead, so it never touches location during render).
14
+ const activePath = signal('');
15
+
16
+ export class GalleryNav extends WebComponent({ current: prop(String) }) {
17
+ #onNav = () => activePath.set(location.pathname);
18
+
19
+ connectedCallback() {
20
+ super.connectedCallback();
21
+ this.#onNav(); // seed from the real URL on hydrate
22
+ document.addEventListener('webjs:navigate', this.#onNav); // soft-nav (link + navigate())
23
+ window.addEventListener('popstate', this.#onNav); // back / forward
24
+ }
25
+
26
+ disconnectedCallback() {
27
+ super.disconnectedCallback();
28
+ document.removeEventListener('webjs:navigate', this.#onNav);
29
+ window.removeEventListener('popstate', this.#onNav);
30
+ }
31
+
32
+ render() {
33
+ // activePath is '' at SSR (and before hydrate), so fall back to the prop.
34
+ const active = activePath.get() || this.current;
35
+ const link = (href: string, title: string) => {
36
+ // Highlight the demo whose route we are on, INCLUDING its subroutes
37
+ // (/features/auth/dashboard highlights Auth). The trailing slash keeps
38
+ // /features/stream from matching /features/streaming.
39
+ const on = active === href || active.startsWith(href + '/');
40
+ const cls = 'block px-3 py-1.5 rounded-lg no-underline transition-colors ' +
41
+ (on ? 'bg-accent text-foreground font-medium' : 'text-muted-foreground hover:bg-accent hover:text-foreground');
42
+ return html`<a href=${href} aria-current=${on ? 'page' : 'false'} class=${cls}>${title}</a>`;
43
+ };
44
+ return html`
45
+ ${FEATURE_GROUPS.map((g) => html`
46
+ <div class="mb-5">
47
+ <div class="px-3 mb-1.5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/60">${g.label}</div>
48
+ <nav class="flex flex-col gap-0.5">${g.items.map((i) => link(i.href, i.title))}</nav>
49
+ </div>
50
+ `)}
51
+ `;
52
+ }
53
+ }
54
+ GalleryNav.register('gallery-nav');
@@ -0,0 +1,79 @@
1
+ /**
2
+ * The gallery's demo index, ONE source of truth for both the home cards and the
3
+ * left sidebar (so they can never drift). A browser-safe data module (no server
4
+ * imports, no client globals): the home flattens the groups into its card grid,
5
+ * and <gallery-nav> renders them grouped. gallery:clear removes this module.
6
+ */
7
+ export interface NavItem { href: string; title: string; blurb: string; }
8
+ export interface NavGroup { label: string; items: NavItem[]; }
9
+
10
+ export const FEATURE_GROUPS: NavGroup[] = [
11
+ {
12
+ label: 'Routing',
13
+ items: [
14
+ { href: '/features/routing', title: 'Routing', blurb: 'A static route plus a dynamic [id] segment that reads params. The file-based router in miniature.' },
15
+ { href: '/features/boundaries', title: 'Boundaries', blurb: 'The control-flow throws (forbidden / unauthorized / notFound) and the nearest boundary file that catches each.' },
16
+ { href: '/features/metadata', title: 'Metadata', blurb: 'Static metadata plus generateMetadata(ctx), which reads the request to compute the title and Open Graph tags.' },
17
+ ],
18
+ },
19
+ {
20
+ label: 'Components',
21
+ items: [
22
+ { href: '/features/components', title: 'Components', blurb: 'The WebComponent factory, reactive props, instance signals, and slot projection in light DOM.' },
23
+ { href: '/features/directives', title: 'Directives', blurb: 'The lit-html directive set: repeat for keyed lists, watch(signal) for a fine-grained node swap.' },
24
+ { 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.' },
25
+ ],
26
+ },
27
+ {
28
+ label: 'Data & actions',
29
+ items: [
30
+ { 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.' },
31
+ { 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.' },
32
+ { href: '/features/forms', title: 'Forms', blurb: 'A no-JS progressive-enhancement form posting to the page action, with server-side validation errors.' },
33
+ { href: '/features/optimistic-ui', title: 'Optimistic UI', blurb: 'The imperative optimistic(signal, value, action) flip: instant update, automatic rollback on failure.' },
34
+ ],
35
+ },
36
+ {
37
+ label: 'Client & streaming',
38
+ items: [
39
+ { href: '/features/client-router', title: 'Client router', blurb: 'Automatic soft navigation: fragment-only fetches, hover prefetch, scroll restore, and graceful no-JS fallback.' },
40
+ { 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.' },
41
+ { 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.' },
42
+ { 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.' },
43
+ { 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.' },
44
+ { 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.' },
45
+ ],
46
+ },
47
+ {
48
+ label: 'Real-time',
49
+ items: [
50
+ { href: '/features/websockets', title: 'WebSockets', blurb: 'A WS(ws, req) route endpoint plus the connectWS() client, echoing messages over a live socket.' },
51
+ { 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.' },
52
+ ],
53
+ },
54
+ {
55
+ label: 'Auth & sessions',
56
+ items: [
57
+ { 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.' },
58
+ { href: '/features/sessions', title: 'Sessions', blurb: 'A signed-cookie session applied by a segment middleware, read and written per visitor with getSession() in a route.' },
59
+ ],
60
+ },
61
+ {
62
+ label: 'Built-ins',
63
+ items: [
64
+ { 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.' },
65
+ { href: '/features/env', title: 'Env vars', blurb: 'The server-only vs WEBJS_PUBLIC_ boundary, read during SSR so secrets never reach the browser.' },
66
+ { href: '/features/rate-limit', title: 'Rate limiting', blurb: 'The rateLimit() middleware scoped to one endpoint, returning a 429 with Retry-After past the window.' },
67
+ { href: '/features/file-storage', title: 'File storage', blurb: 'A no-JS multipart upload streamed into the FileStore, then served back through a streaming route.' },
68
+ { 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).' },
69
+ ],
70
+ },
71
+ ];
72
+
73
+ /** The whole example apps (composed features), shown after the single-feature demos. */
74
+ export const EXAMPLES: NavItem[] = [
75
+ { href: '/examples/todo', title: 'Optimistic todo', blurb: 'A whole app composing several features: the declarative optimistic() list API, progressive-enhancement forms, accessible labels, the modules split, and SQLite.' },
76
+ ];
77
+
78
+ /** Flattened single-feature list (for the home card grid). */
79
+ export const FEATURES: NavItem[] = FEATURE_GROUPS.flatMap((g) => g.items);
@@ -9,6 +9,8 @@
9
9
  // see the declarative optimistic(host, { source, update }) + .add() form in the
10
10
  // /examples/todo app.
11
11
  import { WebComponent, signal, optimistic, html } from '@webjsdev/core';
12
+ import { cn } from '#lib/utils/cn.ts';
13
+ import { buttonClass } from '#components/ui/button.ts';
12
14
  import { likePost } from '../actions/like-post.server.ts';
13
15
 
14
16
  export class LikeButton extends WebComponent {
@@ -23,9 +25,25 @@ export class LikeButton extends WebComponent {
23
25
 
24
26
  render() {
25
27
  const liked = this.liked.get();
28
+ // Build ON the design system, do not re-implement it. The like toggle IS the
29
+ // button variants: unliked reads as `secondary` (card surface), liked as
30
+ // `default` (primary fill). So compose buttonClass() for the base + colors +
31
+ // focus ring + cursor, then override only the genuinely bespoke bits with cn()
32
+ // (which tailwind-merges): a pill radius over the helper's rounded-xl, and a
33
+ // border on the liked state. The border is GEOMETRY, not decoration: the
34
+ // `secondary` variant carries a 1px border and `default` carries none, so under
35
+ // box-sizing border-box an auto-width inline-flex button would change its outer
36
+ // size by ~1px each toggle (a small label jitter). Re-adding `border
37
+ // border-primary` keeps a 1px border in both states so the pill stays put. On
38
+ // the primary fill that border color matches the fill, so it reads as invisible,
39
+ // doing only the no-jitter job. Only the heart SVG stays fully custom.
26
40
  return html`
27
41
  <button @click=${() => this.toggle()} aria-pressed=${liked ? 'true' : 'false'}
28
- class="inline-flex items-center gap-2 px-4 py-2 rounded-full font-semibold text-sm border cursor-pointer transition-all active:scale-[0.97] ${liked ? 'bg-primary text-primary-foreground border-primary' : 'bg-card text-foreground border-border hover:border-border-strong'}">
42
+ class=${cn(
43
+ buttonClass({ variant: liked ? 'default' : 'secondary' }),
44
+ 'rounded-full',
45
+ liked && 'border border-primary',
46
+ )}>
29
47
  <svg viewBox="0 0 24 24" class="w-4 h-4 ${liked ? 'fill-current stroke-none' : 'fill-none stroke-current'}" style="stroke-width:2;stroke-linecap:round;stroke-linejoin:round"><path d="M20.8 4.6a5.5 5.5 0 0 0-7.8 0L12 5.7l-1-1.1a5.5 5.5 0 0 0-7.8 7.8l1.1 1L12 21l7.7-7.6 1.1-1a5.5 5.5 0 0 0 0-7.8Z"/></svg>
30
48
  ${liked ? 'Liked' : 'Like'}
31
49
  </button>
@@ -5,6 +5,7 @@
5
5
  // import and call instead). The button is the interactivity signal, so this
6
6
  // component ships and hydrates.
7
7
  import { WebComponent, signal, html } from '@webjsdev/core';
8
+ import { buttonClass } from '#components/ui/button.ts';
8
9
 
9
10
  interface Probe { n: number; status: number; remaining: string }
10
11
 
@@ -32,7 +33,7 @@ export class RateProbe extends WebComponent {
32
33
  return html`
33
34
  <div class="grid gap-4 max-w-[420px]">
34
35
  <button @click=${() => this.ping()}
35
- class="w-fit px-3.5 py-2 rounded-xl bg-primary text-primary-foreground font-semibold text-sm border-0 cursor-pointer transition-all hover:bg-primary/90 active:scale-[0.97]">Ping the endpoint</button>
36
+ class="${buttonClass({ size: 'sm' })} w-fit">Ping the endpoint</button>
36
37
  <ul class="grid gap-1.5 list-none m-0 p-0">
37
38
  ${this.log.get().map((p) => html`
38
39
  <li class="flex items-center justify-between px-3 py-2 rounded-xl bg-card border border-border text-sm">
@@ -6,6 +6,7 @@
6
6
  // (it runs in the browser), so it lives in a component; with JS off this button
7
7
  // is inert and the page still reads.
8
8
  import { WebComponent, signal, html, richFetch } from '@webjsdev/core';
9
+ import { buttonClass } from '#components/ui/button.ts';
9
10
 
10
11
  interface RichPayload { at: Date; ip: string; requestId: string; cookieCount: number }
11
12
 
@@ -22,7 +23,7 @@ export class RichData extends WebComponent {
22
23
  return html`
23
24
  <div class="flex items-center gap-3 text-[15px]">
24
25
  <button @click=${() => this.load()}
25
- class="px-3.5 py-1.5 rounded-xl bg-primary text-primary-foreground font-semibold text-sm border-0 cursor-pointer transition-all hover:bg-primary/90 active:scale-[0.97]">richFetch() the route</button>
26
+ class=${buttonClass({ size: 'sm' })}>richFetch() the route</button>
26
27
  <span class="text-muted-foreground">${this.line.get()}</span>
27
28
  </div>
28
29
  `;
@@ -13,7 +13,7 @@ import { requireAuth, type AuthUser } from '../middleware/require-auth.server.ts
13
13
  // (the action never runs) or stashes the caller on the request context.
14
14
  export const middleware = [requireAuth];
15
15
 
16
- export async function greet(input: { name: string; signedOut?: boolean }): Promise<ActionResult<{ message: string }>> {
16
+ export async function greet(input: { name: string }): Promise<ActionResult<{ message: string }>> {
17
17
  // actionContext() is populated ONLY on a boundary that runs the middleware
18
18
  // chain (the RPC stub here, or a route() adapter). requireAuth runs there and
19
19
  // guarantees a user, so `caller` is set on every real call. A DIRECT
@@ -33,7 +33,7 @@ export async function greet(input: { name: string; signedOut?: boolean }): Promi
33
33
  // envelope. A guard BEFORE any await can never fire, since nothing has been
34
34
  // awaited yet, which is why the re-check lives after the await.
35
35
  try {
36
- const message = await lookupGreeting(name, caller.name, actionSignal());
36
+ const message = await lookupGreeting(name, caller.name || caller.email || 'a signed-in user', actionSignal());
37
37
  return { success: true, data: { message } };
38
38
  } catch (e) {
39
39
  if (actionSignal().aborted) return { success: false, error: 'Request cancelled.', status: 499 };
@@ -1,15 +1,24 @@
1
1
  // Example node test for the documented test helpers (from @webjsdev/server).
2
2
  // Run it with node --test (or webjs test after moving it under test/). The
3
3
  // handle() harness drives the FULL request pipeline: createRequestHandler({
4
- // appDir }) builds it, and rawActionRequest() fires a 'use server' action
5
- // through it (CSRF + the rich serializer included), returning the raw Response.
4
+ // appDir }) builds it, and rawActionRequest() / invokeActionForTest() fire a
5
+ // 'use server' action through it (CSRF + the rich serializer included).
6
6
  // buildRouteTable(appDir) parses the file router; matchPage / matchApi resolve a
7
7
  // URL against it, params included. See the testing docs.
8
+ //
9
+ // greet is gated by the requireAuth middleware, which reads the REAL signed
10
+ // session off the request (the auth gallery card). So an unauthenticated call is
11
+ // genuinely denied (401), and the success path needs a real session cookie
12
+ // (obtained via a signup + loginAndGetCookies, skipped until the db is migrated).
8
13
  import { test } from 'node:test';
9
14
  import assert from 'node:assert/strict';
10
- import { createRequestHandler, buildRouteTable, matchPage, matchApi, rawActionRequest, invokeActionForTest } from '@webjsdev/server';
15
+ import { createRequestHandler, buildRouteTable, matchPage, matchApi, invokeActionForTest } from '@webjsdev/server';
16
+ import { testRequest, loginAndGetCookies } from '@webjsdev/server/testing';
11
17
 
12
18
  const appDir = process.cwd();
19
+ process.env.AUTH_SECRET ||= 'test-secret-at-least-32-characters-long!!';
20
+
21
+ const GREET = 'modules/server-actions/actions/greet.server.ts';
13
22
 
14
23
  test('buildRouteTable + matchPage resolve a dynamic route with its params', async () => {
15
24
  const table = await buildRouteTable(appDir);
@@ -24,47 +33,41 @@ test('matchApi resolves the route-handler endpoint', async () => {
24
33
  assert.ok(m, 'the route.ts endpoint matches');
25
34
  });
26
35
 
27
- test('rawActionRequest fires the greet action through the pipeline', async () => {
36
+ test('an unauthenticated greet is denied by requireAuth before greet runs', async () => {
28
37
  const app = await createRequestHandler({ appDir, dev: true });
29
38
  if (app.warmup) await app.warmup();
30
- const res = await rawActionRequest(
31
- app,
32
- 'modules/server-actions/actions/greet.server.ts',
33
- 'greet',
34
- [{ name: 'Ada' }],
35
- );
36
- assert.equal(res.status, 200);
39
+ // No session cookie -> requireAuth short-circuits with a 401 failure envelope
40
+ // (it reads only the cookie, so this is real without a migrated db).
41
+ const r = (await invokeActionForTest(
42
+ app, GREET, 'greet', [{ name: 'Bob' }], { throwOnError: false },
43
+ )) as { success: boolean; error?: string; status?: number };
44
+ assert.equal(r.success, false);
45
+ assert.equal(r.status, 401);
37
46
  });
38
47
 
39
- test('the middleware sets the caller on the context and greet reads it via actionContext()', async () => {
48
+ test('an authenticated greet reads the caller off the session via actionContext()', async (t) => {
40
49
  const app = await createRequestHandler({ appDir, dev: true });
41
50
  if (app.warmup) await app.warmup();
42
- // invokeActionForTest returns the deserialized result as unknown; cast to the
43
- // action's ActionResult shape to read it.
51
+
52
+ // Real signup through the auth card's page action, then a real login to capture
53
+ // the signed session cookie. Both hit the users table, so skip until the db is
54
+ // migrated (run db:generate + db:migrate) rather than fail misleadingly.
55
+ const email = `greet+${Date.now()}@example.com`;
56
+ const password = 'password123';
57
+ const signupRes = await testRequest(app.handle, '/features/auth/signup', {
58
+ method: 'POST',
59
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
60
+ body: new URLSearchParams({ name: 'Ada', email, password }).toString(),
61
+ });
62
+ if (signupRes.status !== 302) { t.skip('app deps/db not ready; run db:generate + db:migrate'); return; }
63
+ const { cookies } = await loginAndGetCookies(app.handle, { email, password });
64
+
65
+ // With the session cookie the middleware sets the caller, and greet reads it.
44
66
  const r = (await invokeActionForTest(
45
- app,
46
- 'modules/server-actions/actions/greet.server.ts',
47
- 'greet',
48
- [{ name: 'Bob' }],
49
- )) as { success: boolean; data?: { message: string }; error?: string; status?: number };
67
+ app, GREET, 'greet', [{ name: 'Bob' }], { extraCookies: cookies },
68
+ )) as { success: boolean; data?: { message: string } };
50
69
  assert.equal(r.success, true);
51
- // The message carries BOTH the input (Bob) and the middleware-set caller (Ada).
70
+ // The message carries BOTH the input (Bob) and the session caller (Ada).
52
71
  assert.match(r.data?.message ?? '', /BOB/);
53
72
  assert.match(r.data?.message ?? '', /Ada/);
54
73
  });
55
-
56
- test('the auth middleware short-circuits a signed-out request before greet runs', async () => {
57
- const app = await createRequestHandler({ appDir, dev: true });
58
- if (app.warmup) await app.warmup();
59
- // A middleware short-circuit rides as a normal failure envelope (200 with the
60
- // status inside), so read the result rather than expecting a thrown non-2xx.
61
- const r = (await invokeActionForTest(
62
- app,
63
- 'modules/server-actions/actions/greet.server.ts',
64
- 'greet',
65
- [{ name: 'Bob', signedOut: true }],
66
- { throwOnError: false },
67
- )) as { success: boolean; data?: { message: string }; error?: string; status?: number };
68
- assert.equal(r.success, false);
69
- assert.equal(r.status, 401);
70
- });
@@ -1,18 +1,21 @@
1
1
  // Calls the 'use server' action via a normal import (the RPC stub). Never
2
2
  // hand-write fetch(); importing the action IS the API.
3
3
  import { WebComponent, signal, html } from '@webjsdev/core';
4
+ import { cardClass } from '#components/ui/card.ts';
5
+ import { buttonClass } from '#components/ui/button.ts';
6
+ import { bareInputClass } from '#components/ui/input.ts';
4
7
  import { greet } from '../actions/greet.server.ts';
5
8
 
6
9
  export class Greeter extends WebComponent {
7
10
  private msg = signal('');
8
- // Drives the requireAuth middleware on the action: when true the request is
9
- // treated as signed-out, so the middleware 401s BEFORE greet() runs.
10
- private signedOut = signal(false);
11
11
 
12
12
  async run(e: SubmitEvent) {
13
13
  e.preventDefault();
14
14
  const name = String(new FormData(e.target as HTMLFormElement).get('name') ?? '');
15
- const r = await greet({ name, signedOut: this.signedOut.get() });
15
+ // The action's requireAuth middleware reads the real session off the request.
16
+ // Signed out, this comes back as a 401 failure envelope ("Sign in to
17
+ // continue."); sign in at /features/auth/login and the greeting succeeds.
18
+ const r = await greet({ name });
16
19
  // Narrow on r.success so TS knows `data` (success) vs `error` (failure). A
17
20
  // middleware short-circuit arrives here as a normal failure envelope.
18
21
  this.msg.set(r.success ? (r.data?.message ?? '') : (r.error ?? 'error'));
@@ -21,16 +24,13 @@ export class Greeter extends WebComponent {
21
24
  return html`
22
25
  <div class="grid gap-3 max-w-[420px]">
23
26
  <form @submit=${(e: SubmitEvent) => this.run(e)}
24
- class="flex items-center gap-2 p-2 pl-4 rounded-2xl bg-card border border-border">
27
+ class="${cardClass()} flex items-center gap-2 p-2 pl-4">
25
28
  <input name="name" placeholder="your name" autocomplete="off"
26
- class="flex-1 min-w-0 bg-transparent border-0 outline-none text-foreground text-[15px] placeholder:text-muted-foreground py-1.5" />
29
+ class=${bareInputClass()} />
27
30
  <button type="submit"
28
- class="shrink-0 px-4 py-2 rounded-xl bg-primary text-primary-foreground font-semibold text-sm border-0 cursor-pointer transition-all hover:bg-primary/90 active:scale-[0.97]">Greet</button>
31
+ class="${buttonClass()} shrink-0">Greet</button>
29
32
  </form>
30
- <label class="flex items-center gap-2 text-sm text-muted-foreground cursor-pointer select-none">
31
- <input type="checkbox" @change=${(e: Event) => this.signedOut.set((e.target as HTMLInputElement).checked)} />
32
- Simulate a signed-out visitor (the middleware 401s before the action runs)
33
- </label>
33
+ <p class="m-0 text-sm text-muted-foreground">The action is gated by requireAuth. <a class="text-primary underline underline-offset-2" href="/features/auth/login">Sign in</a> to greet; signed out returns a real 401.</p>
34
34
  ${this.msg.get() ? html`<p class="m-0 font-semibold text-foreground">${this.msg.get()}</p>` : ''}
35
35
  </div>
36
36
  `;
@@ -7,10 +7,16 @@
7
7
  // .server.ts with NO 'use server'): the action imports it server-side; it never
8
8
  // ships to the browser.
9
9
  import type { ActionResult } from '@webjsdev/server';
10
+ // This demo depends on the auth gallery card (modules/auth): getCurrentUser
11
+ // reads the real signed session. If you prune the auth card, prune this
12
+ // server-actions card too (or swap this for your own session read); gallery:clear
13
+ // removes both together.
14
+ import { getCurrentUser } from '#modules/auth/auth.server.ts';
10
15
 
11
16
  export interface AuthUser {
12
17
  id: string;
13
- name: string;
18
+ name?: string | null;
19
+ email?: string;
14
20
  }
15
21
 
16
22
  // The context object the framework passes each middleware. `context` is the
@@ -24,17 +30,16 @@ interface ActionMiddlewareCtx {
24
30
  }
25
31
 
26
32
  export async function requireAuth(ctx: ActionMiddlewareCtx, next: () => Promise<unknown>): Promise<unknown> {
27
- // A REAL guard reads the signed session or JWT off ctx.request, because auth
28
- // belongs to the request, not the payload. This gallery has no login backend on
29
- // the action path, so to keep BOTH branches exercisable the demo treats the
30
- // caller as signed in UNLESS the request asks to simulate a signed-out visitor
31
- // (the checkbox in the component sends { signedOut: true } in the action input).
32
- const [input] = ctx.args as [{ signedOut?: boolean } | undefined];
33
- const user: AuthUser | null = input?.signedOut ? null : { id: 'u_1', name: 'Ada' };
33
+ // A REAL guard reads the signed session off the request, because auth belongs
34
+ // to the request, not the payload. The RPC POST is same-origin, so the auth
35
+ // cookie rides along and getCurrentUser(ctx.request) reads it (this uses the
36
+ // auth gallery card's createAuth config; sign in at /features/auth/login).
37
+ const user = (await getCurrentUser(ctx.request)) as AuthUser | null;
34
38
 
35
39
  // Short-circuit: return an ActionResult WITHOUT calling next(), so the action
36
40
  // never runs. On the RPC boundary the short-circuit rides as the result with its
37
- // status inside the envelope, and a denied call is served no-store (never cached).
41
+ // status inside the envelope, and a denied call is served no-store (never
42
+ // cached). An anonymous caller is genuinely denied here.
38
43
  if (!user) return { success: false, error: 'Sign in to continue.', status: 401 } satisfies ActionResult<never>;
39
44
 
40
45
  ctx.context.user = user; // what actionContext().user reads inside the action
@@ -0,0 +1,81 @@
1
+ // <webjs-stream> is the element-level DOM-update grammar (#248): a self-applying
2
+ // element that clones its <template> and runs one native DOM method against a
3
+ // target (by id), then removes itself. `renderStream(htmlString)` from
4
+ // @webjsdev/core applies such a payload on the client. It is WebJs's take on
5
+ // Turbo Streams: surgical append / prepend / replace / update / remove, without
6
+ // redrawing a whole region.
7
+ //
8
+ // This demo drives it from the client with buttons. The list below is seeded
9
+ // ONCE in render(); every button mutates the LIVE DOM via renderStream() and the
10
+ // component never re-renders (the row counter is a plain field, deliberately not
11
+ // a signal, so reading it never re-runs render() and wipes the surgical updates).
12
+ // That is the whole point: out-of-band updates that a signal re-render or a frame
13
+ // region-swap would clobber.
14
+ import { WebComponent, html, renderStream } from '@webjsdev/core';
15
+ import { buttonClass } from '#components/ui/button.ts';
16
+
17
+ // Build a <webjs-stream> payload string. It is a plain string (NOT an html``
18
+ // template), so interpolating the row markup here is fine. `remove` needs no
19
+ // <template>; every other action wraps its content in one.
20
+ function streamPayload(action: string, target: string, inner = '') {
21
+ const body = action === 'remove' ? '' : `<template>${inner}</template>`;
22
+ return `<webjs-stream action="${action}" target="${target}">${body}</webjs-stream>`;
23
+ }
24
+
25
+ const rowCls = 'flex items-center gap-2 px-3 py-2 rounded-xl bg-card border border-border text-[15px] text-foreground';
26
+ const row = (id: string, label: string) => `<li id="${id}" class="${rowCls}">${label}</li>`;
27
+
28
+ export class StreamDemo extends WebComponent {
29
+ // A plain instance field, NOT a signal: incremented to mint unique row ids.
30
+ // It is never read inside render(), so appending a row does not re-render the
31
+ // component and blow away the streamed-in rows.
32
+ #n = 2;
33
+
34
+ // NOTE the method names: NOT append() / prepend(). Those are native
35
+ // ParentNode methods, and WebJs instruments them on every light-DOM host for
36
+ // the slot API (#1021), so a component method of the same name is shadowed and
37
+ // never runs. Name your handlers something else (see muscle-memory-gotchas).
38
+ appendRow() {
39
+ this.#n++;
40
+ renderStream(streamPayload('append', 'stream-list', row(`row-${this.#n}`, `Row ${this.#n} (appended)`)));
41
+ }
42
+ prependRow() {
43
+ this.#n++;
44
+ renderStream(streamPayload('prepend', 'stream-list', row(`row-${this.#n}`, `Row ${this.#n} (prepended)`)));
45
+ }
46
+ replaceFirst() {
47
+ // `replace` swaps the target element itself. The replacement keeps id row-1,
48
+ // so the button stays repeatable.
49
+ renderStream(streamPayload('replace', 'row-1', row('row-1', 'Row 1 (replaced)')));
50
+ }
51
+ removeSecond() {
52
+ // `remove` deletes the target and needs no <template>.
53
+ renderStream(streamPayload('remove', 'row-2'));
54
+ }
55
+ reset() {
56
+ // `update` replaces the target's children, restoring the seed list.
57
+ renderStream(streamPayload('update', 'stream-list', row('row-1', 'Row 1') + row('row-2', 'Row 2')));
58
+ }
59
+
60
+ render() {
61
+ const btn = buttonClass({ variant: 'secondary', size: 'xs' });
62
+ return html`
63
+ <div class="grid gap-4 max-w-[460px]">
64
+ <div class="flex flex-wrap gap-2">
65
+ <button class=${btn} @click=${() => this.appendRow()}>Append</button>
66
+ <button class=${btn} @click=${() => this.prependRow()}>Prepend</button>
67
+ <button class=${btn} @click=${() => this.replaceFirst()}>Replace Row 1</button>
68
+ <button class=${btn} @click=${() => this.removeSecond()}>Remove Row 2</button>
69
+ <button class=${btn} @click=${() => this.reset()}>Reset</button>
70
+ </div>
71
+ <!-- The target list. renderStream() mutates it by id; this markup renders
72
+ once and is never re-rendered by the component. -->
73
+ <ul id="stream-list" class="grid gap-2 m-0 p-0 list-none">
74
+ <li id="row-1" class="flex items-center gap-2 px-3 py-2 rounded-xl bg-card border border-border text-[15px] text-foreground">Row 1</li>
75
+ <li id="row-2" class="flex items-center gap-2 px-3 py-2 rounded-xl bg-card border border-border text-[15px] text-foreground">Row 2</li>
76
+ </ul>
77
+ </div>
78
+ `;
79
+ }
80
+ }
81
+ StreamDemo.register('stream-demo');
@@ -0,0 +1,17 @@
1
+ 'use server';
2
+ // A STREAMING server action (#489). Detection is purely on the RETURN VALUE, no
3
+ // config export: an action that returns a ReadableStream / async iterable /
4
+ // async generator streams its chunks over the single RPC response instead of
5
+ // buffering. Each `yield` is rich-serialized and flushed as it is produced, so
6
+ // the call site sees tokens arrive live. Back-pressure is respected and the
7
+ // generator is cancelled if the client disconnects. A streamed result is never
8
+ // cached / ETagged / seeded. One function per file, like every action.
9
+ export async function* streamTokens(prompt: string): AsyncGenerator<string> {
10
+ const words = `Streaming ${prompt} one token at a time, straight from the server.`.split(' ');
11
+ for (const word of words) {
12
+ // A deliberate per-token delay so the streaming is visible; a real action
13
+ // would yield as its upstream (an LLM, a DB cursor, a log tail) produces.
14
+ await new Promise((r) => setTimeout(r, 140));
15
+ yield word + ' ';
16
+ }
17
+ }
@@ -0,0 +1,53 @@
1
+ // Consumes a streaming action at the call site with `for await`. The import of
2
+ // a `'use server'` action is rewritten to an RPC stub, so `await streamTokens()`
3
+ // resolves to an async iterable whose chunks arrive as the server yields them.
4
+ // We append each chunk to an instance signal, so the text builds up live as it
5
+ // streams (the built-in SignalWatcher re-renders on each `.set`). With JS off
6
+ // the button is inert and the empty output renders (streaming is inherently a
7
+ // JS behaviour), so nothing here breaks the no-JS first paint.
8
+ import { WebComponent, signal, html } from '@webjsdev/core';
9
+ import { cardClass } from '#components/ui/card.ts';
10
+ import { buttonClass } from '#components/ui/button.ts';
11
+ import { streamTokens } from '../actions/stream-tokens.server.ts';
12
+
13
+ export class TokenStream extends WebComponent {
14
+ // Plain instance signals (not reactive props), so a class field is fine here.
15
+ private output = signal('');
16
+ private busy = signal(false);
17
+
18
+ private async run() {
19
+ this.output.set('');
20
+ this.busy.set(true);
21
+ try {
22
+ // `await streamTokens(...)` gives the async iterable; `for await` pulls
23
+ // each token as it arrives instead of waiting for the whole response.
24
+ for await (const chunk of await streamTokens('webjs')) {
25
+ this.output.set(this.output.get() + chunk);
26
+ }
27
+ } finally {
28
+ this.busy.set(false);
29
+ }
30
+ }
31
+
32
+ render() {
33
+ const busy = this.busy.get();
34
+ const output = this.output.get();
35
+ // Only reserve the output area once streaming starts (or has produced text),
36
+ // so an idle demo has no empty box below the button.
37
+ return html`
38
+ <div class="${cardClass()} p-5">
39
+ <button
40
+ @click=${() => this.run()}
41
+ ?disabled=${busy}
42
+ class=${buttonClass()}
43
+ >
44
+ ${busy ? 'streaming…' : 'Stream tokens'}
45
+ </button>
46
+ ${busy || output
47
+ ? html`<pre class="mt-4 whitespace-pre-wrap font-mono text-sm text-foreground">${output}</pre>`
48
+ : ''}
49
+ </div>
50
+ `;
51
+ }
52
+ }
53
+ TokenStream.register('token-stream');
@@ -0,0 +1,20 @@
1
+ // A bare async-render component whose server data is deliberately SLOW. On its
2
+ // own, async render() BLOCKS the SSR first byte (the resolved value lands in the
3
+ // first paint with no fallback, which is what the async-render demo shows). That
4
+ // is the right default for fast data. Here the data is slow, so the page wraps
5
+ // it in a <webjs-suspense> boundary that flushes its fallback on the first byte
6
+ // and STREAMS this content in when the await settles (progressively on soft
7
+ // navigation too). Same component either way; the boundary is what changes the
8
+ // SSR behaviour from block to stream.
9
+ import { WebComponent, html } from '@webjsdev/core';
10
+ import { cardClass } from '#components/ui/card.ts';
11
+
12
+ export class SlowFact extends WebComponent {
13
+ async render() {
14
+ await new Promise((r) => setTimeout(r, 900));
15
+ return html`<p class="${cardClass()} p-5 text-foreground">
16
+ The answer, after a slow lookup, is <strong>42</strong>.
17
+ </p>`;
18
+ }
19
+ }
20
+ SlowFact.register('slow-fact');