@webjsdev/cli 0.10.35 → 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 (65) hide show
  1. package/lib/create.js +18 -11
  2. package/lib/doctor.js +1 -1
  3. package/lib/saas-template.js +27 -7
  4. package/package.json +1 -1
  5. package/templates/.agents/rules/workflow.md +1 -1
  6. package/templates/.github/copilot-instructions.md +1 -1
  7. package/templates/AGENTS.md +7 -6
  8. package/templates/CONVENTIONS.md +4 -4
  9. package/templates/gallery/app/apple-icon.ts +17 -0
  10. package/templates/gallery/app/features/async-render/page.ts +11 -1
  11. package/templates/gallery/app/features/boundaries/crash/page.ts +7 -0
  12. package/templates/gallery/app/features/boundaries/error.ts +16 -0
  13. package/templates/gallery/app/features/boundaries/gated/forbidden.ts +18 -0
  14. package/templates/gallery/app/features/boundaries/gated/page.ts +10 -0
  15. package/templates/gallery/app/features/boundaries/loading.ts +15 -0
  16. package/templates/gallery/app/features/boundaries/not-found.ts +17 -0
  17. package/templates/gallery/app/features/boundaries/page.ts +77 -0
  18. package/templates/gallery/app/features/boundaries/private/page.ts +10 -0
  19. package/templates/gallery/app/features/boundaries/private/unauthorized.ts +17 -0
  20. package/templates/gallery/app/features/caching/page.ts +9 -0
  21. package/templates/gallery/app/features/client-router/page.ts +3 -0
  22. package/templates/gallery/app/features/components/page.ts +11 -0
  23. package/templates/gallery/app/features/file-storage/file/[key]/route.ts +10 -1
  24. package/templates/gallery/app/features/file-storage/page.ts +2 -2
  25. package/templates/gallery/app/features/forms/page.ts +1 -1
  26. package/templates/gallery/app/features/rate-limit/page.ts +1 -1
  27. package/templates/gallery/app/features/route-handler/data/route.ts +34 -5
  28. package/templates/gallery/app/features/route-handler/page.ts +5 -2
  29. package/templates/gallery/app/features/routing/[id]/page.ts +13 -1
  30. package/templates/gallery/app/features/routing/legacy/page.ts +12 -0
  31. package/templates/gallery/app/features/routing/page.ts +9 -0
  32. package/templates/gallery/app/features/sessions/count/route.ts +12 -0
  33. package/templates/gallery/app/features/sessions/middleware.ts +7 -0
  34. package/templates/gallery/app/features/sessions/page.ts +19 -0
  35. package/templates/gallery/app/global-error.ts +46 -0
  36. package/templates/gallery/app/global-not-found.ts +22 -0
  37. package/templates/gallery/app/icon.ts +19 -0
  38. package/templates/gallery/app/manifest.ts +22 -0
  39. package/templates/gallery/app/opengraph-image.ts +20 -0
  40. package/templates/gallery/app/robots.ts +16 -0
  41. package/templates/gallery/app/sitemap.ts +23 -0
  42. package/templates/gallery/app/sitemaps/route.ts +17 -0
  43. package/templates/gallery/app/twitter-image.ts +19 -0
  44. package/templates/gallery/modules/caching/actions/bust-caches.server.ts +25 -0
  45. package/templates/gallery/modules/caching/components/cache-buster.ts +30 -0
  46. package/templates/gallery/modules/client-router/components/router-controls.ts +52 -0
  47. package/templates/gallery/modules/components/components/browser/counter-card.test.js +21 -1
  48. package/templates/gallery/modules/components/components/counter-card.ts +1 -1
  49. package/templates/gallery/modules/components/components/reactive-meter.ts +64 -0
  50. package/templates/gallery/modules/components/components/server-render.test.ts +20 -0
  51. package/templates/gallery/modules/components/components/task-loader.ts +48 -0
  52. package/templates/gallery/modules/components/components/theme-context.ts +67 -0
  53. package/templates/gallery/modules/directives/components/directive-demo.ts +162 -20
  54. package/templates/gallery/modules/file-storage/actions/store-upload.server.ts +3 -0
  55. package/templates/gallery/modules/file-storage/store.server.ts +29 -0
  56. package/templates/gallery/modules/rate-limit/components/rate-probe.ts +1 -1
  57. package/templates/gallery/modules/route-handler/components/rich-data.ts +31 -0
  58. package/templates/gallery/modules/server-actions/actions/greet.server.ts +9 -1
  59. package/templates/gallery/modules/server-actions/actions/greet.test.ts +37 -0
  60. package/templates/gallery/modules/server-actions/components/greeter.ts +1 -1
  61. package/templates/gallery/modules/sessions/session-config.server.ts +31 -0
  62. package/templates/gallery/modules/todo/components/todo-app.ts +5 -5
  63. package/templates/gallery/modules/websockets/components/ws-echo.ts +19 -1
  64. package/templates/gallery/modules/websockets/echo.test.ts +21 -0
  65. package/templates/instrumentation.ts +16 -0
