@webjsdev/cli 0.10.36 → 0.10.38

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 +52 -7
  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
@@ -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
+ }