@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 +1 -0
- package/package.json +1 -1
- package/templates/.agents/skills/webjs/references/client-router-and-streaming.md +2 -0
- package/templates/gallery/app/features/frames/page.ts +71 -0
- package/templates/gallery/app/features/server-actions/page.ts +8 -0
- package/templates/gallery/modules/frames/utils/tasks.ts +27 -0
- package/templates/gallery/modules/server-actions/actions/greet.server.ts +41 -10
- package/templates/gallery/modules/server-actions/actions/greet.test.ts +34 -1
- package/templates/gallery/modules/server-actions/components/greeter.ts +11 -2
- package/templates/gallery/modules/server-actions/middleware/require-auth.server.ts +42 -0
- package/templates/scripts/clear-gallery.mjs +2 -2
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
|
@@ -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
|
|
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
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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',
|
|
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;
|