@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.
- package/lib/create.js +18 -11
- package/lib/doctor.js +1 -1
- package/lib/saas-template.js +27 -7
- package/package.json +1 -1
- package/templates/.agents/rules/workflow.md +1 -1
- package/templates/.github/copilot-instructions.md +1 -1
- package/templates/AGENTS.md +7 -6
- package/templates/CONVENTIONS.md +4 -4
- 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/boundaries/crash/page.ts +7 -0
- package/templates/gallery/app/features/boundaries/error.ts +16 -0
- package/templates/gallery/app/features/boundaries/gated/forbidden.ts +18 -0
- package/templates/gallery/app/features/boundaries/gated/page.ts +10 -0
- package/templates/gallery/app/features/boundaries/loading.ts +15 -0
- package/templates/gallery/app/features/boundaries/not-found.ts +17 -0
- package/templates/gallery/app/features/boundaries/page.ts +77 -0
- package/templates/gallery/app/features/boundaries/private/page.ts +10 -0
- package/templates/gallery/app/features/boundaries/private/unauthorized.ts +17 -0
- package/templates/gallery/app/features/caching/page.ts +9 -0
- package/templates/gallery/app/features/client-router/page.ts +3 -0
- package/templates/gallery/app/features/components/page.ts +11 -0
- package/templates/gallery/app/features/file-storage/file/[key]/route.ts +10 -1
- package/templates/gallery/app/features/file-storage/page.ts +2 -2
- package/templates/gallery/app/features/forms/page.ts +1 -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/[id]/page.ts +13 -1
- package/templates/gallery/app/features/routing/legacy/page.ts +12 -0
- package/templates/gallery/app/features/routing/page.ts +9 -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/manifest.ts +22 -0
- package/templates/gallery/app/opengraph-image.ts +20 -0
- package/templates/gallery/app/robots.ts +16 -0
- package/templates/gallery/app/sitemap.ts +23 -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 +52 -0
- package/templates/gallery/modules/components/components/browser/counter-card.test.js +21 -1
- package/templates/gallery/modules/components/components/counter-card.ts +1 -1
- package/templates/gallery/modules/components/components/reactive-meter.ts +64 -0
- 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 +162 -20
- 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/rate-limit/components/rate-probe.ts +1 -1
- 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/server-actions/components/greeter.ts +1 -1
- package/templates/gallery/modules/sessions/session-config.server.ts +31 -0
- package/templates/gallery/modules/todo/components/todo-app.ts +5 -5
- 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
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
import { html } from '@webjsdev/core';
|
|
3
3
|
import type { Metadata } from '@webjsdev/core';
|
|
4
4
|
import '#modules/components/components/counter-card.ts';
|
|
5
|
+
import '#modules/components/components/reactive-meter.ts';
|
|
6
|
+
import '#modules/components/components/theme-context.ts';
|
|
7
|
+
import '#modules/components/components/task-loader.ts';
|
|
5
8
|
|
|
6
9
|
export const metadata: Metadata = { title: 'Components (signals + slots) | features' };
|
|
7
10
|
|
|
@@ -10,5 +13,13 @@ export default function ComponentsExample() {
|
|
|
10
13
|
<h1 class="text-h2 font-bold mb-4">Components</h1>
|
|
11
14
|
<p class="text-muted-foreground mb-4">The WebComponent factory, a reactive prop, an instance signal, and a slot.</p>
|
|
12
15
|
<counter-card label="Taps"><strong>A slotted title</strong></counter-card>
|
|
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>
|
|
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>
|
|
13
24
|
`;
|
|
14
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
|
|
@@ -42,7 +42,7 @@ export default function FileStorageExample({
|
|
|
42
42
|
<code class="font-mono">setFileStore()</code> call, no call-site change.
|
|
43
43
|
</p>
|
|
44
44
|
<form method="post" enctype="multipart/form-data" class="flex flex-wrap gap-3 items-center mb-4">
|
|
45
|
-
<input type="file" name="file" required
|
|
45
|
+
<input type="file" name="file" required aria-label="Choose a file to upload"
|
|
46
46
|
class="text-sm text-muted-foreground file:mr-3 file:px-3.5 file:py-2 file:rounded-xl file:border-0 file:bg-card file:border file:border-border file:text-foreground file:text-sm file:cursor-pointer" />
|
|
47
47
|
<button type="submit"
|
|
48
48
|
class="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]">Upload</button>
|
|
@@ -54,7 +54,7 @@ export default function FileStorageExample({
|
|
|
54
54
|
? html`
|
|
55
55
|
<div class="px-4 py-3 rounded-xl bg-card border border-border text-sm">
|
|
56
56
|
Stored <span class="text-foreground font-medium">${name}</span>
|
|
57
|
-
<span class="text-muted-foreground
|
|
57
|
+
<span class="text-muted-foreground">(${size} bytes)</span>
|
|
58
58
|
<a class="text-primary no-underline ml-2" href="/features/file-storage/file/${key}">download</a>
|
|
59
59
|
</div>`
|
|
60
60
|
: ''}
|
|
@@ -26,7 +26,7 @@ const field = (label: string, name: string, input: unknown, error?: string) => h
|
|
|
26
26
|
</div>
|
|
27
27
|
`;
|
|
28
28
|
|
|
29
|
-
const inputCls = 'w-full bg-background border border-border rounded-xl px-3 py-2 text-[15px] text-foreground outline-none transition-colors focus:border-primary placeholder:text-muted-foreground
|
|
29
|
+
const inputCls = 'w-full bg-background border border-border rounded-xl px-3 py-2 text-[15px] text-foreground outline-none transition-colors focus:border-primary placeholder:text-muted-foreground';
|
|
30
30
|
|
|
31
31
|
export default function FormsFeature({ searchParams, actionData }: { searchParams: Record<string, string | undefined>; actionData?: Result }) {
|
|
32
32
|
if (searchParams.sent) {
|
|
@@ -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
|
}
|
|
@@ -12,12 +12,16 @@
|
|
|
12
12
|
// params, so PageProps<'/features/routing/[id]'>['params'] narrows to
|
|
13
13
|
// { id: string } automatically. Rename the folder and the type follows; pass a
|
|
14
14
|
// route literal that does not exist and it is a compile error.
|
|
15
|
-
import { html } from '@webjsdev/core';
|
|
15
|
+
import { html, notFound } from '@webjsdev/core';
|
|
16
16
|
import type { PageProps } from '@webjsdev/core';
|
|
17
17
|
|
|
18
18
|
export default async function RoutingParam({ params }: PageProps<'/features/routing/[id]'>) {
|
|
19
19
|
// The Next-style await also works; `params.id` sync would be identical.
|
|
20
20
|
const { id } = await params;
|
|
21
|
+
// Throw notFound() to short-circuit into the nearest not-found boundary (404).
|
|
22
|
+
// Here the reserved id "missing" stands in for "no such record"; in a real app
|
|
23
|
+
// you throw this after a DB lookup returns nothing. Try /features/routing/missing.
|
|
24
|
+
if (id === 'missing') notFound();
|
|
21
25
|
return html`
|
|
22
26
|
<h1 class="text-h2 font-bold mb-4">Route param</h1>
|
|
23
27
|
<p>The <code>[id]</code> segment is: <strong>${id}</strong></p>
|
|
@@ -28,6 +32,14 @@ export default async function RoutingParam({ params }: PageProps<'/features/rout
|
|
|
28
32
|
<code class="font-mono">params</code> is awaitable too:
|
|
29
33
|
<code class="font-mono">const { id } = await params</code> works, same value.
|
|
30
34
|
</p>
|
|
35
|
+
<p class="text-muted-foreground text-sm mt-3">
|
|
36
|
+
Throwing wins over rendering: <a class="text-primary" href="/features/routing/missing">/features/routing/missing</a>
|
|
37
|
+
throws <code class="font-mono">notFound()</code> and renders the nearest
|
|
38
|
+
not-found boundary at 404. See the
|
|
39
|
+
<a class="text-primary" href="/features/boundaries">Boundaries</a> demo for
|
|
40
|
+
<code class="font-mono">forbidden()</code> and
|
|
41
|
+
<code class="font-mono">unauthorized()</code>.
|
|
42
|
+
</p>
|
|
31
43
|
<p class="mt-3"><a class="text-primary" href="/features/routing">Back</a></p>
|
|
32
44
|
`;
|
|
33
45
|
}
|
|
@@ -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
|
|
@@ -43,5 +44,13 @@ export default function RoutingExample() {
|
|
|
43
44
|
<code class="font-mono">redirect()</code> on the server, call
|
|
44
45
|
<code class="font-mono">navigate()</code> on the client.
|
|
45
46
|
</p>
|
|
47
|
+
<p class="text-muted-foreground text-sm mt-3">
|
|
48
|
+
A page can also THROW to short-circuit rendering:
|
|
49
|
+
<code class="font-mono">notFound()</code> (404),
|
|
50
|
+
<code class="font-mono">forbidden()</code> (403), and
|
|
51
|
+
<code class="font-mono">unauthorized()</code> (401), each rendering the
|
|
52
|
+
nearest matching boundary file. See the
|
|
53
|
+
<a class="text-primary" href="/features/boundaries">Boundaries</a> demo.
|
|
54
|
+
</p>
|
|
46
55
|
`;
|
|
47
56
|
}
|
|
@@ -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,22 @@
|
|
|
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/manifest.ts serves /manifest.json (the web app manifest). The default
|
|
6
|
+
// export returns an object, serialized to JSON. Adapt the name, colors, and
|
|
7
|
+
// icons to your app; pair it with the opt-in service worker for an installable
|
|
8
|
+
// PWA. See agent-docs/service-worker.md. (Gallery files are copied verbatim, so
|
|
9
|
+
// set the real app name here by hand rather than expecting substitution.)
|
|
10
|
+
export default function Manifest() {
|
|
11
|
+
return {
|
|
12
|
+
name: 'webjs app',
|
|
13
|
+
short_name: 'webjs app',
|
|
14
|
+
start_url: '/',
|
|
15
|
+
display: 'standalone',
|
|
16
|
+
background_color: '#ffffff',
|
|
17
|
+
theme_color: '#1c1613',
|
|
18
|
+
icons: [
|
|
19
|
+
{ src: '/favicon.svg', sizes: 'any', type: 'image/svg+xml' },
|
|
20
|
+
],
|
|
21
|
+
};
|
|
22
|
+
}
|
|
@@ -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,16 @@
|
|
|
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/robots.ts serves /robots.txt. The default export returns a string (served
|
|
6
|
+
// as text/plain) or an object. This example allows all crawlers and points them
|
|
7
|
+
// at the sitemap. Tighten the rules (Disallow paths) for a real app.
|
|
8
|
+
const SITE_URL = (process.env.SITE_URL || 'http://localhost:8080').replace(/\/$/, '');
|
|
9
|
+
|
|
10
|
+
export default function Robots() {
|
|
11
|
+
return [
|
|
12
|
+
'User-agent: *',
|
|
13
|
+
'Allow: /',
|
|
14
|
+
`Sitemap: ${SITE_URL}/sitemap.xml`,
|
|
15
|
+
].join('\n');
|
|
16
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// webjs-scaffold-placeholder. Metadata route. Keep and adapt it to your real
|
|
2
|
+
// routes, or prune it (delete this file), then delete this marker line. webjs
|
|
3
|
+
// check fails while the marker remains.
|
|
4
|
+
//
|
|
5
|
+
// app/sitemap.ts serves /sitemap.xml. The default export is a (possibly async)
|
|
6
|
+
// server function; `sitemap(entries)` from @webjsdev/server serializes a
|
|
7
|
+
// spec-valid XML sitemap. In a real app, build the entries from your content
|
|
8
|
+
// (a query over posts/products), so new content is discoverable without editing
|
|
9
|
+
// this file. Here it lists the gallery's static routes as an example.
|
|
10
|
+
import { sitemap } from '@webjsdev/server';
|
|
11
|
+
|
|
12
|
+
const SITE_URL = (process.env.SITE_URL || 'http://localhost:8080').replace(/\/$/, '');
|
|
13
|
+
|
|
14
|
+
export default function Sitemap() {
|
|
15
|
+
const routes = ['/', '/features/routing', '/features/boundaries'];
|
|
16
|
+
return sitemap(
|
|
17
|
+
routes.map((path) => ({
|
|
18
|
+
url: `${SITE_URL}${path}`,
|
|
19
|
+
changeFrequency: 'weekly' as const,
|
|
20
|
+
priority: path === '/' ? 1.0 : 0.7,
|
|
21
|
+
})),
|
|
22
|
+
);
|
|
23
|
+
}
|
|
@@ -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');
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// Programmatic client navigation. `navigate(url)` does the same soft, in-place
|
|
2
|
+
// swap an <a> click does, but from an event handler (after a save, a wizard
|
|
3
|
+
// step, etc.). `revalidate(url?)` evicts the browser snapshot cache so the next
|
|
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';
|
|
15
|
+
|
|
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
|
+
|
|
26
|
+
render() {
|
|
27
|
+
const soft = this.soft.get();
|
|
28
|
+
return html`
|
|
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>
|
|
48
|
+
</div>
|
|
49
|
+
`;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
RouterControls.register('router-controls');
|