@ultimat3/render 1.0.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,60 @@
1
+ /**
2
+ * `ssr` — per-request full render. The whole document waits for the slowest dependency,
3
+ * which is exactly the trade you want for a fresh SEO page and exactly the trade you do
4
+ * not want for an app page (use `stream`).
5
+ */
6
+
7
+ import type { Ctx } from '@ultimat3/core';
8
+ import type { RouteEntry } from './registry';
9
+ import type { RenderResult, RouteParams } from './route';
10
+
11
+ export interface SsrRenderInput {
12
+ readonly entry: RouteEntry;
13
+ readonly params: RouteParams;
14
+ readonly url: URL;
15
+ readonly ctx: Ctx;
16
+ }
17
+
18
+ export type SsrRenderFn = (input: SsrRenderInput) => string | Promise<string>;
19
+
20
+ export interface SsrOptions {
21
+ readonly buildId: string;
22
+ /** Extra `Vary` dimensions beyond the defaults. */
23
+ readonly vary?: readonly string[];
24
+ readonly status?: number;
25
+ }
26
+
27
+ export async function renderSsr(
28
+ input: SsrRenderInput,
29
+ render: SsrRenderFn,
30
+ options: SsrOptions,
31
+ ): Promise<RenderResult> {
32
+ const html = await render(input);
33
+ return {
34
+ status: options.status ?? 200,
35
+ headers: ssrHeaders(input.entry, options),
36
+ body: html,
37
+ };
38
+ }
39
+
40
+ /**
41
+ * A gated page is never shared cache material: one actor's HTML in a CDN is the same bug
42
+ * class as a cache key missing its tenant.
43
+ */
44
+ export function ssrHeaders(
45
+ entry: RouteEntry,
46
+ options: SsrOptions,
47
+ ): Readonly<Record<string, string>> {
48
+ const gated = entry.config.policy !== undefined;
49
+ const vary = new Set<string>(['accept-language', ...(options.vary ?? [])]);
50
+ if (gated) vary.add('cookie');
51
+
52
+ return {
53
+ 'content-type': 'text/html; charset=utf-8',
54
+ 'cache-control': gated
55
+ ? 'private, no-store'
56
+ : 'public, max-age=0, s-maxage=30, stale-while-revalidate=300',
57
+ vary: [...vary].sort().join(', '),
58
+ 'x-ultimate-build': options.buildId,
59
+ };
60
+ }
@@ -0,0 +1,170 @@
1
+ /**
2
+ * `static` — build-time render. Enumerates `prerender()`, renders each params set once,
3
+ * content-hashes the output. The hash is the artifact's identity: it becomes the ETag,
4
+ * the precache revision in `sw.js`, and the asset filename suffix.
5
+ */
6
+
7
+ import { useContext } from '@ultimat3/core';
8
+ import { PrerenderFailedError, RouteModeInvalidError } from './errors';
9
+ import type { RouteEntry } from './registry';
10
+ import type { RenderResult, RouteParams } from './route';
11
+
12
+ export interface StaticArtifact {
13
+ readonly path: string;
14
+ readonly params: RouteParams;
15
+ readonly html: string;
16
+ /** FNV-1a of the HTML. Stable across machines and across Bun versions. */
17
+ readonly hash: string;
18
+ /** Where the file lands on disk, relative to the build output root. */
19
+ readonly outputPath: string;
20
+ readonly headers: Readonly<Record<string, string>>;
21
+ }
22
+
23
+ export type StaticRenderFn = (input: {
24
+ readonly path: string;
25
+ readonly params: RouteParams;
26
+ }) => string | Promise<string>;
27
+
28
+ /** Deterministic, dependency-free 32-bit FNV-1a, hex. */
29
+ export function contentHash(input: string): string {
30
+ let hash = 0x811c9dc5;
31
+ for (let i = 0; i < input.length; i += 1) {
32
+ hash ^= input.charCodeAt(i);
33
+ hash = Math.imul(hash, 0x01000193) >>> 0;
34
+ }
35
+ return hash.toString(16).padStart(8, '0');
36
+ }
37
+
38
+ /**
39
+ * `static` must not observe the request. If a request-scoped context is live while a
40
+ * static route renders, the output would depend on whoever triggered the build — a bug
41
+ * that only shows up as one user's data cached for everyone.
42
+ */
43
+ export function assertNoPerRequestState(file: string): void {
44
+ let ctx: unknown;
45
+ try {
46
+ ctx = useContext();
47
+ } catch {
48
+ return; // no ambient context — the expected build-time situation
49
+ }
50
+ if (typeof ctx !== 'object' || ctx === null) return;
51
+ if ('request' in ctx || 'actor' in ctx) {
52
+ throw new RouteModeInvalidError(
53
+ `${file} declares render: 'static' but rendered inside a request context ` +
54
+ '(actor/request visible), so its output would leak per-request state',
55
+ `change render to 'ssr' in ${file}, or move the request-dependent part into an island`,
56
+ );
57
+ }
58
+ }
59
+
60
+ /** Normalize `prerender()` output. A bare string fills the route's one dynamic param. */
61
+ export async function enumeratePrerender(entry: RouteEntry): Promise<readonly RouteParams[]> {
62
+ const prerender = entry.config.prerender;
63
+ if (prerender === undefined) {
64
+ return entry.pattern.keys.length === 0 ? [{}] : [];
65
+ }
66
+
67
+ let produced: readonly (string | RouteParams)[];
68
+ try {
69
+ produced = await prerender();
70
+ } catch (error) {
71
+ throw new PrerenderFailedError(
72
+ `prerender() for ${entry.path} threw: ${describe(error)}`,
73
+ `fix prerender in ${entry.file} — it runs at build time with no request context`,
74
+ );
75
+ }
76
+
77
+ if (!Array.isArray(produced)) {
78
+ throw new PrerenderFailedError(
79
+ `prerender() for ${entry.path} returned ${typeof produced}, expected an array`,
80
+ `return an array of params from prerender in ${entry.file}`,
81
+ );
82
+ }
83
+
84
+ const keys = entry.pattern.keys;
85
+ return produced.map((item) => {
86
+ if (typeof item !== 'string') return item;
87
+ const only = keys[0];
88
+ if (keys.length !== 1 || only === undefined) {
89
+ throw new PrerenderFailedError(
90
+ `prerender() for ${entry.path} returned the bare string ${JSON.stringify(item)} ` +
91
+ `but the route has ${keys.length} dynamic params (${keys.join(', ')})`,
92
+ `return objects from prerender in ${entry.file}, e.g. { ${keys.join(': …, ')}: … }`,
93
+ );
94
+ }
95
+ return { [only]: item };
96
+ });
97
+ }
98
+
99
+ export interface StaticBuildOptions {
100
+ readonly buildId: string;
101
+ /** Extension-less output files get `/index.html` appended. */
102
+ readonly indexFile?: string;
103
+ }
104
+
105
+ export async function renderStatic(
106
+ entry: RouteEntry,
107
+ render: StaticRenderFn,
108
+ options: StaticBuildOptions,
109
+ ): Promise<readonly StaticArtifact[]> {
110
+ assertNoPerRequestState(entry.file);
111
+ const paramSets = await enumeratePrerender(entry);
112
+ const indexFile = options.indexFile ?? 'index.html';
113
+
114
+ const artifacts: StaticArtifact[] = [];
115
+ for (const params of paramSets) {
116
+ const path = fillPath(entry.pattern.source, params);
117
+ let html: string;
118
+ try {
119
+ html = await render({ path, params });
120
+ } catch (error) {
121
+ throw new PrerenderFailedError(
122
+ `rendering ${path} failed: ${describe(error)}`,
123
+ `run \`x build --route ${path}\` to reproduce, then fix ${entry.file}`,
124
+ );
125
+ }
126
+ const hash = contentHash(html);
127
+ artifacts.push({
128
+ path,
129
+ params,
130
+ html,
131
+ hash,
132
+ outputPath: `${path === '/' ? '' : path}/${indexFile}`.replace(/^\/+/, ''),
133
+ headers: staticHeaders(hash, options.buildId),
134
+ });
135
+ }
136
+ return artifacts;
137
+ }
138
+
139
+ export function staticHeaders(hash: string, buildId: string): Readonly<Record<string, string>> {
140
+ return {
141
+ 'content-type': 'text/html; charset=utf-8',
142
+ // Revalidate cheaply: the HTML URL is stable, its content hash is not.
143
+ 'cache-control': 'public, max-age=0, must-revalidate',
144
+ etag: `"${hash}"`,
145
+ 'x-ultimate-build': buildId,
146
+ };
147
+ }
148
+
149
+ export function staticResult(artifact: StaticArtifact): RenderResult {
150
+ return { status: 200, headers: artifact.headers, body: artifact.html };
151
+ }
152
+
153
+ /** `/blog/:slug` + `{ slug: 'hello' }` → `/blog/hello`. */
154
+ export function fillPath(pattern: string, params: RouteParams): string {
155
+ return (
156
+ pattern
157
+ .split('/')
158
+ .map((segment) => {
159
+ if (segment.startsWith(':')) return params[segment.slice(1)] ?? segment;
160
+ if (segment.startsWith('*')) return params[segment.slice(1)] ?? '';
161
+ return segment;
162
+ })
163
+ .join('/')
164
+ .replace(/\/+$/, '') || '/'
165
+ );
166
+ }
167
+
168
+ function describe(error: unknown): string {
169
+ return error instanceof Error ? error.message : String(error);
170
+ }
@@ -0,0 +1,149 @@
1
+ /**
2
+ * `stream` — the default for `app/` pages. A genuinely static shell is flushed on the
3
+ * first tick; every `<Suspense>` boundary becomes a hole that is filled out of order, in
4
+ * completion order, as its data resolves.
5
+ *
6
+ * Why the shell is free here: Solid compiles templates to DOM operations and tracks
7
+ * updates with signals, so there is no VDOM tree to replay and no hydration pass over the
8
+ * shell. A resolved hole patches the exact nodes bound to it; the surrounding markup is
9
+ * never re-executed. In a VDOM framework a streamed shell still pays to hydrate the whole
10
+ * tree, which buys TTFB but not TBT — here it buys both, so a `<Suspense>` boundary that
11
+ * contains no interactive island costs literally zero JS.
12
+ */
13
+
14
+ import { logger } from '@ultimat3/core';
15
+ import type { RenderResult } from './route';
16
+
17
+ export interface StreamHole {
18
+ /** Stable within a response; becomes the DOM id, so keep it short. */
19
+ readonly id: string;
20
+ /** Rendered synchronously into the first flush (the `<Suspense fallback>`). */
21
+ readonly fallback: string;
22
+ readonly resolve: () => Promise<string>;
23
+ }
24
+
25
+ export interface StreamPlan {
26
+ /** `<!doctype html><html …><head>…</head><body>` — everything before the shell. */
27
+ readonly head: string;
28
+ /** The shell markup, containing one `holeMarker()` per hole. */
29
+ readonly shell: string;
30
+ readonly holes: readonly StreamHole[];
31
+ /** `</body></html>` — flushed after the last hole resolves. */
32
+ readonly tail?: string;
33
+ }
34
+
35
+ const HOLE_PREFIX = 'x:';
36
+
37
+ export function holeId(id: string): string {
38
+ return `${HOLE_PREFIX}${id}`;
39
+ }
40
+
41
+ /** The placeholder that sits in the first flush, holding the fallback markup. */
42
+ export function holeMarker(id: string, fallback: string): string {
43
+ return `<x-hole id="${holeId(id)}">${fallback}</x-hole>`;
44
+ }
45
+
46
+ /**
47
+ * The entire client half of out-of-order streaming. Inline, uncompressed, ~200 bytes; it
48
+ * moves a late `<template>`'s content into the placeholder that is already on screen.
49
+ */
50
+ export const REVEAL_SCRIPT =
51
+ "<script>window.$X=function(i){var t=document.querySelector('template[data-x-hole=\"'+i+'\"]')," +
52
+ 's=document.getElementById(i);if(t&&s){s.replaceWith(t.content);t.remove()}}</script>';
53
+
54
+ export function revealChunk(id: string, html: string): string {
55
+ const key = holeId(id);
56
+ return `<template data-x-hole="${key}">${html}</template><script>$X("${key}")</script>`;
57
+ }
58
+
59
+ export interface StreamOptions {
60
+ readonly buildId: string;
61
+ /** Rendered into a hole whose promise rejected. Keep it a token-styled inline block. */
62
+ readonly errorFallback?: (holeId: string) => string;
63
+ }
64
+
65
+ /**
66
+ * Flush order is completion order, not declaration order — a fast hole never waits behind
67
+ * a slow one. The stream closes only after every hole has settled, so a rejected boundary
68
+ * degrades to its error fallback instead of truncating the document.
69
+ */
70
+ export function renderStreamHtml(
71
+ plan: StreamPlan,
72
+ options: StreamOptions,
73
+ ): ReadableStream<Uint8Array> {
74
+ const encoder = new TextEncoder();
75
+ const tail = plan.tail ?? '</body></html>';
76
+ const errorFallback =
77
+ options.errorFallback ?? ((id) => `<div data-x-hole-error="${id}" hidden></div>`);
78
+
79
+ return new ReadableStream<Uint8Array>({
80
+ start(controller) {
81
+ const write = (chunk: string): void => {
82
+ controller.enqueue(encoder.encode(chunk));
83
+ };
84
+
85
+ // No holes, no reveal script: a page that streams nothing pays nothing.
86
+ write(plan.head + (plan.holes.length > 0 ? REVEAL_SCRIPT : '') + plan.shell);
87
+
88
+ let pending = plan.holes.length;
89
+ if (pending === 0) {
90
+ write(tail);
91
+ controller.close();
92
+ return;
93
+ }
94
+
95
+ const settle = (): void => {
96
+ pending -= 1;
97
+ if (pending === 0) {
98
+ write(tail);
99
+ controller.close();
100
+ }
101
+ };
102
+
103
+ for (const hole of plan.holes) {
104
+ void hole
105
+ .resolve()
106
+ .then(
107
+ (html) => {
108
+ write(revealChunk(hole.id, html));
109
+ },
110
+ (error: unknown) => {
111
+ logger.warn(
112
+ `stream hole ${hole.id} rejected: ${error instanceof Error ? error.message : String(error)}`,
113
+ );
114
+ write(revealChunk(hole.id, errorFallback(hole.id)));
115
+ },
116
+ )
117
+ .then(settle, settle);
118
+ }
119
+ },
120
+ });
121
+ }
122
+
123
+ export function streamResult(plan: StreamPlan, options: StreamOptions, status = 200): RenderResult {
124
+ return {
125
+ status,
126
+ headers: {
127
+ 'content-type': 'text/html; charset=utf-8',
128
+ 'cache-control': 'private, no-store',
129
+ // Proxies that buffer defeat the entire mode.
130
+ 'x-accel-buffering': 'no',
131
+ 'transfer-encoding': 'chunked',
132
+ 'x-ultimate-build': options.buildId,
133
+ },
134
+ body: renderStreamHtml(plan, options),
135
+ };
136
+ }
137
+
138
+ /** Test/SSR-to-string helper: drain a stream into one string. */
139
+ export async function collectStream(stream: ReadableStream<Uint8Array>): Promise<string> {
140
+ const decoder = new TextDecoder();
141
+ const reader = stream.getReader();
142
+ let out = '';
143
+ for (;;) {
144
+ const { done, value } = await reader.read();
145
+ if (done) break;
146
+ if (value !== undefined) out += decoder.decode(value, { stream: true });
147
+ }
148
+ return out + decoder.decode();
149
+ }
package/src/route.ts ADDED
@@ -0,0 +1,158 @@
1
+ /**
2
+ * The `route` primitive. `defineRoute` is the only way to declare a URL's render mode,
3
+ * offline strategy, hydration timing and metadata, and it hands back a descriptor that is
4
+ * already normalized: `meta` always awaits, `budget` is always there.
5
+ *
6
+ * `offline`, `hydrate` and `meta` are REQUIRED BY THE TYPE. That is axiom 3 — enforced,
7
+ * not documented — expressed in the type system: a route that forgets its offline
8
+ * strategy or its `<head>` is a compile error, not a checklist item nobody reads.
9
+ */
10
+
11
+ import type { CacheTag } from '@ultimat3/cache';
12
+ import { serializeTags } from '@ultimat3/cache';
13
+ import type { RouteMeta } from '@ultimat3/seo';
14
+ import { RouteMetaMissingError, RouteOfflineMissingError } from './errors';
15
+ import { assertModeShape } from './modes';
16
+
17
+ export type RenderMode = 'static' | 'isr' | 'ssr' | 'stream' | 'spa';
18
+ export type OfflineStrategy = 'precache' | 'runtime' | 'network-only';
19
+ export type HydrateStrategy = 'idle' | 'visible' | 'interaction' | 'never';
20
+
21
+ export const OFFLINE_STRATEGIES = ['precache', 'runtime', 'network-only'] as const;
22
+ export const HYDRATE_STRATEGIES = ['idle', 'visible', 'interaction', 'never'] as const;
23
+
24
+ export type RouteParams = Readonly<Record<string, string>>;
25
+ export type RouteData = Readonly<Record<string, unknown>>;
26
+
27
+ /** ISR trigger. At least one of `tags` / `ttl` is required by `modes.ts`. */
28
+ export interface RevalidateConfig {
29
+ readonly tags?: readonly CacheTag[];
30
+ /** `'5m'`, `'1h'`, `'7d'` or milliseconds. */
31
+ readonly ttl?: string | number;
32
+ }
33
+
34
+ export interface RouteBudget {
35
+ /** `'40kb'` — measured from the real bundle graph, not the source size. */
36
+ readonly js?: string;
37
+ readonly css?: string;
38
+ /** Milliseconds, median of N headless runs. */
39
+ readonly lcp?: number;
40
+ readonly cls?: number;
41
+ readonly tbt?: number;
42
+ }
43
+
44
+ /**
45
+ * Structural view of a `@ultimat3/policy` guard. Render only needs to know a route HAS
46
+ * one (the `spa` invariant) — evaluation stays in policy, so there is exactly one authz
47
+ * system and render cannot grow a second door.
48
+ */
49
+ export interface RouteGuard {
50
+ readonly permission: string;
51
+ }
52
+
53
+ /** What an author writes. Sync or async, whichever the page's data needs. */
54
+ export type RouteMetaFn<TData = RouteData> = (data: TData) => RouteMeta | Promise<RouteMeta>;
55
+
56
+ /** What the descriptor hands back. One shape, so no consumer branches on a thenable. */
57
+ export type RouteMetaAsyncFn<TData = RouteData> = (data: TData) => Promise<RouteMeta>;
58
+
59
+ /** Returns the params to build at deploy time. Bare strings fill a single dynamic param. */
60
+ export type PrerenderFn = () =>
61
+ | readonly (string | RouteParams)[]
62
+ | Promise<readonly (string | RouteParams)[]>;
63
+
64
+ /** The input shape of `defineRoute` — exactly the contract's eight keys, nothing else. */
65
+ export interface RouteDefinition<TData = RouteData> {
66
+ readonly render: RenderMode;
67
+ readonly revalidate?: RevalidateConfig;
68
+ readonly prerender?: PrerenderFn;
69
+ readonly offline: OfflineStrategy;
70
+ readonly hydrate: HydrateStrategy;
71
+ readonly budget?: RouteBudget;
72
+ readonly meta: RouteMetaFn<TData>;
73
+ readonly policy?: RouteGuard;
74
+ }
75
+
76
+ /**
77
+ * The frozen descriptor. `kind` lets the registry reject non-route exports.
78
+ *
79
+ * Two fields are narrower here than in the declaration so every consumer reads one shape:
80
+ * `meta` always returns a promise, and `budget` is always an object. Its *fields* stay
81
+ * optional — `budget.js === undefined` still means "this route declared no JS budget",
82
+ * which is exactly what `modes.ts` fails a hydrating `site/` route on.
83
+ */
84
+ export interface RouteConfig<TData = RouteData> extends RouteDefinition<TData> {
85
+ readonly kind: 'route';
86
+ readonly meta: RouteMetaAsyncFn<TData>;
87
+ readonly budget: RouteBudget;
88
+ }
89
+
90
+ /** What every render mode hands back to `@ultimat3/http`'s `html()` / `stream()`. */
91
+ export interface RenderResult {
92
+ readonly status: number;
93
+ readonly headers: Readonly<Record<string, string>>;
94
+ readonly body: string | ReadableStream<Uint8Array>;
95
+ }
96
+
97
+ /**
98
+ * Route descriptors carry tags in `@ultimat3/cache`'s wire form (`post`, `post:123`), the
99
+ * same strings every cache tier speaks. Render never invents a key convention of its own.
100
+ */
101
+ export function tagKeys(tags: readonly CacheTag[] | undefined): readonly string[] {
102
+ return tags === undefined ? [] : serializeTags(tags);
103
+ }
104
+
105
+ /**
106
+ * Declare a route. Validates the shape (for JS callers who bypass the types) and the
107
+ * mode-local invariants immediately, so a bad route fails at module evaluation — build
108
+ * time — rather than on the first request in production.
109
+ */
110
+ export function defineRoute<TData = RouteData>(
111
+ definition: RouteDefinition<TData>,
112
+ ): RouteConfig<TData> {
113
+ const def = definition as Partial<RouteDefinition<TData>>;
114
+
115
+ if (def.offline === undefined) {
116
+ throw new RouteOfflineMissingError(
117
+ 'defineRoute() called without an `offline` strategy',
118
+ "add offline: 'precache' | 'runtime' | 'network-only' to defineRoute",
119
+ );
120
+ }
121
+ if (!OFFLINE_STRATEGIES.includes(def.offline)) {
122
+ throw new RouteOfflineMissingError(
123
+ `offline: ${JSON.stringify(def.offline)} is not a known strategy`,
124
+ `use one of ${OFFLINE_STRATEGIES.join(' | ')}`,
125
+ );
126
+ }
127
+ if (typeof def.meta !== 'function') {
128
+ throw new RouteMetaMissingError(
129
+ 'defineRoute() called without a `meta` function',
130
+ 'add meta: () => ({ title, description }) to defineRoute',
131
+ );
132
+ }
133
+
134
+ const declaredMeta = def.meta;
135
+ const config: RouteConfig<TData> = {
136
+ kind: 'route',
137
+ render: def.render as RenderMode,
138
+ offline: def.offline,
139
+ hydrate: def.hydrate as HydrateStrategy,
140
+ // Wrapped rather than stored: the declaration may be sync, the descriptor never is.
141
+ // A meta that throws synchronously becomes a rejection here, so `await config.meta(d)`
142
+ // is the one way to fail as well as the one way to succeed.
143
+ meta: async (data: TData) => declaredMeta(data),
144
+ // Always an object. `budget.js` is the only reach a consumer needs, so an undeclared
145
+ // budget is `{}` instead of a second undefined-check at every call site.
146
+ budget: def.budget ?? {},
147
+ ...(def.revalidate ? { revalidate: def.revalidate } : {}),
148
+ ...(def.prerender ? { prerender: def.prerender } : {}),
149
+ ...(def.policy ? { policy: def.policy } : {}),
150
+ };
151
+
152
+ assertModeShape(config);
153
+ return Object.freeze(config);
154
+ }
155
+
156
+ export function isRouteConfig(value: unknown): value is RouteConfig {
157
+ return typeof value === 'object' && value !== null && 'kind' in value && value.kind === 'route';
158
+ }