create-what 0.11.0 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/index.js +123 -20
  2. package/package.json +1 -1
package/index.js CHANGED
@@ -901,9 +901,18 @@ NODE_ENV=production npm start
901
901
  - \`src/entry-client.js\` — client hydration entry: re-renders the matched page
902
902
  with the server's loader data (\`#__what_data\`) and attaches interactivity.
903
903
  - \`server.js\` — Node adapter + ISR engine + revalidate webhook + poll scheduler
904
- + static serving (client entry, framework modules, \`public/\`).
904
+ + static serving. Static serving is deny-by-default: only allowlisted client
905
+ files (\`src/entry-client.js\`, \`src/styles.css\`, \`src/pages/\`,
906
+ \`src/components/\`, framework modules, \`public/\`) are served over HTTP —
907
+ server-only code (\`src/db.js\`, \`src/actions/\`, \`src/routes.js\`) returns 404.
905
908
  - \`what.config.js\` — deploy adapter + ISR defaults.
906
909
 
910
+ The \`/new\` form works without JavaScript: it SSRs hidden \`_action\`,
911
+ \`what-csrf-token\` and \`_redirect\` fields, so a plain form-encoded POST to
912
+ \`/__what_action\` dispatches the action and redirects. With JavaScript,
913
+ \`src/entry-client.js\` upgrades it to a JSON fetch (no full-page reload).
914
+ Unknown posts (\`/blog/nope\`) return a real 404 and are never ISR-cached.
915
+
907
916
  ### ISR cheat-sheet
908
917
 
909
918
  - \`page = { mode: 'static', revalidate: 60 }\` → cached, regenerated in the
@@ -1000,13 +1009,18 @@ export const createPostAction = action(
1000
1009
  // This module is isomorphic: the server renders it to HTML, then
1001
1010
  // src/entry-client.js hydrates the same component in the browser (reusing the
1002
1011
  // server DOM and attaching the onclick handler + reactive text below).
1012
+ // Server-only code (the database) is imported lazily INSIDE the loader, which
1013
+ // only ever runs on the server — so the browser never requests /src/db.js
1014
+ // (and server.js refuses to serve it anyway).
1003
1015
 
1004
1016
  import { h, Head, signal, useLoaderData } from 'what-framework';
1005
- import { listPosts } from '../db.js';
1006
1017
 
1007
1018
  export const page = { mode: 'static', revalidate: 60, tags: ['posts'] };
1008
1019
 
1009
- export const loader = () => ({ posts: listPosts() });
1020
+ export const loader = async () => {
1021
+ const { listPosts } = await import('../db.js'); // server-only
1022
+ return { posts: listPosts() };
1023
+ };
1010
1024
 
1011
1025
  export default function Home() {
1012
1026
  const { posts } = useLoaderData();
@@ -1034,15 +1048,22 @@ export default function Home() {
1034
1048
  // Dynamic /blog/[slug] — loader + getStaticPaths + ISR + per-post <Head>.
1035
1049
  writeFileSync(resolve(root, 'src', 'pages', 'post.js'), `// Dynamic post page. getStaticPaths pre-renders known slugs at build; unknown
1036
1050
  // slugs render on first hit (fallback: 'blocking') and are then cached.
1051
+ // The database import lives inside the loader/getStaticPaths (server-only),
1052
+ // keeping /src/db.js out of the browser. A missing post sets notFound: true,
1053
+ // which server.js turns into a real 404 status that is never ISR-cached.
1037
1054
 
1038
1055
  import { h, Head, useLoaderData } from 'what-framework';
1039
- import { getPost, listPosts } from '../db.js';
1040
1056
 
1041
1057
  export const page = { mode: 'static', revalidate: 60, tags: ['posts'] };
1042
1058
 
1043
- export const loader = ({ params }) => ({ post: getPost(params.slug) });
1059
+ export const loader = async ({ params }) => {
1060
+ const { getPost } = await import('../db.js'); // server-only
1061
+ const post = getPost(params.slug);
1062
+ return { post, notFound: !post };
1063
+ };
1044
1064
 
