@ultimat3/render 22.0.0 → 22.2.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.
package/CLAUDE.md CHANGED
@@ -25,7 +25,7 @@ axiom 6). Never `cli` (upward).
25
25
  |---|---|
26
26
  | `offline`, `meta` | required by `RouteDefinition`. Never make them optional. |
27
27
  | `hydrate` | optional, **derived from `island()`** — `'interaction'` when the module declared one, `'never'` when not. Declaring it still wins and is the only way to reach `idle` / `visible`. Never give an island its own strategy: `RouteDescriptor.hydrate` is read by `sw.js`, the web manifest and `x routes`. |
28
- | `defineRoute` shape | exactly the contract's 9 keys. New route *metadata* goes inside `meta`. |
28
+ | `defineRoute` shape | exactly the contract's 10 keys (`cache`, `ssr` only, is the tenth). New route *metadata* goes inside `meta`. |
29
29
  | `load` | optional, and the ONE server-side data seam. Resolved once per render by `routeDataFor()` and handed to **both** `meta` and the page component. Absent `load`, the context IS the data (`{ params, url }`). |
30
30
  | `load` is required when the context cannot supply the data | `LoadRequirement<TData>` in `defineRoute`'s parameter. `RouteContext` is a type ALIAS on purpose — only an alias carries the implicit index signature that makes it a `RouteData`. |
31
31
  | A loader's own error | rethrown only when `isUltimateError` says so (core's brand), never a `code` property and never `instanceof UltimateError` — a tier-0 error is branded, not a subclass. Everything else is `X_ROUTE_LOAD_FAILED`. |
package/README.md CHANGED
@@ -61,8 +61,10 @@ returns its data through `withStatus(404, data)`. The same object comes back, so
61
61
  it (`routeStatusOf`), and a 4xx or 5xx is `robots: noindex` by construction, applied by the
62
62
  descriptor's `meta` after the route's own ran. Not a throw: a throw is the framework's error page,
63
63
  outside the app's shell, and `As of 2026-09-07` that was the only way to a 404 — ai-maxxing's
