@webjsdev/cli 0.10.43 → 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
@@ -1351,6 +1351,7 @@ const FEATURES = [
1351
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.' },
1352
1352
  { href: '/features/env', title: 'Env vars', blurb: 'The server-only vs WEBJS_PUBLIC_ boundary, read during SSR so secrets never reach the browser.' },
1353
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.' },
1354
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).' },
1355
1356
  { href: '/features/websockets', title: 'WebSockets', blurb: 'A WS(ws, req) route endpoint plus the connectWS() client, echoing messages over a live socket.' },
1356
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.' },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webjsdev/cli",
3
- "version": "0.10.43",
3
+ "version": "0.10.44",
4
4
  "type": "module",
5
5
  "description": "webjs CLI - dev, start, create, db",
6
6
  "bin": {
@@ -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,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;