1045
1065
  export async function getStaticPaths() {
1066
+ const { listPosts } = await import('../db.js'); // server-only
1046
1067
  return {
1047
1068
  paths: listPosts().map((p) => ({ params: { slug: p.slug } })),
1048
1069
  fallback: 'blocking',
@@ -1064,19 +1085,42 @@ export default function Post() {
1064
1085
  `);
1065
1086
 
1066
1087
  // /new — mode:'server' (never cached), posts to the createPost action.
1067
- writeFileSync(resolve(root, 'src', 'pages', 'new.js'), `// New-post form. mode:'server' so it's always fresh. src/entry-client.js
1068
- // enhances the form: it submits to the createPost server action as JSON with
1069
- // the X-What-Action header (the action protocol), then navigates to the post.
1088
+ writeFileSync(resolve(root, 'src', 'pages', 'new.js'), `// New-post form. mode:'server' so it's always fresh — which also means the
1089
+ // per-user CSRF token can be SSR'd into the form (never into shared cache).
1090
+ //
1091
+ // The form works two ways:
1092
+ // - No JS: a plain form-encoded POST to /__what_action. The hidden fields
1093
+ // carry the action id (_action), the double-submit CSRF token
1094
+ // (what-csrf-token) and the post-submit redirect (_redirect).
1095
+ // - With JS: src/entry-client.js intercepts submit and posts JSON with the
1096
+ // X-What-Action header (the action protocol), then navigates to the post.
1070
1097
 
1071
- import { h, Head } from 'what-framework';
1098
+ import { h, Head, useLoaderData } from 'what-framework';
1072
1099
 
1073
1100
  export const page = { mode: 'server' };
1074
1101
 
1102
+ // server.js passes the per-request CSRF token (the same one the framework
1103
+ // Set-Cookies and embeds as the <meta> tag) into loaders; fall back to the
1104
+ // visitor's existing cookie. Rendered into the hidden field below so the
1105
+ // no-JS form post passes the double-submit check.
1106
+ export const loader = ({ request, csrfToken }) => {
1107
+ if (csrfToken) return { csrfToken };
1108
+ const cookie = request && request.headers && typeof request.headers.get === 'function'
1109
+ ? request.headers.get('cookie')
1110
+ : '';
1111
+ const match = cookie ? cookie.match(/(?:^|;\\s*)what-csrf=([^;]+)/) : null;
1112
+ return { csrfToken: match ? decodeURIComponent(match[1]) : '' };
1113
+ };
1114
+
1075
1115
  export default function NewPost() {
1116
+ const { csrfToken } = useLoaderData();
1076
1117
  return h('main', { class: 'container' },
1077
1118
  h(Head, { title: 'New post — ${packageName}' }),
1078
1119
  h('h1', {}, 'New post'),
1079
1120
  h('form', { method: 'post', action: '/__what_action', 'data-action': 'createPost' },
1121
+ h('input', { type: 'hidden', name: '_action', value: 'createPost' }),
1122
+ h('input', { type: 'hidden', name: 'what-csrf-token', value: csrfToken || '' }),
1123
+ h('input', { type: 'hidden', name: '_redirect', value: '/' }),
1080
1124
  h('input', { name: 'title', placeholder: 'Title', required: true }),
1081
1125
  h('textarea', { name: 'body', placeholder: 'Write something…', required: true }),
1082
1126
  h('button', { type: 'submit' }, 'Publish')
@@ -1093,7 +1137,7 @@ export default function NewPost() {
1093
1137
 
1094
1138
  import Home, { loader as homeLoader, page as homePage } from './pages/home.js';
1095
1139
  import Post, { loader as postLoader, getStaticPaths as postPaths, page as postPage } from './pages/post.js';
1096
- import NewPost, { page as newPage } from './pages/new.js';
1140
+ import NewPost, { loader as newLoader, page as newPage } from './pages/new.js';
1097
1141
 
1098
1142
  // Importing the action registers it so /__what_action can dispatch it.
1099
1143
  import './actions/posts.js';
@@ -1101,7 +1145,7 @@ import './actions/posts.js';
1101
1145
  export const routes = [
1102
1146
  { path: '/', component: Home, loader: homeLoader, page: homePage, mode: homePage.mode },
1103
1147
  { path: '/blog/:slug', component: Post, loader: postLoader, getStaticPaths: postPaths, page: postPage, mode: postPage.mode },
1104
- { path: '/new', component: NewPost, page: newPage, mode: newPage.mode },
1148
+ { path: '/new', component: NewPost, loader: newLoader, page: newPage, mode: newPage.mode },
1105
1149
  ];
1106
1150
  `);
1107
1151
 
@@ -1151,6 +1195,9 @@ for (const form of document.querySelectorAll('form[data-action]')) {
1151
1195
  form.addEventListener('submit', async (event) => {
1152
1196
  event.preventDefault();
1153
1197
  const args = Object.fromEntries(new FormData(form));
1198
+ // The hidden fields exist for the no-JS form-post fallback; the framework
1199
+ // consumes them server-side on that path. Strip them from the JSON args.
1200
+ for (const k of ['_action', '_csrf', '_redirect', 'what-csrf-token', 'data-action']) delete args[k];
1154
1201
  const headers = { 'content-type': 'application/json', 'x-what-action': form.dataset.action };
1155
1202
  const token = csrfToken();
1156
1203
  if (token) headers['x-csrf-token'] = token;
@@ -1256,7 +1303,7 @@ import { randomBytes } from 'node:crypto';
1256
1303
  import { existsSync, statSync, createReadStream } from 'node:fs';
1257
1304
  import { extname, join, resolve } from 'node:path';
1258
1305
  import { fileURLToPath } from 'node:url';
1259
- import { createRequestHandler, toNodeListener } from 'what-framework/server';
1306
+ import { createRequestHandler, renderDocument, toNodeListener } from 'what-framework/server';
1260
1307
  import {
1261
1308
  createCacheEngine,
1262
1309
  createMemoryStore,
@@ -1286,11 +1333,36 @@ if (!REVALIDATE_SECRET) {
1286
1333
 
1287
1334
  // Origin ISR cache. Swap createMemoryStore() for createFilesystemStore({dir})
1288
1335
  // to survive restarts, or createRedisStore({client}) for multi-instance.
1289
- const cache = createCacheEngine({ store: createMemoryStore() });
1336
+ // \`render\` powers scheduled regeneration (renderRoute is defined below;
1337
+ // function declarations hoist, so the reference is valid here).
1338
+ const cache = createCacheEngine({ store: createMemoryStore(), render: renderRoute });
1339
+
1340
+ // Non-200 renders (soft-404s like /blog/nonexistent) must never be ISR-cached —
1341
+ // a cached "Not found" would shadow a post created later and poison SEO. The
1342
+ // engine skips storing them; this wrapper also drops any stored non-200 entry
1343
+ // and forces the response uncacheable (belt and suspenders).
1344
+ const appCache = {
1345
+ ...cache,
1346
+ async handle(routeMatch, render) {
1347
+ let result = await cache.handle(routeMatch, render);
1348
+ if (result && result.status && result.status !== 200) {
1349
+ try { await cache.store.delete(cache.keyFor(routeMatch)); } catch { /* best effort */ }
1350
+ const headers = { ...(result.headers || {}) };
1351
+ delete headers['Cache-Control'];
1352
+ delete headers['cache-control'];
1353
+ headers['Cache-Control'] = 'private, no-store';
1354
+ result = { ...result, headers };
1355
+ }
1356
+ return result;
1357
+ },
1358
+ };
1290
1359
 
1291
1360
  // Keep the home listing warm every 5 minutes regardless of traffic.
1292
1361
  const scheduler = createScheduler(cache);
1293
- scheduler.register({ path: '/', query: {}, config: routes[0].page }, { intervalMs: 5 * 60 * 1000 });
1362
+ scheduler.register(
1363
+ { path: '/', query: {}, params: {}, config: routes[0].page, route: routes[0] },
1364
+ { intervalMs: 5 * 60 * 1000 }
1365
+ );
1294
1366
 
1295
1367
  // The browser loads /src/entry-client.js as a native ES module; this import
1296
1368
  // map resolves its bare imports. Sources are served from node_modules below.
@@ -1309,10 +1381,31 @@ const documentOptions = {
1309
1381
  '<link rel="icon" type="image/svg+xml" href="/favicon.svg">',
1310
1382
  };
1311
1383
 
1384
+ // Render a matched route. Mirrors the framework default renderer, plus:
1385
+ // - passes csrfToken through to loaders (so /new can SSR the no-JS form token)
1386
+ // - honors a loader's \`notFound: true\` with a real 404 status (the appCache
1387
+ // wrapper above keeps those responses out of the ISR cache)
1388
+ async function renderRoute(routeMatch) {
1389
+ const { route, params, query, request, csrfToken } = routeMatch;
1390
+ const reqCtx = { params, query, request, csrfToken };
1391
+ const loaderData = typeof route.loader === 'function' ? await route.loader(reqCtx) : undefined;
1392
+ const notFound = !!(loaderData && loaderData.notFound);
1393
+ const pageModule = { default: route.component, loader: () => loaderData };
1394
+ const opts = csrfToken ? { ...documentOptions, csrfToken } : documentOptions;
1395
+ const html = await renderDocument(pageModule, reqCtx, opts);
1396
+ return {
1397
+ html,
1398
+ status: notFound ? 404 : 200,
1399
+ tags: (routeMatch.config && routeMatch.config.tags) || [],
1400
+ path: routeMatch.path,
1401
+ };
1402
+ }
1403
+
1312
1404
  export function createHandler() {
1313
1405
  return createRequestHandler({
1314
1406
  routes,
1315
- cache,
1407
+ cache: appCache,
1408
+ render: renderRoute,
1316
1409
  revalidateWebhook: createRevalidateWebhook(cache, { secret: REVALIDATE_SECRET }),
1317
1410
  document: documentOptions,
1318
1411
  });
@@ -1333,13 +1426,23 @@ const MIME = {
1333
1426
  '.woff2': 'font/woff2',
1334
1427
  };
1335
1428
 
1336
- // Only these prefixes are served from the project root; anything else falls
1337
- // back to public/ (favicon etc.) and then to the app handler.
1338
- const SERVED_PREFIXES = ['/src/', '/node_modules/what-framework/', '/node_modules/what-core/'];
1429
+ // Deny-by-default static serving: ONLY the allowlisted client files below are
1430
+ // served from the project root. Server-only modules src/db.js, src/actions/**
1431
+ // and src/routes.js — are importable by Node but 404 over HTTP, so DB code and
1432
+ // mutation logic never leak to the browser. When you add new client-side files
1433
+ // outside these locations, add them here explicitly.
1434
+ const SERVED_FILES = new Set(['/src/entry-client.js', '/src/styles.css']);
1435
+ const SERVED_PREFIXES = [
1436
+ '/src/pages/', // page modules (hydrated by entry-client)
1437
+ '/src/components/', // client-shared UI (create as needed)
1438
+ '/node_modules/what-framework/',
1439
+ '/node_modules/what-core/',
1440
+ ];
1339
1441
 
1340
1442
  function resolveStaticFile(pathname) {
1341
- if (pathname.includes('..') || pathname.includes('\\0')) return null;
1342
- const file = SERVED_PREFIXES.some((p) => pathname.startsWith(p))
1443
+ if (pathname.includes('..') || pathname.includes('\0')) return null;
1444
+ const allowed = SERVED_FILES.has(pathname) || SERVED_PREFIXES.some((p) => pathname.startsWith(p));
1445
+ const file = allowed
1343
1446
  ? resolve(join(ROOT, pathname))
1344
1447
  : resolve(join(ROOT, 'public', pathname));
1345
1448
  if (!file.startsWith(resolve(ROOT))) return null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-what",
3
- "version": "0.11.0",
3
+ "version": "0.11.1",
4
4
  "description": "Scaffold a new What Framework project",
5
5
  "type": "module",
6
6
  "bin": {