@webjsdev/cli 0.10.42 → 0.10.44

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/create.js CHANGED
@@ -18,6 +18,7 @@ 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';
21
22
 
22
23
  /**
23
24
  * Detect which package manager invoked us. Reads `npm_config_user_agent`,
@@ -120,12 +121,16 @@ async function readUiComponent(name) {
120
121
  const raw = await readFile(src, 'utf8');
121
122
  // The registry component imports cn() via a relative `../lib/utils.ts`; rewrite
122
123
  // it to the scaffolded app's aliased path (cn lives at lib/utils/cn.ts).
123
- return raw
124
+ const rewritten = raw
124
125
  .replaceAll("'../lib/utils.ts'", "'#lib/utils/cn.ts'")
125
126
  .replaceAll('"../lib/utils.ts"', '"#lib/utils/cn.ts"')
126
127
  // onBeforeCache lives in its own client-only module so cn() stays pure (#819).
127
128
  .replaceAll("'../lib/dom.ts'", "'#lib/utils/dom.ts'")
128
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);
129
134
  }
130
135
 
131
136
  /**
@@ -1346,6 +1351,7 @@ const FEATURES = [
1346
1351
  { 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.' },
1347
1352
  { href: '/features/env', title: 'Env vars', blurb: 'The server-only vs WEBJS_PUBLIC_ boundary, read during SSR so secrets never reach the browser.' },
1348
1353
  { href: '/features/client-router', title: 'Client router', blurb: 'Automatic soft navigation: fragment-only fetches, hover prefetch, scroll restore, and graceful no-JS fallback.' },
1354
+ { 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.' },
1349
1355
  { 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).' },
1350
1356
  { href: '/features/websockets', title: 'WebSockets', blurb: 'A WS(ws, req) route endpoint plus the connectWS() client, echoing messages over a live socket.' },
1351
1357
  { 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.' },
@@ -0,0 +1,43 @@
1
+ /**
2
+ * The scaffold's lean-copy of a ui component (#983).
3
+ *
4
+ * `webjs create` copies a few `@webjsdev/ui` registry components into a
5
+ * generated app. To match what `webjs ui add` writes, a Tier-1 helper's worked
6
+ * `@example` is stripped (the example is served on demand by `webjs ui view` /
7
+ * the MCP `ui` tool), while a Tier-2 element file is kept whole. Both scaffold
8
+ * copiers (`create.js` and `saas-template.js`) go through THIS one helper so
9
+ * they cannot drift.
10
+ *
11
+ * The strip primitives live in `@webjsdev/ui/registry/extract`; if that subpath
12
+ * cannot be resolved, this degrades to a no-op (keep the example) so the strip
13
+ * is never a reason `webjs create` fails.
14
+ *
15
+ * @module lean-copy
16
+ */
17
+
18
+ let _mod = null;
19
+
20
+ async function loadPrimitives() {
21
+ if (_mod) return _mod;
22
+ try {
23
+ const m = await import('@webjsdev/ui/registry/extract');
24
+ _mod = { stripExample: m.stripExample, isCustomElementSource: m.isCustomElementSource };
25
+ } catch {
26
+ _mod = { stripExample: (s) => s, isCustomElementSource: () => true };
27
+ }
28
+ return _mod;
29
+ }
30
+
31
+ /**
32
+ * Return the component source as `webjs ui add` would write it: a Tier-1 helper
33
+ * has its worked `@example` stripped and a pointer left; a Tier-2 element is
34
+ * returned unchanged.
35
+ *
36
+ * @param {string} source the component source (imports already rewritten)
37
+ * @param {string} name the component name (for the pointer)
38
+ * @returns {Promise<string>}
39
+ */
40
+ export async function leanComponentSource(source, name) {
41
+ const { stripExample, isCustomElementSource } = await loadPrimitives();
42
+ return isCustomElementSource(source) ? source : stripExample(source, name);
43
+ }
@@ -5,6 +5,7 @@
5
5
 
6
6
  import { mkdir, writeFile, readFile } from 'node:fs/promises';