64
- `/fleet/nope` rendered the right page and answered 200. A 3xx is `X_ROUTE_STATUS_INVALID`; a
65
- redirect is `@ultimat3/http`'s `redirect()`. The static export writes the document whatever the
64
+ `/fleet/nope` rendered the right page and answered 200. A 3xx is `X_ROUTE_STATUS_INVALID`. A
65
+ redirect is `setRedirect(location, status)` from `@ultimat3/http`, called inside `load`: the page
66
+ path answers that 3xx before rendering a document, `private, no-store` unless the route declares a
67
+ `cache` (`As of 2026-09-24`, #525). The static export writes the document whatever the
66
68
  loader said — a file has no status — and the build's measurer, which renders with `params: {}`,
67
69
  never fails on a loader answering 404.
68
70
 
@@ -106,6 +108,15 @@ a rejection, so one `catch` covers both. The budget's *fields* stay optional:
106
108
  `budget.js === undefined` still means "declared no JS budget", which is exactly what fails
107
109
  a hydrating `site/` route below.
108
110
 
111
+ ## `cache` on an `ssr` route
112
+
113
+ `defineRoute({ render: 'ssr', cache: 'no-store' | CacheHint })` replaces `ssrHeaders`' default
114
+ (`private, no-store` gated, `public, max-age=0, s-maxage=30, stale-while-revalidate=300` ungated).
115
+ `'no-store'` is `private, no-store`; a hint (`@ultimat3/http`'s `CacheHint`, minus `tags`) goes
116
+ through `cacheControl()`, and its `vary` joins the defaults. `As of 2026-09-24` (#525). The type is
117
+ `RouteCache`; `@ultimat3/http` is a dependency of this package for that type and for
118
+ `cacheControl` in the server half.
119
+
109
120
  ## Mode invariants, checked at registration
110
121
 
111
122
  | Mode | Invariant | Error if violated |
@@ -113,6 +124,8 @@ a hydrating `site/` route below.
113
124
  | `static` | no per-request state — no `policy`, no `revalidate` | `X_ROUTE_MODE_INVALID` |
114
125
  | `isr` | needs a trigger: `revalidate.tags` or `revalidate.ttl`; **no `policy`** — one cached document per URL cannot answer two actors | `X_ROUTE_MODE_INVALID` |
115
126
  | `ssr` | cannot be prerendered | `X_ROUTE_MODE_INVALID` |
127
+ | any but `ssr` | declares no `cache` — `static`/`isr` headers are the mode, a `stream` is always `private, no-store` | `X_ROUTE_MODE_INVALID` |
128
+ | gated (`policy`) | its `cache` is never `public`/`immutable` — one actor's document in a shared cache | `X_ROUTE_MODE_INVALID` |
116
129
  | `stream` | at least one `<Suspense>` boundary | `X_ROUTE_MODE_INVALID` |
117
130
 
118
131
  Plus surface rules: `site/` allows `static | isr | ssr`, `app/` allows `stream | ssr`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/render",
3
- "version": "22.0.0",
3
+ "version": "22.2.0",
4
4
  "description": "The route primitive and the five render modes: static, isr, ssr, stream, spa.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -36,10 +36,11 @@
36
36
  "test": "bun test"
37
37
  },
38
38
  "dependencies": {
39
- "@ultimat3/cache": "22.0.0",
40
- "@ultimat3/core": "22.0.0",
41
- "@ultimat3/i18n": "22.0.0",
42
- "@ultimat3/seo": "22.0.0",
39
+ "@ultimat3/cache": "22.2.0",
40
+ "@ultimat3/core": "22.2.0",
41
+ "@ultimat3/http": "22.2.0",
42
+ "@ultimat3/i18n": "22.2.0",
43
+ "@ultimat3/seo": "22.2.0",
43
44
  "sass": "1.104.0"
44
45
  }
45
46
  }
package/src/index.ts CHANGED
@@ -128,6 +128,7 @@ export type {
128
128
  RenderResult,
129
129
  RevalidateConfig,
130
130
  RouteBudget,
131
+ RouteCache,
131
132
  RouteConfig,
132
133
  RouteContext,
133
134
  RouteData,
package/src/modes.ts CHANGED
@@ -133,6 +133,27 @@ export function assertModeShape(config: RouteShape): void {
133
133
  );
134
134
  }
135
135
 
136
+ // cache: the `ssr` mode's key alone. `static` and `isr` are content-hashed / TTL'd documents
137
+ // whose headers ARE the mode, and a `stream` is always `private, no-store` — a declaration on
138
+ // any of them would be read by nothing.
139
+ if (config.cache !== undefined && config.render !== 'ssr') {
140
+ throw new RouteModeInvalidError(
141
+ `render: '${config.render}' decides its own cache headers, but \`cache\` was declared`,
142
+ "change render to 'ssr' to declare the cache, or remove cache",
143
+ );
144
+ }
145
+ // A gated page is one actor's document. A shared-cache offer on it is the tenant-leak class
146
+ // `ssrHeaders` exists to rule out, so a guard can narrow the cache and never widen it.
147
+ const mode =
148
+ config.cache === undefined || config.cache === 'no-store' ? 'no-store' : config.cache.mode;
149
+ if (config.policy !== undefined && (mode === 'public' || mode === 'immutable')) {
150
+ throw new RouteModeInvalidError(
151
+ `a route gated by ${config.policy.permission} declares cache mode '${mode}', which offers ` +
152
+ "one actor's document to a shared cache",
153
+ "declare cache: 'no-store' or { mode: 'private', … }, or drop the policy",
154
+ );
155
+ }
156
+
136
157
  // ssr: cannot be prerendered — the whole point is that it runs per request.
137
158
  if (config.render === 'ssr' && config.prerender !== undefined) {
138
159
  throw new RouteModeInvalidError(
package/src/render-ssr.ts CHANGED
@@ -5,9 +5,10 @@
5
5
  */
6
6
 
7
7
  import type { Ctx } from '@ultimat3/core';
8
+ import { cacheControl } from '@ultimat3/http';
8
9
  import { finiteStatus } from './finite-status';
9
10
  import type { RouteEntry } from './registry';
10
- import type { RenderResult, RouteParams } from './route';
11
+ import type { RenderResult, RouteCache, RouteParams } from './route';
11
12
 
12
13
  export interface SsrRenderInput {
13
14
  readonly entry: RouteEntry;
@@ -40,23 +41,45 @@ export async function renderSsr(
40
41
  };
41
42
  }
42
43
 
44
+ const PRIVATE_NO_STORE = 'private, no-store';
45
+
46
+ /**
47
+ * The route's own `cache`, as a header. `no-store` in either spelling is `private, no-store`, the
48
+ * gated page's answer: `private` is what `documentCarriesScope` reads, so a page the author just
49
+ * made uncacheable carries its principal scope like every other private document. Everything else
50
+ * is `@ultimat3/http`'s one emitter, never a second serializer.
51
+ */
52
+ function declaredCacheControl(cache: RouteCache): string {
53
+ if (cache === 'no-store' || cache.mode === 'no-store') return PRIVATE_NO_STORE;
54
+ return cacheControl(cache);
55
+ }
56
+
43
57
  /**
44
58
  * A gated page is never shared cache material: one actor's HTML in a CDN is the same bug
45
- * class as a cache key missing its tenant.
59
+ * class as a cache key missing its tenant. A route's declared `cache` replaces the default —
60
+ * `modes.ts` refuses a gated one that would widen it — and the pipeline's `cache-headers` stage
61
+ * still reviews a `public` answer against the actor, so a signed-in visitor never gets one.
46
62
  */
47
63
  export function ssrHeaders(
48
64
  entry: RouteEntry,
49
65
  options: SsrOptions,
50
66
  ): Readonly<Record<string, string>> {
51
67
  const gated = entry.config.policy !== undefined;
68
+ const declared = entry.config.cache;
52
69
  const vary = new Set<string>(['accept-language', ...(options.vary ?? [])]);
53
70
  if (gated) vary.add('cookie');
71
+ if (declared !== undefined && declared !== 'no-store') {
72
+ for (const name of declared.vary ?? []) vary.add(name);
73
+ }
54
74
 
55
75
  return {
56
76
  'content-type': 'text/html; charset=utf-8',
57
- 'cache-control': gated
58
- ? 'private, no-store'
59
- : 'public, max-age=0, s-maxage=30, stale-while-revalidate=300',
77
+ 'cache-control':
78
+ declared !== undefined
79
+ ? declaredCacheControl(declared)
80
+ : gated
81
+ ? PRIVATE_NO_STORE
82
+ : 'public, max-age=0, s-maxage=30, stale-while-revalidate=300',
60
83
  vary: [...vary].sort().join(', '),
61
84
  'x-ultimate-build': options.buildId,
62
85
  };
package/src/route.ts CHANGED
@@ -16,6 +16,7 @@ import type { CacheTag } from '@ultimat3/cache';
16
16
  import { serializeTags } from '@ultimat3/cache';
17
17
  import type { HydrateStrategy, OfflineStrategy, RenderMode } from '@ultimat3/core';
18
18
  import { OFFLINE_STRATEGIES } from '@ultimat3/core';
19
+ import type { CacheHint } from '@ultimat3/http';
19
20
  import type { Translator } from '@ultimat3/i18n';
20
21
  import type { RouteMeta } from '@ultimat3/seo';
21
22
  import { RouteLoadInvalidError, RouteMetaMissingError, RouteOfflineMissingError } from './errors';
@@ -159,7 +160,17 @@ export type LoadRequirement<TData> = RouteContext extends TData
159
160
  ? unknown
160
161
  : { readonly load: RouteLoadFn<TData> };
161
162
 
162
- /** The input shape of `defineRoute` — exactly the contract's nine keys, nothing else. */
163
+ /**
164
+ * How an `ssr` response may be cached, when the default is wrong for it. The default is decided
165
+ * by the guard — a gated page is `private, no-store`, an ungated one is offered to a CDN for 30s —
166
+ * and an ungated page can still be personal: a recipient landing addressed by a capability token
167
+ * in its path, a verification page that must never answer a superseded result. `'no-store'` is
168
+ * `private, no-store`; a hint is `@ultimat3/http`'s own `CacheHint`, minus `tags` (a purge
169
+ * targets `isr` documents, and a key nothing reads is a key that lies). `ssr` only — `modes.ts`.
170
+ */
171
+ export type RouteCache = 'no-store' | Omit<CacheHint, 'tags'>;
172
+
173
+ /** The input shape of `defineRoute` — exactly the contract's ten keys, nothing else. */
163
174
  export interface RouteDefinition<TData = RouteData> {
164
175
  readonly render: RenderMode;
165
176
  readonly revalidate?: RevalidateConfig;
@@ -180,6 +191,7 @@ export interface RouteDefinition<TData = RouteData> {
180
191
  readonly load?: RouteLoadFn<TData>;
181
192
  readonly meta: RouteMetaFn<TData>;
182
193
  readonly policy?: RouteGuard;
194
+ readonly cache?: RouteCache;
183
195
  }
184
196
 
185
197
  /**
@@ -290,6 +302,7 @@ export function defineRoute<TData = RouteData>(
290
302
  ...(def.revalidate ? { revalidate: def.revalidate } : {}),
291
303
  ...(def.prerender ? { prerender: def.prerender } : {}),
292
304
  ...(def.policy ? { policy: def.policy } : {}),
305
+ ...(def.cache === undefined ? {} : { cache: def.cache }),
293
306
  };
294
307
 
295
308
  assertModeShape(config);