@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.
- package/lib/create.js +4 -0
- package/lib/saas-template.js +52 -7
- package/package.json +1 -1
- package/templates/gallery/app/apple-icon.ts +17 -0
- package/templates/gallery/app/features/async-render/page.ts +11 -1
- package/templates/gallery/app/features/caching/page.ts +9 -0
- package/templates/gallery/app/features/components/page.ts +8 -0
- package/templates/gallery/app/features/file-storage/file/[key]/route.ts +10 -1
- package/templates/gallery/app/features/rate-limit/page.ts +1 -1
- package/templates/gallery/app/features/route-handler/data/route.ts +34 -5
- package/templates/gallery/app/features/route-handler/page.ts +5 -2
- package/templates/gallery/app/features/routing/legacy/page.ts +12 -0
- package/templates/gallery/app/features/routing/page.ts +1 -0
- package/templates/gallery/app/features/sessions/count/route.ts +12 -0
- package/templates/gallery/app/features/sessions/middleware.ts +7 -0
- package/templates/gallery/app/features/sessions/page.ts +19 -0
- package/templates/gallery/app/global-error.ts +46 -0
- package/templates/gallery/app/global-not-found.ts +22 -0
- package/templates/gallery/app/icon.ts +19 -0
- package/templates/gallery/app/opengraph-image.ts +20 -0
- package/templates/gallery/app/sitemaps/route.ts +17 -0
- package/templates/gallery/app/twitter-image.ts +19 -0
- package/templates/gallery/modules/caching/actions/bust-caches.server.ts +25 -0
- package/templates/gallery/modules/caching/components/cache-buster.ts +30 -0
- package/templates/gallery/modules/client-router/components/router-controls.ts +40 -12
- package/templates/gallery/modules/components/components/browser/counter-card.test.js +21 -1
- package/templates/gallery/modules/components/components/server-render.test.ts +20 -0
- package/templates/gallery/modules/components/components/task-loader.ts +48 -0
- package/templates/gallery/modules/components/components/theme-context.ts +67 -0
- package/templates/gallery/modules/directives/components/directive-demo.ts +100 -3
- package/templates/gallery/modules/file-storage/actions/store-upload.server.ts +3 -0
- package/templates/gallery/modules/file-storage/store.server.ts +29 -0
- package/templates/gallery/modules/route-handler/components/rich-data.ts +31 -0
- package/templates/gallery/modules/server-actions/actions/greet.server.ts +9 -1
- package/templates/gallery/modules/server-actions/actions/greet.test.ts +37 -0
- package/templates/gallery/modules/sessions/session-config.server.ts +31 -0
- package/templates/gallery/modules/websockets/components/ws-echo.ts +19 -1
- package/templates/gallery/modules/websockets/echo.test.ts +21 -0
- 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.' },
|
package/lib/saas-template.js
CHANGED
|
@@ -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,8 +110,18 @@ 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,",
|
|
121
|
+
" // A failed credentials sign-in 302s to `${pages.error}?error=CredentialsSignin`.",
|
|
122
|
+
" // Point it at /login so app/login/page.ts reads searchParams.error and shows a",
|
|
123
|
+
" // message, instead of the createAuth default (the home page) swallowing the error.",
|
|
124
|
+
" pages: { error: '/login' },",
|
|
115
125
|
"});",
|
|
116
126
|
"",
|
|
117
127
|
].join('\n'));
|
|
@@ -333,7 +343,17 @@ export async function writeSaasFiles(appDir, opts = {}) {
|
|
|
333
343
|
"",
|
|
334
344
|
"export const metadata = { title: 'Login' };",
|
|
335
345
|
"",
|
|
336
|
-
"
|
|
346
|
+
"// A failed sign-in 302s back here with ?error=... (createAuth is configured",
|
|
347
|
+
"// with pages.error: '/login' in lib/auth.server.ts). Map the code to a plain",
|
|
348
|
+
"// message so a bad password gets visible feedback instead of a silent bounce.",
|
|
349
|
+
"function errorMessage(code: string | undefined): string | null {",
|
|
350
|
+
" if (!code) return null;",
|
|
351
|
+
" if (code === 'CredentialsSignin') return 'Invalid email or password.';",
|
|
352
|
+
" return 'Could not sign you in. Please try again.';",
|
|
353
|
+
"}",
|
|
354
|
+
"",
|
|
355
|
+
"export default function LoginPage({ searchParams }: { searchParams: { error?: string } }) {",
|
|
356
|
+
" const error = errorMessage(searchParams.error);",
|
|
337
357
|
" return html`",
|
|
338
358
|
" <div class=\"max-w-sm mx-auto mt-12\">",
|
|
339
359
|
" <div class=${cardClass()}>",
|
|
@@ -342,6 +362,7 @@ export async function writeSaasFiles(appDir, opts = {}) {
|
|
|
342
362
|
" <p class=${cardDescriptionClass()}>Welcome back: log in to continue.</p>",
|
|
343
363
|
" </div>",
|
|
344
364
|
" <div class=${cardContentClass()}>",
|
|
365
|
+
" ${error ? html`<p role=\"alert\" class=\"mb-4 text-sm text-destructive\">${error}</p>` : ''}",
|
|
345
366
|
" <form method=\"POST\" action=\"/api/auth/signin/credentials\" class=\"flex flex-col gap-4\">",
|
|
346
367
|
" <!-- createAuth reads redirectTo from the posted form and 302s there after a successful signin. -->",
|
|
347
368
|
" <input type=\"hidden\" name=\"redirectTo\" value=\"/dashboard\">",
|
|
@@ -455,12 +476,39 @@ export async function writeSaasFiles(appDir, opts = {}) {
|
|
|
455
476
|
"",
|
|
456
477
|
].join('\n'));
|
|
457
478
|
|
|
479
|
+
// app/dashboard/layout.ts: a thin sub-nav shared by every /dashboard page
|
|
480
|
+
// (page.ts and settings/page.ts). It carries the logout control so a signed-in
|
|
481
|
+
// user can end the session from anywhere under /dashboard.
|
|
482
|
+
await writeFile(join(appDir, 'app', 'dashboard', 'layout.ts'), [
|
|
483
|
+
"import { html } from '@webjsdev/core';",
|
|
484
|
+
"import { buttonClass } from '#components/ui/button.ts';",
|
|
485
|
+
"",
|
|
486
|
+
"// Nested layout for the protected /dashboard subtree. Logout is a plain",
|
|
487
|
+
"// <form method=\"POST\"> posting to the createAuth signout route: it clears the",
|
|
488
|
+
"// session cookie and 302s home, and works with JS off (progressive-enhancement",
|
|
489
|
+
"// default). signOut is server-only (lib/auth.server.ts), so we POST to its route",
|
|
490
|
+
"// rather than import it into a browser-shipping page. After signout the dashboard",
|
|
491
|
+
"// middleware bounces any later /dashboard visit to /login.",
|
|
492
|
+
"export default function DashboardLayout({ children }: { children: unknown }) {",
|
|
493
|
+
" return html`",
|
|
494
|
+
" <nav class=\"flex items-center gap-4 mb-6 pb-4 border-b border-border\">",
|
|
495
|
+
" <a href=\"/dashboard\" class=\"text-sm font-medium hover:underline\">Dashboard</a>",
|
|
496
|
+
" <a href=\"/dashboard/settings\" class=\"text-sm font-medium hover:underline\">Settings</a>",
|
|
497
|
+
" <form method=\"POST\" action=\"/api/auth/signout\" class=\"ml-auto\">",
|
|
498
|
+
" <button class=${buttonClass({ variant: 'outline', size: 'sm' })} type=\"submit\">Log out</button>",
|
|
499
|
+
" </form>",
|
|
500
|
+
" </nav>",
|
|
501
|
+
" ${children}",
|
|
502
|
+
" `;",
|
|
503
|
+
"}",
|
|
504
|
+
"",
|
|
505
|
+
].join('\n'));
|
|
506
|
+
|
|
458
507
|
// app/dashboard/page.ts
|
|
459
508
|
await writeFile(join(appDir, 'app', 'dashboard', 'page.ts'), [
|
|
460
509
|
"import { html } from '@webjsdev/core';",
|
|
461
510
|
"import { currentUser } from '#modules/auth/queries/current-user.server.ts';",
|
|
462
|
-
"import { cardClass, cardHeaderClass, cardTitleClass, cardDescriptionClass
|
|
463
|
-
"import { buttonClass } from '#components/ui/button.ts';",
|
|
511
|
+
"import { cardClass, cardHeaderClass, cardTitleClass, cardDescriptionClass } from '#components/ui/card.ts';",
|
|
464
512
|
"import { badgeClass } from '#components/ui/badge.ts';",
|
|
465
513
|
"",
|
|
466
514
|
"export const metadata = { title: 'Dashboard' };",
|
|
@@ -477,9 +525,6 @@ export async function writeSaasFiles(appDir, opts = {}) {
|
|
|
477
525
|
" <h2 class=${cardTitleClass()}>Welcome, ${user?.name || user?.email}!</h2>",
|
|
478
526
|
" <p class=${cardDescriptionClass()}>You're authenticated. Replace this scaffold with your real app.</p>",
|
|
479
527
|
" </div>",
|
|
480
|
-
" <div class=${cardContentClass()}>",
|
|
481
|
-
" <a class=${buttonClass({ variant: 'outline' })} href=\"/dashboard/settings\">Settings</a>",
|
|
482
|
-
" </div>",
|
|
483
528
|
" </div>",
|
|
484
529
|
" `;",
|
|
485
530
|
"}",
|
package/package.json
CHANGED
|
@@ -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(
|
|
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.
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
|
|
7
|
-
|
|
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
|
|
11
|
-
<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');
|