7
7
  import { bunifyProse } from './runtime-rewrite.js';
8
+ import { leanComponentSource } from './lean-copy.js';
8
9
  import { existsSync } from 'node:fs';
9
10
  import { join, resolve, dirname } from 'node:path';
10
11
  import { fileURLToPath } from 'node:url';
@@ -25,7 +26,7 @@ async function readUiComponent(name) {
25
26
  const raw = await readFile(src, 'utf8');
26
27
  // The registry component imports cn() via a relative `../lib/utils.ts`; rewrite
27
28
  // it to the scaffolded app's aliased path (cn lives at lib/utils/cn.ts).
28
- return raw
29
+ const rewritten = raw
29
30
  .replaceAll("'../lib/utils.ts'", "'#lib/utils/cn.ts'")
30
31
  .replaceAll('"../lib/utils.ts"', '"#lib/utils/cn.ts"')
31
32
  // onBeforeCache lives in its own client-only module so cn() stays pure (#819).
@@ -33,6 +34,9 @@ async function readUiComponent(name) {
33
34
  // (which resolves to a nonexistent components/lib/dom.ts) and fails typecheck.
34
35
  .replaceAll("'../lib/dom.ts'", "'#lib/utils/dom.ts'")
35
36
  .replaceAll('"../lib/dom.ts"', '"#lib/utils/dom.ts"');
37
+ // Strip a Tier-1 helper's worked @example (same as create.js + `webjs ui add`)
38
+ // so switch / checkbox are lean, not just the full-stack base set (#983).
39
+ return leanComponentSource(rewritten, name);
36
40
  }
37
41
 
38
42
  /** Copy named registry components into `<appDir>/components/ui/`. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webjsdev/cli",
3
- "version": "0.10.42",
3
+ "version": "0.10.44",
4
4
  "type": "module",
5
5
  "description": "webjs CLI - dev, start, create, db",
6
6
  "bin": {
@@ -44,6 +44,7 @@ Classify the task first, then load the smallest useful reference set. Each refer
44
44
  | Tailwind, light-DOM tag-prefix rule, tokens, fixed headers, no-reflow layout | `references/styling.md` |
45
45
  | Client router, prefetch, frames, view transitions, Suspense streaming | `references/client-router-and-streaming.md` |
46
46
  | Optimistic UI for a user-facing mutation | `references/optimistic-ui.md` |
47
+ | The `@webjsdev/ui` component kit (a `components.json` is present): class helpers, tokens, `add` / `view`, the MCP `ui` tool | `references/ui-kit.md` |
47
48
  | TypeScript at runtime, erasable syntax, full-stack types | `references/typescript.md` |
48
49
  | Unit, browser, e2e tests, the `handle()` harness, Bun parity | `references/testing.md` |
49
50
  | Auth, caching, env vars, rate limit, file storage, the `webjs` config block | `references/built-ins.md` |
@@ -17,6 +17,8 @@ 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
+ **Browser resilience: a dropped layout-marker comment.** SSR wraps each layout's children in a `<!--wj:children:<path>-->...<!--/wj:children-->` comment pair, and the deepest-shared-layout swap matches on those markers. Browsers intermittently DROP the trailing `<!--/wj:children-->` comment while parsing a soft-nav response (the open marker survives, the close does not), which leaves the router with no pairable slot and forces the destructive full-body swap that wipes the outer layout (the top navbar). This is a parse/timing race, not a browser-specific quirk: it surfaced first on Android Chrome (far more frequent under mobile CPU/memory pressure) and was later reproduced on desktop Chromium, so treat it as universal. The router recovers an orphaned open marker (treating the children as running to the end of the containing element), so a lost close comment still takes the correct scoped swap and the outer chrome keeps its DOM identity. The fix keys off the symptom (a missing close), never a user-agent check, so it covers every engine. A page whose sole or outermost close was dropped also omits that layout from its `X-Webjs-Have` request, so the server returns the FULL page (with its trailing chrome) rather than a reduced marker-pair-only fragment, and the swap is bounded against that full page's trailing-sibling count so a `<footer>` in the marker's OWN parent is preserved rather than swept. Wrapping `${children}` in a container element (the shipped idiom, `<main>${children}</main>` with the footer a sibling outside it) keeps this trivially correct, since the close marker is then the parent's last child, and it is the safe pattern for the remaining unhandled shape (a dropped INNER close in a nested layout, which mispairs and could otherwise sweep outer trailing content). Like the iOS-WebKit repaint note in [styling.md](./styling.md), this is a real-browser divergence from headless Chromium, but because it reproduces across engines the deterministic dropped-close browser test is the reliable way to guard it (a single desktop or device pass can miss the race).
21
+
20
22
  **Opting out.** App-wide with config, or per moment at runtime.
21
23
 
22
24
  ```jsonc
@@ -0,0 +1,68 @@
1
+ # The `@webjsdev/ui` component kit
2
+
3
+ Load this when the app has a `components.json` (it uses `@webjsdev/ui`, the
4
+ shadcn-style kit for WebJs). The source is copied into your repo (`components/ui/`),
5
+ so you own and edit it. Two tiers:
6
+
7
+ - **Tier 1, class helpers (23 components).** Pure functions returning Tailwind
8
+ class strings (`buttonClass({ variant })`, `cardClass()`), composed with
9
+ whatever native element you write. Reach for these instead of expanding
10
+ Tailwind by hand: the call site is a fraction of the tokens and the class list
11
+ cannot drift.
12
+ - **Tier 2, stateful custom elements (9 components).** `<ui-dialog>`, `<ui-tabs>`,
13
+ `<ui-dropdown-menu>`, and friends own their ARIA (focus trap, roving tabindex,
14
+ `aria-controls` / `inert`, live regions). Write the tag and the accessible
15
+ behaviour comes with it. Do NOT hand-roll these; the wiring is easy to get
16
+ subtly wrong.
17
+
18
+ ## The workflow: query for the structure, do not guess it
19
+
20
+ `add` copies a Tier-1 component's class helpers plus a lean header (what each
21
+ helper is, the accessibility obligations) and a one-line pointer. It does NOT
22
+ copy the worked structural example, because that example is guidance you consume
23
+ once while composing, not code that should sit in your repo. Get the full
24
+ paste-ready structure on demand:
25
+
26
+ - **MCP `ui` tool** (preferred when available): call `ui` with no args for the
27
+ kit inventory (each component's tier, helper signatures, npm deps); pass
28
+ `{ name: "accordion" }` for one component's helper signatures, the paste-ready
29
+ structural example, the accessibility header, and deps.
30
+ - **CLI**: `webjs ui list` (inventory), `webjs ui view <name>` (the projected
31
+ view plus the full source). Same data as the MCP tool (one shared projector).
32
+
33
+ So the loop is: `add` the component, then query `ui <name>` (MCP) or
34
+ `webjs ui view <name>` for the accessible structure, paste it, and fill it in.
35
+
36
+ ## Setup and resolution
37
+
38
+ - `webjs ui init` writes `components.json`, `lib/utils.ts`, and the CSS design
39
+ tokens the helpers render against (`--background`, `--foreground`,
40
+ `--destructive`, ...). It HARD-FAILS if the tokens cannot be written, so a
41
+ clean exit means the kit is styled. `add` self-heals the tokens if they go
42
+ missing.
43
+ - Resolution is LOCAL-FIRST: `init` / `add` / `list` / `view` read the registry
44
+ that ships inside the installed `@webjsdev/ui`, with no network. This pins you
45
+ to the installed version; run `webjs ui diff` to see where your local copies
46
+ drift from the upstream (that command alone compares against the live registry).
47
+
48
+ ## Inventory (run `webjs ui list` or the MCP `ui` tool for the authoritative, current set)
49
+
50
+ **Tier 1 (class helpers):** accordion, alert, aspect-ratio, avatar, badge,
51
+ breadcrumb, button, card, checkbox, collapsible, input, kbd, label,
52
+ native-select, pagination, popover, progress, radio-group, separator, skeleton,
53
+ switch, table, textarea.
54
+
55
+ **Tier 2 (custom elements, own their ARIA):** alert-dialog, dialog,
56
+ dropdown-menu, hover-card, sonner, tabs, tooltip, plus toggle and toggle-group
57
+ (these two register an element AND export a `*Class` helper).
58
+
59
+ ## Idioms
60
+
61
+ - A helper is a function, so compose it: `class=${buttonClass({ variant: 'outline' })}`.
62
+ The unquoted `${...}` is a normal `html` attribute hole.
63
+ - Tier-1 helpers assume the design tokens exist; if a component paints unstyled,
64
+ the tokens are missing (re-run `webjs ui init` or let `add` self-heal them).
65
+ - Custom elements are display-only-safe at SSR and hydrate in the browser, the
66
+ standard WebJs component model (`references/components.md`).
67
+
68
+ Full per-package reference lives in the installed `@webjsdev/ui/AGENTS.md`.
@@ -0,0 +1,71 @@
1
+ // <webjs-frame> is a URL-addressable region that swaps ON ITS OWN, driven by a
2
+ // link targeting its id, shipping zero component JS. It is WebJs's take on Turbo
3
+ // Frames. Unlike the client router (which swaps the whole page's children when
4
+ // you navigate to a DIFFERENT url), a frame refreshes just ONE sub-region in
5
+ // place. The filter links below live INSIDE the frame, so a click walks
6
+ // closest('webjs-frame'), refetches THIS same page with the new ?status, and the
7
+ // server returns ONLY the <webjs-frame id="tasks"> subtree (open the network tab
8
+ // to see it). The router swaps that subtree in; everything outside the frame,
9
+ // the heading and the intro copy, never re-renders.
10
+ //
11
+ // Progressive enhancement: with JS off, each filter link is a normal full-page
12
+ // navigation to ?status=..., which re-renders the whole page with the same
13
+ // filtered list. The frame is an enhancement on top of a working page, never a
14
+ // requirement. The frame element itself upgrades because the root layout ships a
15
+ // component (the theme toggle), so @webjsdev/core and the router load app-wide.
16
+ import { html } from '@webjsdev/core';
17
+ import type { Metadata } from '@webjsdev/core';
18
+ import { filterTasks, normalizeStatus, type Status } from '#modules/frames/utils/tasks.ts';
19
+
20
+ export const metadata: Metadata = { title: 'Frames (webjs-frame partial swap) | features' };
21
+
22
+ // One filter tab. The href targets THIS page with a new ?status. Because it sits
23
+ // inside the frame, the router scopes the swap to the frame id automatically. A
24
+ // link OUTSIDE the frame would drive it from anywhere via data-webjs-frame="tasks".
25
+ function filterTab(current: Status, status: Status, label: string) {
26
+ const active = current === status;
27
+ const base = 'px-3 py-1.5 rounded-lg font-semibold text-sm no-underline transition-colors';
28
+ const cls = active
29
+ ? base + ' bg-primary text-primary-foreground'
30
+ : base + ' bg-card border border-border text-foreground font-medium hover:border-border-strong';
31
+ return html`<a href="/features/frames?status=${status}" class=${cls}>${label}</a>`;
32
+ }
33
+
34
+ export default function FramesExample({ searchParams }: { searchParams: Record<string, string | undefined> }) {
35
+ const status = normalizeStatus(searchParams?.status);
36
+ const tasks = filterTasks(status);
37
+ return html`
38
+ <h1 class="text-h2 font-bold mb-4">Frames</h1>
39
+ <p class="text-muted-foreground mb-4">
40
+ Filter the list. With JS on, only the framed region swaps (the response is
41
+ just the frame's subtree, not the whole page) and the heading above never
42
+ re-renders. With JS off, the same links do full-page navigations. It is one
43
+ region refreshing independently of a navigation, which a page cannot express.
44
+ </p>
45
+ <webjs-frame id="tasks" class="block p-4 rounded-2xl bg-card border border-border">
46
+ <div class="flex gap-2 mb-4">
47
+ ${filterTab(status, 'all', 'All')}
48
+ ${filterTab(status, 'active', 'Active')}
49
+ ${filterTab(status, 'done', 'Done')}
50
+ </div>
51
+ <ul class="grid gap-2 m-0 p-0 list-none">
52
+ ${tasks.map(
53
+ (t) => html`
54
+ <li class="flex items-center gap-2 text-foreground">
55
+ <span class=${t.done ? 'text-primary' : 'text-muted-foreground'}>${t.done ? '✓' : '○'}</span>
56
+ <span class=${t.done ? 'line-through text-muted-foreground' : ''}>${t.title}</span>
57
+ </li>
58
+ `,
59
+ )}
60
+ </ul>
61
+ </webjs-frame>
62
+ <p class="text-muted-foreground text-sm mt-6">
63
+ A frame can also self-load with <code class="font-mono">src</code>
64
+ (<code class="font-mono">loading="lazy"</code> defers the fetch to viewport
65
+ entry), or be driven from outside via
66
+ <code class="font-mono">data-webjs-frame="tasks"</code>.
67
+ <code class="font-mono">data-webjs-frame="_top"</code> breaks out to a
68
+ full-page navigation.
69
+ </p>
70
+ `;
71
+ }
@@ -8,6 +8,14 @@ export default function ServerActionsExample() {
8
8
  return html`
9
9
  <h1 class="text-h2 font-bold mb-4">Server actions</h1>
10
10
  <p class="text-muted-foreground mb-4">A 'use server' action is RPC-callable from the client; a plain .server.ts is a server-only utility you never import into a component.</p>
11
+ <p class="text-muted-foreground mb-4">
12
+ This action also declares <code class="font-mono">export const middleware</code>: a
13
+ chain that runs around it on every boundary. The auth middleware sets the
14
+ caller on the request context (read back with <code class="font-mono">actionContext()</code>)
15
+ or 401s before the action runs. The action threads
16
+ <code class="font-mono">actionSignal()</code>, the request AbortSignal, through
17
+ its work so a client disconnect or a superseded render stops it early.
18
+ </p>
11
19
  <server-greeter></server-greeter>
12
20
  `;
13
21
  }
@@ -0,0 +1,27 @@
1
+ // Pure data plus a filter for the frames demo. No server-only deps and no
2
+ // 'use server', so it is a plain browser-safe .ts the page reads during SSR to
3
+ // render the frame's current contents. The frame swap re-renders THIS list in
4
+ // place from the ?status query, shipping no component JS.
5
+ export type Status = 'all' | 'active' | 'done';
6
+ export interface Task {
7
+ title: string;
8
+ done: boolean;
9
+ }
10
+
11
+ const TASKS: Task[] = [
12
+ { title: 'Draft the release notes', done: true },
13
+ { title: 'Review the frames demo', done: false },
14
+ { title: 'Ship the gallery update', done: false },
15
+ { title: 'Reply on the tracking issue', done: true },
16
+ ];
17
+
18
+ // Coerce an untrusted ?status value to a known Status (defaults to 'all').
19
+ export function normalizeStatus(raw: unknown): Status {
20
+ return raw === 'active' || raw === 'done' ? raw : 'all';
21
+ }
22
+
23
+ export function filterTasks(status: Status): Task[] {
24
+ if (status === 'active') return TASKS.filter((t) => !t.done);
25
+ if (status === 'done') return TASKS.filter((t) => t.done);
26
+ return TASKS;
27
+ }
@@ -1,20 +1,51 @@
1
1
  'use server';
2
2
  // A 'use server' action IS the API: a client import is rewritten to a typed RPC
3
3
  // stub POSTing to the server. It may use server-only utilities (they run here,
4
- // server-side), which is why the util above stays off the client.
4
+ // server-side), which is why the shout() util stays off the client.
5
5
  import { shout } from '../utils/format.server.ts';
6
6
  import { actionContext, actionSignal } from '@webjsdev/server';
7
7
  import type { ActionResult } from '@webjsdev/server';
8
+ import { requireAuth, type AuthUser } from '../middleware/require-auth.server.ts';
8
9
 
9
- export async function greet(input: { name: string }): Promise<ActionResult<{ message: string }>> {
10
- // actionSignal() is the request's AbortSignal (fires on client disconnect or a
11
- // superseded render); bail early on long work instead of finishing wasted work.
12
- if (actionSignal().aborted) return { success: false, error: 'Request cancelled.', status: 499 };
13
- // actionContext() is the per-action middleware context (e.g. actionContext().user
14
- // set by an auth middleware via `export const middleware`). Empty here, no middleware.
15
- const who = (actionContext().user as { name?: string } | undefined)?.name;
10
+ // `export const middleware` is a reserved sibling config export the framework
11
+ // reads statically (the same way a page declares `export const revalidate`). The
12
+ // chain runs around greet() on every boundary: requireAuth either short-circuits
13
+ // (the action never runs) or stashes the caller on the request context.
14
+ export const middleware = [requireAuth];
16
15
 
17
- const name = String(input?.name ?? who ?? '').trim();
16
+ export async function greet(input: { name: string; signedOut?: boolean }): Promise<ActionResult<{ message: string }>> {
17
+ // actionContext() is populated ONLY on a boundary that runs the middleware
18
+ // chain (the RPC stub here, or a route() adapter). requireAuth runs there and
19
+ // guarantees a user, so `caller` is set on every real call. A DIRECT
20
+ // server-to-server greet() call skips middleware and leaves it undefined, so
21
+ // GUARD rather than assume the cast (from server code, pass the caller in
22
+ // explicitly instead of relying on the context).
23
+ const caller = actionContext().user as AuthUser | undefined;
24
+ if (!caller) return { success: false, error: 'Unauthorized.', status: 401 };
25
+
26
+ const name = String(input?.name ?? '').trim();
18
27
  if (!name) return { success: false, error: 'Name required.', status: 400 };
19
- return { success: true, data: { message: shout('hello ' + name) } };
28
+
29
+ // actionSignal() is the request's AbortSignal (fires on a client disconnect or a
30
+ // superseded render). Thread it into the slow work so the work itself aborts
31
+ // (a real fetch(url, { signal: actionSignal() }) or a DB driver rejects on
32
+ // abort). lookupGreeting models that, and we map an abort to a cancelled
33
+ // envelope. A guard BEFORE any await can never fire, since nothing has been
34
+ // awaited yet, which is why the re-check lives after the await.
35
+ try {
36
+ const message = await lookupGreeting(name, caller.name, actionSignal());
37
+ return { success: true, data: { message } };
38
+ } catch (e) {
39
+ if (actionSignal().aborted) return { success: false, error: 'Request cancelled.', status: 499 };
40
+ throw e;
41
+ }
42
+ }
43
+
44
+ // A private (non-exported) stand-in for a slow lookup or upstream fetch, so the
45
+ // file still has exactly one action (the one-action-per-configured-file rule). A
46
+ // real fetch / DB call rejects with an AbortError when the request aborts; greet()
47
+ // catches that and returns the 499 envelope.
48
+ async function lookupGreeting(name: string, who: string, signal: AbortSignal): Promise<string> {
49
+ if (signal.aborted) throw new DOMException('Aborted', 'AbortError');
50
+ return shout('hello ' + name) + ' (greeted by ' + who + ')';
20
51
  }
@@ -7,7 +7,7 @@
7
7
  // URL against it, params included. See the testing docs.
8
8
  import { test } from 'node:test';
9
9
  import assert from 'node:assert/strict';
10
- import { createRequestHandler, buildRouteTable, matchPage, matchApi, rawActionRequest } from '@webjsdev/server';
10
+ import { createRequestHandler, buildRouteTable, matchPage, matchApi, rawActionRequest, invokeActionForTest } from '@webjsdev/server';
11
11
 
12
12
  const appDir = process.cwd();
13
13
 
@@ -35,3 +35,36 @@ test('rawActionRequest fires the greet action through the pipeline', async () =>
35
35
  );
36
36
  assert.equal(res.status, 200);
37
37
  });
38
+
39
+ test('the middleware sets the caller on the context and greet reads it via actionContext()', async () => {
40
+ const app = await createRequestHandler({ appDir, dev: true });
41
+ 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.
44
+ 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 };
50
+ assert.equal(r.success, true);
51
+ // The message carries BOTH the input (Bob) and the middleware-set caller (Ada).
52
+ assert.match(r.data?.message ?? '', /BOB/);
53
+ assert.match(r.data?.message ?? '', /Ada/);
54
+ });
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
+ });
@@ -5,11 +5,16 @@ import { greet } from '../actions/greet.server.ts';
5
5
 
6
6
  export class Greeter extends WebComponent {
7
7
  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
+
8
12
  async run(e: SubmitEvent) {
9
13
  e.preventDefault();
10
14
  const name = String(new FormData(e.target as HTMLFormElement).get('name') ?? '');
11
- const r = await greet({ name });
12
- // Narrow on r.success so TS knows `data` (success) vs `error` (failure).
15
+ const r = await greet({ name, signedOut: this.signedOut.get() });
16
+ // Narrow on r.success so TS knows `data` (success) vs `error` (failure). A
17
+ // middleware short-circuit arrives here as a normal failure envelope.
13
18
  this.msg.set(r.success ? (r.data?.message ?? '') : (r.error ?? 'error'));
14
19
  }
15
20
  render() {
@@ -22,6 +27,10 @@ export class Greeter extends WebComponent {
22
27
  <button type="submit"
23
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>
24
29
  </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>
25
34
  ${this.msg.get() ? html`<p class="m-0 font-semibold text-foreground">${this.msg.get()}</p>` : ''}
26
35
  </div>
27
36
  `;
@@ -0,0 +1,42 @@
1
+ // Per-action middleware. An action opts in with `export const middleware =
2
+ // [requireAuth]` (a reserved sibling config export), and the framework runs this
3
+ // around the action on EVERY boundary (the RPC stub and a route.ts adapter). The
4
+ // signature is `async (ctx, next) => result`, where ctx = { request, args,
5
+ // signal, context }. Whatever it writes onto ctx.context is exactly what the
6
+ // action reads back via actionContext(). This is a server-only utility (a
7
+ // .server.ts with NO 'use server'): the action imports it server-side; it never
8
+ // ships to the browser.
9
+ import type { ActionResult } from '@webjsdev/server';
10
+
11
+ export interface AuthUser {
12
+ id: string;
13
+ name: string;
14
+ }
15
+
16
+ // The context object the framework passes each middleware. `context` is the
17
+ // shared mutable bag actionContext() returns to the action; `args` is the
18
+ // action's argument list; `signal` is the request AbortSignal.
19
+ interface ActionMiddlewareCtx {
20
+ request: Request;
21
+ args: unknown[];
22
+ signal: AbortSignal;
23
+ context: Record<string, unknown>;
24
+ }
25
+
26
+ 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' };
34
+
35
+ // Short-circuit: return an ActionResult WITHOUT calling next(), so the action
36
+ // 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).
38
+ if (!user) return { success: false, error: 'Sign in to continue.', status: 401 } satisfies ActionResult<never>;
39
+
40
+ ctx.context.user = user; // what actionContext().user reads inside the action
41
+ return next(); // run the next middleware, ending at the action
42
+ }
@@ -40,8 +40,8 @@ const galleryPaths = [
40
40
  // 2) The gallery's feature modules (by name, so saas auth modules survive).
41
41
  const galleryModules = [
42
42
  'async-render', 'broadcast', 'caching', 'client-router', 'components',
43
- 'directives', 'file-storage', 'optimistic-ui', 'rate-limit', 'route-handler',
44
- 'server-actions', 'sessions', 'todo', 'websockets',
43
+ 'directives', 'file-storage', 'frames', 'optimistic-ui', 'rate-limit',
44
+ 'route-handler', 'server-actions', 'sessions', 'todo', 'websockets',
45
45
  ].map((m) => `modules/${m}`);
46
46
 
47
47
  let removed = 0;