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