@webjsdev/cli 0.10.43 → 0.10.45
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -3
- package/bin/webjs.js +18 -18
- package/lib/api-gallery.js +1 -1
- package/lib/create.js +52 -108
- package/package.json +1 -1
- package/templates/.agents/skills/webjs/references/client-router-and-streaming.md +11 -1
- package/templates/.agents/skills/webjs/references/components.md +19 -4
- package/templates/.agents/skills/webjs/references/muscle-memory-gotchas.md +5 -1
- package/templates/.agents/skills/webjs/references/runtime.md +1 -1
- package/templates/.agents/skills/webjs/references/service-worker.md +1 -1
- package/templates/gallery/app/api/auth/[...path]/route.ts +7 -0
- package/templates/gallery/app/examples/layout.ts +11 -0
- package/templates/gallery/app/features/auth/dashboard/layout.ts +20 -0
- package/templates/gallery/app/features/auth/dashboard/middleware.ts +14 -0
- package/templates/gallery/app/features/auth/dashboard/page.ts +18 -0
- package/templates/gallery/app/features/auth/dashboard/settings/page.ts +21 -0
- package/templates/gallery/app/features/auth/login/page.ts +40 -0
- package/templates/gallery/app/features/auth/page.ts +33 -0
- package/templates/gallery/app/features/auth/signup/page.ts +58 -0
- package/templates/gallery/app/features/frames/page.ts +79 -0
- package/templates/gallery/app/features/layout.ts +12 -0
- package/templates/gallery/app/features/server-actions/page.ts +10 -0
- package/templates/gallery/app/features/stream/page.ts +45 -0
- package/templates/gallery/app/features/streaming/page.ts +31 -0
- package/templates/gallery/app/features/suspense/page.ts +34 -0
- package/templates/gallery/app/features/view-transitions/page.ts +41 -0
- package/templates/gallery/app/features/view-transitions/second/page.ts +28 -0
- package/templates/gallery/modules/auth/actions/signup.server.ts +19 -0
- package/templates/gallery/modules/auth/auth.server.ts +53 -0
- package/templates/gallery/modules/auth/password.server.ts +20 -0
- package/templates/gallery/modules/auth/queries/current-user.server.ts +12 -0
- package/templates/gallery/modules/auth/types.ts +9 -0
- package/templates/gallery/modules/frames/utils/tasks.ts +27 -0
- package/templates/gallery/modules/server-actions/actions/greet.server.ts +40 -9
- package/templates/gallery/modules/server-actions/actions/greet.test.ts +47 -11
- package/templates/gallery/modules/server-actions/components/greeter.ts +7 -1
- package/templates/gallery/modules/server-actions/middleware/require-auth.server.ts +47 -0
- package/templates/gallery/modules/stream/components/stream-demo.ts +76 -0
- package/templates/gallery/modules/streaming/actions/stream-tokens.server.ts +17 -0
- package/templates/gallery/modules/streaming/components/token-stream.ts +46 -0
- package/templates/gallery/modules/suspense/components/slow-fact.ts +19 -0
- package/templates/gallery/test/auth/auth.test.ts +81 -0
- package/templates/scripts/clear-api-gallery.mjs +55 -0
- package/templates/scripts/clear-gallery.mjs +24 -12
- package/lib/lean-copy.js +0 -43
- package/lib/saas-template.js +0 -568
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { html } from '@webjsdev/core';
|
|
2
|
+
import { currentUser } from '#modules/auth/queries/current-user.server.ts';
|
|
3
|
+
|
|
4
|
+
export const metadata = { title: 'Dashboard' };
|
|
5
|
+
|
|
6
|
+
export default async function Dashboard() {
|
|
7
|
+
const user = await currentUser();
|
|
8
|
+
return html`
|
|
9
|
+
<div class="flex items-center justify-between mb-6">
|
|
10
|
+
<h1 class="text-2xl font-semibold">Dashboard</h1>
|
|
11
|
+
<span class="text-xs font-medium rounded-full bg-primary/15 text-primary px-2.5 py-1">Signed in</span>
|
|
12
|
+
</div>
|
|
13
|
+
<div class="p-6 rounded-2xl bg-card border border-border">
|
|
14
|
+
<h2 class="text-lg font-semibold text-foreground m-0 mb-1">Welcome, ${user?.name || user?.email}!</h2>
|
|
15
|
+
<p class="text-sm text-muted-foreground m-0">This route is gated by middleware.ts. Promote it into your product, or drop the whole auth card with gallery:clear.</p>
|
|
16
|
+
</div>
|
|
17
|
+
`;
|
|
18
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { html } from '@webjsdev/core';
|
|
2
|
+
import { currentUser } from '#modules/auth/queries/current-user.server.ts';
|
|
3
|
+
|
|
4
|
+
export const metadata = { title: 'Settings' };
|
|
5
|
+
|
|
6
|
+
export default async function Settings() {
|
|
7
|
+
const user = await currentUser();
|
|
8
|
+
return html`
|
|
9
|
+
<h1 class="text-2xl font-semibold mb-6">Settings</h1>
|
|
10
|
+
<div class="p-6 rounded-2xl bg-card border border-border">
|
|
11
|
+
<h2 class="text-lg font-semibold text-foreground m-0 mb-1">Account</h2>
|
|
12
|
+
<p class="text-sm text-muted-foreground m-0 mb-4">Your basic profile information.</p>
|
|
13
|
+
<dl class="grid grid-cols-[max-content_1fr] gap-x-6 gap-y-2 text-sm">
|
|
14
|
+
<dt class="text-muted-foreground">Email</dt>
|
|
15
|
+
<dd>${user?.email}</dd>
|
|
16
|
+
<dt class="text-muted-foreground">Name</dt>
|
|
17
|
+
<dd>${user?.name || 'Not set'}</dd>
|
|
18
|
+
</dl>
|
|
19
|
+
</div>
|
|
20
|
+
`;
|
|
21
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { html } from '@webjsdev/core';
|
|
2
|
+
|
|
3
|
+
export const metadata = { title: 'Log in' };
|
|
4
|
+
|
|
5
|
+
const inputCls = 'w-full bg-background border border-border rounded-xl px-3 py-2 text-[15px] text-foreground outline-none transition-colors focus:border-primary placeholder:text-muted-foreground';
|
|
6
|
+
|
|
7
|
+
// A failed sign-in 302s back here with ?error=... (createAuth is configured with
|
|
8
|
+
// pages.error: '/features/auth/login' in modules/auth/auth.server.ts). Map the
|
|
9
|
+
// code to a plain message so a bad password gets visible feedback instead of a
|
|
10
|
+
// silent bounce.
|
|
11
|
+
function errorMessage(code: string | undefined): string | null {
|
|
12
|
+
if (!code) return null;
|
|
13
|
+
if (code === 'CredentialsSignin') return 'Invalid email or password.';
|
|
14
|
+
return 'Could not sign you in. Please try again.';
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export default function LoginPage({ searchParams }: { searchParams: { error?: string } }) {
|
|
18
|
+
const error = errorMessage(searchParams.error);
|
|
19
|
+
return html`
|
|
20
|
+
<div class="max-w-[420px] mx-auto">
|
|
21
|
+
<h1 class="text-h2 font-bold mb-2">Sign in</h1>
|
|
22
|
+
<p class="text-muted-foreground mb-5">Welcome back: log in to continue.</p>
|
|
23
|
+
${error ? html`<p role="alert" class="mb-4 text-sm text-destructive">${error}</p>` : ''}
|
|
24
|
+
<form method="POST" action="/api/auth/signin/credentials" class="grid gap-4 p-5 rounded-2xl bg-card border border-border">
|
|
25
|
+
<!-- createAuth reads redirectTo from the posted form and 302s there after a successful signin. -->
|
|
26
|
+
<input type="hidden" name="redirectTo" value="/features/auth/dashboard">
|
|
27
|
+
<div class="grid gap-1.5">
|
|
28
|
+
<label for="email" class="text-[13px] font-medium text-muted-foreground">Email</label>
|
|
29
|
+
<input id="email" name="email" type="email" required class=${inputCls} placeholder="ada@example.com" />
|
|
30
|
+
</div>
|
|
31
|
+
<div class="grid gap-1.5">
|
|
32
|
+
<label for="password" class="text-[13px] font-medium text-muted-foreground">Password</label>
|
|
33
|
+
<input id="password" name="password" type="password" required class=${inputCls} />
|
|
34
|
+
</div>
|
|
35
|
+
<button type="submit" class="justify-self-start 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]">Sign in</button>
|
|
36
|
+
</form>
|
|
37
|
+
<p class="text-sm text-muted-foreground mt-4">Don't have an account? <a href="/features/auth/signup" class="text-primary">Sign up</a></p>
|
|
38
|
+
</div>
|
|
39
|
+
`;
|
|
40
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Auth: password login/signup on top of createAuth, a signed session cookie, and
|
|
2
|
+
// a genuinely protected route. This card wires a REAL auth baseline (a users
|
|
3
|
+
// table with a passwordHash, createAuth in modules/auth/auth.server.ts, and the
|
|
4
|
+
// /features/auth/dashboard subtree gated by a middleware.ts), so a fresh app can
|
|
5
|
+
// promote it into a product. gallery:clear removes the whole surface (this card,
|
|
6
|
+
// modules/auth, app/api/auth, the passwordHash column) back to the minimal base.
|
|
7
|
+
//
|
|
8
|
+
// This index page is public and reads the current session with currentUser() so
|
|
9
|
+
// it can show who is signed in. The read is a 'use server' action, so the same
|
|
10
|
+
// line is the real query during SSR and a safe RPC stub on the client.
|
|
11
|
+
import { html } from '@webjsdev/core';
|
|
12
|
+
import type { Metadata } from '@webjsdev/core';
|
|
13
|
+
import { currentUser } from '#modules/auth/queries/current-user.server.ts';
|
|
14
|
+
|
|
15
|
+
export const metadata: Metadata = { title: 'Auth (login + protected route) | features' };
|
|
16
|
+
|
|
17
|
+
export default async function AuthExample() {
|
|
18
|
+
const user = await currentUser();
|
|
19
|
+
return html`
|
|
20
|
+
<h1 class="text-h2 font-bold mb-4">Auth</h1>
|
|
21
|
+
<p class="text-muted-foreground mb-4">Password login on <code>createAuth</code>, a signed session cookie, and a protected <code>/features/auth/dashboard</code> that redirects anonymous visitors to login.</p>
|
|
22
|
+
|
|
23
|
+
${user
|
|
24
|
+
? html`
|
|
25
|
+
<p class="mb-4">Signed in as <strong>${user.name || user.email}</strong>.</p>
|
|
26
|
+
<p><a class="text-primary" href="/features/auth/dashboard">Open the protected dashboard</a></p>`
|
|
27
|
+
: html`
|
|
28
|
+
<p class="mb-4">You are signed out. <a class="text-primary" href="/features/auth/dashboard">Visiting the dashboard</a> bounces you to login.</p>
|
|
29
|
+
<p class="flex gap-4"><a class="text-primary" href="/features/auth/login">Log in</a><a class="text-primary" href="/features/auth/signup">Create an account</a></p>`}
|
|
30
|
+
|
|
31
|
+
<p class="text-muted-foreground text-sm mt-6">The gate is <code class="font-mono">app/features/auth/dashboard/middleware.ts</code> calling <code class="font-mono">auth(req)</code>; the login form posts to the <code class="font-mono">app/api/auth/[...path]</code> handler; OAuth (GitHub / Google) activates once you set the matching env vars.</p>
|
|
32
|
+
`;
|
|
33
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { html } from '@webjsdev/core';
|
|
2
|
+
import { signup } from '#modules/auth/actions/signup.server.ts';
|
|
3
|
+
|
|
4
|
+
export const metadata = { title: 'Sign up' };
|
|
5
|
+
|
|
6
|
+
const inputCls = 'w-full bg-background border border-border rounded-xl px-3 py-2 text-[15px] text-foreground outline-none transition-colors focus:border-primary placeholder:text-muted-foreground';
|
|
7
|
+
|
|
8
|
+
// Page server action: handles the POST from the form below. With JS disabled this
|
|
9
|
+
// is a plain <form> round-trip; with JS the client router swaps the 422 re-render
|
|
10
|
+
// (errors) or follows the 302 (success) in place. A validation failure returns
|
|
11
|
+
// fieldErrors + values so the page re-renders with messages and the user's typed
|
|
12
|
+
// input preserved.
|
|
13
|
+
export async function action({ formData }: { formData: FormData }) {
|
|
14
|
+
const name = String(formData.get('name') || '').trim();
|
|
15
|
+
const email = String(formData.get('email') || '').trim();
|
|
16
|
+
const password = String(formData.get('password') || '');
|
|
17
|
+
const values = { name, email };
|
|
18
|
+
const fieldErrors: Record<string, string> = {};
|
|
19
|
+
if (!name) fieldErrors.name = 'Name is required';
|
|
20
|
+
if (!email.includes('@')) fieldErrors.email = 'Enter a valid email';
|
|
21
|
+
if (password.length < 8) fieldErrors.password = 'At least 8 characters';
|
|
22
|
+
if (Object.keys(fieldErrors).length) return { success: false, fieldErrors, values, status: 422 };
|
|
23
|
+
const result = await signup({ name, email, password });
|
|
24
|
+
// On success signup returns signIn's 302 Response (auto-login -> dashboard); a
|
|
25
|
+
// page action may return a Response, so pass it straight through.
|
|
26
|
+
if (result instanceof Response) return result;
|
|
27
|
+
return { success: false, fieldErrors: { email: result.error }, values, status: result.status };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export default function SignupPage({ actionData }: { actionData?: { fieldErrors?: Record<string, string>; values?: Record<string, string> } }) {
|
|
31
|
+
const errors = actionData?.fieldErrors || {};
|
|
32
|
+
const values = actionData?.values || {};
|
|
33
|
+
return html`
|
|
34
|
+
<div class="max-w-[420px] mx-auto">
|
|
35
|
+
<h1 class="text-h2 font-bold mb-2">Create an account</h1>
|
|
36
|
+
<p class="text-muted-foreground mb-5">Get started with your new workspace.</p>
|
|
37
|
+
<form method="POST" class="grid gap-4 p-5 rounded-2xl bg-card border border-border">
|
|
38
|
+
<div class="grid gap-1.5">
|
|
39
|
+
<label for="name" class="text-[13px] font-medium text-muted-foreground">Name</label>
|
|
40
|
+
<input id="name" name="name" type="text" value=${values.name || ''} required class=${inputCls} placeholder="Ada Lovelace" />
|
|
41
|
+
${errors.name ? html`<p class="m-0 text-[12.5px] text-destructive">${errors.name}</p>` : ''}
|
|
42
|
+
</div>
|
|
43
|
+
<div class="grid gap-1.5">
|
|
44
|
+
<label for="email" class="text-[13px] font-medium text-muted-foreground">Email</label>
|
|
45
|
+
<input id="email" name="email" type="email" value=${values.email || ''} required class=${inputCls} placeholder="ada@example.com" />
|
|
46
|
+
${errors.email ? html`<p class="m-0 text-[12.5px] text-destructive">${errors.email}</p>` : ''}
|
|
47
|
+
</div>
|
|
48
|
+
<div class="grid gap-1.5">
|
|
49
|
+
<label for="password" class="text-[13px] font-medium text-muted-foreground">Password</label>
|
|
50
|
+
<input id="password" name="password" type="password" minlength="8" required class=${inputCls} />
|
|
51
|
+
${errors.password ? html`<p class="m-0 text-[12.5px] text-destructive">${errors.password}</p>` : ''}
|
|
52
|
+
</div>
|
|
53
|
+
<button type="submit" class="justify-self-start 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]">Create account</button>
|
|
54
|
+
</form>
|
|
55
|
+
<p class="text-sm text-muted-foreground mt-4">Already have an account? <a href="/features/auth/login" class="text-primary">Log in</a></p>
|
|
56
|
+
</div>
|
|
57
|
+
`;
|
|
58
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
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. It is also NOT a layout: a layout (layout.ts) is server-rendered chrome
|
|
6
|
+
// that WRAPS a route subtree via ${children} and re-renders only as part of a
|
|
7
|
+
// navigation, so it answers "what structure wraps these routes". A frame answers
|
|
8
|
+
// "which region updates itself in place", with no navigation at all. Use a layout
|
|
9
|
+
// for shared chrome across routes; use a frame when one region (a filtered list,
|
|
10
|
+
// a paginated table, a tab panel) must refresh on its own without navigating.
|
|
11
|
+
// The filter links below live INSIDE the frame, so a click walks
|
|
12
|
+
// closest('webjs-frame'), refetches THIS same page with the new ?status, and the
|
|
13
|
+
// server returns ONLY the <webjs-frame id="tasks"> subtree (open the network tab
|
|
14
|
+
// to see it). The router swaps that subtree in; everything outside the frame,
|
|
15
|
+
// the heading and the intro copy, never re-renders.
|
|
16
|
+
//
|
|
17
|
+
// Progressive enhancement: with JS off, each filter link is a normal full-page
|
|
18
|
+
// navigation to ?status=..., which re-renders the whole page with the same
|
|
19
|
+
// filtered list. The frame is an enhancement on top of a working page, never a
|
|
20
|
+
// requirement. The frame element itself upgrades because the root layout ships a
|
|
21
|
+
// component (the theme toggle), so @webjsdev/core and the router load app-wide.
|
|
22
|
+
import { html } from '@webjsdev/core';
|
|
23
|
+
import type { Metadata } from '@webjsdev/core';
|
|
24
|
+
import { filterTasks, normalizeStatus, type Status } from '#modules/frames/utils/tasks.ts';
|
|
25
|
+
|
|
26
|
+
export const metadata: Metadata = { title: 'Frames (webjs-frame partial swap) | features' };
|
|
27
|
+
|
|
28
|
+
// One filter tab. The href targets THIS page with a new ?status. Because it sits
|
|
29
|
+
// inside the frame, the router scopes the swap to the frame id automatically. A
|
|
30
|
+
// link OUTSIDE the frame would drive it from anywhere via data-webjs-frame="tasks".
|
|
31
|
+
function filterTab(current: Status, status: Status, label: string) {
|
|
32
|
+
const active = current === status;
|
|
33
|
+
const base = 'px-3 py-1.5 rounded-lg font-semibold text-sm no-underline transition-colors';
|
|
34
|
+
const cls = active
|
|
35
|
+
? base + ' bg-primary text-primary-foreground'
|
|
36
|
+
: base + ' bg-card border border-border text-foreground font-medium hover:border-border-strong';
|
|
37
|
+
return html`<a href="/features/frames?status=${status}" class=${cls}>${label}</a>`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export default function FramesExample({ searchParams }: { searchParams: Record<string, string | undefined> }) {
|
|
41
|
+
const status = normalizeStatus(searchParams?.status);
|
|
42
|
+
const tasks = filterTasks(status);
|
|
43
|
+
return html`
|
|
44
|
+
<h1 class="text-h2 font-bold mb-4">Frames</h1>
|
|
45
|
+
<p class="text-muted-foreground mb-4">
|
|
46
|
+
Filter the list. With JS on, only the framed region swaps (the response is
|
|
47
|
+
just the frame's subtree, not the whole page) and the heading above never
|
|
48
|
+
re-renders. With JS off, the same links do full-page navigations. It is one
|
|
49
|
+
region refreshing independently of a navigation, which neither a page nor a
|
|
50
|
+
layout can express: a layout wraps routes with shared chrome and only
|
|
51
|
+
re-renders when you navigate, while a frame updates one region in place.
|
|
52
|
+
</p>
|
|
53
|
+
<webjs-frame id="tasks" class="block p-4 rounded-2xl bg-card border border-border">
|
|
54
|
+
<div class="flex gap-2 mb-4">
|
|
55
|
+
${filterTab(status, 'all', 'All')}
|
|
56
|
+
${filterTab(status, 'active', 'Active')}
|
|
57
|
+
${filterTab(status, 'done', 'Done')}
|
|
58
|
+
</div>
|
|
59
|
+
<ul class="grid gap-2 m-0 p-0 list-none">
|
|
60
|
+
${tasks.map(
|
|
61
|
+
(t) => html`
|
|
62
|
+
<li class="flex items-center gap-2 text-foreground">
|
|
63
|
+
<span class=${t.done ? 'text-primary' : 'text-muted-foreground'}>${t.done ? '✓' : '○'}</span>
|
|
64
|
+
<span class=${t.done ? 'line-through text-muted-foreground' : ''}>${t.title}</span>
|
|
65
|
+
</li>
|
|
66
|
+
`,
|
|
67
|
+
)}
|
|
68
|
+
</ul>
|
|
69
|
+
</webjs-frame>
|
|
70
|
+
<p class="text-muted-foreground text-sm mt-6">
|
|
71
|
+
A frame can also self-load with <code class="font-mono">src</code>
|
|
72
|
+
(<code class="font-mono">loading="lazy"</code> defers the fetch to viewport
|
|
73
|
+
entry), or be driven from outside via
|
|
74
|
+
<code class="font-mono">data-webjs-frame="tasks"</code>.
|
|
75
|
+
<code class="font-mono">data-webjs-frame="_top"</code> breaks out to a
|
|
76
|
+
full-page navigation.
|
|
77
|
+
</p>
|
|
78
|
+
`;
|
|
79
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { html } from '@webjsdev/core';
|
|
2
|
+
|
|
3
|
+
// Shared layout for every gallery feature demo under /features/*. It adds a slim
|
|
4
|
+
// "back to the gallery" link above each demo so a card is never a dead end.
|
|
5
|
+
// Nested layouts (like the auth dashboard's sub-nav) render inside ${children}.
|
|
6
|
+
// A non-root layout, so it never writes the document shell (the framework does).
|
|
7
|
+
export default function FeaturesLayout({ children }: { children: unknown }) {
|
|
8
|
+
return html`
|
|
9
|
+
<a href="/" class="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors no-underline mb-6">← Gallery</a>
|
|
10
|
+
${children}
|
|
11
|
+
`;
|
|
12
|
+
}
|
|
@@ -8,6 +8,16 @@ 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 reads the
|
|
14
|
+
real signed session (from the <a class="text-primary" href="/features/auth">auth card</a>) and
|
|
15
|
+
sets the caller on the request context (read back with <code class="font-mono">actionContext()</code>),
|
|
16
|
+
or 401s before the action runs. The action threads
|
|
17
|
+
<code class="font-mono">actionSignal()</code>, the request AbortSignal, through
|
|
18
|
+
its work so a client disconnect or a superseded render stops it early.
|
|
19
|
+
</p>
|
|
20
|
+
<p class="text-muted-foreground mb-4 text-sm">Signed out, the greeter returns a real 401. <a class="text-primary" href="/features/auth/login">Sign in</a> first to see it succeed. (This card depends on the auth card; prune both together.)</p>
|
|
11
21
|
<server-greeter></server-greeter>
|
|
12
22
|
`;
|
|
13
23
|
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Stream updates: the <webjs-stream> element + renderStream() (#248). It is the
|
|
2
|
+
// element-level DOM-update grammar (Turbo Streams parity): a self-applying
|
|
3
|
+
// element that clones its <template> and runs ONE native DOM method (append /
|
|
4
|
+
// prepend / before / after / replace / update / remove) against a target id, then
|
|
5
|
+
// removes itself. See modules/stream/components/stream-demo.ts.
|
|
6
|
+
//
|
|
7
|
+
// How it differs from the neighbours:
|
|
8
|
+
// - The client router swaps a whole page (a navigation to a different URL).
|
|
9
|
+
// - A <webjs-frame> refreshes one URL-addressable region in place.
|
|
10
|
+
// - <webjs-stream> is finer still: it mutates individual ELEMENTS by id (add a
|
|
11
|
+
// row, remove a row, replace one node), with no region redraw and no
|
|
12
|
+
// component re-render. Reach for it when a signal re-render or a frame swap
|
|
13
|
+
// would be too coarse (a chat append, an optimistic row removal, a toast).
|
|
14
|
+
//
|
|
15
|
+
// Three delivery paths share this one applier: the client renderStream() below;
|
|
16
|
+
// a content-negotiated <form> response the router applies surgically (JS off
|
|
17
|
+
// degrades to a normal round-trip); and a live channel (a broadcast() / connectWS
|
|
18
|
+
// message applied with renderStream() from a WS handler, see the WebSockets card).
|
|
19
|
+
import { html } from '@webjsdev/core';
|
|
20
|
+
import type { Metadata } from '@webjsdev/core';
|
|
21
|
+
import '#modules/stream/components/stream-demo.ts';
|
|
22
|
+
|
|
23
|
+
export const metadata: Metadata = { title: 'Stream updates (webjs-stream) | features' };
|
|
24
|
+
|
|
25
|
+
export default function StreamExample() {
|
|
26
|
+
return html`
|
|
27
|
+
<h1 class="text-h2 font-bold mb-4">Stream updates</h1>
|
|
28
|
+
<p class="text-muted-foreground mb-4">
|
|
29
|
+
<code class="font-mono">renderStream()</code> applies a
|
|
30
|
+
<code class="font-mono"><webjs-stream action="..." target="..."></code>
|
|
31
|
+
payload: a surgical, element-level DOM update by id. Each button below mutates
|
|
32
|
+
the live list in place, and the component never re-renders (contrast a frame,
|
|
33
|
+
which swaps a whole region, and the client router, which swaps a whole page).
|
|
34
|
+
</p>
|
|
35
|
+
<stream-demo></stream-demo>
|
|
36
|
+
<p class="text-muted-foreground text-sm mt-6">
|
|
37
|
+
The same grammar arrives over HTTP (a content-negotiated
|
|
38
|
+
<code class="font-mono"><form></code> response the router applies
|
|
39
|
+
surgically, degrading to a full round-trip with JS off) and over a live
|
|
40
|
+
channel (a <code class="font-mono">broadcast()</code> message applied with
|
|
41
|
+
<code class="font-mono">renderStream()</code> from a
|
|
42
|
+
<code class="font-mono">connectWS</code> handler, see the WebSockets card).
|
|
43
|
+
</p>
|
|
44
|
+
`;
|
|
45
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Streaming server-action results (#489). A `'use server'` action that returns
|
|
2
|
+
// an async generator streams its chunks over the one RPC response; the call
|
|
3
|
+
// site consumes them with `for await`, rendering each as it arrives. This is
|
|
4
|
+
// the token-by-token shape (LLM output, a log tail, a DB cursor) over the
|
|
5
|
+
// normal action call site, back-pressured and cancelled on client disconnect.
|
|
6
|
+
// Contrast with a route.ts that hands back a raw HTTP ReadableStream: this rides
|
|
7
|
+
// the typed action mechanism, no hand-written fetch.
|
|
8
|
+
import { html } from '@webjsdev/core';
|
|
9
|
+
import type { Metadata } from '@webjsdev/core';
|
|
10
|
+
import '#modules/streaming/components/token-stream.ts';
|
|
11
|
+
|
|
12
|
+
export const metadata: Metadata = { title: 'Streaming actions (for await) | features' };
|
|
13
|
+
|
|
14
|
+
export default function StreamingExample() {
|
|
15
|
+
return html`
|
|
16
|
+
<h1 class="text-h2 font-bold mb-4">Streaming actions</h1>
|
|
17
|
+
<p class="text-muted-foreground mb-4">
|
|
18
|
+
A <code class="font-mono">'use server'</code> action that returns an
|
|
19
|
+
<code class="font-mono">async function*</code> streams each
|
|
20
|
+
<code class="font-mono">yield</code> over the single RPC response. The call
|
|
21
|
+
site consumes it with <code class="font-mono">for await (const chunk of await streamTokens())</code>,
|
|
22
|
+
so tokens render as they arrive instead of waiting for the whole result.
|
|
23
|
+
</p>
|
|
24
|
+
<p class="text-muted-foreground mb-6 text-sm">
|
|
25
|
+
Detection is on the return value (no config export), and a streamed result
|
|
26
|
+
is never cached, ETagged, or seeded. The source generator is cancelled if
|
|
27
|
+
the client navigates away mid-stream.
|
|
28
|
+
</p>
|
|
29
|
+
<token-stream></token-stream>
|
|
30
|
+
`;
|
|
31
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Component-level <webjs-suspense> streaming (#471). Page-level Suspense (the
|
|
2
|
+
// async-render demo) streams a region of the PAGE; <webjs-suspense> is the
|
|
3
|
+
// element that wraps one or more COMPONENTS, flushes its .fallback on the first
|
|
4
|
+
// byte, and streams the resolved content in. It is the only way to show a
|
|
5
|
+
// first-paint fallback for a SLOW component, and the deliberate choice when
|
|
6
|
+
// blocking the first byte on that component's data would hurt. Plain async
|
|
7
|
+
// render() blocks SSR so its data is in the first paint with NO fallback (right
|
|
8
|
+
// for fast data); reach for <webjs-suspense> when the data is genuinely slow.
|
|
9
|
+
import { html } from '@webjsdev/core';
|
|
10
|
+
import type { Metadata } from '@webjsdev/core';
|
|
11
|
+
import '#modules/suspense/components/slow-fact.ts';
|
|
12
|
+
|
|
13
|
+
export const metadata: Metadata = { title: 'Suspense boundary (<webjs-suspense>) | features' };
|
|
14
|
+
|
|
15
|
+
export default function SuspenseExample() {
|
|
16
|
+
return html`
|
|
17
|
+
<h1 class="text-h2 font-bold mb-4">Suspense boundary</h1>
|
|
18
|
+
<p class="text-muted-foreground mb-4">
|
|
19
|
+
The slow component below is wrapped in
|
|
20
|
+
<code class="font-mono"><webjs-suspense></code>. Its fallback shows on
|
|
21
|
+
the first byte and the resolved content streams in when the slow await
|
|
22
|
+
settles. Reload to see the fallback, then the fact stream in.
|
|
23
|
+
</p>
|
|
24
|
+
<p class="text-muted-foreground mb-6 text-sm">
|
|
25
|
+
<code class="font-mono">.fallback</code> is a property hole (unquoted, per
|
|
26
|
+
invariant 4). Contrast with the
|
|
27
|
+
<a href="/features/async-render" class="text-primary no-underline font-medium">async render</a>
|
|
28
|
+
demo, where SSR blocks so the data is in the first paint with no fallback.
|
|
29
|
+
</p>
|
|
30
|
+
<webjs-suspense .fallback=${html`<p class="rounded-2xl border border-dashed border-border p-5 text-muted-foreground">loading the fact…</p>`}>
|
|
31
|
+
<slow-fact></slow-fact>
|
|
32
|
+
</webjs-suspense>
|
|
33
|
+
`;
|
|
34
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// View Transitions: opt in with a <meta name="view-transition" content="same-origin">
|
|
2
|
+
// (declared here via metadata.other, so it scopes to this page, not the whole
|
|
3
|
+
// app). When present, the client router wraps its DOM swap in the native View
|
|
4
|
+
// Transitions API, so a soft navigation cross-fades instead of snapping. Where
|
|
5
|
+
// startViewTransition is unavailable the swap runs synchronously with no flash
|
|
6
|
+
// and no throw, so it degrades cleanly. The <input> below is marked
|
|
7
|
+
// data-webjs-permanent with an id, so the router carries the SAME live node
|
|
8
|
+
// across the swap: type into it, navigate, and your text survives the transition.
|
|
9
|
+
import { html } from '@webjsdev/core';
|
|
10
|
+
import type { Metadata } from '@webjsdev/core';
|
|
11
|
+
|
|
12
|
+
export const metadata: Metadata = {
|
|
13
|
+
title: 'View transitions (soft-nav cross-fade) | features',
|
|
14
|
+
other: { 'view-transition': 'same-origin' },
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export default function ViewTransitionsExample() {
|
|
18
|
+
return html`
|
|
19
|
+
<h1 class="text-h2 font-bold mb-4">View transitions</h1>
|
|
20
|
+
<div class="rounded-2xl bg-primary/10 border border-primary/30 p-6 mb-6">
|
|
21
|
+
<p class="text-foreground m-0">Page one. Navigate to page two: with the
|
|
22
|
+
<code class="font-mono"><meta name="view-transition"></code> opt-in,
|
|
23
|
+
the swap cross-fades.</p>
|
|
24
|
+
</div>
|
|
25
|
+
<label class="block mb-6">
|
|
26
|
+
<span class="text-muted-foreground text-sm">Type here, then navigate. This input is
|
|
27
|
+
<code class="font-mono">data-webjs-permanent</code>, so its value survives the swap:</span>
|
|
28
|
+
<input id="vt-note" data-webjs-permanent type="text" placeholder="type something…"
|
|
29
|
+
class="mt-2 block w-full max-w-sm rounded-xl border border-border bg-card px-4 py-2 text-foreground" />
|
|
30
|
+
</label>
|
|
31
|
+
<div class="flex gap-3 items-center">
|
|
32
|
+
<a href="/features/view-transitions/second" class="inline-flex items-center px-4 py-2 rounded-xl bg-primary text-primary-foreground font-semibold text-sm no-underline transition-all hover:bg-primary/90 active:scale-[0.97]">Go to page two</a>
|
|
33
|
+
<a href="/" class="text-muted-foreground no-underline font-medium text-sm hover:text-foreground transition-colors">Home</a>
|
|
34
|
+
</div>
|
|
35
|
+
<p class="text-muted-foreground text-sm mt-6">
|
|
36
|
+
Opt in app-wide instead by putting the meta on the root layout. Keep the
|
|
37
|
+
transition simple: toggling root attributes mid-nav can cause a one-frame
|
|
38
|
+
repaint flash on iOS WebKit.
|
|
39
|
+
</p>
|
|
40
|
+
`;
|
|
41
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// The soft-navigation target. It carries the same view-transition opt-in so the
|
|
2
|
+
// cross-fade wraps the swap in BOTH directions, and the same
|
|
3
|
+
// data-webjs-permanent input (same id) so the router regrafts the one live node
|
|
4
|
+
// across the swap: the text you typed on page one is still here.
|
|
5
|
+
import { html } from '@webjsdev/core';
|
|
6
|
+
import type { Metadata } from '@webjsdev/core';
|
|
7
|
+
|
|
8
|
+
export const metadata: Metadata = {
|
|
9
|
+
title: 'View transitions: page two | features',
|
|
10
|
+
other: { 'view-transition': 'same-origin' },
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export default function ViewTransitionsSecond() {
|
|
14
|
+
return html`
|
|
15
|
+
<h1 class="text-h2 font-bold mb-4">Page two</h1>
|
|
16
|
+
<div class="rounded-2xl bg-foreground/5 border border-border p-6 mb-6">
|
|
17
|
+
<p class="text-foreground m-0">You arrived with a cross-fade, no full
|
|
18
|
+
reload. The input below is the same node from page one, regrafted across
|
|
19
|
+
the swap.</p>
|
|
20
|
+
</div>
|
|
21
|
+
<label class="block mb-6">
|
|
22
|
+
<span class="text-muted-foreground text-sm">Its value persisted across the transition:</span>
|
|
23
|
+
<input id="vt-note" data-webjs-permanent type="text" placeholder="type something…"
|
|
24
|
+
class="mt-2 block w-full max-w-sm rounded-xl border border-border bg-card px-4 py-2 text-foreground" />
|
|
25
|
+
</label>
|
|
26
|
+
<a href="/features/view-transitions" class="text-primary no-underline font-medium">← Back to page one</a>
|
|
27
|
+
`;
|
|
28
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
'use server';
|
|
2
|
+
|
|
3
|
+
import { db } from '#db/connection.server.ts';
|
|
4
|
+
import { users } from '#db/schema.server.ts';
|
|
5
|
+
import { hash } from '../password.server.ts';
|
|
6
|
+
import { signIn } from '../auth.server.ts';
|
|
7
|
+
|
|
8
|
+
// Creates the account, then signs the new user in and lands on the dashboard.
|
|
9
|
+
// signIn returns a 302 Response carrying the session cookie; the signup page
|
|
10
|
+
// action returns that Response as-is (a page action may return a Response).
|
|
11
|
+
// signIn lives in the server-only auth module, imported here server-to-server,
|
|
12
|
+
// so it never reaches the browser (the signup page only imports this action's
|
|
13
|
+
// RPC stub).
|
|
14
|
+
export async function signup(input: { name: string; email: string; password: string }) {
|
|
15
|
+
const exists = await db.query.users.findFirst({ where: { email: input.email }, columns: { id: true } });
|
|
16
|
+
if (exists) return { success: false as const, error: 'Email already registered', status: 409 };
|
|
17
|
+
await db.insert(users).values({ name: input.name, email: input.email, passwordHash: await hash(input.password) });
|
|
18
|
+
return signIn('credentials', { email: input.email, password: input.password }, { redirectTo: '/features/auth/dashboard' });
|
|
19
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// The auth configuration. A server-only utility (a .server.ts with NO
|
|
2
|
+
// 'use server'), so it runs server-side and its browser import is a throw-at-load
|
|
3
|
+
// stub. Pages and route handlers import the pieces they need server-to-server;
|
|
4
|
+
// the client only ever reaches auth through a 'use server' action's RPC stub.
|
|
5
|
+
//
|
|
6
|
+
// createAuth returns { auth, signIn, signOut, handlers }. `handlers` mounts at
|
|
7
|
+
// app/api/auth/[...path]/route.ts (createAuth hardcodes the /api/auth/signin/*
|
|
8
|
+
// and /api/auth/callback/* paths, which is why that route stays at the app root
|
|
9
|
+
// even though the rest of this card lives under app/features/auth/).
|
|
10
|
+
import { createAuth, Credentials, GitHub, Google } from '@webjsdev/server';
|
|
11
|
+
import { db } from '#db/connection.server.ts';
|
|
12
|
+
import { compare } from './password.server.ts';
|
|
13
|
+
|
|
14
|
+
// AUTH_SECRET signs session tokens. Set a strong value in .env for any real
|
|
15
|
+
// deployment. The dev fallback keeps a fresh scaffold booting, but is NOT safe
|
|
16
|
+
// for production, so we fail fast if it is missing or blank there.
|
|
17
|
+
const trimmedSecret = process.env.AUTH_SECRET?.trim();
|
|
18
|
+
if (process.env.NODE_ENV === 'production' && !trimmedSecret) {
|
|
19
|
+
throw new Error('AUTH_SECRET must be set in production');
|
|
20
|
+
}
|
|
21
|
+
const authSecret = trimmedSecret || 'dev-insecure-secret-change-me';
|
|
22
|
+
|
|
23
|
+
export const { auth, signIn, signOut, handlers } = createAuth({
|
|
24
|
+
providers: [
|
|
25
|
+
Credentials({
|
|
26
|
+
async authorize(credentials: { email: string; password: string }) {
|
|
27
|
+
const user = await db.query.users.findFirst({ where: { email: credentials.email } });
|
|
28
|
+
if (!user?.passwordHash || !await compare(credentials.password, user.passwordHash)) return null;
|
|
29
|
+
return { id: String(user.id), name: user.name, email: user.email };
|
|
30
|
+
},
|
|
31
|
+
}),
|
|
32
|
+
// OAuth providers: add GitHub / Google sign-in by setting the matching env
|
|
33
|
+
// vars. Each preset (GitHub(), Google()) reads AUTH_<PROVIDER>_ID / _SECRET,
|
|
34
|
+
// so they only activate once configured and a fresh scaffold still boots with
|
|
35
|
+
// just Credentials.
|
|
36
|
+
...(process.env.AUTH_GITHUB_ID ? [GitHub({ clientId: process.env.AUTH_GITHUB_ID, clientSecret: process.env.AUTH_GITHUB_SECRET })] : []),
|
|
37
|
+
...(process.env.AUTH_GOOGLE_ID ? [Google({ clientId: process.env.AUTH_GOOGLE_ID, clientSecret: process.env.AUTH_GOOGLE_SECRET })] : []),
|
|
38
|
+
],
|
|
39
|
+
secret: authSecret,
|
|
40
|
+
// A failed credentials sign-in 302s to `${pages.error}?error=CredentialsSignin`.
|
|
41
|
+
// Point it at the login page so it reads searchParams.error and shows a message,
|
|
42
|
+
// instead of the createAuth default (the home page) swallowing the error.
|
|
43
|
+
pages: { error: '/features/auth/login' },
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// Read the signed-in user off a request. auth(req) reads the session cookie
|
|
47
|
+
// (falling back to the ambient request when called with no argument), so this
|
|
48
|
+
// works from a page/layout, a segment middleware, or an action middleware that
|
|
49
|
+
// holds the request (the greet demo's require-auth reads it this way).
|
|
50
|
+
export async function getCurrentUser(req?: Request) {
|
|
51
|
+
const session = await auth(req);
|
|
52
|
+
return session?.user ?? null;
|
|
53
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// Password hashing with scrypt from node:crypto (built into Node AND Bun, no
|
|
2
|
+
// dependency). A server-only utility: hashes live server-side and never reach
|
|
3
|
+
// the browser. Swap in argon2/bcrypt here if you prefer; the call sites only use
|
|
4
|
+
// hash() and compare().
|
|
5
|
+
import { scrypt, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
6
|
+
import { promisify } from 'node:util';
|
|
7
|
+
|
|
8
|
+
const scryptAsync = promisify(scrypt);
|
|
9
|
+
|
|
10
|
+
export async function hash(password: string): Promise<string> {
|
|
11
|
+
const salt = randomBytes(16).toString('hex');
|
|
12
|
+
const buf = (await scryptAsync(password, salt, 64)) as Buffer;
|
|
13
|
+
return salt + ':' + buf.toString('hex');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function compare(password: string, stored: string): Promise<boolean> {
|
|
17
|
+
const [salt, key] = stored.split(':');
|
|
18
|
+
const buf = (await scryptAsync(password, salt, 64)) as Buffer;
|
|
19
|
+
return timingSafeEqual(buf, Buffer.from(key, 'hex'));
|
|
20
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
'use server';
|
|
2
|
+
|
|
3
|
+
import { getCurrentUser } from '../auth.server.ts';
|
|
4
|
+
|
|
5
|
+
// This read deliberately stays POST-default (no 'method' export). A GET server
|
|
6
|
+
// action (a cacheable, SSR-seeded read) is wrong for a per-session lookup: the
|
|
7
|
+
// result differs per user and changes on sign-in / sign-out, so it must never be
|
|
8
|
+
// browser-cached or shared. Reserve GET + cache + tags for data identical for
|
|
9
|
+
// every visitor.
|
|
10
|
+
export async function currentUser() {
|
|
11
|
+
return getCurrentUser();
|
|
12
|
+
}
|
|
@@ -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
|
+
}
|