@ultimat3/render 27.6.1 → 27.8.0

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.
@@ -0,0 +1,100 @@
1
+ /**
2
+ * The invariants of `revalidate`'s keys beyond "it has a trigger": `onInvalidate`, `maxStale` and
3
+ * `query`, checked where `modes.ts` checks the trigger, and `tags` as purge keys, checked where
4
+ * the registry knows the file. Each is a declaration the ISR controller acts on per request, so
5
+ * one it cannot act on is refused up front, with the edit, rather than read as its permissive
6
+ * default for the life of the process.
7
+ */
8
+
9
+ import type { CacheTag } from '@ultimat3/cache';
10
+ import { serializeTag, surrogateKeys } from '@ultimat3/cache';
11
+ import { isUltimateError, renderCauseValue, renderFixLiteral } from '@ultimat3/core';
12
+ import { parseStaleMs, parseTtlMs } from './duration';
13
+ import { RouteModeInvalidError } from './errors';
14
+ import type { RevalidateConfig } from './route';
15
+ import { INVALIDATE_MODES } from './route';
16
+
17
+ /** The framework's own key dimensions ride in parameters spelled this way (`ISR_LOCALE_PARAM`). */
18
+ const RESERVED_QUERY_PREFIX = '__x_';
19
+
20
+ export function assertRevalidateShape(revalidate: RevalidateConfig): void {
21
+ const { onInvalidate, maxStale, query } = revalidate;
22
+ if (onInvalidate !== undefined && !INVALIDATE_MODES.includes(onInvalidate)) {
23
+ throw new RouteModeInvalidError(
24
+ `revalidate.onInvalidate: ${renderCauseValue(onInvalidate)} is not an invalidation mode`,
25
+ `onInvalidate: '${INVALIDATE_MODES.join("' | '")}'`,
26
+ );
27
+ }
28
+ if (onInvalidate === 'purge' && (revalidate.tags === undefined || revalidate.tags.length === 0)) {
29
+ throw new RouteModeInvalidError(
30
+ "revalidate.onInvalidate: 'purge' deletes a page when one of its tags is busted, but revalidate declares no tags",
31
+ "revalidate: { tags: [tag.post], onInvalidate: 'purge' } // or drop onInvalidate",
32
+ );
33
+ }
34
+ // The purge reaches a replica through the invalidation broadcast, and a broadcast can be lost.
35
+ // The ttl is what bounds the copy on a replica that never heard it; with none, that copy is
36
+ // the withdrawn page for the life of the process.
37
+ if (onInvalidate === 'purge' && parseTtlMs(revalidate.ttl) === null) {
38
+ throw new RouteModeInvalidError(
39
+ "revalidate.onInvalidate: 'purge' needs a ttl: a replica that misses the purge serves its copy until the ttl, and a tag-only page has none",
40
+ "revalidate: { tags: [tag.post], ttl: '10m', onInvalidate: 'purge' } // the ttl is the longest a missed purge can last",
41
+ );
42
+ }
43
+ if (maxStale !== undefined) {
44
+ if (parseStaleMs(maxStale) === null) {
45
+ throw new RouteModeInvalidError(
46
+ `revalidate.maxStale: ${renderCauseValue(maxStale)} is not a duration`,
47
+ "maxStale: '1h' // '30s', '5m', '7d', a number of milliseconds, or 0 for never",
48
+ );
49
+ }
50
+ // It is counted from the TTL's expiry, and a tag-only page has no clock to count from.
51
+ if (parseTtlMs(revalidate.ttl) === null) {
52
+ throw new RouteModeInvalidError(
53
+ 'revalidate.maxStale bounds how long past its ttl a page is served stale, but revalidate declares no ttl',
54
+ "revalidate: { ttl: '10m', maxStale: '1h' } // maxStale counts from the ttl's expiry",
55
+ );
56
+ }
57
+ }
58
+ if (query === undefined) return;
59
+ if (!Array.isArray(query)) {
60
+ throw new RouteModeInvalidError(
61
+ `revalidate.query: ${renderCauseValue(query)} is not a list of parameter names`,
62
+ "query: ['page'] // or [] for a page that varies on no parameter",
63
+ );
64
+ }
65
+ for (const name of query) {
66
+ if (typeof name !== 'string' || name === '') {
67
+ throw new RouteModeInvalidError(
68
+ `revalidate.query names ${renderCauseValue(name)}, which is not a query parameter name`,
69
+ "query: ['page'] // each parameter the page varies on, as a non-empty string",
70
+ );
71
+ }
72
+ if (name.startsWith(RESERVED_QUERY_PREFIX)) {
73
+ throw new RouteModeInvalidError(
74
+ `revalidate.query names ${renderCauseValue(name)}, and ${RESERVED_QUERY_PREFIX}* is the framework's own part of a stored page's key`,
75
+ `query: ['page'] // rename it: a name starting with ${RESERVED_QUERY_PREFIX} is never the app's`,
76
+ );
77
+ }
78
+ }
79
+ }
80
+
81
+ /**
82
+ * A `revalidate.tags` entry goes out as a purge key on every response of the route
83
+ * (`render-isr.ts`), so one a CDN would split is refused at registration, naming the file. Asked of
84
+ * `@ultimat3/cache`'s own screen rather than restated: found at serve time it is a 500 on a
85
+ * public page, found at purge time it is a document nothing can clear.
86
+ */
87
+ export function assertPurgeableTags(file: string, tags: readonly CacheTag[] | undefined): void {
88
+ for (const owned of tags ?? []) {
89
+ try {
90
+ surrogateKeys([owned], file);
91
+ } catch (error) {
92
+ if (!isUltimateError(error) || error.code !== 'X_CACHE_PURGE_FAILED') throw error;
93
+ const wire = renderFixLiteral(serializeTag(owned), '<the tag>');
94
+ throw new RouteModeInvalidError(
95
+ `${file} declares revalidate tag ${wire}, which cannot be a CDN purge key — a key is split on whitespace and commas and capped at 1024 bytes, so every response of the route would carry one no purge can name`,
96
+ `edit revalidate.tags in ${file}: replace ${wire} with a tag whose entity and id carry no whitespace or comma`,
97
+ );
98
+ }
99
+ }
100
+ }
@@ -68,5 +68,26 @@ export function routeStatusOf(data: unknown): number {
68
68
  return status === undefined ? DEFAULT_ROUTE_STATUS : status;
69
69
  }
