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/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
- bodyAttrs?: string;
196
- scripts?: string[];
197
- styles?: string[];
198
- [key: string]: any;
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
- csrfSecret?: string;
253
- onError?: (error: unknown) => void;
254
- [key: string]: any;
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
- export interface RequestHandlerOptions {
264
- routes?: any[];
265
- documentOptions?: DocumentOptions;
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, env?: any, ctx?: any) => Promise<Response>;
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.2",
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.2",
55
- "what-router": "^0.12.2"
54
+ "what-core": "^0.12.4",
55
+ "what-router": "^0.12.4"
56
56
  },
57
57
  "peerDependenciesMeta": {
58
58
  "what-router": {
@@ -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
- const opts = routeMatch.csrfToken
57
- ? { ...documentOptions, csrfToken: routeMatch.csrfToken }
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, { params, query, request }, opts);
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 in
39
- // (typically from a loader). On the client it can be recovered from the page.
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. Server-rendered forms must ` +
67
- 'receive one (`csrfToken` is passed to loaders), otherwise the double-submit ' +
68
- 'check rejects the POST and the failure is silent.'
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