what-server 0.12.2 → 0.12.4
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/dist/index.min.js +8 -8
- package/dist/islands.min.js +2 -1
- package/dist/node.min.js +8 -8
- package/index.d.ts +153 -14
- package/package.json +3 -3
- package/src/adapter/core.js +17 -4
- package/src/form.js +12 -5
- package/src/index.js +262 -18
- package/src/islands.js +161 -35
package/index.d.ts
CHANGED
|
@@ -14,6 +14,13 @@ export interface RenderRequestContext {
|
|
|
14
14
|
params?: Record<string, string>;
|
|
15
15
|
query?: Record<string, string>;
|
|
16
16
|
request?: any;
|
|
17
|
+
/**
|
|
18
|
+
* The visitor's per-request CSRF token, on directly-rendered routes only.
|
|
19
|
+
* Return it from the loader to pass into <Form csrfToken> so the no-JS submit
|
|
20
|
+
* survives the double-submit check. A CACHED route never has one: its HTML is
|
|
21
|
+
* shared, so a form there only submits with the client enhancer active.
|
|
22
|
+
*/
|
|
23
|
+
csrfToken?: string;
|
|
17
24
|
[key: string]: any;
|
|
18
25
|
}
|
|
19
26
|
|
|
@@ -66,10 +73,21 @@ export type { IslandOptions, IslandStore, IslandStatus } from './islands.js';
|
|
|
66
73
|
// --- Server Actions ---
|
|
67
74
|
|
|
68
75
|
export interface ActionOptions {
|
|
76
|
+
/** Stable id shared by the client and server bundles. The compiler emits one; pass it by hand only for uncompiled builds. */
|
|
69
77
|
id?: string;
|
|
70
78
|
onError?: (error: Error) => void;
|
|
71
79
|
onSuccess?: (result: any) => void;
|
|
80
|
+
/** Paths to revalidate after the action succeeds. */
|
|
72
81
|
revalidate?: string[];
|
|
82
|
+
/**
|
|
83
|
+
* Cache tags to purge after the action succeeds (handleActionRequest reads
|
|
84
|
+
* these off the registered action). The only way to purge by tag from an
|
|
85
|
+
* action declaration, so leaving it undeclared made the documented call a
|
|
86
|
+
* compile error.
|
|
87
|
+
*/
|
|
88
|
+
revalidateTags?: string[];
|
|
89
|
+
/** Client-side fetch timeout in ms. Default 30000. */
|
|
90
|
+
timeout?: number;
|
|
73
91
|
}
|
|
74
92
|
|
|
75
93
|
/** Define a server action */
|
|
@@ -189,13 +207,21 @@ export function renderToStringAsync(
|
|
|
189
207
|
ctx?: unknown,
|
|
190
208
|
): Promise<{ body: string; head: string; resources: Record<string, unknown> }>;
|
|
191
209
|
|
|
210
|
+
// The five keys wrapHtmlDocument reads. `bodyAttrs`, `scripts` and `styles`
|
|
211
|
+
// were declared here and read by nothing: `styles: ['/app.css']` typechecked,
|
|
212
|
+
// shipped, and produced a page with no stylesheet and no error. (PageConfig's
|
|
213
|
+
// scripts/styles are real; these were not.)
|
|
192
214
|
export interface DocumentOptions {
|
|
215
|
+
/** <html lang>, default 'en'. */
|
|
193
216
|
lang?: string;
|
|
217
|
+
/** Raw markup appended to <head>. */
|
|
194
218
|
head?: string;
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
219
|
+
/** class attribute for <body>. */
|
|
220
|
+
bodyClass?: string;
|
|
221
|
+
/** Module script src for the hydration entry. */
|
|
222
|
+
clientEntry?: string;
|
|
223
|
+
/** Per-request CSRF token, inlined as <meta name="what-csrf-token">. The deploy adapter supplies this. */
|
|
224
|
+
csrfToken?: string;
|
|
199
225
|
}
|
|
200
226
|
|
|
201
227
|
/**
|
|
@@ -208,9 +234,6 @@ export function renderDocument(
|
|
|
208
234
|
options?: DocumentOptions,
|
|
209
235
|
): Promise<string>;
|
|
210
236
|
|
|
211
|
-
/** Render a PageConfig to a complete static HTML document. */
|
|
212
|
-
export function generateStaticPage(page: PageConfig, data?: any): string;
|
|
213
|
-
|
|
214
237
|
// --- <Form> ---
|
|
215
238
|
// A real <form method="post"> that posts to the action endpoint, so it submits
|
|
216
239
|
// with JavaScript disabled and is enhanced to a fetch when JS is present.
|
|
@@ -248,10 +271,17 @@ export function csrfMetaTag(token: string): string;
|
|
|
248
271
|
// --- Action handlers ---
|
|
249
272
|
// Runtime-neutral core, plus the two host bindings.
|
|
250
273
|
|
|
274
|
+
// The three options createActionHandler reads, and only those. `csrfSecret` and
|
|
275
|
+
// `onError` were declared here and read by nothing: an `onError` handler that
|
|
276
|
+
// is never called is worse than no handler, so the open index signature that
|
|
277
|
+
// hid the mismatch is gone too.
|
|
251
278
|
export interface ActionHandlerOptions {
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
279
|
+
/** Resolve the session's CSRF token for a request (sync or async). Omit together with `skipCsrf: true`. */
|
|
280
|
+
getCsrfToken?: (reqLike: any) => string | null | undefined | Promise<string | null | undefined>;
|
|
281
|
+
/** Opt out of CSRF validation (e.g. a token-authed API behind another gateway). */
|
|
282
|
+
skipCsrf?: boolean;
|
|
283
|
+
/** Mount path, default '/__what_action'. Read by nodeActionMiddleware. */
|
|
284
|
+
basePath?: string;
|
|
255
285
|
}
|
|
256
286
|
|
|
257
287
|
export function createActionHandler(options?: ActionHandlerOptions): (request: any) => Promise<any>;
|
|
@@ -260,14 +290,123 @@ export function fetchActionHandler(options?: ActionHandlerOptions): (request: Re
|
|
|
260
290
|
|
|
261
291
|
// --- Deploy adapters ---
|
|
262
292
|
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
293
|
+
/**
|
|
294
|
+
* A route the adapter can match and render. Open on purpose: routes carry
|
|
295
|
+
* app-specific metadata (titles, guards, nested children) the adapter ignores.
|
|
296
|
+
*/
|
|
297
|
+
export interface RouteDefinition {
|
|
298
|
+
path: string;
|
|
299
|
+
/**
|
|
300
|
+
* The page component. The `any` in `VNode<any>` is load-bearing, NOT laziness:
|
|
301
|
+
* `VNode<P>` carries P through `tag: string | Component<P>`, and `Component<P>`
|
|
302
|
+
* is contravariant in its props under strictFunctionTypes, so `VNode<P>` is
|
|
303
|
+
* effectively invariant. `h('div', { class: 'x' })` returns
|
|
304
|
+
* `VNode<{ class: string }>`, which is therefore NOT assignable to bare
|
|
305
|
+
* `VNode` (= `VNode<Record<string, any>>`). Writing `VNode` here made every
|
|
306
|
+
* route component built with h() and any props at all a TS2322, including the
|
|
307
|
+
* `h(Form, { csrfToken: loaderData.token })` page the csrfToken plumbing above
|
|
308
|
+
* exists to make writable. Only `() => null` and `h('div', {})` survived it.
|
|
309
|
+
*/
|
|
310
|
+
component: (props: any) => VNode<any> | null;
|
|
311
|
+
/** Runs per request before the component. Receives `csrfToken` on directly-rendered (uncached) routes. */
|
|
312
|
+
loader?: (ctx: RenderRequestContext) => any;
|
|
313
|
+
mode?: 'static' | 'server' | 'client' | 'hybrid';
|
|
314
|
+
/** The route's cache config. Mirrors what-isr's PageCacheConfig structurally so what-server keeps working without what-isr installed. */
|
|
315
|
+
page?: {
|
|
316
|
+
mode?: 'static' | 'server' | 'client' | 'hybrid';
|
|
317
|
+
revalidate?: number;
|
|
318
|
+
swr?: number;
|
|
319
|
+
tags?: string[];
|
|
320
|
+
vary?: string[] | string;
|
|
321
|
+
[key: string]: any;
|
|
322
|
+
};
|
|
266
323
|
[key: string]: any;
|
|
267
324
|
}
|
|
268
325
|
|
|
326
|
+
/** What the adapter passes to (and expects back from) a render function. */
|
|
327
|
+
export interface RouteMatchContext {
|
|
328
|
+
path: string;
|
|
329
|
+
query: Record<string, string>;
|
|
330
|
+
params: Record<string, string>;
|
|
331
|
+
config: Record<string, any>;
|
|
332
|
+
route: RouteDefinition;
|
|
333
|
+
request: Request;
|
|
334
|
+
/** Present only on the direct-render path. Cached HTML is shared, so it never carries a per-visitor token. */
|
|
335
|
+
csrfToken?: string;
|
|
336
|
+
varyHeaders?: Record<string, string>;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// What a `render` override hands back. Closed, like the options above, so every
|
|
340
|
+
// key has to be one the runtime reads: the adapter reads `html` and `status`,
|
|
341
|
+
// and when a cache engine is present what-isr's makeEntry reads the whole object
|
|
342
|
+
// (see its own declaration of the makeEntry input, which this mirrors).
|
|
343
|
+
export interface RenderRouteResult {
|
|
344
|
+
html: string;
|
|
345
|
+
status?: number;
|
|
346
|
+
tags?: string[];
|
|
347
|
+
path?: string;
|
|
348
|
+
head?: string;
|
|
349
|
+
state?: unknown;
|
|
350
|
+
/**
|
|
351
|
+
* Keep this render out of the shared cache. what-isr stores an entry only when
|
|
352
|
+
* `status === 200 && !private`, and buildCacheHeaders strips the public
|
|
353
|
+
* directives from it. Leaving it off this interface made the one switch that
|
|
354
|
+
* stops a per-visitor page being served to every visitor a compile error,
|
|
355
|
+
* which is the same shape of hazard as baking a per-visitor CSRF token into
|
|
356
|
+
* cached HTML.
|
|
357
|
+
*/
|
|
358
|
+
private?: boolean;
|
|
359
|
+
/** The render read request headers, so it is per-user: makeEntry folds this into `private`. */
|
|
360
|
+
usedRequestHeaders?: boolean;
|
|
361
|
+
/** A partial render (skeleton or streamed shell). buildCacheHeaders forces its s-maxage to 0. */
|
|
362
|
+
partial?: boolean;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* The subset of what-isr's CacheEngine the adapter touches. Structural so
|
|
367
|
+
* what-server never has to import what-isr (an optional peer).
|
|
368
|
+
*/
|
|
369
|
+
export interface CacheEngineLike {
|
|
370
|
+
handle(routeMatch: RouteMatchContext, render?: () => any): Promise<{
|
|
371
|
+
html: string;
|
|
372
|
+
status?: number;
|
|
373
|
+
headers?: Record<string, string>;
|
|
374
|
+
}>;
|
|
375
|
+
revalidatePath?: (path: string, options?: any) => any;
|
|
376
|
+
revalidateTag?: (tag: string, options?: any) => any;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// Every key here is destructured by createRequestHandler. There is no index
|
|
380
|
+
// signature: an option the adapter does not read is dead config, and
|
|
381
|
+
// `documentOptions` (declared here, never read, the real key is `document`)
|
|
382
|
+
// silently rendered every page without the caller's document options.
|
|
383
|
+
export interface RequestHandlerOptions {
|
|
384
|
+
routes?: RouteDefinition[];
|
|
385
|
+
/** ISR engine. Omit for no caching. */
|
|
386
|
+
cache?: CacheEngineLike;
|
|
387
|
+
/** Replace the built-in route renderer. */
|
|
388
|
+
render?: (routeMatch: RouteMatchContext) => RenderRouteResult | Promise<RenderRouteResult>;
|
|
389
|
+
/** Handler for POST /__what_revalidate, e.g. what-isr's createRevalidateWebhook. */
|
|
390
|
+
revalidateWebhook?: (req: { headers: Record<string, string>; body: any }) => Promise<{ status: number; body: any }>;
|
|
391
|
+
/** Document shell options passed to renderDocument. */
|
|
392
|
+
document?: DocumentOptions;
|
|
393
|
+
/** Body for unmatched paths. */
|
|
394
|
+
notFound?: () => string;
|
|
395
|
+
/** Path prefix stripped before matching. */
|
|
396
|
+
basePath?: string;
|
|
397
|
+
/** Double-submit CSRF, on by default. */
|
|
398
|
+
csrf?: boolean;
|
|
399
|
+
/** Take over /__what_action entirely. A custom handler owns its own CSRF policy, so cookie/meta auto-provisioning is skipped. */
|
|
400
|
+
actionHandler?: (reqLike: any) => Promise<{ status: number; headers: Record<string, string>; body: any }>;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// Request in, Response out, and nothing else: the handler takes ONE argument.
|
|
404
|
+
// It was declared with (request, env, ctx) like a Workers fetch handler, which
|
|
405
|
+
// it is not. It can still BE one (createCloudflareHandler wraps it and puts
|
|
406
|
+
// env/ctx on the request), and a one-argument function is assignable where a
|
|
407
|
+
// three-argument one is expected, so nothing that worked stops working.
|
|
269
408
|
/** Runtime-neutral request handler: Request in, Response out. */
|
|
270
|
-
export function createRequestHandler(options?: RequestHandlerOptions): (request: Request
|
|
409
|
+
export function createRequestHandler(options?: RequestHandlerOptions): (request: Request) => Promise<Response>;
|
|
271
410
|
|
|
272
411
|
/** Cloudflare Workers entry wrapping createRequestHandler. */
|
|
273
412
|
export function createCloudflareHandler(options?: RequestHandlerOptions): { fetch: (request: Request, env?: any, ctx?: any) => Promise<Response> };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "what-server",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.4",
|
|
4
4
|
"description": "What Framework - SSR, islands architecture, static generation",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
@@ -51,8 +51,8 @@
|
|
|
51
51
|
"author": "ZVN DEV (https://zvndev.com)",
|
|
52
52
|
"license": "MIT",
|
|
53
53
|
"peerDependencies": {
|
|
54
|
-
"what-core": "^0.12.
|
|
55
|
-
"what-router": "^0.12.
|
|
54
|
+
"what-core": "^0.12.4",
|
|
55
|
+
"what-router": "^0.12.4"
|
|
56
56
|
},
|
|
57
57
|
"peerDependenciesMeta": {
|
|
58
58
|
"what-router": {
|
package/src/adapter/core.js
CHANGED
|
@@ -51,12 +51,25 @@ async function readJsonBody(request) {
|
|
|
51
51
|
|
|
52
52
|
function defaultRenderRoute(documentOptions) {
|
|
53
53
|
return async function renderRoute(routeMatch) {
|
|
54
|
-
const { route, params, query, request } = routeMatch;
|
|
54
|
+
const { route, params, query, request, csrfToken } = routeMatch;
|
|
55
55
|
const pageModule = { default: route.component, loader: route.loader };
|
|
56
|
-
|
|
57
|
-
|
|
56
|
+
// The token goes to the LOADER as well as the document. A server-rendered
|
|
57
|
+
// <Form> has to put the per-visitor token in a hidden field, and the loader
|
|
58
|
+
// is the only per-request hook a page has before its component runs, so
|
|
59
|
+
// without this the token exists (as a cookie and a <meta> tag) and is still
|
|
60
|
+
// unreachable from the markup: the form ships an empty field and the no-JS
|
|
61
|
+
// submit dies on the double-submit check with a silent 403. The create-what
|
|
62
|
+
// scaffold already hand-rolled its own renderRoute to do exactly this.
|
|
63
|
+
//
|
|
64
|
+
// Only the direct-render branch below sets routeMatch.csrfToken, so a
|
|
65
|
+
// CACHED route's loader still sees no token. That is deliberate, not an
|
|
66
|
+
// omission: cached HTML is shared between visitors and must never carry one
|
|
67
|
+
// visitor's token.
|
|
68
|
+
const reqCtx = csrfToken ? { params, query, request, csrfToken } : { params, query, request };
|
|
69
|
+
const opts = csrfToken
|
|
70
|
+
? { ...documentOptions, csrfToken }
|
|
58
71
|
: documentOptions;
|
|
59
|
-
const html = await renderDocument(pageModule,
|
|
72
|
+
const html = await renderDocument(pageModule, reqCtx, opts);
|
|
60
73
|
return {
|
|
61
74
|
html,
|
|
62
75
|
status: 200,
|
package/src/form.js
CHANGED
|
@@ -35,8 +35,10 @@ function resolveActionId(action) {
|
|
|
35
35
|
throw new Error('[what] <Form> requires an `action` prop: a server action or its id.');
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
// On the server there is no document, so the per-request token must be handed
|
|
39
|
-
//
|
|
38
|
+
// On the server there is no document, so the per-request token must be handed
|
|
39
|
+
// in: the adapter puts it on the loader context as `ctx.csrfToken`, and the
|
|
40
|
+
// page returns it in loader data. On the client it can be recovered from the
|
|
41
|
+
// page (meta tag, then cookie).
|
|
40
42
|
function readClientCsrfToken() {
|
|
41
43
|
if (typeof document === 'undefined') return null;
|
|
42
44
|
const meta = document.querySelector('meta[name="what-csrf-token"]');
|
|
@@ -62,10 +64,15 @@ export function Form({
|
|
|
62
64
|
&& typeof process !== 'undefined'
|
|
63
65
|
&& process.env?.NODE_ENV !== 'production'
|
|
64
66
|
) {
|
|
67
|
+
// Both halves of the contract, because only naming the first one sends
|
|
68
|
+
// developers looking for a token on a cached page that will never have one.
|
|
65
69
|
console.warn(
|
|
66
|
-
`[what] <Form action="${actionId}"> has no CSRF token.
|
|
67
|
-
'
|
|
68
|
-
'
|
|
70
|
+
`[what] <Form action="${actionId}"> has no CSRF token. Pass one: the adapter hands ` +
|
|
71
|
+
'the per-request token to the route loader as `ctx.csrfToken`, so return it in ' +
|
|
72
|
+
'loader data and set `csrfToken` on the form. Cached routes (page mode "static" or ' +
|
|
73
|
+
'"hybrid") have no per-visitor token by design, since their HTML is shared, so a ' +
|
|
74
|
+
'form that must submit without JavaScript belongs on a "server" mode route. ' +
|
|
75
|
+
'Otherwise the double-submit check rejects the POST and the failure is silent.'
|
|
69
76
|
);
|
|
70
77
|
}
|
|
71
78
|
|