@@ -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');
@@ -24,7 +24,7 @@ export class CounterCard extends WebComponent({
24
24
  <slot></slot>
25
25
  <div class="flex items-baseline gap-2">
26
26
  <span class="text-[2.5rem] font-bold tabular-nums leading-none text-foreground">${this.count.get()}</span>
27
- <span class="text-sm text-muted-foreground/70">${this.label}</span>
27
+ <span class="text-sm text-muted-foreground">${this.label}</span>
28
28
  </div>
29
29
  <button @click=${() => this.count.set(this.count.get() + 1)}
30
30
  class="w-fit px-4 py-2 rounded-xl bg-primary text-primary-foreground font-semibold text-sm border-0 cursor-pointer transition-all hover:bg-primary/90 active:scale-[0.97]">Increment</button>
@@ -0,0 +1,64 @@
1
+ // A shadow-DOM component: `static shadow = true` scopes `static styles = css\`\``
2
+ // to this element (bare selectors do not leak out), the one place the lit reflex
3
+ // to scope CSS is right in webjs. It also shows the rest of the signals API:
4
+ // `computed` (a derived signal), `effect` (a browser-side reaction that runs on
5
+ // change), and `batch` (coalesce several writes into ONE re-render).
6
+ import { WebComponent, html, css, signal, computed, effect, batch } from '@webjsdev/core';
7
+
8
+ export class ReactiveMeter extends WebComponent {
9
+ static shadow = true;
10
+ static styles = css`
11
+ .row { display: flex; align-items: center; gap: 8px; }
12
+ button {
13
+ padding: 6px 12px; border-radius: 10px; border: 1px solid #8883;
14
+ background: transparent; color: inherit; cursor: pointer; font: inherit;
15
+ }
16
+ .val { font-variant-numeric: tabular-nums; font-weight: 600; }
17
+ .muted { opacity: 0.6; font-size: 13px; }
18
+ `;
19
+
20
+ private count = signal(0);
21
+ // computed: recomputed lazily when count changes, cached otherwise.
22
+ private doubled = computed(() => this.count.get() * 2);
23
+ private lastLogged = signal('');
24
+ private disposeEffect?: () => void;
25
+
26
+ connectedCallback() {
27
+ super.connectedCallback();
28
+ // effect: runs now and again whenever a signal it reads changes. Browser-only
29
+ // (SSR never calls connectedCallback), so it is the place for reactive
30
+ // side effects.
31
+ this.disposeEffect = effect(() => {
32
+ this.lastLogged.set('count is ' + this.count.get());
33
+ });
34
+ }
35
+ disconnectedCallback() {
36
+ super.disconnectedCallback?.();
37
+ this.disposeEffect?.();
38
+ }
39
+
40
+ private bump(n: number) {
41
+ this.count.set(this.count.get() + n);
42
+ }
43
+ // batch: both writes commit together, so the component re-renders ONCE.
44
+ private reset() {
45
+ batch(() => {
46
+ this.count.set(0);
47
+ this.lastLogged.set('reset');
48
+ });
49
+ }
50
+
51
+ render() {
52
+ return html`
53
+ <div class="row">
54
+ <button @click=${() => this.bump(-1)}>-1</button>
55
+ <span class="val">${this.count.get()}</span>
56
+ <button @click=${() => this.bump(1)}>+1</button>
57
+ <span class="muted">doubled: ${this.doubled.get()}</span>
58
+ <button @click=${() => this.reset()}>reset</button>
59
+ </div>
60
+ <p class="muted">${this.lastLogged.get()}</p>
61
+ `;
62
+ }
63
+ }
64
+ ReactiveMeter.register('reactive-meter');
@@ -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,10 +1,18 @@
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
5
- // the signal changes, without re-running the whole template.
5
+ // the signal changes, without re-running the whole template. The second card
6
+ // shows more of the set: `live` (a controlled input), `ref` + `createRef` (a
7
+ // handle to a DOM node), `until` (a pending fallback for a promise),
8
+ // `unsafeHTML` (trusted raw HTML, NEVER user input), and `keyed` (force a fresh
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).
6
14
  import { WebComponent, signal, html } from '@webjsdev/core';
7
- import { repeat, watch } from '@webjsdev/core/directives';
15
+ import { repeat, watch, live, until, keyed, unsafeHTML, ref, createRef, guard, cache, templateContent, asyncAppend, asyncReplace } from '@webjsdev/core/directives';
8
16
 
9
17
  interface Item { id: number; label: string }
10
18
 
@@ -17,6 +25,65 @@ export class DirectiveDemo extends WebComponent {
17
25
  { id: 3, label: 'Charlie' },
18
26
  ]);
