@webjsdev/cli 0.10.36 → 0.10.37

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.
Files changed (39) hide show
  1. package/lib/create.js +4 -0
  2. package/lib/saas-template.js +7 -1
  3. package/package.json +1 -1
  4. package/templates/gallery/app/apple-icon.ts +17 -0
  5. package/templates/gallery/app/features/async-render/page.ts +11 -1
  6. package/templates/gallery/app/features/caching/page.ts +9 -0
  7. package/templates/gallery/app/features/components/page.ts +8 -0
  8. package/templates/gallery/app/features/file-storage/file/[key]/route.ts +10 -1
  9. package/templates/gallery/app/features/rate-limit/page.ts +1 -1
  10. package/templates/gallery/app/features/route-handler/data/route.ts +34 -5
  11. package/templates/gallery/app/features/route-handler/page.ts +5 -2
  12. package/templates/gallery/app/features/routing/legacy/page.ts +12 -0
  13. package/templates/gallery/app/features/routing/page.ts +1 -0
  14. package/templates/gallery/app/features/sessions/count/route.ts +12 -0
  15. package/templates/gallery/app/features/sessions/middleware.ts +7 -0
  16. package/templates/gallery/app/features/sessions/page.ts +19 -0
  17. package/templates/gallery/app/global-error.ts +46 -0
  18. package/templates/gallery/app/global-not-found.ts +22 -0
  19. package/templates/gallery/app/icon.ts +19 -0
  20. package/templates/gallery/app/opengraph-image.ts +20 -0
  21. package/templates/gallery/app/sitemaps/route.ts +17 -0
  22. package/templates/gallery/app/twitter-image.ts +19 -0
  23. package/templates/gallery/modules/caching/actions/bust-caches.server.ts +25 -0
  24. package/templates/gallery/modules/caching/components/cache-buster.ts +30 -0
  25. package/templates/gallery/modules/client-router/components/router-controls.ts +40 -12
  26. package/templates/gallery/modules/components/components/browser/counter-card.test.js +21 -1
  27. package/templates/gallery/modules/components/components/server-render.test.ts +20 -0
  28. package/templates/gallery/modules/components/components/task-loader.ts +48 -0
  29. package/templates/gallery/modules/components/components/theme-context.ts +67 -0
  30. package/templates/gallery/modules/directives/components/directive-demo.ts +100 -3
  31. package/templates/gallery/modules/file-storage/actions/store-upload.server.ts +3 -0
  32. package/templates/gallery/modules/file-storage/store.server.ts +29 -0
  33. package/templates/gallery/modules/route-handler/components/rich-data.ts +31 -0
  34. package/templates/gallery/modules/server-actions/actions/greet.server.ts +9 -1
  35. package/templates/gallery/modules/server-actions/actions/greet.test.ts +37 -0
  36. package/templates/gallery/modules/sessions/session-config.server.ts +31 -0
  37. package/templates/gallery/modules/websockets/components/ws-echo.ts +19 -1
  38. package/templates/gallery/modules/websockets/echo.test.ts +21 -0
  39. package/templates/instrumentation.ts +16 -0
