@webjsdev/cli 0.10.45 → 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/lib/create.js +109 -141
- 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 +28 -4
- package/templates/.agents/skills/webjs/references/components.md +82 -2
- package/templates/.agents/skills/webjs/references/data-and-actions.md +19 -2
- 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/styling.md +82 -2
- package/templates/AGENTS.md +12 -5
- package/templates/gallery/app/apple-icon.ts +2 -5
- package/templates/gallery/app/examples/layout.ts +7 -2
- 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 +2 -1
- package/templates/gallery/app/features/auth/dashboard/page.ts +4 -2
- package/templates/gallery/app/features/auth/dashboard/settings/page.ts +2 -1
- package/templates/gallery/app/features/auth/login/middleware.ts +15 -0
- package/templates/gallery/app/features/auth/login/page.ts +7 -4
- package/templates/gallery/app/features/auth/page.ts +6 -5
- package/templates/gallery/app/features/auth/signup/middleware.ts +11 -0
- package/templates/gallery/app/features/auth/signup/page.ts +7 -4
- 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 +16 -8
- package/templates/gallery/app/features/layout.ts +60 -5
- 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 +5 -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 +4 -3
- package/templates/gallery/app/features/streaming/page.ts +4 -3
- package/templates/gallery/app/features/suspense/page.ts +4 -3
- package/templates/gallery/app/features/view-transitions/page.ts +6 -3
- package/templates/gallery/app/features/view-transitions/second/page.ts +4 -2
- 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/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/components/greeter.ts +7 -4
- package/templates/gallery/modules/stream/components/stream-demo.ts +10 -5
- package/templates/gallery/modules/streaming/components/token-stream.ts +10 -3
- package/templates/gallery/modules/suspense/components/slow-fact.ts +2 -1
- package/templates/gallery/modules/todo/components/todo-app.ts +8 -4
- package/templates/gallery/modules/websockets/components/ws-echo.ts +5 -3
- package/templates/public/favicon.svg +10 -3
- package/templates/scripts/clear-gallery.mjs +126 -19
package/lib/create.js
CHANGED
|
@@ -124,7 +124,16 @@ async function copyGallery(appDir) {
|
|
|
124
124
|
const galleryDir = join(TEMPLATES, 'gallery');
|
|
125
125
|
// `test` carries the auth card's real request-pipeline test (test/auth); it
|
|
126
126
|
// ships with the gallery and is pruned by gallery:clear alongside the card.
|
|
127
|
-
|
|
127
|
+
// `components` carries the gallery's EXAMPLE design system (components/ui/ class
|
|
128
|
+
// helpers) the demos import. It ships here so a fresh app's demos work, but it
|
|
129
|
+
// is an example to learn from, so gallery:clear REMOVES it (the agent then runs
|
|
130
|
+
// `webjs ui add` and themes its own components/ui/); only cn.ts is kept as the
|
|
131
|
+
// `webjs ui add` prerequisite. It merges into the app's components/ alongside
|
|
132
|
+
// the separately-written theme toggle (also removed by gallery:clear).
|
|
133
|
+
// `lib` merges the gallery's markup-chunk helpers (lib/utils/ui.ts) alongside
|
|
134
|
+
// the ui bootstrap's cn.ts/dom.ts (written earlier); gallery:clear removes just
|
|
135
|
+
// ui.ts. cp is recursive-merge, so the pre-written lib/utils/ files are kept.
|
|
136
|
+
for (const sub of ['app', 'modules', 'test', 'components', 'lib']) {
|
|
128
137
|
await cp(join(galleryDir, sub), join(appDir, sub), { recursive: true });
|
|
129
138
|
}
|
|
130
139
|
}
|
|
@@ -1111,26 +1120,34 @@ import '#components/theme-toggle.ts';
|
|
|
1111
1120
|
* mapped into Tailwind via @theme in public/input.css, so bg-background,
|
|
1112
1121
|
* text-foreground, bg-card, bg-primary, and border-border all work.
|
|
1113
1122
|
*/
|
|
1123
|
+
|
|
1124
|
+
// Declare the favicon via metadata.icons (NOT a hand-written <link> in the
|
|
1125
|
+
// template): the framework emits metadata links into <head>, whereas a <link>
|
|
1126
|
+
// written in the layout body stays in <body>, where browsers ignore it. The SVG
|
|
1127
|
+
// lives at public/favicon.svg and serves at /public/favicon.svg.
|
|
1128
|
+
export const metadata = { icons: '/public/favicon.svg' };
|
|
1129
|
+
|
|
1114
1130
|
export default function RootLayout({ children }: { children: unknown }) {
|
|
1115
1131
|
// Read the in-flight request's CSP nonce so the theme-detection inline script
|
|
1116
1132
|
// passes strict CSP. Returns '' when no CSP nonce is set.
|
|
1117
1133
|
const nonce = cspNonce();
|
|
1118
1134
|
return html\`
|
|
1119
1135
|
<script nonce="\${nonce}">
|
|
1120
|
-
// Light/dark theme:
|
|
1121
|
-
//
|
|
1122
|
-
//
|
|
1136
|
+
// Light/dark theme: apply the saved choice before paint (no flash). The
|
|
1137
|
+
// tokens follow color-scheme, which [data-theme] forces and otherwise
|
|
1138
|
+
// follows the OS, so an unset choice needs NO inline work here. The .dark
|
|
1139
|
+
// class is synced only for @webjsdev/ui components (they key off .dark).
|
|
1140
|
+
// Delete this block and the [data-theme] rules below for a single-theme app.
|
|
1123
1141
|
(function(){
|
|
1124
1142
|
try {
|
|
1125
|
-
var mq = window.matchMedia('(prefers-color-scheme:
|
|
1143
|
+
var mq = window.matchMedia('(prefers-color-scheme: dark)');
|
|
1126
1144
|
function apply(){
|
|
1127
1145
|
var t = null;
|
|
1128
1146
|
try { t = localStorage.getItem('webjs_theme'); } catch (_) {}
|
|
1129
1147
|
var el = document.documentElement;
|
|
1130
1148
|
if (t === 'light' || t === 'dark') el.dataset.theme = t;
|
|
1131
1149
|
else delete el.dataset.theme;
|
|
1132
|
-
|
|
1133
|
-
el.classList.toggle('dark', dark);
|
|
1150
|
+
el.classList.toggle('dark', t === 'dark' || (t !== 'light' && mq.matches));
|
|
1134
1151
|
}
|
|
1135
1152
|
apply();
|
|
1136
1153
|
mq.addEventListener('change', apply);
|
|
@@ -1144,7 +1161,9 @@ export default function RootLayout({ children }: { children: unknown }) {
|
|
|
1144
1161
|
function measure(){
|
|
1145
1162
|
try {
|
|
1146
1163
|
var hdr = document.querySelector('header');
|
|
1147
|
-
|
|
1164
|
+
// Only a FIXED header leaves normal flow and needs its height
|
|
1165
|
+
// reserved; a normal in-flow header (the gallery's navbar) does not.
|
|
1166
|
+
if (!hdr || getComputedStyle(hdr).position !== 'fixed') return;
|
|
1148
1167
|
var apply = function(){
|
|
1149
1168
|
document.documentElement.style.setProperty('--header-h', hdr.offsetHeight + 'px');
|
|
1150
1169
|
};
|
|
@@ -1157,7 +1176,6 @@ export default function RootLayout({ children }: { children: unknown }) {
|
|
|
1157
1176
|
})();
|
|
1158
1177
|
</script>
|
|
1159
1178
|
<meta name="color-scheme" content="light dark">
|
|
1160
|
-
<link rel="icon" href="/public/favicon.svg" type="image/svg+xml">
|
|
1161
1179
|
<!-- JetBrains Mono for body/UI (its monospaced, developer-console feel) and
|
|
1162
1180
|
Bricolage Grotesque for the display wordmark. Swap these for your own
|
|
1163
1181
|
fonts (and update --font-sans / --font-display below). -->
|
|
@@ -1172,89 +1190,59 @@ export default function RootLayout({ children }: { children: unknown }) {
|
|
|
1172
1190
|
|
|
1173
1191
|
<link rel="stylesheet" href="/public/tailwind.css">
|
|
1174
1192
|
<style>
|
|
1175
|
-
/* Design tokens
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1193
|
+
/* Design tokens: ONE definition per colour via light-dark(LIGHT, DARK), so
|
|
1194
|
+
a palette change lands in a single place (DRY). The token NAMES are
|
|
1195
|
+
infrastructure (public/input.css maps them into Tailwind via @theme); the
|
|
1196
|
+
VALUES are a cool neutral-grey palette with a monospaced type system.
|
|
1197
|
+
color-scheme decides which side of each light-dark() applies: the default
|
|
1198
|
+
'light dark' follows the OS, and the toggle FORCES one via [data-theme]
|
|
1199
|
+
below. The light theme is a crisp WHITE page with near-black text, a
|
|
1200
|
+
readable muted grey, and visible borders (a washed-out light theme comes
|
|
1201
|
+
from a grey page + too-light muted text + faint borders). For a
|
|
1202
|
+
single-theme app, delete the [data-theme] rules and give each token a
|
|
1203
|
+
single colour instead of light-dark().
|
|
1204
|
+
EDGE CASES: light-dark() is COLOUR-only. A colour needed in just one
|
|
1205
|
+
theme sets the unused side to a no-op, e.g. light-dark(#fff, transparent).
|
|
1206
|
+
A DERIVED token that references a light-dark() one (like --primary-tint
|
|
1207
|
+
below) tracks both themes for free. A NON-colour token that must differ
|
|
1208
|
+
per theme (a shadow's geometry, a gradient, a size, an image) cannot use
|
|
1209
|
+
light-dark(); give it a :root[data-theme='dark'] override plus an
|
|
1210
|
+
@media (prefers-color-scheme: dark) { :root:not([data-theme]) { ... } }
|
|
1211
|
+
rule for the OS default. */
|
|
1180
1212
|
:root {
|
|
1181
1213
|
--font-sans: 'JetBrains Mono', ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
|
|
1182
1214
|
--font-serif: ui-serif, 'Iowan Old Style', Palatino, Georgia, serif;
|
|
1183
1215
|
--font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
|
|
1184
1216
|
--font-display: 'Bricolage Grotesque', 'JetBrains Mono', ui-sans-serif, system-ui, sans-serif;
|
|
1185
1217
|
--header-h: 0px;
|
|
1218
|
+
|
|
1219
|
+
color-scheme: light dark; /* default: follow the OS */
|
|
1220
|
+
--background: light-dark(#ffffff, #1e2226);
|
|
1221
|
+
--foreground: light-dark(#191c20, #dee2e6);
|
|
1222
|
+
--card: light-dark(#f7f8fa, #313539);
|
|
1223
|
+
--card-foreground: light-dark(#191c20, #dee2e6);
|
|
1224
|
+
--popover: light-dark(#ffffff, #313539);
|
|
1225
|
+
--popover-foreground: light-dark(#191c20, #dee2e6);
|
|
1226
|
+
--primary: light-dark(#1e2226, #dee2e6);
|
|
1227
|
+
--primary-foreground: light-dark(#ffffff, #1e2226);
|
|
1228
|
+
--secondary: light-dark(#eef0f2, #363a3e);
|
|
1229
|
+
--secondary-foreground: light-dark(#191c20, #dee2e6);
|
|
1230
|
+
--muted: light-dark(#eef0f2, #313539);
|
|
1231
|
+
--muted-foreground: light-dark(#565c64, #94989c);
|
|
1232
|
+
--accent: light-dark(#e9ebef, #363a3e);
|
|
1233
|
+
--accent-foreground: light-dark(#191c20, #f7fbff);
|
|
1234
|
+
--border: light-dark(#e2e5e9, #3d434b);
|
|
1235
|
+
--border-strong: light-dark(#ccd1d7, #454b51);
|
|
1236
|
+
--input: light-dark(#e2e5e9, #34393e);
|
|
1237
|
+
--ring: light-dark(#8b9198, #6b7075);
|
|
1186
1238
|
/* A translucent tint of the primary, tracked automatically across
|
|
1187
1239
|
light/dark. Used for focus rings (ring-primary-tint). */
|
|
1188
1240
|
--primary-tint: color-mix(in srgb, var(--primary) 22%, transparent);
|
|
1189
1241
|
}
|
|
1190
|
-
/*
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
--foreground: #dee2e6;
|
|
1195
|
-
--card: #313539;
|
|
1196
|
-
--card-foreground: #dee2e6;
|
|
1197
|
-
--popover: #313539;
|
|
1198
|
-
--popover-foreground: #dee2e6;
|
|
1199
|
-
--primary: #dee2e6;
|
|
1200
|
-
--primary-foreground: #1e2226;
|
|
1201
|
-
--secondary: #363a3e;
|
|
1202
|
-
--secondary-foreground: #dee2e6;
|
|
1203
|
-
--muted: #313539;
|
|
1204
|
-
--muted-foreground: #94989c;
|
|
1205
|
-
--accent: #363a3e;
|
|
1206
|
-
--accent-foreground: #f7fbff;
|
|
1207
|
-
--border: #34393e;
|
|
1208
|
-
--border-strong: #454b51;
|
|
1209
|
-
--input: #34393e;
|
|
1210
|
-
--ring: #6b7075;
|
|
1211
|
-
}
|
|
1212
|
-
/* light (explicit via the toggle) */
|
|
1213
|
-
:root[data-theme='light'] {
|
|
1214
|
-
color-scheme: light;
|
|
1215
|
-
--background: #dee2e6;
|
|
1216
|
-
--foreground: #313539;
|
|
1217
|
-
--card: #f0f4f7;
|
|
1218
|
-
--card-foreground: #313539;
|
|
1219
|
-
--popover: #f0f4f7;
|
|
1220
|
-
--popover-foreground: #313539;
|
|
1221
|
-
--primary: #313539;
|
|
1222
|
-
--primary-foreground: #f7fbff;
|
|
1223
|
-
--secondary: #f7fbff;
|
|
1224
|
-
--secondary-foreground: #313539;
|
|
1225
|
-
--muted: #eaeef1;
|
|
1226
|
-
--muted-foreground: #767b80;
|
|
1227
|
-
--accent: #f7fbff;
|
|
1228
|
-
--accent-foreground: #313539;
|
|
1229
|
-
--border: #c9d0d6;
|
|
1230
|
-
--border-strong: #b3bbc2;
|
|
1231
|
-
--input: #c9d0d6;
|
|
1232
|
-
--ring: #9aa0a5;
|
|
1233
|
-
}
|
|
1234
|
-
/* light (OS preference, when the user has made no explicit choice) */
|
|
1235
|
-
@media (prefers-color-scheme: light) {
|
|
1236
|
-
:root:not(.dark):not([data-theme='dark']) {
|
|
1237
|
-
color-scheme: light;
|
|
1238
|
-
--background: #dee2e6;
|
|
1239
|
-
--foreground: #313539;
|
|
1240
|
-
--card: #f0f4f7;
|
|
1241
|
-
--card-foreground: #313539;
|
|
1242
|
-
--popover: #f0f4f7;
|
|
1243
|
-
--popover-foreground: #313539;
|
|
1244
|
-
--primary: #313539;
|
|
1245
|
-
--primary-foreground: #f7fbff;
|
|
1246
|
-
--secondary: #f7fbff;
|
|
1247
|
-
--secondary-foreground: #313539;
|
|
1248
|
-
--muted: #eaeef1;
|
|
1249
|
-
--muted-foreground: #767b80;
|
|
1250
|
-
--accent: #f7fbff;
|
|
1251
|
-
--accent-foreground: #313539;
|
|
1252
|
-
--border: #c9d0d6;
|
|
1253
|
-
--border-strong: #b3bbc2;
|
|
1254
|
-
--input: #c9d0d6;
|
|
1255
|
-
--ring: #9aa0a5;
|
|
1256
|
-
}
|
|
1257
|
-
}
|
|
1242
|
+
/* The toggle writes data-theme to FORCE a scheme; with neither attribute
|
|
1243
|
+
the default 'color-scheme: light dark' above follows the OS. */
|
|
1244
|
+
:root[data-theme='light'] { color-scheme: light; }
|
|
1245
|
+
:root[data-theme='dark'] { color-scheme: dark; }
|
|
1258
1246
|
</style>
|
|
1259
1247
|
<style>
|
|
1260
1248
|
/* Base styles utility classes can't reach. */
|
|
@@ -1268,7 +1256,24 @@ export default function RootLayout({ children }: { children: unknown }) {
|
|
|
1268
1256
|
-moz-osx-font-smoothing: grayscale;
|
|
1269
1257
|
}
|
|
1270
1258
|
</style>
|
|
1271
|
-
|
|
1259
|
+
<!-- Top navbar, on every page: brand on the left, links + theme toggle on
|
|
1260
|
+
the right. It floats (no separator) and is in normal flow, so it just
|
|
1261
|
+
scrolls with the page; make it a fixed header only if you want it pinned
|
|
1262
|
+
(position: fixed, never sticky, which flickers on iOS during a nav). -->
|
|
1263
|
+
<header class="max-w-5xl mx-auto px-4 sm:px-6 h-14 flex items-center justify-between gap-4">
|
|
1264
|
+
<a href="/" class="inline-flex items-center gap-2 no-underline text-foreground font-bold tracking-tight" style="font-family: var(--font-display)">
|
|
1265
|
+
<span class="w-[22px] h-[22px] rounded-[7px] bg-gradient-to-br from-foreground to-muted-foreground" aria-hidden="true"></span>
|
|
1266
|
+
WebJs Gallery
|
|
1267
|
+
</a>
|
|
1268
|
+
<nav class="flex items-center gap-4 text-sm" aria-label="Primary">
|
|
1269
|
+
<a href="https://docs.webjs.dev" target="_blank" rel="noopener" class="hidden sm:inline text-muted-foreground hover:text-foreground no-underline transition-colors">Docs</a>
|
|
1270
|
+
<a href="https://github.com/webjsdev/webjs" target="_blank" rel="noopener" class="hidden sm:inline text-muted-foreground hover:text-foreground no-underline transition-colors">GitHub</a>
|
|
1271
|
+
<theme-toggle></theme-toggle>
|
|
1272
|
+
</nav>
|
|
1273
|
+
</header>
|
|
1274
|
+
<!-- Fill the viewport minus the h-14 (3.5rem) navbar, so a short page has no
|
|
1275
|
+
spurious scrollbar (min-h-dvh alone would overflow by the navbar height). -->
|
|
1276
|
+
<main class="min-h-[calc(100dvh-3.5rem)] max-w-5xl mx-auto px-4 sm:px-6 py-8">
|
|
1272
1277
|
\${children}
|
|
1273
1278
|
</main>
|
|
1274
1279
|
\`;
|
|
@@ -1281,74 +1286,34 @@ export default function RootLayout({ children }: { children: unknown }) {
|
|
|
1281
1286
|
// app/features/<x> route AND its modules/<x>), then reshape this page into the
|
|
1282
1287
|
// app's real landing page.
|
|
1283
1288
|
await writeFile(join(appDir, 'app', 'page.ts'), `import { html } from '@webjsdev/core';
|
|
1289
|
+
import { cardClass } from '#components/ui/card.ts';
|
|
1290
|
+
import { badgeClass } from '#components/ui/badge.ts';
|
|
1291
|
+
// The demo index is defined once in modules/gallery/nav.ts (the same source the
|
|
1292
|
+
// left sidebar reads), so the home cards and the sidebar can never drift.
|
|
1293
|
+
import { FEATURES, EXAMPLES } from '#modules/gallery/nav.ts';
|
|
1284
1294
|
|
|
1285
1295
|
export const metadata = {
|
|
1286
1296
|
title: '${displayName}',
|
|
1287
1297
|
};
|
|
1288
1298
|
|
|
1289
|
-
// The gallery this page links. FEATURES are single-concept demos (one WebJs
|
|
1290
|
-
// concept each, under app/features/, logic in modules/). EXAMPLES are whole apps
|
|
1291
|
-
// composing several features (under app/examples/). Prune what you do not use
|
|
1292
|
-
// (delete the route AND its modules/<name>), then reshape this page.
|
|
1293
|
-
const FEATURES = [
|
|
1294
|
-
{ href: '/features/routing', title: 'Routing', blurb: 'A static route plus a dynamic [id] segment that reads params. The file-based router in miniature.' },
|
|
1295
|
-
{ href: '/features/boundaries', title: 'Boundaries', blurb: 'The control-flow throws (forbidden / unauthorized / notFound) and the nearest boundary file that catches each.' },
|
|
1296
|
-
{ href: '/features/auth', title: 'Auth', blurb: 'Password login on createAuth, a signed session cookie, and a real protected route that redirects anonymous visitors to login.' },
|
|
1297
|
-
{ href: '/features/components', title: 'Components', blurb: 'The WebComponent factory, reactive props, instance signals, and slot projection in light DOM.' },
|
|
1298
|
-
{ href: '/features/server-actions', title: 'Server actions', blurb: 'A use-server RPC action next to a server-only .server.ts utility, and why the boundary matters.' },
|
|
1299
|
-
{ href: '/features/optimistic-ui', title: 'Optimistic UI', blurb: 'The imperative optimistic(signal, value, action) flip: instant update, automatic rollback on failure.' },
|
|
1300
|
-
{ href: '/features/async-render', title: 'Async render', blurb: 'A component that awaits server data in async render(), so the resolved value is in the first paint.' },
|
|
1301
|
-
{ href: '/features/streaming', title: 'Streaming actions', blurb: 'A use-server action that returns an async generator, streamed to the call site token by token with for await.' },
|
|
1302
|
-
{ href: '/features/stream', title: 'Stream updates', blurb: 'The <webjs-stream> element: renderStream() applies surgical append / replace / remove DOM updates by target id, no region redraw.' },
|
|
1303
|
-
{ href: '/features/suspense', title: 'Suspense boundary', blurb: 'The <webjs-suspense> element: a first-paint fallback for a SLOW component, with the resolved content streamed in.' },
|
|
1304
|
-
{ href: '/features/view-transitions', title: 'View transitions', blurb: 'The opt-in view-transition meta cross-fades a soft navigation, with a data-webjs-permanent element persisted across the swap.' },
|
|
1305
|
-
{ href: '/features/directives', title: 'Directives', blurb: 'The lit-html directive set: repeat for keyed lists, watch(signal) for a fine-grained node swap.' },
|
|
1306
|
-
{ href: '/features/route-handler', title: 'Route handlers', blurb: 'A server-only route.ts HTTP endpoint returning JSON, the WebJs equivalent of a Next route handler.' },
|
|
1307
|
-
{ href: '/features/forms', title: 'Forms', blurb: 'A no-JS progressive-enhancement form posting to the page action, with server-side validation errors.' },
|
|
1308
|
-
{ href: '/features/metadata', title: 'Metadata', blurb: 'Static metadata plus generateMetadata(ctx), which reads the request to compute the title and Open Graph tags.' },
|
|
1309
|
-
{ href: '/features/caching', title: 'Caching', blurb: 'export const revalidate caches the page HTML per URL, with the safety rule for when a shared cache is allowed.' },
|
|
1310
|
-
{ href: '/features/env', title: 'Env vars', blurb: 'The server-only vs WEBJS_PUBLIC_ boundary, read during SSR so secrets never reach the browser.' },
|
|
1311
|
-
{ href: '/features/client-router', title: 'Client router', blurb: 'Automatic soft navigation: fragment-only fetches, hover prefetch, scroll restore, and graceful no-JS fallback.' },
|
|
1312
|
-
{ href: '/features/frames', title: 'Frames', blurb: 'A webjs-frame region that swaps a filtered sub-list in place from a link, shipping zero component JS, with a no-JS full-nav fallback.' },
|
|
1313
|
-
{ href: '/features/service-worker', title: 'Service worker', blurb: 'The opt-in offline enhancement, registered from a browser-only lifecycle hook (never a page or layout).' },
|
|
1314
|
-
{ href: '/features/websockets', title: 'WebSockets', blurb: 'A WS(ws, req) route endpoint plus the connectWS() client, echoing messages over a live socket.' },
|
|
1315
|
-
{ href: '/features/broadcast', title: 'Broadcast', blurb: 'Fan a message out to every connected client on a WebSocket path, so all open tabs stay in sync.' },
|
|
1316
|
-
{ href: '/features/rate-limit', title: 'Rate limiting', blurb: 'The rateLimit() middleware scoped to one endpoint, returning a 429 with Retry-After past the window.' },
|
|
1317
|
-
{ href: '/features/file-storage', title: 'File storage', blurb: 'A no-JS multipart upload streamed into the FileStore, then served back through a streaming route.' },
|
|
1318
|
-
{ href: '/features/sessions', title: 'Sessions', blurb: 'A signed-cookie session applied by a segment middleware, read and written per visitor with getSession() in a route.' },
|
|
1319
|
-
];
|
|
1320
|
-
const EXAMPLES = [
|
|
1321
|
-
{ href: '/examples/todo', title: 'Optimistic todo', blurb: 'A whole app composing several features: the declarative optimistic() list API, progressive-enhancement forms, accessible labels, the modules split, and SQLite.' },
|
|
1322
|
-
];
|
|
1323
|
-
|
|
1324
1299
|
export default function Home() {
|
|
1325
1300
|
return html\`
|
|
1326
|
-
<div class="
|
|
1327
|
-
|
|
1328
|
-
<div class="max-w-5xl mx-auto px-6 py-16 flex flex-col items-center gap-16">
|
|
1329
|
-
<!-- Masthead -->
|
|
1301
|
+
<div class="py-8 flex flex-col items-center gap-16">
|
|
1302
|
+
<!-- Hero -->
|
|
1330
1303
|
<section class="flex flex-col items-center text-center gap-5">
|
|
1331
|
-
<
|
|
1332
|
-
|
|
1333
|
-
WebJs Gallery
|
|
1304
|
+
<h1 class="text-5xl sm:text-6xl font-bold tracking-tight leading-none m-0 break-words bg-gradient-to-b from-foreground to-muted-foreground bg-clip-text text-transparent" style="font-family: var(--font-display); letter-spacing: -0.02em;">
|
|
1305
|
+
Explore the gallery
|
|
1334
1306
|
</h1>
|
|
1335
1307
|
<p class="text-base sm:text-lg text-muted-foreground max-w-lg leading-relaxed m-0">
|
|
1336
|
-
|
|
1308
|
+
Each demo isolates a single WebJs capability in real, runnable code. Read the ones you need, then build your app on the same patterns.
|
|
1337
1309
|
</p>
|
|
1338
1310
|
</section>
|
|
1339
1311
|
|
|
1340
1312
|
<!-- Gallery: every feature demo + the example app -->
|
|
1341
1313
|
<section class="w-full flex flex-col gap-6">
|
|
1342
|
-
<div class="flex flex-col items-center gap-2 text-center">
|
|
1343
|
-
<h2 class="text-xs font-semibold uppercase tracking-[0.16em] text-muted-foreground m-0">Explore the gallery</h2>
|
|
1344
|
-
<p class="text-sm text-muted-foreground max-w-lg leading-relaxed m-0">
|
|
1345
|
-
One WebJs concept per demo under <code class="text-[0.9em] text-foreground">app/features/</code>, with logic
|
|
1346
|
-
in <code class="text-[0.9em] text-foreground">modules/</code>.
|
|
1347
|
-
</p>
|
|
1348
|
-
</div>
|
|
1349
1314
|
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
|
1350
1315
|
\${FEATURES.map(f => html\`
|
|
1351
|
-
<a href="\${f.href}" class
|
|
1316
|
+
<a href="\${f.href}" class=\${cardClass('group flex flex-col gap-1.5 rounded-xl p-4 no-underline transition-colors hover:border-border-strong hover:bg-accent')}>
|
|
1352
1317
|
<span class="flex items-center justify-between gap-2">
|
|
1353
1318
|
<span class="text-sm font-medium text-foreground">\${f.title}</span>
|
|
1354
1319
|
<span class="text-muted-foreground transition-transform group-hover:translate-x-0.5" aria-hidden="true">→</span>
|
|
@@ -1358,9 +1323,9 @@ export default function Home() {
|
|
|
1358
1323
|
\`)}
|
|
1359
1324
|
</div>
|
|
1360
1325
|
\${EXAMPLES.map(e => html\`
|
|
1361
|
-
<a href="\${e.href}" class
|
|
1326
|
+
<a href="\${e.href}" class=\${cardClass('group flex flex-col gap-2 rounded-xl p-5 no-underline transition-colors hover:border-border-strong hover:bg-accent')}>
|
|
1362
1327
|
<span class="flex items-center gap-2.5">
|
|
1363
|
-
<span class
|
|
1328
|
+
<span class=\${badgeClass({ variant: 'outline' })}>Example app</span>
|
|
1364
1329
|
<span class="text-sm font-medium text-foreground">\${e.title}</span>
|
|
1365
1330
|
<span class="ml-auto text-muted-foreground transition-transform group-hover:translate-x-0.5" aria-hidden="true">→</span>
|
|
1366
1331
|
</span>
|
|
@@ -1372,8 +1337,8 @@ export default function Home() {
|
|
|
1372
1337
|
<!-- Footer: docs + source -->
|
|
1373
1338
|
<footer class="flex flex-col items-center gap-3">
|
|
1374
1339
|
<nav class="flex items-center gap-6 text-sm text-muted-foreground" aria-label="WebJs links">
|
|
1375
|
-
<a href="https://docs.webjs.dev" class="inline-flex items-center gap-2 hover:text-foreground transition-colors no-underline">\${iconBook()}<span>Docs</span></a>
|
|
1376
|
-
<a href="https://github.com/webjsdev/webjs" class="inline-flex items-center gap-2 hover:text-foreground transition-colors no-underline">\${iconGithub()}<span>GitHub</span></a>
|
|
1340
|
+
<a href="https://docs.webjs.dev" target="_blank" rel="noopener" class="inline-flex items-center gap-2 hover:text-foreground transition-colors no-underline">\${iconBook()}<span>Docs</span></a>
|
|
1341
|
+
<a href="https://github.com/webjsdev/webjs" target="_blank" rel="noopener" class="inline-flex items-center gap-2 hover:text-foreground transition-colors no-underline">\${iconGithub()}<span>GitHub</span></a>
|
|
1377
1342
|
</nav>
|
|
1378
1343
|
<p class="text-[0.7rem] uppercase tracking-[0.15em] text-muted-foreground m-0 text-center">
|
|
1379
1344
|
Built with WebJs · MIT License
|
|
@@ -1397,6 +1362,8 @@ function iconGithub() {
|
|
|
1397
1362
|
// --- Theme toggle component ---
|
|
1398
1363
|
|
|
1399
1364
|
await writeFile(join(appDir, 'components', 'theme-toggle.ts'), `import { WebComponent, html, signal } from '@webjsdev/core';
|
|
1365
|
+
import { cn } from '#lib/utils/cn.ts';
|
|
1366
|
+
import { buttonClass } from '#components/ui/button.ts';
|
|
1400
1367
|
|
|
1401
1368
|
type Theme = 'system' | 'light' | 'dark';
|
|
1402
1369
|
|
|
@@ -1432,9 +1399,10 @@ export class ThemeToggle extends WebComponent {
|
|
|
1432
1399
|
const el = document.documentElement;
|
|
1433
1400
|
if (next === 'system') delete el.dataset.theme;
|
|
1434
1401
|
else el.dataset.theme = next;
|
|
1435
|
-
//
|
|
1402
|
+
// Our own tokens follow color-scheme via data-theme (set above). Keep the
|
|
1403
|
+
// .dark class in sync only for @webjsdev/ui components, which key off it.
|
|
1436
1404
|
const dark = next === 'dark'
|
|
1437
|
-
|| (next === 'system' &&
|
|
1405
|
+
|| (next === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
|
1438
1406
|
el.classList.toggle('dark', dark);
|
|
1439
1407
|
}
|
|
1440
1408
|
|
|
@@ -1444,7 +1412,7 @@ export class ThemeToggle extends WebComponent {
|
|
|
1444
1412
|
const icon = t === 'light' ? ICONS.sun : t === 'dark' ? ICONS.moon : ICONS.system;
|
|
1445
1413
|
return html\`
|
|
1446
1414
|
<button
|
|
1447
|
-
class
|
|
1415
|
+
class=\${cn(buttonClass({ variant: 'secondary', size: 'none' }), 'w-9 h-9 rounded-full text-muted-foreground duration-150 hover:text-foreground active:scale-[0.94]')}
|
|
1448
1416
|
@click=\${() => this.cycle()}
|
|
1449
1417
|
aria-label="Cycle theme (currently \${label})"
|
|
1450
1418
|
title="Theme: \${label.toLowerCase()}"
|
package/package.json
CHANGED
|
@@ -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
|