@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 +1 -1
- package/README.md +15 -2
- package/package.json +6 -5
- package/src/index.ts +1 -0
- package/src/modes.ts +21 -0
- package/src/render-ssr.ts +28 -5
- package/src/route.ts +14 -1
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
|
|
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
|
|
65
|
-
redirect is `@ultimat3/http
|
|
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.
|
|
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.
|
|
40
|
-
"@ultimat3/core": "22.
|
|
41
|
-
"@ultimat3/
|
|
42
|
-
"@ultimat3/
|
|
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
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':
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
/**
|
|
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);
|