package/lib/create.js CHANGED
@@ -539,6 +539,8 @@ export async function scaffoldApp(name, cwd, opts = {}) {
539
539
  'test/hello/browser/hello.test.js',
540
540
  'test/hello/e2e/hello.test.ts',
541
541
  'web-test-runner.config.js',
542
+ // Optional boot-time APM hook (setOnError). Delete if unused.
543
+ 'instrumentation.ts',
542
544
  // Environment variables
543
545
  '.env.example',
544
546
  // Project-level gitignore (node_modules, .webjs, .env, OS junk).
@@ -1381,6 +1383,7 @@ const features = [
1381
1383
  { 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.' },
1382
1384
  { href: '/features/rate-limit', title: 'Rate limiting', blurb: 'The rateLimit() middleware scoped to one endpoint, returning a 429 with Retry-After past the window.' },
1383
1385
  { href: '/features/file-storage', title: 'File storage', blurb: 'A no-JS multipart upload streamed into the FileStore, then served back through a streaming route.' },
1386
+ { 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.' },
1384
1387
  ];
1385
1388
  const examples = [
1386
1389
  { 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.' },
@@ -1479,6 +1482,7 @@ const features = [
1479
1482
  { 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.' },
1480
1483
  { href: '/features/rate-limit', title: 'Rate limiting', blurb: 'The rateLimit() middleware scoped to one endpoint, returning a 429 with Retry-After past the window.' },
1481
1484
  { href: '/features/file-storage', title: 'File storage', blurb: 'A no-JS multipart upload streamed into the FileStore, then served back through a streaming route.' },
1485
+ { 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.' },
1482
1486
  ];
1483
1487
  const examples = [
1484
1488
  { 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.' },
@@ -88,7 +88,7 @@ export async function writeSaasFiles(appDir, opts = {}) {
88
88
 
89
89
  // lib/auth.server.ts
90
90
  await writeFile(join(appDir, 'lib', 'auth.server.ts'), [
91
- "import { createAuth, Credentials } from '@webjsdev/server';",
91
+ "import { createAuth, Credentials, GitHub, Google } from '@webjsdev/server';",
92
92
  "import { db } from '#db/connection.server.ts';",
93
93
  "import { compare } from './password.server.ts';",
94
94
  "",
@@ -110,6 +110,12 @@ export async function writeSaasFiles(appDir, opts = {}) {
110
110
  " return { id: String(user.id), name: user.name, email: user.email };",
111
111
  " },",
112
112
  " }),",
113
+ " // OAuth providers: add GitHub / Google sign-in by setting the matching",
114
+ " // env vars. Each preset (GitHub(), Google()) reads AUTH_<PROVIDER>_ID /",
115
+ " // _SECRET, so they only activate once configured and a fresh scaffold",
116
+ " // still boots with just Credentials.",
117
+ " ...(process.env.AUTH_GITHUB_ID ? [GitHub({ clientId: process.env.AUTH_GITHUB_ID, clientSecret: process.env.AUTH_GITHUB_SECRET })] : []),",
118
+ " ...(process.env.AUTH_GOOGLE_ID ? [Google({ clientId: process.env.AUTH_GOOGLE_ID, clientSecret: process.env.AUTH_GOOGLE_SECRET })] : []),",
113
119
  " ],",
114
120
  " secret: authSecret,",
115
121
  "});",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webjsdev/cli",
3
- "version": "0.10.36",
3
+ "version": "0.10.37",
4
4
  "type": "module",
5
5
  "description": "webjs CLI - dev, start, create, db",
6
6
  "bin": {
@@ -0,0 +1,17 @@
1
+ // webjs-scaffold-placeholder. Metadata route. Keep and adapt it, or prune it
2
+ // (delete this file), then delete this marker line. webjs check fails while the
3
+ // marker remains.
4
+ //
5
+ // app/apple-icon.ts serves /apple-icon (the Apple touch icon iOS uses when a
6
+ // visitor adds the site to their home screen). Apple expects a 180x180 square
7
+ // with no rounded corners (iOS rounds them). Same shape as icon.ts: return a
8
+ // Response with the exact content type. Swap the inline SVG for your real mark.
9
+ export default function AppleIcon() {
10
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="180" height="180" viewBox="0 0 180 180">
11
+ <rect width="180" height="180" fill="#1c1613"/>
12
+ <text x="90" y="120" font-family="system-ui, sans-serif" font-size="104" font-weight="700" fill="#ff8a3d" text-anchor="middle">w</text>
13
+ </svg>`;
14
+ return new Response(svg, {
15
+ headers: { 'content-type': 'image/svg+xml', 'cache-control': 'public, max-age=3600' },
16
+ });
17
+ }
@@ -1,14 +1,24 @@
1
1
  // webjs-scaffold-placeholder. Feature gallery route. Keep and adapt it, or prune it (delete this app/features/async-render route AND modules/async-render), then delete this marker line. webjs check fails while the marker remains.
2
- import { html } from '@webjsdev/core';
2
+ import { html, Suspense } from '@webjsdev/core';
3
3
  import type { Metadata } from '@webjsdev/core';
4
4
  import '#modules/async-render/components/server-clock.ts';
5
5
 
6
6
  export const metadata: Metadata = { title: 'Async render (server data in first paint) | features' };
7
7
 
8
+ // A slow server region. Suspense flushes the fallback on the first byte and
9
+ // streams the resolved content in when it settles, so a slow query does not
10
+ // block the whole page's first paint. Multiple boundaries stream concurrently.
11
+ async function slowRegion() {
12
+ await new Promise((r) => setTimeout(r, 800));
13
+ return html`<p class="text-foreground">Streamed in after the first byte.</p>`;
14
+ }
15
+
8
16
  export default function AsyncRenderExample() {
9
17
  return html`
10
18
  <h1 class="text-h2 font-bold mb-4">Async render</h1>
11
19
  <p class="text-muted-foreground mb-4">A component's <code>async render()</code> awaits server data. SSR blocks, so the resolved value is in the first paint (no fallback, readable with JS off).</p>
12
20
  <server-clock></server-clock>
21
+ <p class="text-muted-foreground mt-6 mb-2">For a SLOW region where blocking the first byte hurts, wrap it in <code class="font-mono">Suspense</code> to stream it instead:</p>
22
+ ${Suspense({ fallback: html`<p class="text-muted-foreground">loading slow region…</p>`, children: slowRegion() })}
13
23
  `;
14
24
  }
@@ -8,6 +8,7 @@
8
8
  // use HTTP Cache-Control + ETag (conditional GET).
9
9
  import { html } from '@webjsdev/core';
10
10
  import type { Metadata } from '@webjsdev/core';
11
+ import '#modules/caching/components/cache-buster.ts';
11
12
 
12
13
  export const metadata: Metadata = { title: 'Caching (revalidate) | features' };
13
14
 
@@ -35,5 +36,13 @@ export default function CachingExample() {
35
36
  <code>revalidateTag</code>, or a GET action's
36
37
  <code>export const cache</code>.
37
38
  </p>
39
+ <p class="text-muted-foreground text-sm mt-6 mb-2">
40
+ A mutation evicts the cache on demand. Click below (it calls
41
+ <code class="font-mono">revalidatePath('/features/caching')</code>), then refresh:
42
+ the timestamp updates immediately, even inside the 10s window, because the
43
+ cached HTML was dropped. Without clicking, the refresh serves the cached
44
+ copy until the window elapses.
45
+ </p>
46
+ <cache-buster></cache-buster>
38
47
  `;
39
48
  }
@@ -3,6 +3,8 @@ import { html } from '@webjsdev/core';
3
3
  import type { Metadata } from '@webjsdev/core';
4
4
  import '#modules/components/components/counter-card.ts';
5
5
  import '#modules/components/components/reactive-meter.ts';
6
+ import '#modules/components/components/theme-context.ts';
7
+ import '#modules/components/components/task-loader.ts';
6
8
 
7
9
  export const metadata: Metadata = { title: 'Components (signals + slots) | features' };
8
10
 
@@ -13,5 +15,11 @@ export default function ComponentsExample() {
13
15
  <counter-card label="Taps"><strong>A slotted title</strong></counter-card>
14
16
  <p class="text-muted-foreground mt-6 mb-2">Shadow DOM (scoped <code class="font-mono">css</code>) plus the rest of the signals API (<code class="font-mono">computed</code>, <code class="font-mono">effect</code>, <code class="font-mono">batch</code>):</p>
15
17
  <reactive-meter></reactive-meter>
18
+ <p class="text-muted-foreground mt-6 mb-2">The context API (<code class="font-mono">createContext</code> + <code class="font-mono">ContextProvider</code> / <code class="font-mono">ContextConsumer</code>): a value passed to a nested child without attribute drilling.</p>
19
+ <theme-provider>
20
+ <theme-consumer></theme-consumer>
21
+ </theme-provider>
22
+ <p class="text-muted-foreground mt-6 mb-2">A <code class="font-mono">Task</code> for client-only async data, switching on <code class="font-mono">TaskStatus</code>:</p>
23
+ <task-loader></task-loader>
16
24
  `;
17
25
  }
@@ -4,8 +4,17 @@
4
4
  // storage singleton here is safe. The [key] segment is validated inside the
5
5
  // store (traversal-safe), so a crafted key cannot escape the uploads directory.
6
6
  import { getFileStore } from '@webjsdev/server';
7
+ import { isValidSignedRequest } from '#modules/file-storage/store.server.ts';
7
8
 
8
- export async function GET(_req: Request, { params }: { params: { key: string } }) {
9
+ export async function GET(req: Request, { params }: { params: { key: string } }) {
10
+ // If the request carries signed-URL params (?exp&sig), require them to be
11
+ // valid: this is how a private file is shared by link. A request WITHOUT the
12
+ // params is served normally here (the gallery demo keeps public access); drop
13
+ // that branch to make every download require a valid signature.
14
+ const url = new URL(req.url);
15
+ if (url.searchParams.has('sig') && !isValidSignedRequest(req.url)) {
16
+ return new Response('Forbidden', { status: 403 });
17
+ }
9
18
  const file = await getFileStore().get(params.key);
10
19
  if (!file) return new Response('Not found', { status: 404 });
11
20
  // file.body is a web ReadableStream at runtime (the diskStore streams the
@@ -22,7 +22,7 @@ export default function RateLimitExample() {
22
22
  <rate-probe></rate-probe>
23
23
  <p class="text-muted-foreground text-sm mt-4">
24
24
  With JavaScript off, hit
25
- <a class="text-primary" href="/features/rate-limit/ping">/features/rate-limit/ping</a>
25
+ <a class="text-primary" href="/features/rate-limit/ping" data-no-router>/features/rate-limit/ping</a>
26
26
  directly (refresh past five times in ten seconds).
27
27
  </p>
28
28
  `;
@@ -1,8 +1,37 @@
1
1
  // A route.ts is a server-only HTTP handler (named GET / POST / PUT / PATCH /
2
2
  // DELETE exports). It is NOT isomorphic and never ships to the client, the webjs
3
- // equivalent of a Next route handler. Each handler returns a Response (a plain
4
- // value auto-JSONs). A folder cannot have BOTH page.ts and route.ts, so this
5
- // endpoint lives one segment deeper, at /features/route-handler/data.
6
- export async function GET() {
7
- return Response.json({ ok: true, at: new Date().toISOString() });
3
+ // equivalent of a Next route handler. A folder cannot have BOTH page.ts and
4
+ // route.ts, so this endpoint lives one segment deeper, at
5
+ // /features/route-handler/data.
6
+ //
7
+ // `json(data)` (from @webjsdev/server) responds with the WebJs rich serializer,
8
+ // so a `Date` (or Map / Set / BigInt) round-trips as its real type when the
9
+ // caller uses `richFetch` (see modules/route-handler/components/rich-data.ts).
10
+ // The request accessors read the IN-FLIGHT request from context: `headers()`,
11
+ // `cookies()`, and `requestId()` take no argument (they read the active
12
+ // request), while `clientIp(req)` and `readBody(req)` take it explicitly.
13
+ import { json, headers, cookies, clientIp, requestId, cspNonce, readBody } from '@webjsdev/server';
14
+
15
+ export async function GET(req: Request) {
16
+ return json({
17
+ ok: true,
18
+ at: new Date(), // a real Date; richFetch decodes it back to a Date, not a string
19
+ ip: clientIp(req),
20
+ requestId: requestId(),
21
+ userAgent: headers().get('user-agent') ?? 'unknown',
22
+ // cookies() reads the REQUEST cookies. Report how many are present (a
23
+ // truthful, always-correct value); the app's theme lives in localStorage,
24
+ // not a cookie, so do not read it here.
25
+ cookieCount: cookies().entries().length,
26
+ // cspNonce() reads the request's CSP nonce ('' with CSP off). Server-side
27
+ // you use it to nonce a server-rendered inline <script>/<style> under CSP.
28
+ hasNonce: cspNonce().length > 0,
29
+ });
30
+ }
31
+
32
+ export async function POST(req: Request) {
33
+ // readBody(req) parses the request body (the inverse of json()): rich types
34
+ // sent by richFetch are decoded here.
35
+ const body = await readBody(req);
36
+ return json({ echoed: body, at: new Date() });
8
37
  }
@@ -1,13 +1,16 @@
1
1
  // webjs-scaffold-placeholder. Feature gallery route. Keep and adapt it, or prune it (delete this app/features/route-handler route, including its data/route.ts handler), then delete this marker line. webjs check fails while the marker remains.
2
2
  import { html } from '@webjsdev/core';
3
3
  import type { Metadata } from '@webjsdev/core';
4
+ import '#modules/route-handler/components/rich-data.ts';
4
5
 
5
6
  export const metadata: Metadata = { title: 'Route handlers (route.ts) | features' };
6
7
 
7
8
  export default function RouteHandlerExample() {
8
9
  return html`
9
10
  <h1 class="text-h2 font-bold mb-4">Route handlers</h1>
10
- <p class="text-muted-foreground mb-4">A <code>route.ts</code> is a server-only HTTP endpoint (named <code>GET</code>/<code>POST</code>/... exports), the webjs equivalent of a Next route handler. It never ships to the client.</p>
11
- <p><a class="text-primary" href="/features/route-handler/data">GET /features/route-handler/data</a> returns JSON.</p>
11
+ <p class="text-muted-foreground mb-4">A <code>route.ts</code> is a server-only HTTP endpoint (named <code>GET</code>/<code>POST</code>/... exports), the WebJs equivalent of a Next route handler. It never ships to the client.</p>
12
+ <p>GET <a class="text-primary" href="/features/route-handler/data" data-no-router>/features/route-handler/data</a> returns rich JSON via <code class="font-mono">json()</code>. It carries <code class="font-mono">data-no-router</code> so the client router does not try to soft-navigate to it: a <code>route.ts</code> is not a page, so the browser loads its JSON directly.</p>
13
+ <p class="text-muted-foreground mt-6 mb-2">A client component reading it with <code class="font-mono">richFetch</code>, so <code class="font-mono">at</code> comes back as a real <code class="font-mono">Date</code>:</p>
14
+ <rich-data></rich-data>
12
15
  `;
13
16
  }
@@ -0,0 +1,12 @@
1
+ // A redirect: throw redirect(url) from a page to short-circuit the render into
2
+ // a redirect response. This route always sends you to the routing index, the
3
+ // pattern for a moved or renamed URL. The status is convention-picked at the
4
+ // catching site (302 for a GET page-render gate, 307 for a server-action
5
+ // redirect); pass redirect(url, 308) for a permanent one, or an absolute URL
6
+ // for an external redirect. NEVER throw redirect() from a route.ts handler,
7
+ // which must return Response.redirect(url, 303) instead.
8
+ import { redirect } from '@webjsdev/core';
9
+
10
+ export default function LegacyRoute() {
11
+ redirect('/features/routing');
12
+ }
@@ -14,6 +14,7 @@ export default function RoutingExample() {
14
14
  <ul class="list-disc pl-5 mb-4">
15
15
  <li><a class="text-primary" href="/features/routing/42">/features/routing/42</a></li>
16
16
  <li><a class="text-primary" href="/features/routing/hello">/features/routing/hello</a></li>
17
+ <li><a class="text-primary" href="/features/routing/legacy">/features/routing/legacy</a> throws <code class="font-mono">redirect()</code> back here</li>
17
18
  </ul>
18
19
  <p class="text-muted-foreground text-sm mb-2">
19
20
  Routes are type-safe: <code class="font-mono">webjs types</code> (run by
@@ -0,0 +1,12 @@
1
+ // getSession(req) reads the Session for the in-flight request (populated by the
2
+ // session middleware one level up). It is a small key/value store with .get() /
3
+ // .set() / .flash() / .destroy(); mutating it makes the middleware re-sign and
4
+ // set the cookie on the way out. Here each request bumps a per-visitor counter.
5
+ import { json, getSession } from '@webjsdev/server';
6
+
7
+ export async function GET(req: Request) {
8
+ const s = getSession(req);
9
+ const count = (Number(s.get('count')) || 0) + 1;
10
+ s.set('count', count);
11
+ return json({ count });
12
+ }
@@ -0,0 +1,7 @@
1
+ // Per-segment middleware: applies the cookie session to every request under
2
+ // /features/sessions, so getSession(req) works in the route below. Middleware
3
+ // nests by folder (outermost to innermost); this one scopes the session to just
4
+ // this feature. A root middleware.ts would apply it app-wide.
5
+ import { cookieSessions } from '#modules/sessions/session-config.server.ts';
6
+
7
+ export default cookieSessions;
@@ -0,0 +1,19 @@
1
+ // webjs-scaffold-placeholder. Feature gallery route. Keep and adapt it, or prune it (delete this app/features/sessions route AND modules/sessions), then delete this marker line. webjs check fails while the marker remains.
2
+ // Sessions: a per-segment middleware.ts applies session() (a signed cookie by
3
+ // default; store-backed for larger sessions), and a route.ts reads/writes it
4
+ // with getSession(req). Session state is per-user, so it lives on the server
5
+ // boundary (a route/middleware), never in a page/component that ships to the
6
+ // browser. See modules/sessions/session-config.server.ts.
7
+ import { html } from '@webjsdev/core';
8
+ import type { Metadata } from '@webjsdev/core';
9
+
10
+ export const metadata: Metadata = { title: 'Sessions (cookie + store) | features' };
11
+
12
+ export default function SessionsExample() {
13
+ return html`
14
+ <h1 class="text-h2 font-bold mb-4">Sessions</h1>
15
+ <p class="text-muted-foreground mb-4">A per-segment <code>middleware.ts</code> applies <code>session()</code>; a <code>route.ts</code> reads it with <code>getSession(req)</code>.</p>
16
+ <p>GET <a class="text-primary" href="/features/sessions/count" data-no-router>/features/sessions/count</a> increments a per-visitor counter kept in the signed session cookie. Reload it and the count climbs; open it in a private window and it starts over. (<code class="font-mono">data-no-router</code> opts the link out of the client router, since a <code>route.ts</code> returns JSON, not a page.)</p>
17
+ <p class="text-muted-foreground text-sm mt-3">Swap the storage from <code class="font-mono">cookieSession()</code> to <code class="font-mono">storeSession()</code> to hold larger sessions in the active store (Redis in production).</p>
18
+ `;
19
+ }
@@ -0,0 +1,46 @@
1
+ // webjs-scaffold-placeholder. Keep and adapt it, or prune it (delete this
2
+ // file), then delete this marker line. webjs check fails while the marker
3
+ // remains.
4
+ //
5
+ // app/global-error.ts is the ROOT-ONLY, app-wide catch-all error boundary. It
6
+ // fires only after every nested error.ts boundary is exhausted, which includes
7
+ // a failure in the root layout itself. Because a root-layout failure is exactly
8
+ // when it runs, it renders its OWN complete document (<!doctype><html><body>),
9
+ // returned verbatim with NO framework <head> splice, so it ships no importmap
10
+ // and no boot script. Keep it static HTML with no components or hydration: a
11
+ // last-resort page must not depend on the module system that may have just
12
+ // failed. (Under an opt-in CSP, any inline <style>/<script> here needs a nonce
13
+ // via cspNonce() from @webjsdev/server.)
14
+ //
15
+ // Distinct from error.ts (a nested, per-segment boundary that renders a body
16
+ // fragment the framework wraps) and from global-not-found.ts (the unmatched-URL
17
+ // 404). In production only error.message is exposed, never the stack.
18
+ import { html, cspNonce } from '@webjsdev/core';
19
+
20
+ export default function GlobalError({ error }: { error: Error }) {
21
+ const message = process.env.NODE_ENV === 'production'
22
+ ? 'Something went wrong. Please try again.'
23
+ : error?.message || 'Unknown error';
24
+ // cspNonce() is '' with CSP off (the default), so this is safe as-is; under an
25
+ // opt-in CSP it carries the per-request nonce so the inline <style> is allowed.
26
+ return html`<!doctype html>
27
+ <html lang="en">
28
+ <head>
29
+ <meta charset="utf-8" />
30
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
31
+ <title>Something went wrong</title>
32
+ <style nonce="${cspNonce()}">
33
+ body { font: 16px/1.6 system-ui, sans-serif; margin: 0; display: grid; place-items: center; min-height: 100vh; background: #1c1613; color: #f5f0eb; }
34
+ main { max-width: 32rem; padding: 2rem; text-align: center; }
35
+ a { color: #ff8a3d; }
36
+ </style>
37
+ </head>
38
+ <body>
39
+ <main>
40
+ <h1>Something went wrong</h1>
41
+ <p>${message}</p>
42
+ <p><a href="/">Back to home</a></p>
43
+ </main>
44
+ </body>
45
+ </html>`;
46
+ }
@@ -0,0 +1,22 @@
1
+ // webjs-scaffold-placeholder. Keep and adapt it, or prune it (delete this
2
+ // file), then delete this marker line. webjs check fails while the marker
3
+ // remains.
4
+ //
5
+ // app/global-not-found.ts is the ROOT-ONLY 404 for a URL that matches nothing
6
+ // anywhere, used when no nested not-found.ts applies. Unlike global-error.ts it
7
+ // renders only a BODY fragment: the framework wraps it in the document shell
8
+ // (head, importmap, boot script), so the client router and components work
9
+ // here. Use a nested <segment>/not-found.ts for a section-specific 404 (nearest
10
+ // wins); this file is the app-wide fallback.
11
+ import { html } from '@webjsdev/core';
12
+
13
+ export default function GlobalNotFound() {
14
+ return html`
15
+ <main class="mx-auto max-w-[40rem] px-6 py-24 text-center">
16
+ <p class="text-sm font-semibold uppercase tracking-wide text-orange-500">404</p>
17
+ <h1 class="mt-2 text-3xl font-bold">Page not found</h1>
18
+ <p class="mt-4 text-neutral-500">We could not find the page you were looking for.</p>
19
+ <a href="/" class="mt-8 inline-block rounded-md bg-neutral-900 px-4 py-2 text-white transition-colors hover:bg-neutral-700 dark:bg-white dark:text-neutral-900 dark:hover:bg-neutral-200">Back to home</a>
20
+ </main>
21
+ `;
22
+ }
@@ -0,0 +1,19 @@
1
+ // webjs-scaffold-placeholder. Metadata route. Keep and adapt it, or prune it
2
+ // (delete this file), then delete this marker line. webjs check fails while the
3
+ // marker remains.
4
+ //
5
+ // app/icon.ts serves /icon (the dynamic favicon). The default export is a
6
+ // (possibly async) server function; returning a Response lets you set the exact
7
+ // content type, so an inline SVG needs no asset file. For a favicon that never
8
+ // changes, put a static file in public/ instead (e.g. public/favicon.ico) and
9
+ // delete this route. Generate it dynamically (per-theme, per-tenant) when the
10
+ // mark must be computed at request time.
11
+ export default function Icon() {
12
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
13
+ <rect width="32" height="32" rx="7" fill="#1c1613"/>
14
+ <text x="16" y="22" font-family="system-ui, sans-serif" font-size="18" font-weight="700" fill="#ff8a3d" text-anchor="middle">w</text>
15
+ </svg>`;
16
+ return new Response(svg, {
17
+ headers: { 'content-type': 'image/svg+xml', 'cache-control': 'public, max-age=3600' },
18
+ });
19
+ }
@@ -0,0 +1,20 @@
1
+ // webjs-scaffold-placeholder. Metadata route. Keep and adapt it, or prune it
2
+ // (delete this file), then delete this marker line. webjs check fails while the
3
+ // marker remains.
4
+ //
5
+ // app/opengraph-image.ts serves /opengraph-image (the preview card social
6
+ // platforms show when the site is shared). The Open Graph spec wants 1200x630.
7
+ // Returning a Response with an inline SVG keeps this buildless; for per-page
8
+ // previews, read the request in a nested static segment's opengraph-image.ts
9
+ // and compose the title in. Reference it from metadata via
10
+ // `openGraph: { images: ['/opengraph-image'] }`.
11
+ export default function OpengraphImage() {
12
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630" viewBox="0 0 1200 630">
13
+ <rect width="1200" height="630" fill="#1c1613"/>
14
+ <text x="80" y="330" font-family="system-ui, sans-serif" font-size="88" font-weight="700" fill="#f5f0eb">My App</text>
15
+ <text x="80" y="410" font-family="system-ui, sans-serif" font-size="36" fill="#ff8a3d">Build on the platform, not against it</text>
16
+ </svg>`;
17
+ return new Response(svg, {
18
+ headers: { 'content-type': 'image/svg+xml', 'cache-control': 'public, max-age=3600' },
19
+ });
20
+ }
@@ -0,0 +1,17 @@
1
+ // A sitemap INDEX: for a large site split across several sitemaps, this points
2
+ // crawlers at each child sitemap. `sitemapIndex(sitemaps)` (from @webjsdev/server)
3
+ // serializes the spec-valid <sitemapindex> XML, the counterpart of `sitemap(entries)`
4
+ // in app/sitemap.ts (the single-file case). A route.ts serves it at /sitemaps;
5
+ // in a real app the children would be sharded (posts, products, ...).
6
+ import { sitemapIndex } from '@webjsdev/server';
7
+
8
+ const SITE_URL = (process.env.SITE_URL || 'http://localhost:8080').replace(/\/$/, '');
9
+
10
+ export async function GET() {
11
+ return new Response(
12
+ sitemapIndex([
13
+ { url: `${SITE_URL}/sitemap.xml` },
14
+ ]),
15
+ { headers: { 'content-type': 'application/xml; charset=utf-8' } },
16
+ );
17
+ }
@@ -0,0 +1,19 @@
1
+ // webjs-scaffold-placeholder. Metadata route. Keep and adapt it, or prune it
2
+ // (delete this file), then delete this marker line. webjs check fails while the
3
+ // marker remains.
4
+ //
5
+ // app/twitter-image.ts serves /twitter-image (the card image shown when the
6
+ // site is shared on Twitter/X). Its own route so the Twitter card can differ
7
+ // from the Open Graph image (opengraph-image.ts); when they are identical, drop
8
+ // this file and let the OG image cover both. A `summary_large_image` card wants
9
+ // roughly 1200x630. Reference it via metadata `twitter: { images: [...] }`.
10
+ export default function TwitterImage() {
11
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630" viewBox="0 0 1200 630">
12
+ <rect width="1200" height="630" fill="#1c1613"/>
13
+ <text x="80" y="330" font-family="system-ui, sans-serif" font-size="88" font-weight="700" fill="#f5f0eb">My App</text>
14
+ <text x="80" y="410" font-family="system-ui, sans-serif" font-size="36" fill="#ff8a3d">Build on the platform, not against it</text>
15
+ </svg>`;
16
+ return new Response(svg, {
17
+ headers: { 'content-type': 'image/svg+xml', 'cache-control': 'public, max-age=3600' },
18
+ });
19
+ }
@@ -0,0 +1,25 @@
1
+ 'use server';
2
+ // A mutation that evicts cached reads. After you change data, call the narrowest
3
+ // revalidate that covers the change so the next request refetches instead of
4
+ // serving a stale cache() result or a revalidate-cached page. (A configured
5
+ // action already runs its declared `invalidates` tags automatically; these are
6
+ // the same helpers, callable directly when you need finer control.)
7
+ import { revalidateTag, revalidateTags, revalidatePath, revalidateAll, getStore, memoryStore } from '@webjsdev/server';
8
+
9
+ export async function bustCaches(scope: 'tags' | 'path' | 'all' = 'tags') {
10
+ // getStore() is the store cache() reads from (a memoryStore() in dev, a
11
+ // redisStore() in prod via setStore()); fall back to a fresh in-memory one.
12
+ const active = getStore();
13
+ const store = active ?? memoryStore();
14
+
15
+ // Evict only what changed. Pick the narrowest scope that covers the mutation.
16
+ if (scope === 'all') {
17
+ revalidateAll(); // nuclear: drop the whole store
18
+ } else if (scope === 'path') {
19
+ revalidatePath('/features/caching'); // one revalidate-cached page URL
20
+ } else {
21
+ revalidateTag('todos'); // one cache() tag
22
+ revalidateTags(['todos', 'user:me']); // several tags at once
23
+ }
24
+ return { success: true as const, data: { evicted: scope, store: store === active ? 'active' : 'memory' } };
25
+ }
@@ -0,0 +1,30 @@
1
+ // A button that calls the bustCaches() server action (imported as a typed RPC
2
+ // stub). Client-only interactivity, so it lives in a component; with JS off the
3
+ // button is inert (cache eviction there happens as a side effect of the write
4
+ // action that changed the data).
5
+ import { WebComponent, signal, html } from '@webjsdev/core';
6
+ import { bustCaches } from '#modules/caching/actions/bust-caches.server.ts';
7
+
8
+ export class CacheBuster extends WebComponent {
9
+ private status = signal('');
10
+
11
+ private async bust() {
12
+ this.status.set('evicting…');
13
+ // 'path' evicts THIS page's cached HTML (revalidatePath), so a refresh
14
+ // re-renders with a fresh timestamp even inside the 10s window. 'tags' would
15
+ // only evict cache() query results, which this page does not use.
16
+ const result = await bustCaches('path');
17
+ this.status.set(result.success ? 'evicted, now refresh the page' : 'failed');
18
+ }
19
+
20
+ render() {
21
+ return html`
22
+ <div class="flex items-center gap-3 text-[15px]">
23
+ <button @click=${() => this.bust()}
24
+ class="px-3.5 py-1.5 rounded-xl bg-card border border-border text-foreground text-sm cursor-pointer transition-colors hover:border-border-strong">revalidate this page</button>
25
+ <span class="text-muted-foreground">${this.status.get()}</span>
26
+ </div>
27
+ `;
28
+ }
29
+ }
30
+ CacheBuster.register('cache-buster');
@@ -1,22 +1,50 @@
1
1
  // Programmatic client navigation. `navigate(url)` does the same soft, in-place
2
2
  // swap an <a> click does, but from an event handler (after a save, a wizard
3
3
  // step, etc.). `revalidate(url?)` evicts the browser snapshot cache so the next
4
- // visit refetches fresh HTML instead of the cached page. Both are client-only
5
- // (they run in the browser), so a component is the right home; a page/layout
6
- // never hydrates. With JS off this component is inert, so keep real navigation
7
- // on plain <a href> and use navigate() only for JS-driven flows.
8
- import { WebComponent, html, navigate, revalidate } from '@webjsdev/core';
4
+ // visit refetches fresh HTML instead of the cached page. `disableClientRouter()`
5
+ // / `enableClientRouter()` turn soft navigation off / back on at runtime (for a
6
+ // moment where you want a full page load, e.g. handing off to a third-party
7
+ // flow). disableClientRouter() removes the document-level <a>/<form> click
8
+ // interception, so PLAIN links start doing full page loads again. It does NOT
9
+ // affect navigate(), which is an explicit programmatic swap the toggle never
10
+ // intercepts, so the plain link below is what visibly changes when you toggle.
11
+ // All are client-only (they run in the browser), so a component is the right
12
+ // home; a page/layout never hydrates. With JS off the plain link still works
13
+ // (progressive enhancement), while the buttons are inert.
14
+ import { WebComponent, html, signal, navigate, revalidate, disableClientRouter, enableClientRouter } from '@webjsdev/core';
9
15
 
10
16
  export class RouterControls extends WebComponent {
17
+ // Instance signal mirroring whether soft navigation is currently on.
18
+ private soft = signal(true);
19
+
20
+ private toggleRouter() {
21
+ if (this.soft.get()) disableClientRouter();
22
+ else enableClientRouter();
23
+ this.soft.set(!this.soft.get());
24
+ }
25
+
11
26
  render() {
27
+ const soft = this.soft.get();
12
28
  return html`
13
- <div class="flex gap-3 items-center">
14
- <button
15
- @click=${() => navigate('/features/client-router/second')}
16
- class="inline-flex items-center px-4 py-2 rounded-xl bg-card border border-border text-foreground font-medium text-sm cursor-pointer transition-colors hover:border-border-strong">navigate() to page two</button>
17
- <button
18
- @click=${() => revalidate()}
19
- class="text-muted-foreground font-medium text-sm cursor-pointer transition-colors hover:text-foreground underline decoration-dotted underline-offset-4">revalidate() the snapshot cache</button>
29
+ <div class="grid gap-3">
30
+ <div class="flex flex-wrap gap-3 items-center">
31
+ <button
32
+ @click=${() => navigate('/features/client-router/second')}
33
+ class="inline-flex items-center px-4 py-2 rounded-xl bg-card border border-border text-foreground font-medium text-sm cursor-pointer transition-colors hover:border-border-strong">navigate() to page two</button>
34
+ <button
35
+ @click=${() => revalidate()}
36
+ class="text-muted-foreground font-medium text-sm cursor-pointer transition-colors hover:text-foreground underline decoration-dotted underline-offset-4">revalidate() the snapshot cache</button>
37
+ <button
38
+ @click=${() => this.toggleRouter()}
39
+ class="text-muted-foreground font-medium text-sm cursor-pointer transition-colors hover:text-foreground underline decoration-dotted underline-offset-4">${soft ? 'disableClientRouter()' : 'enableClientRouter()'} (soft nav: ${soft ? 'on' : 'off'})</button>
40
+ </div>
41
+ <p class="text-sm text-muted-foreground">
42
+ Plain link:
43
+ <a href="/features/client-router/second" class="text-primary underline">/features/client-router/second</a>.
44
+ ${soft
45
+ ? html`The router is on, so this soft-navigates (no full reload). Toggle it off, then click again to watch the browser do a full page load.`
46
+ : html`The router is off, so this now does a FULL page load. The navigate() button still soft-navigates (it is explicit and ignores the toggle).`}
47
+ </p>
20
48
  </div>
21
49
  `;
22
50
  }
@@ -9,7 +9,7 @@
9
9
  // REAL SSR output and the client interactivity, not a jsdom approximation.
10
10
  // It lives NEXT TO the component (a `browser/` dir inside the module), the
11
11
  // co-located default; the runner discovers browser tests under any browser dir.
12
- import { html } from '@webjsdev/core';
12
+ import { html, render } from '@webjsdev/core';
13
13
  import { ssrFixture } from '@webjsdev/core/testing';
14
14
  import '../counter-card.ts';
15
15
 
@@ -22,6 +22,26 @@ suite('<counter-card>', () => {
22
22
  assert(el.textContent.includes('Clicks'), 'the default label renders');
23
23
  });
24
24
 
25
+ test('render() mounts a template imperatively into a container', async () => {
26
+ // render(value, container) is the client-side imperative render: mount a
27
+ // WebJs template into any DOM node (a portal, a plain page). Components
28
+ // render themselves, so reach for this only when you need to drive the
29
+ // mount point yourself. The container must be CONNECTED to the document, or
30
+ // the custom element never upgrades (connectedCallback, and so its first
31
+ // render, only fire on connection).
32
+ const container = document.createElement('div');
33
+ document.body.appendChild(container);
34
+ try {
35
+ render(html`<counter-card label="Imperative"></counter-card>`, container);
36
+ const el = container.querySelector('counter-card');
37
+ assert(el, 'the element mounts into the container');
38
+ await el.updateComplete; // wait for the component's first client render
39
+ assert(container.textContent.includes('Imperative'), 'the label renders');
40
+ } finally {
41
+ container.remove();
42
+ }
43
+ });
44
+
25
45
  test('reads the label reactive prop', async () => {
26
46
  const el = await ssrFixture(html`<counter-card label="Taps"></counter-card>`);
27
47
  assert(el.textContent.includes('Taps'), 'the provided label renders');
@@ -0,0 +1,20 @@
1
+ // A node unit test (webjs test --server): `renderToString` server-renders an
2
+ // html`` template to a string, with Declarative Shadow DOM for shadow-DOM
3
+ // components. Import it from `@webjsdev/core/server` (NOT the root), so the test
4
+ // stays explicit about which side it runs on. Use it to assert SSR output and
5
+ // escaping without a browser; for a full request through the pipeline, use the
6
+ // handle() harness from @webjsdev/server/testing instead (see the testing docs).
7
+ import { test } from 'node:test';
8
+ import assert from 'node:assert/strict';
9
+ import { html } from '@webjsdev/core';
10
+ import { renderToString } from '@webjsdev/core/server';
11
+
12
+ test('renderToString renders interpolated text and escapes it', async () => {
13
+ const out = await renderToString(html`<p>${'hello'}</p>`);
14
+ assert.match(out, /hello/);
15
+ });
16
+
17
+ test('renderToString escapes an interpolated angle bracket (no injection)', async () => {
18
+ const out = await renderToString(html`<p>${'<script>'}</p>`);
19
+ assert.doesNotMatch(out, /<script>/);
20
+ });
@@ -0,0 +1,48 @@
1
+ // `Task` runs an async function and exposes its state (via `TaskStatus`:
2
+ // INITIAL / PENDING / COMPLETE / ERROR) so render() can show a spinner, the
3
+ // value, or an error without hand-rolling the bookkeeping. Use it for
4
+ // genuinely CLIENT-only async data (a browser-driven fetch, a retry-on-click
5
+ // load). For request-time server data that should be in the first paint, prefer
6
+ // `async render()` instead, which blocks SSR so the data is server-rendered;
7
+ // a Task shows its PENDING state at SSR, so the value is not in the first paint.
8
+ import { WebComponent, html } from '@webjsdev/core';
9
+ import { Task, TaskStatus } from '@webjsdev/core/task';
10
+
11
+ export class TaskLoader extends WebComponent {
12
+ // Bumped on each reload so args change and the task re-runs.
13
+ private attempt = 0;
14
+
15
+ // Task calls task(...args(), { signal }), so the args() array is SPREAD into
16
+ // positional parameters (not passed as one array). One arg here, so read it
17
+ // directly as `attempt`.
18
+ private task = new Task<string>(this, {
19
+ task: async (attempt: number) => {
20
+ await new Promise((r) => setTimeout(r, 600));
21
+ if (attempt % 3 === 2) throw new Error('unlucky attempt');
22
+ return `loaded on attempt #${attempt}`;
23
+ },
24
+ args: () => [this.attempt],
25
+ });
26
+
27
+ private reload() {
28
+ this.attempt += 1;
29
+ this.task.run();
30
+ }
31
+
32
+ render() {
33
+ const t = this.task;
34
+ const body =
35
+ t.status === TaskStatus.PENDING ? html`<span class="text-muted-foreground">loading…</span>`
36
+ : t.status === TaskStatus.ERROR ? html`<span class="text-red-500">error: ${String((t.error as Error)?.message ?? t.error)}</span>`
37
+ : t.status === TaskStatus.COMPLETE ? html`<span class="text-foreground">${t.value}</span>`
38
+ : html`<span class="text-muted-foreground">idle</span>`;
39
+ return html`
40
+ <div class="flex items-center gap-3 text-[15px]">
41
+ <button @click=${() => this.reload()}
42
+ class="px-3.5 py-1.5 rounded-xl bg-card border border-border text-foreground text-sm cursor-pointer transition-colors hover:border-border-strong">reload</button>
43
+ ${body}
44
+ </div>
45
+ `;
46
+ }
47
+ }
48
+ TaskLoader.register('task-loader');
@@ -0,0 +1,67 @@
1
+ // The context API: pass a value down to descendant components WITHOUT threading
2
+ // it through every level as an attribute. `createContext(name)` mints a typed
3
+ // key; a `ContextProvider` on an ancestor holds the value; a `ContextConsumer`
4
+ // on any descendant reads it (and, with `subscribe: true`, re-renders when it
5
+ // changes). Under the hood a consumer dispatches a `ContextRequestEvent` that
6
+ // bubbles up to the nearest provider, which is the low-level protocol the two
7
+ // controllers automate. All light DOM, so the provider's <slot> projects its
8
+ // children normally.
9
+ import { WebComponent, html, signal } from '@webjsdev/core';
10
+ import { createContext, ContextProvider, ContextConsumer, ContextRequestEvent } from '@webjsdev/core/context';
11
+
12
+ type Theme = 'light' | 'dark';
13
+
14
+ // One shared key. Descendants that read `themeContext` get the provider's value.
15
+ export const themeContext = createContext<Theme>('demo-theme');
16
+
17
+ export class ThemeProvider extends WebComponent {
18
+ // The provider holds the value. setValue() pushes it to every subscribing
19
+ // consumer in one shot.
20
+ private provider = new ContextProvider<Theme>(this, { context: themeContext, initialValue: 'light' });
21
+
22
+ private toggle() {
23
+ this.provider.setValue(this.provider.value === 'light' ? 'dark' : 'light');
24
+ this.requestUpdate(); // re-render the provider's own button label too
25
+ }
26
+
27
+ render() {
28
+ return html`
29
+ <div class="grid gap-3 p-3 rounded-xl bg-card border border-border max-w-[420px]">
30
+ <button @click=${() => this.toggle()}
31
+ class="w-fit px-3.5 py-1.5 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]">
32
+ provider theme: ${this.provider.value} (toggle)
33
+ </button>
34
+ <slot></slot>
35
+ </div>
36
+ `;
37
+ }
38
+ }
39
+ ThemeProvider.register('theme-provider');
40
+
41
+ export class ThemeConsumer extends WebComponent {
42
+ // subscribe: true re-renders this element whenever the provider calls setValue.
43
+ private consumer = new ContextConsumer<Theme>(this, { context: themeContext, subscribe: true });
44
+ private lastRead = signal<Theme | 'not read yet'>('not read yet');
45
+
46
+ // The imperative escape hatch: dispatch a ContextRequestEvent yourself to
47
+ // grab the current value ONCE without subscribing. It bubbles up to the
48
+ // nearest provider, which answers via the callback.
49
+ private readOnce() {
50
+ this.dispatchEvent(
51
+ new ContextRequestEvent<Theme>(themeContext, (value) => this.lastRead.set(value), false),
52
+ );
53
+ }
54
+
55
+ render() {
56
+ const theme = this.consumer.value ?? 'light';
57
+ return html`
58
+ <div class="flex items-center gap-3 text-[15px] text-foreground">
59
+ <span>child sees: <strong>${theme}</strong></span>
60
+ <button @click=${() => this.readOnce()}
61
+ class="px-3 py-1 rounded-lg text-sm bg-card border border-border text-foreground cursor-pointer transition-colors hover:border-border-strong">read once</button>
62
+ <span class="text-muted-foreground text-sm">last one-shot: ${this.lastRead.get()}</span>
63
+ </div>
64
+ `;
65
+ }
66
+ }
67
+ ThemeConsumer.register('theme-consumer');
@@ -1,4 +1,4 @@
1
- // The lit-html directive set webjs re-exports (from '@webjsdev/core/directives').
1
+ // The lit-html directive set WebJs re-exports (from '@webjsdev/core/directives').
2
2
  // `repeat` keys a list so DOM nodes are REUSED across reorders instead of being
3
3
  // recreated (use it for keyed lists that reorder; plain `.map()` is fine for
4
4
  // static lists). `watch(signal)` does a fine-grained DOM swap of ONE node when
@@ -6,9 +6,13 @@
6
6
  // shows more of the set: `live` (a controlled input), `ref` + `createRef` (a
7
7
  // handle to a DOM node), `until` (a pending fallback for a promise),
8
8
  // `unsafeHTML` (trusted raw HTML, NEVER user input), and `keyed` (force a fresh
9
- // subtree when a key changes).
9
+ // subtree when a key changes). The third card shows the rest: `guard` (skip a
10
+ // re-render when its deps are unchanged), `cache` (keep an inactive branch's
11
+ // DOM around while you toggle), `templateContent` (stamp an existing template's
12
+ // HTML), and `asyncAppend` / `asyncReplace` (stream values from an async
13
+ // iterable, appending each or replacing with the latest).
10
14
  import { WebComponent, signal, html } from '@webjsdev/core';
11
- import { repeat, watch, live, until, keyed, unsafeHTML, ref, createRef } from '@webjsdev/core/directives';
15
+ import { repeat, watch, live, until, keyed, unsafeHTML, ref, createRef, guard, cache, templateContent, asyncAppend, asyncReplace } from '@webjsdev/core/directives';
12
16
 
13
17
  interface Item { id: number; label: string }
14
18
 
@@ -29,6 +33,57 @@ export class DirectiveDemo extends WebComponent {
29
33
  // Created ONCE (not per render), so `until` keeps the resolved value across
30
34
  // re-renders instead of flashing back to the fallback each time.
31
35
  private asyncValue: Promise<string> = this.later();
36
+ // Which cached branch is showing (for `cache`), and a counter that lets us
37
+ // prove `guard` skips its recompute unless its dep actually changes.
38
+ private tab = signal<'a' | 'b'>('a');
39
+ private guardBumps = signal(0);
40
+ // Async iterables consumed on the client (both render empty at SSR). The
41
+ // generators are lazy, so the field initializer just creates the iterator
42
+ // without running the body. Both are FINITE and run ONCE: they animate on
43
+ // first paint, then settle on their final value. restartStreams() swaps in
44
+ // fresh iterables to replay them (a new iterable identity makes asyncAppend /
45
+ // asyncReplace tear down and re-subscribe). streamRun is read in render() so
46
+ // the swap triggers a re-render.
47
+ private logIter: AsyncIterable<string> = this.log();
48
+ private countIter: AsyncIterable<number> = this.countdown();
49
+ private streamRun = signal(0);
50
+
51
+ private restartStreams() {
52
+ this.logIter = this.log();
53
+ this.countIter = this.countdown();
54
+ this.streamRun.set(this.streamRun.get() + 1);
55
+ }
56
+ // For `templateContent`: a real <template> element on the CLIENT (the client
57
+ // directive clones its `.content`), and a plain { innerHTML } object at SSR
58
+ // (there is no document to build a template with, and the server directive
59
+ // emits innerHTML directly). The real template is built in connectedCallback,
60
+ // which SSR never calls, so the two paths agree on the output.
61
+ private stampTpl: HTMLTemplateElement | { innerHTML: string } = {
62
+ innerHTML: '<strong>stamped</strong> from a template',
63
+ };
64
+
65
+ connectedCallback() {
66
+ super.connectedCallback();
67
+ const tpl = document.createElement('template');
68
+ tpl.innerHTML = '<strong>stamped</strong> from a template';
69
+ this.stampTpl = tpl;
70
+ }
71
+
72
+ // A finite async iterable: asyncAppend adds each line as it arrives.
73
+ private async *log(): AsyncGenerator<string> {
74
+ for (const line of ['connecting', 'authenticated', 'ready']) {
75
+ await new Promise((r) => setTimeout(r, 500));
76
+ yield line;
77
+ }
78
+ }
79
+ // A finite async iterable: asyncReplace shows only the latest value, counting
80
+ // down 5 -> 0 one step at a time.
81
+ private async *countdown(): AsyncGenerator<number> {
82
+ for (let n = 5; n >= 0; n--) {
83
+ yield n;
84
+ await new Promise((r) => setTimeout(r, 500));
85
+ }
86
+ }
32
87
 
33
88
  private reverse() {
34
89
  this.items.set(this.items.get().slice().reverse());
@@ -91,6 +146,48 @@ export class DirectiveDemo extends WebComponent {
91
146
  class="w-fit text-sm text-muted-foreground cursor-pointer transition-colors hover:text-foreground underline decoration-dotted underline-offset-4">rekey</button>
92
147
  ${keyed(variant, html`<div class="text-[15px] text-foreground">${unsafeHTML('<em>fresh subtree</em>')} #${variant}</div>`)}
93
148
  </div>
149
+
150
+ <div class="grid gap-3 border-t border-border pt-4">
151
+ <!-- guard([deps], fn) only re-runs fn when a dep changes. Bumping the
152
+ guard counter re-renders it; the OTHER counter (ticks above) does
153
+ not, so the guarded value stays put. -->
154
+ <button @click=${() => this.guardBumps.set(this.guardBumps.get() + 1)}
155
+ class="w-fit text-sm text-muted-foreground cursor-pointer transition-colors hover:text-foreground underline decoration-dotted underline-offset-4">bump guard dep</button>
156
+ <p class="text-sm text-foreground">guarded: ${guard([this.guardBumps.get()], () => html`computed at bump #${this.guardBumps.get()}`)}</p>
157
+
158
+ <!-- cache(value) keeps the inactive tab's DOM alive while you toggle,
159
+ so switching back is instant and preserves any element state. -->
160
+ <div class="flex gap-2">
161
+ <button @click=${() => this.tab.set('a')}
162
+ class="px-3 py-1 rounded-lg text-sm border cursor-pointer transition-colors ${this.tab.get() === 'a' ? 'bg-primary text-primary-foreground border-transparent' : 'bg-card border-border text-foreground hover:border-border-strong'}">Tab A</button>
163
+ <button @click=${() => this.tab.set('b')}
164
+ class="px-3 py-1 rounded-lg text-sm border cursor-pointer transition-colors ${this.tab.get() === 'b' ? 'bg-primary text-primary-foreground border-transparent' : 'bg-card border-border text-foreground hover:border-border-strong'}">Tab B</button>
165
+ </div>
166
+ <div class="text-[15px] text-foreground">${cache(
167
+ this.tab.get() === 'a'
168
+ ? html`<span>Panel A content</span>`
169
+ : html`<span>Panel B content</span>`,
170
+ )}</div>
171
+
172
+ <!-- templateContent stamps a <template> element's content (see
173
+ stampTpl: a real template on the client, a plain { innerHTML } at
174
+ SSR, so first paint and hydration match). -->
175
+ <div class="text-[15px] text-foreground">${templateContent(this.stampTpl)}</div>
176
+
177
+ <!-- asyncAppend appends each value from an async iterable as it
178
+ arrives; asyncReplace shows only the latest. Both render empty at
179
+ SSR, stream in on the client, and finish (the log stops at
180
+ "ready", the countdown at 0). Restart swaps in fresh iterables so
181
+ you can watch them replay. The run counter forces the re-render. -->
182
+ <div class="flex items-center gap-3">
183
+ <button @click=${() => this.restartStreams()}
184
+ class="w-fit px-3.5 py-1.5 rounded-xl bg-card border border-border text-foreground text-sm cursor-pointer transition-colors hover:border-border-strong">restart streams</button>
185
+ <span class="text-sm text-muted-foreground">run #${this.streamRun.get()}</span>
186
+ </div>
187
+ <div class="text-sm text-muted-foreground">log (asyncAppend, one row per value):</div>
188
+ <ul class="grid gap-1 list-none m-0 p-0 text-sm text-muted-foreground min-h-[1.25rem]">${asyncAppend(this.logIter, (line: string) => html`<li>· ${line}</li>`)}</ul>
189
+ <p class="text-sm text-foreground">countdown (asyncReplace, latest value only): ${asyncReplace(this.countIter)}</p>
190
+ </div>
94
191
  </div>
95
192
  `;
96
193
  }
@@ -7,6 +7,9 @@
7
7
  // whitelisted extension.
8
8
  'use server';
9
9
  import { getFileStore, generateKey } from '@webjsdev/server';
10
+ // Importing the config module runs setFileStore(diskStore(...)) once at load,
11
+ // so uploads land in the configured store. See ../store.server.ts.
12
+ import '../store.server.ts';
10
13
 
11
14
  export async function storeUpload(file: File) {
12
15
  if (!(file instanceof File) || file.size === 0) {
@@ -0,0 +1,29 @@
1
+ // Server-only file-store configuration (no 'use server': a plain server
2
+ // utility, imported by the upload action and the serve route). `setFileStore()`
3
+ // swaps the active store; `diskStore()` is the built-in local-disk store
4
+ // (streaming, traversal-safe), the drop-in slot for an S3 / R2 / GCS adapter of
5
+ // the same shape. `signedUrl(key, { secret })` mints a time-limited,
6
+ // tamper-proof download URL and `verifySignedUrl(input, secret)` checks it, so a
7
+ // private file can be shared by link without making the serve route public.
8
+ import { setFileStore, diskStore, signedUrl, verifySignedUrl } from '@webjsdev/server';
9
+
10
+ // Configure the store once at module load. This mirrors the framework default
11
+ // (a local diskStore under .webjs/uploads); in production swap diskStore for an
12
+ // S3/R2 adapter with the same put/get/delete shape.
13
+ setFileStore(diskStore({ dir: '.webjs/uploads' }));
14
+
15
+ const URL_SECRET = process.env.FILE_URL_SECRET || 'dev-file-url-secret-change-me';
16
+
17
+ // A 1-hour signed link to the serve route for a given key.
18
+ export function signedDownloadUrl(key: string): string {
19
+ return signedUrl(key, {
20
+ secret: URL_SECRET,
21
+ base: `/features/file-storage/file/${encodeURIComponent(key)}`,
22
+ expiresIn: 3600,
23
+ });
24
+ }
25
+
26
+ // True when a request's ?key&exp&sig params are a valid, unexpired signature.
27
+ export function isValidSignedRequest(url: string): boolean {
28
+ return verifySignedUrl(url, URL_SECRET).valid;
29
+ }
@@ -0,0 +1,31 @@
1
+ // `richFetch(url, init?)` is a drop-in `fetch` for your own API routes that
2
+ // preserves rich types: it sends Accept: application/vnd.webjs+json, and when
3
+ // the route answered with `json(...)` it decodes the WebJs wire, so a `Date`
4
+ // comes back as a real Date (not an ISO string), and Map / Set / BigInt / Blob
5
+ // round-trip too. A plain-object `body` is encoded the same way. Client-only
6
+ // (it runs in the browser), so it lives in a component; with JS off this button
7
+ // is inert and the page still reads.
8
+ import { WebComponent, signal, html, richFetch } from '@webjsdev/core';
9
+
10
+ interface RichPayload { at: Date; ip: string; requestId: string; cookieCount: number }
11
+
12
+ export class RichData extends WebComponent {
13
+ private line = signal('click to fetch');
14
+
15
+ private async load() {
16
+ const data = await richFetch<RichPayload>('/features/route-handler/data');
17
+ // data.at is a Date, so Date methods work with no manual parsing.
18
+ this.line.set(`at ${data.at.toLocaleTimeString()} · id ${data.requestId} · ${data.cookieCount} cookie(s)`);
19
+ }
20
+
21
+ render() {
22
+ return html`
23
+ <div class="flex items-center gap-3 text-[15px]">
24
+ <button @click=${() => this.load()}
25
+ class="px-3.5 py-1.5 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]">richFetch() the route</button>
26
+ <span class="text-muted-foreground">${this.line.get()}</span>
27
+ </div>
28
+ `;
29
+ }
30
+ }
31
+ RichData.register('rich-data');
@@ -3,10 +3,18 @@
3
3
  // stub POSTing to the server. It may use server-only utilities (they run here,
4
4
  // server-side), which is why the util above stays off the client.
5
5
  import { shout } from '../utils/format.server.ts';
6
+ import { actionContext, actionSignal } from '@webjsdev/server';
6
7
  import type { ActionResult } from '@webjsdev/server';
7
8
 
8
9
  export async function greet(input: { name: string }): Promise<ActionResult<{ message: string }>> {
9
- const name = String(input?.name ?? '').trim();
10
+ // actionSignal() is the request's AbortSignal (fires on client disconnect or a
11
+ // superseded render); bail early on long work instead of finishing wasted work.
12
+ if (actionSignal().aborted) return { success: false, error: 'Request cancelled.', status: 499 };
13
+ // actionContext() is the per-action middleware context (e.g. actionContext().user
14
+ // set by an auth middleware via `export const middleware`). Empty here, no middleware.
15
+ const who = (actionContext().user as { name?: string } | undefined)?.name;
16
+
17
+ const name = String(input?.name ?? who ?? '').trim();
10
18
  if (!name) return { success: false, error: 'Name required.', status: 400 };
11
19
  return { success: true, data: { message: shout('hello ' + name) } };
12
20
  }
@@ -0,0 +1,37 @@
1
+ // Example node test for the documented test helpers (from @webjsdev/server).
2
+ // Run it with node --test (or webjs test after moving it under test/). The
3
+ // handle() harness drives the FULL request pipeline: createRequestHandler({
4
+ // appDir }) builds it, and rawActionRequest() fires a 'use server' action
5
+ // through it (CSRF + the rich serializer included), returning the raw Response.
6
+ // buildRouteTable(appDir) parses the file router; matchPage / matchApi resolve a
7
+ // URL against it, params included. See the testing docs.
8
+ import { test } from 'node:test';
9
+ import assert from 'node:assert/strict';
10
+ import { createRequestHandler, buildRouteTable, matchPage, matchApi, rawActionRequest } from '@webjsdev/server';
11
+
12
+ const appDir = process.cwd();
13
+
14
+ test('buildRouteTable + matchPage resolve a dynamic route with its params', async () => {
15
+ const table = await buildRouteTable(appDir);
16
+ const m = matchPage(table, '/features/routing/42');
17
+ assert.ok(m, 'the [id] route matches');
18
+ assert.equal(m.params.id, '42');
19
+ });
20
+
21
+ test('matchApi resolves the route-handler endpoint', async () => {
22
+ const table = await buildRouteTable(appDir);
23
+ const m = matchApi(table, '/features/route-handler/data');
24
+ assert.ok(m, 'the route.ts endpoint matches');
25
+ });
26
+
27
+ test('rawActionRequest fires the greet action through the pipeline', async () => {
28
+ const app = await createRequestHandler({ appDir, dev: true });
29
+ if (app.warmup) await app.warmup();
30
+ const res = await rawActionRequest(
31
+ app,
32
+ 'modules/server-actions/actions/greet.server.ts',
33
+ 'greet',
34
+ [{ name: 'Ada' }],
35
+ );
36
+ assert.equal(res.status, 200);
37
+ });
@@ -0,0 +1,31 @@
1
+ // The session helpers. `session(opts)` builds session MIDDLEWARE; its storage is
2
+ // pluggable. `cookieSessionStorage()` (alias `cookieSession`) keeps the whole
3
+ // session in a signed cookie (stateless, the default). `storeSessionStorage()`
4
+ // (alias `storeSession`) persists the session in the active store (memoryStore
5
+ // in dev, Redis in prod via setStore()) and keeps only an id in the cookie, for
6
+ // larger or server-held sessions. `getSession(req)` reads the current session
7
+ // inside a route or middleware the session wraps.
8
+ import { session, getSession, cookieSession, cookieSessionStorage, storeSession, storeSessionStorage } from '@webjsdev/server';
9
+
10
+ // A dev fallback keeps a fresh scaffold booting; set SESSION_SECRET in .env for
11
+ // any real deployment (and fail fast in production).
12
+ const trimmed = process.env.SESSION_SECRET?.trim();
13
+ if (process.env.NODE_ENV === 'production' && !trimmed) {
14
+ throw new Error('SESSION_SECRET must be set in production');
15
+ }
16
+ const secret = trimmed || 'dev-insecure-session-secret-change-me';
17
+
18
+ // Cookie-backed session middleware (all state in a signed cookie): the default
19
+ // this demo applies. cookieSession() is the alias for cookieSessionStorage().
20
+ export const cookieSessions = session({ secret, storage: cookieSession() });
21
+
22
+ // Store-backed alternative (session in the active store, id in the cookie).
23
+ // storeSession() is the alias for storeSessionStorage(); swap it in above for
24
+ // larger sessions. Kept here to show both strategies.
25
+ export const storeSessions = session({ secret, storage: storeSession() });
26
+
27
+ // Equivalent explicit spellings (the aliases just name the storage factories):
28
+ export const cookieStorageExplicit = cookieSessionStorage();
29
+ export const storeStorageExplicit = storeSessionStorage();
30
+
31
+ export { getSession };
@@ -4,7 +4,7 @@
4
4
  // it never runs during SSR) and closed in disconnectedCallback. At SSR the
5
5
  // component renders its disconnected state, so with JS off the page still reads
6
6
  // (a live socket has no no-JS equivalent, which is the honest fallback here).
7
- import { WebComponent, signal, html, connectWS } from '@webjsdev/core';
7
+ import { WebComponent, signal, html, connectWS, renderStream } from '@webjsdev/core';
8
8
 
9
9
  export class WsEcho extends WebComponent {
10
10
  private connected = signal(false);
@@ -36,6 +36,19 @@ export class WsEcho extends WebComponent {
36
36
  input.value = '';
37
37
  }
38
38
 
39
+ #streamN = 0;
40
+ // renderStream() applies a <webjs-stream> payload with native DOM methods:
41
+ // the SAME element-level applier a connectWS onMessage handler (or a
42
+ // broadcast() push) uses for surgical live updates, so a chat / presence /
43
+ // toast reuses it instead of hand-written DOM code. Here a button drives it
44
+ // locally; over a channel the server would send this HTML string.
45
+ private applyStreamUpdate() {
46
+ this.#streamN += 1;
47
+ renderStream(
48
+ `<webjs-stream action="append" target="ws-stream-log"><template><li class="px-3 py-2 rounded-xl bg-card border border-border text-[15px] text-foreground">streamed row #${this.#streamN}</li></template></webjs-stream>`,
49
+ );
50
+ }
51
+
39
52
  render() {
40
53
  const on = this.connected.get();
41
54
  return html`
@@ -55,6 +68,11 @@ export class WsEcho extends WebComponent {
55
68
  <li class="px-3 py-2 rounded-xl bg-card border border-border text-[15px] text-foreground font-mono">${line}</li>
56
69
  `)}
57
70
  </ul>
71
+ <div class="grid gap-2 border-t border-border pt-4">
72
+ <button @click=${() => this.applyStreamUpdate()}
73
+ class="w-fit px-3.5 py-1.5 rounded-xl bg-card border border-border text-foreground text-sm cursor-pointer transition-colors hover:border-border-strong">renderStream() an element-level update</button>
74
+ <ul id="ws-stream-log" class="grid gap-1.5 list-none m-0 p-0"></ul>
75
+ </div>
58
76
  </div>
59
77
  `;
60
78
  }
@@ -0,0 +1,21 @@
1
+ // Example node test for booting the app in-process (from @webjsdev/server).
2
+ // startServer({ appDir, port }) boots the whole app and resolves to
3
+ // { server, close } (the same entry `webjs start` uses; port 0 picks a free
4
+ // port, so a test never collides). Close it in a finally so the port is
5
+ // released. This is the realistic way to smoke-test that the app boots and
6
+ // listens; for asserting on responses without a socket, use the handle()
7
+ // harness (see modules/server-actions/actions/greet.test.ts and the docs).
8
+ import { test } from 'node:test';
9
+ import assert from 'node:assert/strict';
10
+ import { startServer } from '@webjsdev/server';
11
+
12
+ const appDir = process.cwd();
13
+
14
+ test('startServer boots the app in-process on an ephemeral port', async () => {
15
+ const { server, close } = await startServer({ appDir, dev: true, port: 0 });
16
+ try {
17
+ assert.ok(server.address(), 'the server is listening');
18
+ } finally {
19
+ await close();
20
+ }
21
+ });
@@ -0,0 +1,16 @@
1
+ // Optional boot-time hook (app root, sibling of app/). register() runs once at
2
+ // server start, the place to wire APM / logging / tracing. setOnError(fn) (from
3
+ // @webjsdev/server) registers a sink for every request error the framework
4
+ // catches (an SSR render crash, a thrown server action, a 500), so you can
5
+ // forward it to Sentry / a logger with the request context. Call setOnError
6
+ // INSIDE register() so it runs within the instrumentation context (a top-level
7
+ // call has no context yet and is a no-op). Delete this file if you do not need
8
+ // the hook.
9
+ import { setOnError } from '@webjsdev/server';
10
+
11
+ export function register() {
12
+ setOnError((error, ctx) => {
13
+ // Replace with your APM. `ctx` carries request context (e.g. a correlation id).
14
+ console.error('[instrumentation] request error:', error, ctx ?? '');
15
+ });
16
+ }