@webjsdev/cli 0.10.44 → 0.10.46
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/README.md +2 -3
- package/bin/webjs.js +18 -18
- package/lib/api-gallery.js +1 -1
- package/lib/create.js +154 -243
- package/package.json +1 -1
- package/templates/.agents/rules/workflow.md +7 -3
- package/templates/.agents/skills/webjs/SKILL.md +4 -2
- package/templates/.agents/skills/webjs/references/auth-and-sessions.md +78 -16
- package/templates/.agents/skills/webjs/references/built-ins.md +16 -2
- package/templates/.agents/skills/webjs/references/client-router-and-streaming.md +38 -6
- package/templates/.agents/skills/webjs/references/components.md +101 -6
- package/templates/.agents/skills/webjs/references/data-and-actions.md +19 -2
- package/templates/.agents/skills/webjs/references/muscle-memory-gotchas.md +5 -1
- package/templates/.agents/skills/webjs/references/optimistic-ui.md +18 -0
- package/templates/.agents/skills/webjs/references/routing-and-pages.md +25 -2
- package/templates/.agents/skills/webjs/references/runtime.md +1 -1
- package/templates/.agents/skills/webjs/references/service-worker.md +1 -1
- package/templates/.agents/skills/webjs/references/styling.md +82 -2
- package/templates/AGENTS.md +12 -5
- package/templates/gallery/app/api/auth/[...path]/route.ts +7 -0
- package/templates/gallery/app/apple-icon.ts +2 -5
- package/templates/gallery/app/examples/layout.ts +16 -0
- package/templates/gallery/app/examples/todo/page.ts +2 -1
- package/templates/gallery/app/features/async-render/page.ts +3 -2
- package/templates/gallery/app/features/auth/dashboard/layout.ts +21 -0
- package/templates/gallery/app/features/auth/dashboard/middleware.ts +14 -0
- package/templates/gallery/app/features/auth/dashboard/page.ts +20 -0
- package/templates/gallery/app/features/auth/dashboard/settings/page.ts +22 -0
- package/templates/gallery/app/features/auth/login/middleware.ts +15 -0
- package/templates/gallery/app/features/auth/login/page.ts +43 -0
- package/templates/gallery/app/features/auth/page.ts +34 -0
- package/templates/gallery/app/features/auth/signup/middleware.ts +11 -0
- package/templates/gallery/app/features/auth/signup/page.ts +61 -0
- package/templates/gallery/app/features/boundaries/error.ts +5 -4
- package/templates/gallery/app/features/boundaries/gated/forbidden.ts +5 -4
- package/templates/gallery/app/features/boundaries/not-found.ts +5 -4
- package/templates/gallery/app/features/boundaries/page.ts +11 -10
- package/templates/gallery/app/features/boundaries/private/unauthorized.ts +5 -4
- package/templates/gallery/app/features/broadcast/page.ts +4 -3
- package/templates/gallery/app/features/caching/page.ts +5 -4
- package/templates/gallery/app/features/client-router/page.ts +7 -5
- package/templates/gallery/app/features/client-router/second/page.ts +4 -3
- package/templates/gallery/app/features/components/page.ts +3 -2
- package/templates/gallery/app/features/directives/page.ts +3 -2
- package/templates/gallery/app/features/env/page.ts +4 -3
- package/templates/gallery/app/features/file-storage/page.ts +8 -5
- package/templates/gallery/app/features/forms/page.ts +10 -6
- package/templates/gallery/app/features/frames/page.ts +26 -10
- package/templates/gallery/app/features/layout.ts +67 -0
- package/templates/gallery/app/features/metadata/page.ts +7 -6
- package/templates/gallery/app/features/optimistic-ui/page.ts +3 -2
- package/templates/gallery/app/features/rate-limit/page.ts +6 -5
- package/templates/gallery/app/features/route-handler/page.ts +4 -3
- package/templates/gallery/app/features/routing/[id]/page.ts +7 -6
- package/templates/gallery/app/features/routing/page.ts +10 -9
- package/templates/gallery/app/features/server-actions/page.ts +7 -4
- package/templates/gallery/app/features/service-worker/page.ts +4 -3
- package/templates/gallery/app/features/sessions/page.ts +5 -4
- package/templates/gallery/app/features/stream/page.ts +46 -0
- package/templates/gallery/app/features/streaming/page.ts +32 -0
- package/templates/gallery/app/features/suspense/page.ts +35 -0
- package/templates/gallery/app/features/view-transitions/page.ts +44 -0
- package/templates/gallery/app/features/view-transitions/second/page.ts +30 -0
- package/templates/gallery/app/features/websockets/page.ts +4 -3
- package/templates/gallery/app/global-error.ts +2 -5
- package/templates/gallery/app/global-not-found.ts +4 -6
- package/templates/gallery/app/icon.ts +2 -5
- package/templates/gallery/app/manifest.ts +1 -4
- package/templates/gallery/app/opengraph-image.ts +3 -6
- package/templates/gallery/app/robots.ts +0 -3
- package/templates/gallery/app/sitemap.ts +0 -3
- package/templates/gallery/app/twitter-image.ts +3 -6
- package/templates/gallery/components/ui/badge.ts +41 -0
- package/templates/gallery/components/ui/button.ts +86 -0
- package/templates/gallery/components/ui/card.ts +36 -0
- package/templates/gallery/components/ui/input.ts +50 -0
- package/templates/gallery/lib/utils/ui.ts +31 -0
- package/templates/gallery/modules/auth/actions/signup.server.ts +19 -0
- package/templates/gallery/modules/auth/auth.server.ts +53 -0
- package/templates/gallery/modules/auth/password.server.ts +20 -0
- package/templates/gallery/modules/auth/queries/current-user.server.ts +12 -0
- package/templates/gallery/modules/auth/types.ts +9 -0
- package/templates/gallery/modules/broadcast/components/broadcast-feed.ts +4 -2
- package/templates/gallery/modules/caching/components/cache-buster.ts +2 -1
- package/templates/gallery/modules/client-router/components/router-controls.ts +4 -3
- package/templates/gallery/modules/components/components/counter-card.ts +4 -2
- package/templates/gallery/modules/components/components/reactive-meter.ts +9 -1
- package/templates/gallery/modules/components/components/task-loader.ts +3 -2
- package/templates/gallery/modules/components/components/theme-context.ts +5 -3
- package/templates/gallery/modules/directives/components/directive-demo.ts +17 -10
- package/templates/gallery/modules/gallery/components/gallery-nav.ts +54 -0
- package/templates/gallery/modules/gallery/nav.ts +79 -0
- package/templates/gallery/modules/optimistic-ui/components/like-button.ts +19 -1
- package/templates/gallery/modules/rate-limit/components/rate-probe.ts +2 -1
- package/templates/gallery/modules/route-handler/components/rich-data.ts +2 -1
- package/templates/gallery/modules/server-actions/actions/greet.server.ts +2 -2
- package/templates/gallery/modules/server-actions/actions/greet.test.ts +39 -36
- package/templates/gallery/modules/server-actions/components/greeter.ts +11 -11
- package/templates/gallery/modules/server-actions/middleware/require-auth.server.ts +14 -9
- package/templates/gallery/modules/stream/components/stream-demo.ts +81 -0
- package/templates/gallery/modules/streaming/actions/stream-tokens.server.ts +17 -0
- package/templates/gallery/modules/streaming/components/token-stream.ts +53 -0
- package/templates/gallery/modules/suspense/components/slow-fact.ts +20 -0
- package/templates/gallery/modules/todo/components/todo-app.ts +8 -4
- package/templates/gallery/modules/websockets/components/ws-echo.ts +5 -3
- package/templates/gallery/test/auth/auth.test.ts +81 -0
- package/templates/public/favicon.svg +10 -3
- package/templates/scripts/clear-api-gallery.mjs +55 -0
- package/templates/scripts/clear-gallery.mjs +143 -24
- package/lib/lean-copy.js +0 -43
- package/lib/saas-template.js +0 -568
|
@@ -16,7 +16,9 @@ Read `AGENTS.md` first. Full hosted docs are at https://docs.webjs.dev.
|
|
|
16
16
|
the demos relevant to your task under `app/features/<x>` for the runnable idiom
|
|
17
17
|
(the skill teaches the same and SURVIVES the clear, so you never lose it);
|
|
18
18
|
(2) run `npm run gallery:clear` to shed the whole gallery in one step (it keeps
|
|
19
|
-
the agent skill
|
|
19
|
+
the agent skill and the database wiring, and resets the home AND the root
|
|
20
|
+
layout to a token-free blank slate, no gallery palette or navbar survives; a
|
|
21
|
+
layout you already customised is kept, only its theme-toggle wiring stripped);
|
|
20
22
|
(3) regenerate the database and grow the app in place under `app/`,
|
|
21
23
|
`components/`, and `modules/<feature>/`. Keep the gallery only while exploring,
|
|
22
24
|
never ship it.
|
|
@@ -27,8 +29,10 @@ Read `AGENTS.md` first. Full hosted docs are at https://docs.webjs.dev.
|
|
|
27
29
|
- **`app/` is routing-only.** Only routing files live in `app/` (page, layout,
|
|
28
30
|
route, middleware, metadata routes). Browser-safe helpers go in `lib/utils/`,
|
|
29
31
|
feature logic in `modules/`, server-only code behind `.server.ts`.
|
|
30
|
-
- **Give a UI app its own design.**
|
|
31
|
-
|
|
32
|
+
- **Give a UI app its own design.** Define design tokens in `app/layout.ts` with
|
|
33
|
+
a palette that fits the app (after `gallery:clear` the layout is a token-free
|
|
34
|
+
blank slate; `.agents/skills/webjs/references/styling.md` is the guide).
|
|
35
|
+
Render the app and LOOK before calling UI work
|
|
32
36
|
done: `webjs check` and `webjs typecheck` pass even when a layout collapses, so
|
|
33
37
|
open every route you changed in a real browser and play through its states.
|
|
34
38
|
|
|
@@ -112,17 +112,19 @@ Find the right export fast. Load the linked reference for full examples.
|
|
|
112
112
|
### `@webjsdev/core` (browser + isomorphic)
|
|
113
113
|
|
|
114
114
|
- `html` / `css` tagged templates. `WebComponent({ ... })` base-class factory; `prop(type?, opts?)` declares one reactive property. `register(tag, C)` / `Class.register('tag')`.
|
|
115
|
-
- `signal` / `computed` reactive state; `render(v, el)` client render.
|
|
115
|
+
- `signal` / `computed` reactive state, `effect(fn)` client-only reaction (returns a disposer), `batch(fn)` coalesced writes; `render(v, el)` client render.
|
|
116
116
|
- `notFound()` / `redirect(url[, status])` control-flow throws (page/layout/action only, NOT `route.ts`). `forbidden()` / `unauthorized()` render the nearest boundary.
|
|
117
117
|
- `Suspense({fallback, children})` page-level streaming; `<webjs-suspense>` component-level streaming.
|
|
118
118
|
- `optimistic()` optimistic UI; `navigate(url)` / `revalidate(url?)` client-router control; `connectWS` / `richFetch`.
|
|
119
119
|
- Types: `Metadata`, `PageProps<R>`, `LayoutProps<R>`, `RouteHandlerContext<R>`, `WebjsConfig`.
|
|
120
120
|
- `@webjsdev/core/server`: `renderToString` / `renderToStream` (Node side).
|
|
121
|
-
- `@webjsdev/core/directives`: `repeat`, `unsafeHTML` (trusted only), `live`, `keyed`, `guard`, `cache`, `until`, `watch(signal)`, `ref` / `createRef`. `Task`
|
|
121
|
+
- `@webjsdev/core/directives`: `repeat`, `unsafeHTML` (trusted only), `live`, `keyed`, `guard`, `cache`, `until`, `watch(signal)`, `ref` / `createRef`, `asyncAppend` / `asyncReplace`, `templateContent`. `Task` / `TaskStatus` live at `@webjsdev/core/task`, context (`createContext` / `ContextProvider` / `ContextConsumer`) at `/context`. See `references/components.md` for the directive table + Task + context.
|
|
122
122
|
|
|
123
123
|
### `@webjsdev/server` (server side)
|
|
124
124
|
|
|
125
125
|
- `createRequestHandler`, `cors()`, `route(action, opts?)` REST adapter, `sitemap()` / `sitemapIndex()`, `actionContext()`, `actionSignal()`, `requestId()`, `cache()` / `revalidateTag`.
|
|
126
|
+
- Route-handler toolkit: `json(v)` rich responder, `readBody(req)`, `clientIp(req)`, no-arg `headers()` / `cookies()` / `cspNonce()` (client counterpart `richFetch` is in `@webjsdev/core`). See `references/routing-and-pages.md`.
|
|
127
|
+
- Auth + sessions: `createAuth` (+ `Credentials` / `Google` / `GitHub`), `auth()` / `auth(req)`, `session()` + `cookieSession` / `storeSession`, `getSession(req)` (`.get` / `.set` / `.flash` / `.destroy`). File storage: `getFileStore` / `diskStore` / `signedUrl`. See `references/auth-and-sessions.md` + `references/built-ins.md`.
|
|
126
128
|
- Data layer is Drizzle in `db/*.server.ts`. Auth, sessions, caching, rate limit, file storage are built in and pluggable (`references/built-ins.md`).
|
|
127
129
|
|
|
128
130
|
### File conventions
|
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
## What This Covers
|
|
4
4
|
|
|
5
|
-
- Sessions:
|
|
6
|
-
- Authentication: `createAuth` (NextAuth-style), Credentials plus OAuth providers, `auth()` in a page or action
|
|
7
|
-
- Login and logout flows
|
|
8
|
-
- Protecting a route:
|
|
5
|
+
- Sessions: the `session()` middleware + storage factories (`cookieSession` / `storeSession`), the `getSession(req)` method API (`.get` / `.set` / `.flash` / `.destroy`), the `SESSION_SECRET` requirement
|
|
6
|
+
- Authentication: `createAuth` (NextAuth-style), Credentials plus OAuth providers, `auth()` in a page or action, scrypt password hashing
|
|
7
|
+
- Login and logout flows: mounting `handlers` at `app/api/auth/[...path]`, the no-JS credentials form (`/api/auth/signin/credentials` + `redirectTo` + `?error`), `signIn` / `signOut`
|
|
8
|
+
- Protecting a route: a page-top `auth()` gate OR a per-segment `middleware.ts` calling `auth(req)`
|
|
9
9
|
- `forbidden()` (403) vs `unauthorized()` (401) and their nearest-wins boundary files
|
|
10
10
|
- Returning an `ActionResult` for an auth failure inside a `'use server'` action (do NOT throw there)
|
|
11
11
|
- The Origin / `Sec-Fetch-Site` CSRF model (not a token cookie)
|
|
@@ -21,23 +21,30 @@ scaling, the full caching surface).
|
|
|
21
21
|
|
|
22
22
|
## Sessions
|
|
23
23
|
|
|
24
|
-
Enable sessions
|
|
24
|
+
Enable sessions with `session()` MIDDLEWARE, then read and write them with `getSession(req)` in any route or middleware the session wraps.
|
|
25
25
|
|
|
26
26
|
```ts
|
|
27
|
-
// middleware.ts: enable on all routes
|
|
28
|
-
import { session } from '@webjsdev/server';
|
|
29
|
-
export default session(
|
|
27
|
+
// middleware.ts: enable on all routes. Storage is pluggable.
|
|
28
|
+
import { session, cookieSession, storeSession } from '@webjsdev/server';
|
|
29
|
+
export default session({ secret: process.env.SESSION_SECRET, storage: cookieSession() });
|
|
30
|
+
// cookieSession() -> whole session in a signed cookie (stateless, the default)
|
|
31
|
+
// storeSession() -> session in the active store (memoryStore in dev, Redis in prod), id in the cookie
|
|
32
|
+
```
|
|
30
33
|
|
|
31
|
-
|
|
34
|
+
`getSession(req)` returns a small key/value `Session` with a METHOD API, not property assignment. Mutating it makes the middleware re-sign and set the cookie on the way out:
|
|
35
|
+
|
|
36
|
+
```ts
|
|
32
37
|
import { getSession } from '@webjsdev/server';
|
|
33
|
-
|
|
34
|
-
s
|
|
38
|
+
export async function GET(req: Request) {
|
|
39
|
+
const s = getSession(req);
|
|
40
|
+
s.set('userId', user.id); // write
|
|
41
|
+
const id = s.get('userId'); // read
|
|
42
|
+
s.flash('notice', 'Saved'); // one-read-only value (cleared after the next read)
|
|
43
|
+
s.destroy(); // clear the whole session (logout)
|
|
44
|
+
}
|
|
35
45
|
```
|
|
36
46
|
|
|
37
|
-
Cookie sessions (the default) are signed and
|
|
38
|
-
state. Store sessions (with Redis) keep the session id in the cookie and
|
|
39
|
-
the data in Redis. Both require `SESSION_SECRET`, read from the
|
|
40
|
-
environment (never a literal in source) so boot fails if it is missing.
|
|
47
|
+
Cookie sessions (the default) are signed with no server state; store sessions keep only the id in the cookie and the data in the store. `cookieSession` / `storeSession` are aliases for `cookieSessionStorage` / `storeSessionStorage`. Both strategies require `SESSION_SECRET`, read from the environment (never a literal in source) so boot fails if it is missing.
|
|
41
48
|
|
|
42
49
|
## Authentication (`createAuth`)
|
|
43
50
|
|
|
@@ -55,7 +62,7 @@ export const { auth, signIn, signOut, handlers } = createAuth({
|
|
|
55
62
|
Credentials({
|
|
56
63
|
async authorize(credentials) {
|
|
57
64
|
const user = await db.query.users.findFirst({ where: { email: credentials.email } });
|
|
58
|
-
if (!user || !
|
|
65
|
+
if (!user || !(await compare(credentials.password, user.passwordHash))) return null;
|
|
59
66
|
return { id: user.id, name: user.name, email: user.email, role: user.role };
|
|
60
67
|
},
|
|
61
68
|
}),
|
|
@@ -63,9 +70,50 @@ export const { auth, signIn, signOut, handlers } = createAuth({
|
|
|
63
70
|
GitHub(), // reads AUTH_GITHUB_ID, AUTH_GITHUB_SECRET
|
|
64
71
|
],
|
|
65
72
|
secret: process.env.AUTH_SECRET, // required, 32+ random chars, from the env
|
|
73
|
+
pages: { error: '/login' }, // a failed sign-in 302s here with ?error=<code>
|
|
66
74
|
});
|
|
67
75
|
```
|
|
68
76
|
|
|
77
|
+
**Password hashing is the app's job** (WebJs ships no `verifyPassword`). Use `scrypt` from `node:crypto` (built into Node AND Bun, no dependency) in a server-only utility, and call it from `authorize`:
|
|
78
|
+
|
|
79
|
+
```ts
|
|
80
|
+
// modules/auth/password.server.ts (a server-only utility, never reaches the browser)
|
|
81
|
+
import { scrypt, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
82
|
+
import { promisify } from 'node:util';
|
|
83
|
+
const scryptAsync = promisify(scrypt);
|
|
84
|
+
export async function hash(pw: string) {
|
|
85
|
+
const salt = randomBytes(16).toString('hex');
|
|
86
|
+
return salt + ':' + ((await scryptAsync(pw, salt, 64)) as Buffer).toString('hex');
|
|
87
|
+
}
|
|
88
|
+
export async function compare(pw: string, stored: string) {
|
|
89
|
+
const [salt, key] = stored.split(':');
|
|
90
|
+
return timingSafeEqual((await scryptAsync(pw, salt, 64)) as Buffer, Buffer.from(key, 'hex'));
|
|
91
|
+
}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
**Mount `handlers` at an `app/api/auth/[...path]/route.ts` catch-all** (at the app root, NOT under a feature folder): `createAuth` hardcodes `/api/auth/signin/*` and `/api/auth/callback/*` for its form posts and OAuth callback URIs.
|
|
95
|
+
|
|
96
|
+
```ts
|
|
97
|
+
// app/api/auth/[...path]/route.ts
|
|
98
|
+
import { handlers } from '#modules/auth/auth.server.ts';
|
|
99
|
+
export const GET = handlers.GET;
|
|
100
|
+
export const POST = handlers.POST;
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
**The no-JS sign-in / sign-out flow is plain forms** (progressive-enhancement-safe). Sign in by POSTing to `/api/auth/signin/credentials` with a hidden `redirectTo`, and read `?error` (mapped from `pages.error`) for feedback; sign out by POSTing to `/api/auth/signout`:
|
|
104
|
+
|
|
105
|
+
```html
|
|
106
|
+
<form method="POST" action="/api/auth/signin/credentials">
|
|
107
|
+
<input type="hidden" name="redirectTo" value="/dashboard">
|
|
108
|
+
<input name="email" type="email" required><input name="password" type="password" required>
|
|
109
|
+
<button>Sign in</button>
|
|
110
|
+
</form>
|
|
111
|
+
<!-- log out -->
|
|
112
|
+
<form method="POST" action="/api/auth/signout"><button>Log out</button></form>
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
For a programmatic sign-in (the auto-login-after-signup pattern), `signIn('credentials', creds, { redirectTo })` returns a `302` `Response` that a page `action` can return directly.
|
|
116
|
+
|
|
69
117
|
Sessions are JWT by default (stateless, scales horizontally). OAuth
|
|
70
118
|
providers handle the full redirect flow. Read the session anywhere on the
|
|
71
119
|
server with `auth()`.
|
|
@@ -119,6 +167,20 @@ Reading the session through `auth()` also auto-excludes the page from the
|
|
|
119
167
|
server HTML response cache, so a per-user page is never cached and served
|
|
120
168
|
to another visitor (see `built-ins.md`).
|
|
121
169
|
|
|
170
|
+
To gate a WHOLE subtree in one place, use a per-segment `middleware.ts` that reads `auth(req)` (the explicit-request form) and returns a `302` BEFORE the page renders. It runs for every request under its segment and needs only a cookie read (no DB query), so the gate is real the moment the app boots:
|
|
171
|
+
|
|
172
|
+
```ts
|
|
173
|
+
// app/dashboard/middleware.ts (protects /dashboard/*)
|
|
174
|
+
import { auth } from '#modules/auth/auth.server.ts';
|
|
175
|
+
export default async function requireAuth(req: Request, next: () => Promise<Response>) {
|
|
176
|
+
const session = await auth(req);
|
|
177
|
+
if (!session?.user) return new Response(null, { status: 302, headers: { location: '/login' } });
|
|
178
|
+
return next();
|
|
179
|
+
}
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
`auth(req)` takes the in-flight request explicitly (for a middleware / route); the ambient `auth()` (no argument) reads from context inside a page or action.
|
|
183
|
+
|
|
122
184
|
## `forbidden()` (403) vs `unauthorized()` (401)
|
|
123
185
|
|
|
124
186
|
Two control-flow throws from `@webjsdev/core`, mirroring the `notFound()`
|
|
@@ -4,7 +4,7 @@ Env vars, caching, rate limiting, broadcast, file storage, and the `package.json
|
|
|
4
4
|
|
|
5
5
|
## What This Covers
|
|
6
6
|
|
|
7
|
-
- **Environment variables
|
|
7
|
+
- **Environment variables**, the `WEBJS_PUBLIC_` browser-exposed prefix, and `env.ts` boot validation.
|
|
8
8
|
- **Caching primitives.** `cache()` with tag invalidation, HTTP `Cache-Control`, the server HTML response cache (`export const revalidate`), content-hash asset URLs, conditional GET (ETag).
|
|
9
9
|
- **Rate limiting** (`rateLimit()` middleware) and **broadcast** (`broadcast()` over WebSockets).
|
|
10
10
|
- **File storage.** `FileStore` / `diskStore`, safe keys, signed URLs.
|
|
@@ -25,6 +25,18 @@ Read this when wiring caching or rate limiting, storing uploads, hardening heade
|
|
|
25
25
|
|
|
26
26
|
Defaults are single-instance memory stores. To scale horizontally, switch the store once at startup: `setStore(redisStore({ url: process.env.REDIS_URL }))`.
|
|
27
27
|
|
|
28
|
+
**Validate required vars at boot with an app-root `env.ts`** (optional). It default-exports either a SCHEMA object (each var mapped to a type `string` / `number` / `boolean` / `url` / `enum`, or an options object with `optional` / `default` / `minLength` / `pattern` / `values`) OR a validator function `(env) => void` that throws. It runs at boot after `.env` loads, coerces values and writes defaults back to `process.env`, and fails fast naming EVERY bad var:
|
|
29
|
+
|
|
30
|
+
```ts
|
|
31
|
+
// env.ts
|
|
32
|
+
export default {
|
|
33
|
+
DATABASE_URL: 'url',
|
|
34
|
+
SESSION_SECRET: { type: 'string', minLength: 16 },
|
|
35
|
+
PORT: { type: 'number', default: 8080 },
|
|
36
|
+
LOG_LEVEL: { type: 'enum', values: ['debug', 'info', 'warn'], default: 'info' },
|
|
37
|
+
};
|
|
38
|
+
```
|
|
39
|
+
|
|
28
40
|
## Caching
|
|
29
41
|
|
|
30
42
|
### `cache()` for query and computation results
|
|
@@ -112,7 +124,7 @@ setFileStore(diskStore({ dir: '/var/data/uploads', baseUrl: '/files' }));
|
|
|
112
124
|
|
|
113
125
|
**Never trust a user filename as a key.** `generateKey(file.name)` returns an opaque `<uuid>.<ext>` with a sanitized extension; a traversal attempt yields a bare safe key. Keys are containment-checked before any filesystem op.
|
|
114
126
|
|
|
115
|
-
**Signed URLs** gate serving without a session lookup. `signedUrl(key, { secret, expiresIn })` mints an expiring HMAC signature; `verifySignedUrl(searchParams, secret)` returns `{ valid }`. An `expiresIn` of `0` or negative fails closed.
|
|
127
|
+
**Signed URLs** gate serving without a session lookup. `signedUrl(key, { secret, expiresIn })` mints an expiring HMAC signature; `verifySignedUrl(searchParams, secret)` returns `{ valid }`. An `expiresIn` of `0` or negative fails closed. Pass `base` to point the signed link at your own serve route instead of the default upload URL: `signedUrl(key, { secret, base: '/files/' + key, expiresIn: 3600 })`.
|
|
116
128
|
|
|
117
129
|
**Serving-XSS warning.** The recorded content-type is attacker-controlled (the browser sent it at upload). A serving route MUST send `X-Content-Type-Options: nosniff` and SHOULD send `Content-Disposition: attachment` for user uploads. Only serve inline after validating bytes against a strict inert allowlist, never `text/html` / `image/svg+xml`. Add the uploads directory to `.gitignore`.
|
|
118
130
|
|
|
@@ -136,6 +148,8 @@ On by default (`X-Content-Type-Options: nosniff`, `X-Frame-Options: SAMEORIGIN`,
|
|
|
136
148
|
|
|
137
149
|
Off by default. `{ "webjs": { "csp": true } }` enables a strict-dynamic + per-request nonce posture. An object form merges `directives` and supports `reportOnly`. Read the nonce with `cspNonce()` from `@webjsdev/core` to stamp your own inline `<script>`.
|
|
138
150
|
|
|
151
|
+
Enforcement is the HTTP `Content-Security-Policy` HEADER, never a `<meta http-equiv>` tag, so `frame-ancestors` / `report-uri` work. The emitted `<meta name="csp-nonce">` is only the client-side nonce CARRIER. Across a client-router soft navigation the ORIGINAL page-load nonce stays authoritative (the browser enforces the original document's CSP header, not the fetched response's fresh one), so the router preserves that meta and re-stamps every dynamically-inserted script / preload with the original nonce via `getCspNonce()`. The server still mints a fresh nonce per request, and CSP pages are excluded from the HTML cache so a nonce is never served stale. No client config is needed.
|
|
152
|
+
|
|
139
153
|
### Redirects, trailing-slash, basePath, allowed origins
|
|
140
154
|
|
|
141
155
|
```jsonc
|
|
@@ -17,7 +17,15 @@ Read this when a task touches client navigation, prefetch, partial-page swaps, s
|
|
|
17
17
|
|
|
18
18
|
The router auto-enables the moment `@webjsdev/core` loads in the browser, which is any page that ships a component. There is nothing to import or opt into. It intercepts same-origin `<a>` clicks (including inside shadow DOM), fetches the target HTML, and replaces only the inside of the deepest shared layout. Outer header, sidenav, and footer DOM is never re-rendered, so scroll positions, input values, and `<details>` state survive a navigation.
|
|
19
19
|
|
|
20
|
-
**
|
|
20
|
+
**The nav parse must preserve comments.** SSR wraps each layout's children AND the page itself in a KEYED boundary comment pair (open `<!--wj:children:<segment>:<route-key>-->`, close `<!--/wj:children:<segment>-->`, #1015). The route-key is the region's resolved concrete path with each substituted param value percent-encoded (so a user-controlled value can never terminate the comment or collide with the `:` delimiter). The router STRICTLY scans both the live and incoming DOM into segment maps: a close must id-match its innermost open, and ANY truncation, mispair, duplicate, or legacy anonymous open poisons the whole scan. The swap decision is two-tier with Next.js remount parity: a CHANGED route-key REPLACES (a fresh remount, permanents regrafted) at the PARENT of the shallowest changed boundary (a layout's boundary wraps only its children, so its own param-derived markup lives in the parent's range; anchoring there remounts the layout chrome too, exactly like Next re-rendering the layout with new params), else MORPH (the keyed state-preserving reconcile) at the deepest shared boundary when it is the leaf on both sides. The X-Webjs-Have header carries `segment:route-key` entries so the server re-renders (and re-ships) a dynamic layout the client holds for other params instead of short-circuiting past it. A poisoned scan or no shared segment degrades to a FULL PAGE LOAD (dev logs the cause), never a guessed recovery, so silent DOM corruption is structurally impossible. Hydration keys off another comment (`<!--webjs-hydrate-->`, which `__isHydrating()` reads as a component's first child). So the router and hydration both ride on comments SURVIVING the parse that turns a navigation response into a Document, which makes that parse a load-bearing correctness boundary rather than an implementation detail.
|
|
21
|
+
|
|
22
|
+
`Document.parseHTMLUnsafe` STRIPS every comment in Chromium 150 (#1007). No other parse API does: `DOMParser`, `setHTMLUnsafe`, `template.innerHTML`, and plain `innerHTML` all preserve them, and so does the document's own navigation parser, which is why a hard refresh always looked correct and only soft nav broke. With the boundaries gone the router degrades to a full page load (correct, just not soft); with `webjs-hydrate` gone a slotted light-DOM component misses the hydration adopt path. `parseHTML` therefore PROBES `parseHTMLUnsafe` once for losslessness instead of sniffing versions, uses it when it is lossless (it is the only single-pass API that also processes Declarative Shadow DOM), and otherwise parses with `DOMParser`, which preserves comments. A fixed browser silently returns to the fast path.
|
|
23
|
+
|
|
24
|
+
On that fallback, Declarative Shadow DOM is left UNPROCESSED (`DOMParser` does not attach it), a deliberate limitation tracked in #1011, because both ways of adding it back are worse than the gap. Re-serializing via `body.setHTMLUnsafe(body.innerHTML)` is not idempotent (Chromium omits the spec's LF-compensation, so a leading newline in `pre` / `textarea` is silently eaten, which in a `textarea` is form-data corruption), and attaching each root by hand yields a NON-declarative root, which makes any element whose constructor unconditionally calls `attachShadow()` throw `NotSupportedError` on upgrade. The gap costs a JS-less DSD-dependent element its shadow content on a full-body-swap nav, on a stripping browser only; a `static shadow = true` component attaches and renders its own root on upgrade, and a soft nav runs JS by definition.
|
|
25
|
+
|
|
26
|
+
Note for anyone testing this: **the Chromium web-test-runner currently resolves (148) is LOSSLESS, so CI cannot observe the bug at all** (and `playwright` is a caret range, so that version moves on any dependency refresh). A test that merely asserts "markers survive" passes there whether or not the fix exists. The guard in `packages/core/test/routing/browser/comment-preserving-parse.test.js` SIMULATES a stripping parser so it is provable on every engine.
|
|
27
|
+
|
|
28
|
+
**There is NO dropped-marker recovery (#1015 replaced #994's).** The pre-#1015 router "recovered" an orphaned open marker by guessing where its children ended (bounded by the other side's trailing-sibling count), which could guess wrong and corrupt silently. Keyed closes make a mispair DETECTABLE instead, and every integrity violation now degrades to a bounded, correct full page load. The historical producers of lost comments (our own comment-stripping parse #1007, mid-parse soft navs #1008) are fixed upstream, so the degradation is a rare backstop, not a common path. Wrapping `${children}` in a container element (the shipped idiom, `<main>${children}</main>` with the footer a sibling outside it) remains a fine layout pattern, though no correctness now depends on it.
|
|
21
29
|
|
|
22
30
|
**Opting out.** App-wide with config, or per moment at runtime.
|
|
23
31
|
|
|
@@ -27,10 +35,13 @@ The router auto-enables the moment `@webjsdev/core` loads in the browser, which
|
|
|
27
35
|
```
|
|
28
36
|
|
|
29
37
|
```js
|
|
30
|
-
import { disableClientRouter } from '@webjsdev/core';
|
|
31
|
-
disableClientRouter();
|
|
38
|
+
import { disableClientRouter, enableClientRouter } from '@webjsdev/core';
|
|
39
|
+
disableClientRouter(); // stop intercepting document <a> / <form> (plain links resume full loads)
|
|
40
|
+
enableClientRouter(); // turn soft navigation back on
|
|
32
41
|
```
|
|
33
42
|
|
|
43
|
+
`disableClientRouter()` / `enableClientRouter()` are a runtime pair that toggle only the document-level `<a>` / `<form>` interception. An explicit `navigate(url)` call still does a soft navigation either way (it is not gated by the toggle).
|
|
44
|
+
|
|
34
45
|
Per link, opt out with `data-no-router` (auth flows like `/logout`, OAuth redirects, print views, an experimental route with a different runtime). Cross-origin hrefs, `download`, a non-`_self` target, pure same-page hash jumps, and non-HTML extensions are auto-skipped.
|
|
35
46
|
|
|
36
47
|
**Programmatic navigation and cache eviction.**
|
|
@@ -105,7 +116,16 @@ The router can wrap a navigation's DOM mutation in the native View Transitions A
|
|
|
105
116
|
<meta name="view-transition" content="same-origin">
|
|
106
117
|
```
|
|
107
118
|
|
|
108
|
-
|
|
119
|
+
A page (or layout) does not write raw `<head>` markup, so emit that meta through the `other` metadata field, which scopes it to the page that declares it:
|
|
120
|
+
|
|
121
|
+
```ts
|
|
122
|
+
// app/gallery/page.ts
|
|
123
|
+
export const metadata = { other: { 'view-transition': 'same-origin' } };
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
The accepted value is `same-origin`. When enabled it wraps every swap path (the two-tier boundary swap, the `<webjs-frame>` swap, and the background-revalidation full-body path). When `startViewTransition` is unavailable the swap runs synchronously with no flash and no throw. To persist a live element (a playing `<audio>`, an open menu) across a swap by node identity, mark it `data-webjs-permanent` and give it an `id`.
|
|
127
|
+
|
|
128
|
+
The opt-in is **per page**, so it is a page-scoped meta: put it on a page's metadata to animate that page, or on the root layout to animate the whole app. Navigating to a page that does NOT declare it turns transitions back off, because the soft-nav head merge reconciles page-scoped `<meta>` tags (a stale one the previous page declared is removed, not left to leak, #1046). View transitions **compose with Suspense streaming**: a streamed boundary (a `loading.{js,ts}` skeleton or a `<webjs-suspense>` region) navigated to under an active transition still resolves its content progressively, because the streamed resolve waits for the transition's DOM swap to commit before it applies (#1048).
|
|
109
129
|
|
|
110
130
|
## `<webjs-stream>` Surgical Updates
|
|
111
131
|
|
|
@@ -178,13 +198,25 @@ export function WS(ws, req, { params }) {
|
|
|
178
198
|
}
|
|
179
199
|
```
|
|
180
200
|
|
|
181
|
-
**Client.** `connectWS(url, handlers)` from `@webjsdev/core` auto-reconnects with exponential backoff, handles JSON parse/stringify, and queues sends while disconnected.
|
|
201
|
+
**Client.** `connectWS(url, handlers)` from `@webjsdev/core` auto-reconnects with exponential backoff, handles JSON parse/stringify, and queues sends while disconnected. The handler set is `{ onOpen, onMessage, onClose }`, and it RETURNS a connection handle with `.send(data)` and `.close()`. Open it in `connectedCallback` and close it in `disconnectedCallback`, driving a connection-status signal from `onOpen` / `onClose`:
|
|
182
202
|
|
|
183
203
|
```js
|
|
184
204
|
import { connectWS, renderStream } from '@webjsdev/core';
|
|
185
|
-
|
|
205
|
+
|
|
206
|
+
connectedCallback() {
|
|
207
|
+
super.connectedCallback();
|
|
208
|
+
this.conn = connectWS('/feed', {
|
|
209
|
+
onOpen: () => (this.online = true),
|
|
210
|
+
onClose: () => (this.online = false),
|
|
211
|
+
onMessage: (m) => renderStream(m), // apply a server-pushed <webjs-stream> payload
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
disconnectedCallback() { super.disconnectedCallback(); this.conn?.close(); }
|
|
215
|
+
send(text) { this.conn.send(text); }
|
|
186
216
|
```
|
|
187
217
|
|
|
218
|
+
**Gotcha: a component re-render clobbers surgical `renderStream()` updates.** `renderStream()` (and `<webjs-stream>` in general) mutates the DOM out of band, appending rows the component's own `render()` does not know about. If the component then re-renders, `render()` re-runs and wipes those out-of-band rows. So render the target container ONCE and drive any mutation counter with a PLAIN instance field, never a signal or reactive prop that `render()` reads (a read would re-render and blow away the streamed-in DOM).
|
|
219
|
+
|
|
188
220
|
**Broadcast.** `broadcast(path, data)` from `@webjsdev/server` fans a message to every connected client on that path (single-instance). For multi-instance, add Redis pub/sub yourself, there is no framework magic.
|
|
189
221
|
|
|
190
222
|
## Navigation-Loading Indicator (opt-in)
|
|
@@ -3,11 +3,13 @@
|
|
|
3
3
|
## What This Covers
|
|
4
4
|
|
|
5
5
|
- Declaring reactive properties through the `WebComponent({ ... })` factory and `prop()`, with options (`reflect`, `state`, `attribute`, `default`, `converter`, `hasChanged`)
|
|
6
|
-
- Signals as the default state primitive for component-local and shared state
|
|
6
|
+
- Signals as the default state primitive for component-local and shared state, plus `effect` / `batch`
|
|
7
7
|
- The Lit-aligned lifecycle and exactly which hooks SSR runs versus skips
|
|
8
8
|
- Light DOM (default) versus shadow DOM, and the light-host `display: block` rule
|
|
9
9
|
- Slots with full shadow-DOM parity in both DOM modes
|
|
10
10
|
- `async render()`: SSR-blocking first paint, client stale-while-revalidate, `renderFallback()` / `renderError()`
|
|
11
|
+
- `Task` for client-only async data, and context (`createContext` / `ContextProvider` / `ContextConsumer`) to avoid attribute drilling
|
|
12
|
+
- The lit-html directive set (`repeat`, `watch`, `live`, `keyed`, `guard`, `cache`, `until`, `unsafeHTML`, `ref`, `asyncAppend` / `asyncReplace`, `templateContent`)
|
|
11
13
|
- Display-only elision (when a component is stripped from the browser)
|
|
12
14
|
- Inherited members app code must NOT shadow (`title`, `remove`, `render`, ...)
|
|
13
15
|
|
|
@@ -70,6 +72,19 @@ const count = computed(() => cart.get().length); // derived
|
|
|
70
72
|
|
|
71
73
|
Read with `signal.get()` inside `render()`; the built-in `SignalWatcher` tracks the read and re-renders on change. An instance signal created in the constructor is component-local. For a fine-grained DOM swap use `${watch(signal)}` from `@webjsdev/core/directives`.
|
|
72
74
|
|
|
75
|
+
Two more signal primitives from `@webjsdev/core` cover client-side reactions and batched writes:
|
|
76
|
+
|
|
77
|
+
- `effect(fn)` runs `fn` now and re-runs it whenever a signal it read changes. It is a BROWSER-ONLY side-effect primitive (a subscription, a `document.title` sync, an analytics ping), not a render path. It returns a disposer, so create it in `connectedCallback` and call the disposer in `disconnectedCallback` to avoid a leak.
|
|
78
|
+
- `batch(fn)` coalesces several `.set()` writes inside `fn` into ONE re-render instead of one per write. Reach for it when a handler updates multiple signals at once.
|
|
79
|
+
|
|
80
|
+
```ts
|
|
81
|
+
import { signal, effect, batch } from '@webjsdev/core';
|
|
82
|
+
const open = signal(false), count = signal(0);
|
|
83
|
+
connectedCallback() { super.connectedCallback(); this.dispose = effect(() => { document.title = `(${count.get()})`; }); }
|
|
84
|
+
disconnectedCallback() { super.disconnectedCallback(); this.dispose?.(); }
|
|
85
|
+
reset() { batch(() => { open.set(false); count.set(0); }); } // one re-render, not two
|
|
86
|
+
```
|
|
87
|
+
|
|
73
88
|
## Lifecycle (Lit-aligned) and what SSR runs
|
|
74
89
|
|
|
75
90
|
Each update cycle runs these in order; each receives a `changedProperties` Map.
|
|
@@ -103,7 +118,7 @@ class Panel extends WebComponent({ label: String }) {
|
|
|
103
118
|
|
|
104
119
|
## Slots
|
|
105
120
|
|
|
106
|
-
The full `<slot>` surface works in light DOM with shadow-DOM parity; migrating modes never requires a template rewrite.
|
|
121
|
+
The full `<slot>` surface works in light DOM with shadow-DOM parity; migrating modes never requires a template rewrite. A forwarded slot projects its content everywhere (client, SSR, hydration).
|
|
107
122
|
|
|
108
123
|
```ts
|
|
109
124
|
class MyCard extends WebComponent {
|
|
@@ -116,7 +131,21 @@ class MyCard extends WebComponent {
|
|
|
116
131
|
}
|
|
117
132
|
```
|
|
118
133
|
|
|
119
|
-
Named slots, the default slot (unnamed children, text, comments), fallback content (a slot's inner markup when nothing matches), first-wins resolution
|
|
134
|
+
Named slots, the default slot (unnamed children, text, comments), fallback content (a slot's inner markup when nothing matches), and first-wins resolution all behave per spec. The DOM API mirrors shadow slots: `assignedNodes` / `assignedElements` (with `{ flatten: true }`), `element.assignedSlot`, and the `slotchange` event. Both modes are SSR'd (light DOM places children into `<slot data-webjs-light data-projection="actual">`, shadow DOM via Declarative Shadow DOM), so slotted content renders with no JS.
|
|
135
|
+
|
|
136
|
+
**Light-DOM slots ARE the native DOM slot API (#1021, full shadow parity).** There is no WebJs-specific slot API. Post-mount writes are live exactly as in shadow DOM, and moving a component between `static shadow = false` and `true` never needs a rewrite:
|
|
137
|
+
|
|
138
|
+
```ts
|
|
139
|
+
const card = document.querySelector('my-card');
|
|
140
|
+
card.appendChild(node); // live, projected
|
|
141
|
+
card.querySelector('[slot=old]').slot = 'new'; // flip re-projects
|
|
142
|
+
card.innerHTML = '<p>replaced</p>'; // replaces slotted content
|
|
143
|
+
card.querySelector('slot').assignedNodes(); // read, mirrors shadow
|
|
144
|
+
node.assignedSlot;
|
|
145
|
+
card.querySelector('slot').addEventListener('slotchange', ...); // async + coalesced
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
Things to internalize. (1) Every native mutation is live: `appendChild` / `insertBefore` / `removeChild` / `el.remove()` / `innerHTML` / `el.slot=` flip / `HTMLSlotElement.assign()`. Reorder-by-append moves a child to the end (native semantics), a fragment expands and drains, and `insertBefore` against a renderer/non-child ref throws `NotFoundError`. One caveat rides `assign()`: the light-DOM version is an EXTENSION (an element-bound overlay while name matching keeps working), and native shadow `assign()` needs `slotAssignment: 'manual'` which WebJs does not set, so `assign()` is the one write that does NOT survive flipping to `static shadow = true`; avoid it in mode-portable components. (2) Four inherent gaps (from light DOM having no shadow boundary). The gaps: structural host reads (`host.children` / `host.childNodes` / `querySelector(':scope > ...')` / the `innerHTML` GETTER read the rendered template, not the authored children, so read slotted content with `assignedNodes()`); `assignedChild.parentNode` is the `<slot>`; `::slotted()` CSS is shadow-only (style slotted content with normal selectors / Tailwind); and initial-projection lifecycle timing (`firstUpdated` sees the `<slot>` element with EMPTY `assignedNodes()`, because the first light-DOM projection lands one microtask after the first render, where shadow DOM projects natively before it; read assigned content from a `slotchange` listener or after a microtask). (3) Conditional-on-slot at render time does not exist in EITHER mode (a shadow template can't branch on light-child presence at render time either); use CSS `:has()` / `slot:empty` or a `slotchange` listener. (4) The name `default` is a reserved alias for the default slot; do not name a slot `default`. (5) A display-only slotted wrapper still elides; a component whose slots are mutated at runtime is already shipped because a consumer references its tag (force a ship with `static interactive = true` only for a dynamically-resolved reference the analyser cannot see). (6) A generic DOM library should operate on the assigned nodes, never on the host element itself; writes into an ACTIVELY ASSIGNED slot container are folded into the record (self-heal), while a fallback-mode slot's content is renderer-owned and out of contract. (7) A FORWARDED slot projects its content everywhere (#1023): a template may forward a slot into a nested component (html`<inner-shell><slot></slot></inner-shell>`), and the outer component's content projects through it on a client-only mount, in the SSR first paint, and across hydration (no flash back to fallback). The renderer stamps each slot with its template owner (carried across SSR as `data-wj-slot-owner`), so a forwarded slot routes to the outer host that rendered it, not the child it nests in. (8) A LAYOUT's named slots stay in sync across soft navigation (#1024): when a layout renders its `${children}` inside a slotted shell and a page emits top-level `slot=`-attributed children, the named-slot slices update on a soft-nav boundary swap just as the default slice does (the swap resyncs every own slot of the enclosing shell from the incoming page).
|
|
120
149
|
|
|
121
150
|
A compound child reads its parent at the first server paint via `closest('ui-tabs')` (only tag-name selectors resolve at SSR, and the compound parent must be light DOM). Genuine live-DOM reads (`querySelector`, `classList`, geometry) still throw at SSR, so keep them in `connectedCallback` / `firstUpdated`.
|
|
122
151
|
|
|
@@ -144,6 +173,71 @@ Errors are isolated per component by default (no user code): a thrown `await` re
|
|
|
144
173
|
|
|
145
174
|
Decision rules. Use `async render()` for request-time server data that should be in the first paint (the default). Add `renderFallback()` when a client re-fetch's stale content would mislead. Use `Task` / signals for genuinely client-only data (a click, viewport, live updates). For SLOW data where blocking the first byte hurts, wrap the region in `<webjs-suspense .fallback=${html\`Loading...\`}>` to stream it (the only way to show a first-paint fallback; see `client-router-and-streaming.md`). Do NOT fetch in `connectedCallback` for data knowable server-side, and do NOT prop-drill what a leaf can fetch itself.
|
|
146
175
|
|
|
176
|
+
## Task: client-only async data
|
|
177
|
+
|
|
178
|
+
For async data that is genuinely CLIENT-only (it depends on a click, viewport, or a live source, so `async render()` cannot bake it in at SSR), use the `Task` reactive controller. It shows its pending state at SSR (staying `INITIAL`), then runs in the browser.
|
|
179
|
+
|
|
180
|
+
```ts
|
|
181
|
+
import { Task, TaskStatus } from '@webjsdev/core/task';
|
|
182
|
+
|
|
183
|
+
class SearchResults extends WebComponent({ q: String }) {
|
|
184
|
+
#search = new Task(this, {
|
|
185
|
+
task: async ([q], { signal }) => (await fetch(`/api/s?q=${q}`, { signal })).json(),
|
|
186
|
+
args: () => [this.q], // the args array spreads into the task's first parameter
|
|
187
|
+
});
|
|
188
|
+
render() {
|
|
189
|
+
switch (this.#search.status) {
|
|
190
|
+
case TaskStatus.PENDING: return html`<p>Searching...</p>`;
|
|
191
|
+
case TaskStatus.ERROR: return html`<p>${this.#search.error.message}</p>`;
|
|
192
|
+
case TaskStatus.COMPLETE: return html`<ul>${this.#search.value.map((r) => html`<li>${r.title}</li>`)}</ul>`;
|
|
193
|
+
default: return html`<p>Type to search.</p>`; // INITIAL (also the SSR state)
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
`args()` re-runs the task whenever its return changes; call `this.#task.run()` to trigger it manually. `TaskStatus` is `INITIAL` / `PENDING` / `COMPLETE` / `ERROR`. Prefer `async render()` for server data that should be in the first paint; reach for `Task` only when the data cannot exist until the browser runs.
|
|
200
|
+
|
|
201
|
+
## Context: share state without attribute drilling
|
|
202
|
+
|
|
203
|
+
When a value must reach a deep descendant without threading it through every intermediate component's attributes, use context (from `@webjsdev/core/context`). This is a CLIENT-TIME concern: a provider publishes on connect, so context is empty at SSR. For server-known data, pass it through the page function or a `.prop` instead (see `muscle-memory-gotchas.md`).
|
|
204
|
+
|
|
205
|
+
```ts
|
|
206
|
+
import { createContext, ContextProvider, ContextConsumer } from '@webjsdev/core/context';
|
|
207
|
+
|
|
208
|
+
export const themeContext = createContext<'light' | 'dark'>('theme');
|
|
209
|
+
|
|
210
|
+
class ThemeRoot extends WebComponent({}) {
|
|
211
|
+
#provider = new ContextProvider(this, { context: themeContext, initialValue: 'dark' });
|
|
212
|
+
toggle() { this.#provider.setValue(this.#provider.value === 'dark' ? 'light' : 'dark'); }
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
class ThemedCard extends WebComponent({}) {
|
|
216
|
+
#theme = new ContextConsumer(this, { context: themeContext, subscribe: true }); // re-renders on change
|
|
217
|
+
render() { return html`<div class=${this.#theme.value === 'dark' ? 'bg-black' : 'bg-white'}>...</div>`; }
|
|
218
|
+
}
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
`subscribe: true` re-renders the consumer on every provider change; omit it for a one-shot read. A component can also fire a `ContextRequestEvent` to pull a value imperatively.
|
|
222
|
+
|
|
223
|
+
## Directives (lit-html parity)
|
|
224
|
+
|
|
225
|
+
Import from `@webjsdev/core/directives`. Everything a `class`/`style`/conditional needs is plain JS (`classMap` is `class=${cond ? 'a' : 'b'}`, `when` is a ternary, `map` is `.map`); reach for a directive only for the jobs below.
|
|
226
|
+
|
|
227
|
+
| Directive | Use it for |
|
|
228
|
+
|---|---|
|
|
229
|
+
| `repeat(items, keyFn, tpl)` | A keyed list where items reorder / insert / remove (preserves DOM + state per key). A static list is a plain `.map`. |
|
|
230
|
+
| `watch(signal)` | A fine-grained DOM swap of one signal's value without re-rendering the whole component. |
|
|
231
|
+
| `live(value)` | An `input` / `textarea` `.value` bound to state, so a user edit that equals the last committed value still resets. |
|
|
232
|
+
| `keyed(key, tpl)` | Force a fresh subtree (discard old DOM + state) when `key` changes. |
|
|
233
|
+
| `guard(deps, () => tpl)` | Skip re-rendering an expensive subtree unless `deps` change. |
|
|
234
|
+
| `cache(tpl)` | Keep the DOM of an inactive branch around when toggling between templates. |
|
|
235
|
+
| `until(promise, fallback)` | Render `fallback` until `promise` resolves (prefer `Task` in a component, `Suspense` for a page). |
|
|
236
|
+
| `unsafeHTML(str)` | Render a TRUSTED raw HTML string. NEVER pass user input (XSS). |
|
|
237
|
+
| `ref(cb)` / `createRef()` | Get a handle to the rendered DOM node. |
|
|
238
|
+
| `asyncAppend(iter)` / `asyncReplace(iter)` | Stream from an async iterable, appending each value or replacing with the latest. |
|
|
239
|
+
| `templateContent(el)` | Render the content of a `<template>` element. |
|
|
240
|
+
|
|
147
241
|
## Display-only elision
|
|
148
242
|
|
|
149
243
|
A component that does no client-side work renders the same SSR'd HTML with or without its JS, so WebJs strips its import from the served source (and any vendor reachable only through it). This is automatic and conservative. A component stays elidable while it has NONE of:
|
|
@@ -153,15 +247,16 @@ A component that does no client-side work renders the same SSR'd HTML with or wi
|
|
|
153
247
|
- an overridden lifecycle hook (including `renderFallback` / `renderError`)
|
|
154
248
|
- an imported `signal` / `computed` / `watch` / `Task` / `ref` / streaming directive, or `addController` / `requestUpdate`
|
|
155
249
|
- code that runs at module load (a top-level call, non-data `new`, dynamic `import(...)`, top-level `await`); only declarations and `X.register(...)` are allowed
|
|
156
|
-
- a
|
|
250
|
+
- the dynamic slot READ surface (`slotchange`, `assignedNodes` / `assignedElements` / `assignedSlot`); merely RENDERING a `<slot>` does not ship (the SSR output carries the placed children, so a display-only slotted wrapper is byte-identical without its JS; native-write liveness is consumer-driven and the consumer's tag reference forces the ship)
|
|
251
|
+
- being rendered by a component that itself ships
|
|
157
252
|
|
|
158
253
|
A bare `async render()` (no other signal, light DOM) is elided too: the SSR'd data is the complete first paint. Force shipping with `static interactive = true` when interactivity is invisible to static analysis (a dynamically-built tag string, a `:defined` rule in an external stylesheet). `static shadow = true` always ships (Declarative Shadow DOM re-attaches only during parsing). Turn elision off app-wide with `{ "webjs": { "elide": false } }` or `WEBJS_ELIDE=0`.
|
|
159
254
|
|
|
160
255
|
## Members app code must not shadow
|
|
161
256
|
|
|
162
|
-
A `WebComponent` inherits `HTMLElement` (browser) or an `ElementShim` (SSR) plus the framework reactivity base. A reactive prop or method whose NAME collides either fails to compile (`TS2415` for a type-incompatible property, `TS2416` for a method signature) or silently hijacks the native member at runtime. The fix is always to rename.
|
|
257
|
+
A `WebComponent` inherits `HTMLElement` (browser) or an `ElementShim` (SSR) plus the framework reactivity base. A reactive prop or method whose NAME collides either fails to compile (`TS2415` for a type-incompatible property, `TS2416` for a method signature) or silently hijacks the native member at runtime. The fix is always to rename. The DOM MUTATION methods WebJs instruments for the light-DOM slot API (`append`, `prepend`, `before`, `after`, `replaceWith`, `replaceChildren`, `remove`, `appendChild`, `insertBefore`, `removeChild`, `replaceChild`) are the dangerous case TypeScript does NOT catch (a shorter override is assignable to the native signature), so a handler named `append()` compiles yet silently never runs. `webjs check`'s `no-shadowed-native-member` rule catches exactly these.
|
|
163
258
|
|
|
164
259
|
- HTMLElement / Element: `title`, `id`, `slot`, `role`, `hidden`, `dir`, `lang`, `translate`, `draggable`, `tabIndex`, `className`, `dataset`, `remove`, `closest`, `matches`, `focus`, `blur`, `click`, `append` / `prepend`, `before` / `after`. Rename (`postTitle`, `removeItem`, `handleClick`).
|
|
165
|
-
- WebComponent base: `render`, `update`, `requestUpdate`, `updated` / `firstUpdated`, `willUpdate` / `shouldUpdate`, `connectedCallback`, `renderError` / `renderFallback`, `addController` / `removeController`, `updateComplete
|
|
260
|
+
- WebComponent base: `render`, `update`, `requestUpdate`, `updated` / `firstUpdated`, `willUpdate` / `shouldUpdate`, `connectedCallback`, `renderError` / `renderFallback`, `addController` / `removeController`, `updateComplete` (#1021: there is no WebJs slot API to override; slots are native). Only override one deliberately, with its exact signature; never repurpose the name for app logic.
|
|
166
261
|
|
|
167
262
|
Framework-private fields are underscore-prefixed (`_renderRoot`, `_connected`, `_changedProperties`, `_updatePromise`, `_isUpdating`); never declare a prop or field that matches one. Safe, non-inherited names: `label`, `open`, `count`, `value`, `name`, `items`, `todos`, `active`, `variant`, `size`, `checked`, `selected`, `heading`, `message`, `status`. When in doubt, grep the base surface in `node_modules/@webjsdev/core/src/component.js`.
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
- The `modules/<feature>/` architecture (thin `app/` adapters, `actions/` mutations, `queries/` reads, one function per file)
|
|
6
6
|
- `'use server'` RPC actions, the serializer-safe wire, and how a client import becomes a typed stub
|
|
7
7
|
- Input validation at the boundary via `export const validate`
|
|
8
|
-
- HTTP-verb config exports (`method`, `cache`, `tags`, `invalidates`, `middleware`)
|
|
8
|
+
- HTTP-verb config exports (`method`, `cache`, `tags`, `invalidates`, `middleware`), the middleware `ctx` shape, and `actionSignal()` cancellation
|
|
9
9
|
- The `ActionResult<T>` envelope and its robust failure detection
|
|
10
10
|
- The `route()` REST adapter that exposes an action over HTTP
|
|
11
11
|
- Drizzle rc.3 reads (`db.query.*`) and mutations (`.returning()`)
|
|
@@ -141,7 +141,24 @@ export async function updateUser(id: number, patch: Partial<User>) { /* ... */ }
|
|
|
141
141
|
|
|
142
142
|
- A **GET** rides args in the URL (POST fallback over a 4KB cap), is CSRF-exempt, and carries `Cache-Control` + a weak `ETag` (304 on `If-None-Match`) + `X-Webjs-Tags`. A **mutation** (POST/PUT/PATCH/DELETE) sends the rich body (DELETE rides the URL), is CSRF-protected, and on success evicts its `invalidates` tags and reports them via `X-Webjs-Invalidate`. A method mismatch is a `405` + `Allow`.
|
|
143
143
|
- **SAFETY.** `cache` with `public: true` SHARES one response across ALL users, keyed only by URL + args. Use it ONLY for data identical for every visitor (the same rule as a page's `export const revalidate`), never for a session or per-user read.
|
|
144
|
-
- Per-action `middleware` short-circuits by returning an `ActionResult` instead of calling `next()`, and accumulates context the action reads via `actionContext()` from `@webjsdev/server`.
|
|
144
|
+
- Per-action `middleware` short-circuits by returning an `ActionResult` instead of calling `next()`, and accumulates context the action reads via `actionContext()` from `@webjsdev/server`. Each middleware is `async (ctx, next) => result` where `ctx` is `{ request, args, signal, context }`. It writes to the shared bag `ctx.context.<key>` (for example `ctx.context.user = user`), which is exactly what `actionContext().user` reads back in the action. A direct server-to-server call skips the RPC boundary (so its middleware does NOT run), so the action must guard rather than assume a middleware-set value is present.
|
|
145
|
+
|
|
146
|
+
### Cancellation with `actionSignal()`
|
|
147
|
+
|
|
148
|
+
Inside an action, `actionSignal()` from `@webjsdev/server` returns the request's `AbortSignal`. It fires when the client disconnects OR when a newer client render supersedes this one (the RPC stub aborts the previous in-flight fetch). Thread it into the work you start, and re-check it after an await to map an abort to a cancelled envelope:
|
|
149
|
+
|
|
150
|
+
```ts
|
|
151
|
+
'use server';
|
|
152
|
+
import { actionSignal } from '@webjsdev/server';
|
|
153
|
+
export async function search(q: string) {
|
|
154
|
+
const signal = actionSignal();
|
|
155
|
+
const res = await fetch(`https://api/x?q=${q}`, { signal }); // aborts the fetch on disconnect
|
|
156
|
+
if (signal.aborted) return { success: false, error: 'Request cancelled.', status: 499 };
|
|
157
|
+
return { success: true, data: await res.json() };
|
|
158
|
+
}
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
A guard placed BEFORE any await can never fire (nothing has yielded yet). Outside an action the signal never aborts, so a server-to-server call stays safe.
|
|
145
162
|
|
|
146
163
|
## The `ActionResult<T>` envelope
|
|
147
164
|
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
## What This Covers
|
|
4
4
|
|
|
5
5
|
- The Next.js patterns that LOOK right in WebJs but break, because WebJs borrows Next's file-based routing shape but not its execution model (no RSC, no `'use client'` split): `redirect()` in a route handler, `fetch()` in a page, `<Link>`, `NEXT_PUBLIC_`, `await params`.
|
|
6
|
-
- The Lit patterns that break WebJs SSR or reactivity, because WebJs is HTML-first (real HTML first paint, JS opt-in per behaviour) not JS-first: `static properties` / the `@property()` decorator, class-field initializers, browser globals in `render()`, fetching in `connectedCallback`, interpolation into `<style
|
|
6
|
+
- The Lit patterns that break WebJs SSR or reactivity, because WebJs is HTML-first (real HTML first paint, JS opt-in per behaviour) not JS-first: `static properties` / the `@property()` decorator, class-field initializers, browser globals in `render()`, fetching in `connectedCallback`, interpolation into `<style>`, reading `assignedNodes()` in `firstUpdated` of a light-DOM component.
|
|
7
7
|
- The WebJs-shaped fix for each, with short code.
|
|
8
8
|
|
|
9
9
|
Read this when a pattern feels familiar from Next.js or Lit but you are not sure it transfers. For the component runtime see `components.md`; for the routing surface see `routing-and-pages.md`. The one difference underneath everything: pages and layouts render server-only and never hydrate, and the one client boundary is a `WebComponent` custom element.
|
|
@@ -149,6 +149,10 @@ The `@property()` decorator is banned by the erasable-TS invariant (decorators a
|
|
|
149
149
|
|
|
150
150
|
Lit defaults to shadow DOM, so `static styles = css` scopes automatically. WebJs defaults to light DOM. A `static styles` block without `static shadow = true` does nothing useful and any inline `<style>` with bare class names leaks globally. The webjs-shaped fix is Tailwind utilities, which apply directly in light DOM. Reach for `static shadow = true` plus `static styles` only when scoped CSS genuinely belongs in a shadow root, or prefix every selector with the tag name if authoring vanilla light-DOM CSS.
|
|
151
151
|
|
|
152
|
+
### Reading `assignedNodes()` in `firstUpdated` of a light-DOM component
|
|
153
|
+
|
|
154
|
+
In shadow DOM the browser projects slotted content natively before `firstUpdated`, so Lit muscle memory says `this.shadowRoot.querySelector('slot').assignedNodes()` is populated there. In light DOM the first projection lands one microtask AFTER the first render, so `firstUpdated` sees the `<slot>` element with an EMPTY `assignedNodes()`. The webjs-shaped fix: read assigned content from a `slotchange` listener (fires once projection lands, and on every later change), or wait a microtask. Every later read and every mutation-driven update behaves identically in both modes; only the first-render read differs.
|
|
155
|
+
|
|
152
156
|
### `:host { display: block }` on a light-DOM component
|
|
153
157
|
|
|
154
158
|
A custom element is `display: inline` by default, so a block container collapses. In Lit you fix this with `:host { display: block }`, which works because Lit is shadow-DOM-first. A light-DOM WebJs component has no shadow root, so there is no `:host` to write. There is nothing to do: the framework already defaults every light-DOM host to `display: block` via a low-priority `@layer webjs-host` rule, overridable by any Tailwind utility (`class="flex"` wins). A shadow-DOM component (`static shadow = true`) still sets `:host { display: block }` in `static styles` itself, exactly like Lit.
|
|
@@ -67,6 +67,24 @@ TodoList.register('todo-list');
|
|
|
67
67
|
- Multiple `.add()` calls stack independently. Each carries its own release by ID, so overlapping in-flight mutations do not clobber one another.
|
|
68
68
|
- When `update` is omitted, the payload REPLACES the state directly (`Action = State`), matching the simple `useOptimistic(setState)` pattern.
|
|
69
69
|
|
|
70
|
+
### Author the optimistic mutation as a degrade-first form
|
|
71
|
+
|
|
72
|
+
Wrap the mutation in a REAL `<form method="post" action="">` posting to the page's own URL, then intercept it for the optimistic path. One form then serves both: with JS off the browser submits to the page `action` (the no-JS write path, see `routing-and-pages.md`), and with JS on `@submit` calls `e.preventDefault()` and runs the optimistic path. That is the progressive-enhancement contract, not a fetch-only handler.
|
|
73
|
+
|
|
74
|
+
```ts
|
|
75
|
+
render() {
|
|
76
|
+
return html`
|
|
77
|
+
<form method="post" action="" @submit=${this.handleSubmit}>
|
|
78
|
+
<input type="hidden" name="intent" value="create"> <!-- one page action dispatches on intent -->
|
|
79
|
+
<input name="title" required>
|
|
80
|
+
<button>Add</button>
|
|
81
|
+
</form>
|
|
82
|
+
<ul>${this.optimisticTodos.value.map(t => html`<li class=${t.pending ? 'opacity-50' : ''}>${t.title}</li>`)}</ul>`;
|
|
83
|
+
}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
When a page owns SEVERAL mutations (create, toggle, delete), give each form a hidden `intent` field and let the single page `action` dispatch on it. Each interactive control (a toggle button, a delete button) is also its own tiny form so it still works with JS off.
|
|
87
|
+
|
|
70
88
|
## Seed the list from the server for SSR plus optimistic
|
|
71
89
|
|
|
72
90
|
For a page that server-renders a list AND lets the user add to it optimistically, let ONE component own both the list and the form, and seed it from the page through a `.prop` hole (a DOM property that round-trips through SSR on custom elements). The list is then fully server-rendered on first paint (readable with JS off) and re-renders optimistically on each add. A separate static list in the page would not update on an optimistic add.
|