19
27
  private ticks = signal(0);
28
+ // A controlled value (for `live`) and a key (for `keyed`).
29
+ private text = signal('type here');
30
+ private variant = signal(0);
31
+ // A handle to the input node, attached by `ref` in the browser.
32
+ private inputRef = createRef<HTMLInputElement>();
33
+ // Created ONCE (not per render), so `until` keeps the resolved value across
34
+ // re-renders instead of flashing back to the fallback each time.
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
+ }
20
87
 
21
88
  private reverse() {
22
89
  this.items.set(this.items.get().slice().reverse());
@@ -25,27 +92,102 @@ export class DirectiveDemo extends WebComponent {
25
92
  const id = nextId++;
26
93
  this.items.set([...this.items.get(), { id, label: 'Item ' + id }]);
27
94
  }
95
+ private focusInput() {
96
+ this.inputRef.value?.focus();
97
+ }
98
+ // A promise that resolves after a tick, so `until` shows its fallback first.
99
+ private later(): Promise<string> {
100
+ return new Promise((resolve) => setTimeout(() => resolve('resolved!'), 400));
101
+ }
28
102
 
29
103
  render() {
104
+ const variant = this.variant.get();
30
105
  return html`
31
- <div class="grid gap-4 max-w-[420px]">
32
- <div class="flex gap-2">
33
- <button @click=${() => this.reverse()}
34
- 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]">Reverse</button>
35
- <button @click=${() => this.add()}
36
- class="px-3.5 py-1.5 rounded-xl bg-card border border-border text-foreground font-medium text-sm cursor-pointer transition-colors hover:border-border-strong">Add</button>
106
+ <div class="grid gap-6 max-w-[420px]">
107
+ <div class="grid gap-4">
108
+ <div class="flex gap-2">
109
+ <button @click=${() => this.reverse()}
110
+ 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]">Reverse</button>
111
+ <button @click=${() => this.add()}
112
+ class="px-3.5 py-1.5 rounded-xl bg-card border border-border text-foreground font-medium text-sm cursor-pointer transition-colors hover:border-border-strong">Add</button>
113
+ </div>
114
+ <!-- repeat keyed by item.id: reversing reuses the existing <li> nodes. -->
115
+ <ul class="grid gap-2 list-none m-0 p-0">
116
+ ${repeat(this.items.get(), (it: Item) => it.id, (it: Item) => html`
117
+ <li class="flex items-center gap-2 px-3 py-2 rounded-xl bg-card border border-border text-[15px] text-foreground">
118
+ <span class="text-muted-foreground tabular-nums text-[13px]">#${it.id}</span>${it.label}
119
+ </li>
120
+ `)}
121
+ </ul>
122
+ <!-- watch(signal) swaps only this node when ticks changes. -->
123
+ <button @click=${() => this.ticks.set(this.ticks.get() + 1)}
124
+ class="w-fit text-sm text-muted-foreground cursor-pointer transition-colors hover:text-foreground underline decoration-dotted underline-offset-4">ticks: ${watch(this.ticks)}</button>
125
+ </div>
126
+
127
+ <div class="grid gap-3 border-t border-border pt-4">
128
+ <!-- live keeps the input's DOM value tracking the signal even after the
129
+ user edits it, so it stays a controlled input. ref hands you the
130
+ node via inputRef.value once it is attached in the browser. -->
131
+ <div class="flex gap-2">
132
+ <input
133
+ ${ref(this.inputRef)}
134
+ aria-label="Editable text for the ref focus demo"
135
+ class="flex-1 px-3 py-1.5 rounded-xl bg-card border border-border text-[15px] text-foreground"
136
+ .value=${live(this.text.get())}
137
+ @input=${(e: Event) => this.text.set((e.target as HTMLInputElement).value)}>
138
+ <button @click=${() => this.focusInput()}
139
+ 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">Focus</button>
140
+ </div>
141
+ <!-- until shows the fallback until the promise resolves. -->
142
+ <p class="text-sm text-muted-foreground">async: ${until(this.asyncValue, 'loading...')}</p>
143
+ <!-- keyed discards and rebuilds this subtree when the key changes;
144
+ unsafeHTML injects trusted, author-written HTML (NEVER user input). -->
145
+ <button @click=${() => this.variant.set(variant + 1)}
146
+ class="w-fit text-sm text-muted-foreground cursor-pointer transition-colors hover:text-foreground underline decoration-dotted underline-offset-4">rekey</button>
147
+ ${keyed(variant, html`<div class="text-[15px] text-foreground">${unsafeHTML('<em>fresh subtree</em>')} #${variant}</div>`)}
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>
37
190
  </div>
38
- <!-- repeat keyed by item.id: reversing reuses the existing <li> nodes. -->
39
- <ul class="grid gap-2 list-none m-0 p-0">
40
- ${repeat(this.items.get(), (it: Item) => it.id, (it: Item) => html`
41
- <li class="flex items-center gap-2 px-3 py-2 rounded-xl bg-card border border-border text-[15px] text-foreground">
42
- <span class="text-muted-foreground/70 tabular-nums text-[13px]">#${it.id}</span>${it.label}
43
- </li>
44
- `)}
45
- </ul>
46
- <!-- watch(signal) swaps only this node when ticks changes. -->
47
- <button @click=${() => this.ticks.set(this.ticks.get() + 1)}
48
- class="w-fit text-sm text-muted-foreground cursor-pointer transition-colors hover:text-foreground underline decoration-dotted underline-offset-4">ticks: ${watch(this.ticks)}</button>
49
191
  </div>
50
192
  `;
51
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
+ }
@@ -38,7 +38,7 @@ export class RateProbe extends WebComponent {
38
38
  <li class="flex items-center justify-between px-3 py-2 rounded-xl bg-card border border-border text-sm">
39
39
  <span class="text-muted-foreground">#${p.n}</span>
40
40
  <span class="font-mono ${p.status === 429 ? 'text-destructive' : 'text-primary'}">${p.status === 429 ? '429 limited' : '200 ok'}</span>
41
- <span class="text-muted-foreground/70 text-[13px]">remaining: ${p.remaining}</span>
41
+ <span class="text-muted-foreground text-[13px]">remaining: ${p.remaining}</span>
42
42
  </li>
43
43
  `)}
44
44
  </ul>
@@ -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
+ });
@@ -18,7 +18,7 @@ export class Greeter extends WebComponent {
18
18
  <form @submit=${(e: SubmitEvent) => this.run(e)}
19
19
  class="flex items-center gap-2 p-2 pl-4 rounded-2xl bg-card border border-border">
20
20
  <input name="name" placeholder="your name" autocomplete="off"
21
- class="flex-1 min-w-0 bg-transparent border-0 outline-none text-foreground text-[15px] placeholder:text-muted-foreground/70 py-1.5" />
21
+ class="flex-1 min-w-0 bg-transparent border-0 outline-none text-foreground text-[15px] placeholder:text-muted-foreground py-1.5" />
22
22
  <button type="submit"
23
23
  class="shrink-0 px-4 py-2 rounded-xl bg-primary text-primary-foreground font-semibold text-sm border-0 cursor-pointer transition-all hover:bg-primary/90 active:scale-[0.97]">Greet</button>
24
24
  </form>
@@ -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 };