70
70
 
71
+ const UNSTORED = new WeakSet<object>();
72
+
73
+ /**
74
+ * "Do not keep this answer": the loader's own word that this render is for this request only — a
75
+ * preview, a page built from a read that came back degraded. Rides on the data by identity, as
76
+ * `withStatus` does, and composes with it: `noStore(withStatus(404, data))`.
77
+ *
78
+ * Read by `isr`, the one mode that keeps a document: the page is served `private, no-store` and
79
+ * never written to the store, and a stored copy of it is dropped. A 5xx needs no mark — `isr`
80
+ * never stores one. `isr` only: an `ssr` page's cache is its route's `cache` key (`'no-store'`),
81
+ * decided per route because the document's principal scope is decided with it.
82
+ */
83
+ export function noStore<TData extends object>(data: TData): TData {
84
+ UNSTORED.add(data);
85
+ return data;
86
+ }
87
+
88
+ /** Whether `noStore` marked this data. TOTAL over `unknown`, as `routeStatusOf` is. */
89
+ export const routeNoStoreOf = (data: unknown): boolean =>
90
+ canCarryStatus(data) && UNSTORED.has(data);
91
+
71
92
  /** A 4xx or 5xx: a document a crawler must forget, whatever its `meta` said. */
72
93
  export const isErrorStatus = (status: number): boolean => status >= 400;
package/src/route.ts CHANGED
@@ -43,11 +43,40 @@ export const DEFAULT_ISLAND_HYDRATE: HydrateStrategy = 'interaction';
43
43
  export type RouteParams = Readonly<Record<string, string>>;
44
44
  export type RouteData = Readonly<Record<string, unknown>>;
45
45
 
