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.
- package/index.js +123 -20
- 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
|
|
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 = () =>
|
|
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 }) =>
|
|
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
|
|
1068
|
-
//
|
|
1069
|
-
//
|
|
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
|
-
|
|
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(
|
|
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
|
-
//
|
|
1337
|
-
//
|
|
1338
|
-
|
|
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('
|
|
1342
|
-
const
|
|
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;
|