46
+ /**
47
+ * What a tag bust does to a stored `isr` page. `'stale'` keeps the copy and answers it once more
48
+ * while a fresh one renders behind the request; `'purge'` deletes it, on every replica, so the
49
+ * next request renders and waits — for a page that must come DOWN, not merely catch up.
50
+ */
51
+ export type InvalidateMode = 'stale' | 'purge';
52
+
53
+ export const INVALIDATE_MODES: readonly InvalidateMode[] = ['stale', 'purge'];
54
+
46
55
  /** ISR trigger. At least one of `tags` / `ttl` is required by `modes.ts`. */
47
56
  export interface RevalidateConfig {
48
57
  readonly tags?: readonly CacheTag[];
49
58
  /** `'5m'`, `'1h'`, `'7d'` or milliseconds. */
50
59
  readonly ttl?: string | number;
60
+ /**
61
+ * `'stale'` when omitted. `'purge'` needs `tags` (what it purges by) and a `ttl` (what bounds a
62
+ * replica the purge never reached), and serves nothing stale past that ttl unless `maxStale` says so.
63
+ */
64
+ readonly onInvalidate?: InvalidateMode;
65
+ /**
66
+ * How long PAST its `ttl` a stored page may still be answered stale — `'1h'`, milliseconds, or
67
+ * `0` for never. Older than that, it is rendered again and the request waits. Omitted, there is
68
+ * no bound (a page nobody asked for in a week answers its next visitor once with last week's
69
+ * document) — except under `onInvalidate: 'purge'`, where omitted is `0`.
70
+ */
71
+ readonly maxStale?: string | number;
72
+ /**
73
+ * The query parameters this page varies on. ONLY these are part of the stored page's key and
74
+ * of the `url` / `query` that `load`, `meta` and the page are given; every other parameter is
75
+ * dropped before either, so `?utm_source=…` is the same stored page and no visitor can mint one.
76
+ * `[]` is "none", and what most pages want. OMITTED, the key is the whole query string — any
77
+ * visitor can then mint stored pages (`x verify` says so) — until the next major makes it `[]`.
78
+ */
79
+ readonly query?: readonly string[];
51
80
  }
52
81
 
53
82
  /**
package/src/server.ts CHANGED
@@ -39,22 +39,30 @@ export {
39
39
  // ---- the render pipeline: one entry point per mode ----------------------------------------------
40
40
  export type { RenderHtmlOptions } from './render-html';
41
41
  export { ROOT_ELEMENT_ID, renderComponent, renderToHtml } from './render-html';
42
- export type {
43
- IsrController,
44
- IsrControllerOptions,
45
- IsrRendered,
46
- IsrRenderFn,
47
- IsrServeResult,
48
- } from './render-isr';
42
+
49
43
  export {
50
44
  DEFAULT_ISR_REGENERATE_DEADLINE_MS,
51
- ISR_LOCALE_PARAM,
52
45
  invalidateAndRevalidate,
53
46
  isrController,
54
- isrKey,
55
47
  } from './render-isr';
56
- export type { IsrEntry, IsrState, IsrStore, MemoryIsrStoreOptions } from './render-isr-store';
48
+ export { DEFAULT_ISR_FAILURE_COOLDOWN_MS } from './render-isr-cooldown';
49
+ export { ISR_LOCALE_PARAM, isrKey, isrRequestUrl, undeclaredQuery } from './render-isr-key';
50
+ export type {
51
+ IsrEntry,
52
+ IsrState,
53
+ IsrStore,
54
+ IsrTagFence,
55
+ MemoryIsrStoreOptions,
56
+ } from './render-isr-store';
57
57
  export { DEFAULT_ISR_MAX_ENTRIES, memoryIsrStore } from './render-isr-store';
58
+ export type {
59
+ IsrController,
60
+ IsrControllerOptions,
61
+ IsrFence,
62
+ IsrRendered,
63
+ IsrRenderFn,
64
+ IsrServeResult,
65
+ } from './render-isr-types';
58
66
  export type { SsrOptions, SsrRenderFn, SsrRenderInput } from './render-ssr';
59
67
  export { renderSsr, ssrHeaders } from './render-ssr';
60
68
  export type { StaticArtifact, StaticBuildOptions, StaticRenderFn } from './render-static';