@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.
- package/LICENSE +21 -0
- package/README.md +149 -0
- package/package.json +37 -0
- package/src/errors.ts +167 -0
- package/src/head-seo.ts +46 -0
- package/src/head.ts +137 -0
- package/src/hydrate.ts +121 -0
- package/src/index.ts +170 -0
- package/src/islands.ts +171 -0
- package/src/modes.ts +208 -0
- package/src/registry.ts +313 -0
- package/src/render-isr.ts +290 -0
- package/src/render-spa.ts +74 -0
- package/src/render-ssr.ts +60 -0
- package/src/render-static.ts +170 -0
- package/src/render-stream.ts +149 -0
- package/src/route.ts +158 -0
- package/src/router-client.ts +225 -0
- package/src/surfaces.ts +248 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/** Public API of `@ultimat3/render`: the `route` primitive, the five modes, the table. */
|
|
2
|
+
|
|
3
|
+
export type { RenderErrorCode } from './errors';
|
|
4
|
+
export {
|
|
5
|
+
BudgetExceededError,
|
|
6
|
+
PrerenderFailedError,
|
|
7
|
+
RENDER_ERROR_CODES,
|
|
8
|
+
RENDER_ERROR_TITLES,
|
|
9
|
+
RouteDuplicateError,
|
|
10
|
+
RouteFileInvalidError,
|
|
11
|
+
RouteMetaMissingError,
|
|
12
|
+
RouteModeInvalidError,
|
|
13
|
+
RouteOfflineMissingError,
|
|
14
|
+
SurfaceBoundaryError,
|
|
15
|
+
} from './errors';
|
|
16
|
+
export type {
|
|
17
|
+
HeadRenderers,
|
|
18
|
+
HeadTag,
|
|
19
|
+
HeadTagKind,
|
|
20
|
+
LdRenderer,
|
|
21
|
+
MetaRenderer,
|
|
22
|
+
ThemeScriptOptions,
|
|
23
|
+
} from './head';
|
|
24
|
+
export {
|
|
25
|
+
headFromMeta,
|
|
26
|
+
mergeHead,
|
|
27
|
+
renderHead,
|
|
28
|
+
THEME_SCRIPT_MAX_BYTES,
|
|
29
|
+
themeScript,
|
|
30
|
+
} from './head';
|
|
31
|
+
export { headTagKey, seoRenderers, toHeadTag } from './head-seo';
|
|
32
|
+
export type { IslandDirective } from './hydrate';
|
|
33
|
+
export {
|
|
34
|
+
DEFAULT_REPLAY_EVENTS,
|
|
35
|
+
emitIslandAttributes,
|
|
36
|
+
emitIslandProps,
|
|
37
|
+
hydrateRuntime,
|
|
38
|
+
hydrateRuntimeBytes,
|
|
39
|
+
requiredStrategies,
|
|
40
|
+
} from './hydrate';
|
|
41
|
+
export type { BudgetReport, BundleGraph, GraphName, Island, RouteBytes } from './islands';
|
|
42
|
+
export {
|
|
43
|
+
assertBudget,
|
|
44
|
+
checkBudget,
|
|
45
|
+
checkBudgets,
|
|
46
|
+
formatBytes,
|
|
47
|
+
graphFor,
|
|
48
|
+
parseByteBudget,
|
|
49
|
+
routeJsBytes,
|
|
50
|
+
} from './islands';
|
|
51
|
+
export type { ModeCheckContext, ModeSpec, RouteShape } from './modes';
|
|
52
|
+
export {
|
|
53
|
+
assertModeInvariants,
|
|
54
|
+
assertModeShape,
|
|
55
|
+
defaultHydrate,
|
|
56
|
+
MODE_SPECS,
|
|
57
|
+
RENDER_MODES,
|
|
58
|
+
} from './modes';
|
|
59
|
+
export type {
|
|
60
|
+
CompiledPattern,
|
|
61
|
+
RegisterRouteInput,
|
|
62
|
+
RouteDescriptor,
|
|
63
|
+
RouteEntry,
|
|
64
|
+
RouteMatch,
|
|
65
|
+
} from './registry';
|
|
66
|
+
export {
|
|
67
|
+
clearRoutes,
|
|
68
|
+
compilePattern,
|
|
69
|
+
describeRoutes,
|
|
70
|
+
matchRoute,
|
|
71
|
+
ROUTE_FILENAME,
|
|
72
|
+
registerRoute,
|
|
73
|
+
routeCount,
|
|
74
|
+
routeEntries,
|
|
75
|
+
routeFor,
|
|
76
|
+
routePathFromFile,
|
|
77
|
+
} from './registry';
|
|
78
|
+
export type {
|
|
79
|
+
IsrController,
|
|
80
|
+
IsrControllerOptions,
|
|
81
|
+
IsrEntry,
|
|
82
|
+
IsrRenderFn,
|
|
83
|
+
IsrServeResult,
|
|
84
|
+
IsrState,
|
|
85
|
+
IsrStore,
|
|
86
|
+
} from './render-isr';
|
|
87
|
+
|
|
88
|
+
export {
|
|
89
|
+
createIsrController,
|
|
90
|
+
invalidateAndRevalidate,
|
|
91
|
+
memoryIsrStore,
|
|
92
|
+
parseTtlMs,
|
|
93
|
+
} from './render-isr';
|
|
94
|
+
export type { SpaShell, SpaShellInput } from './render-spa';
|
|
95
|
+
export { renderSpa, renderSpaShell, SPA_ROOT_ID } from './render-spa';
|
|
96
|
+
export type { SsrOptions, SsrRenderFn, SsrRenderInput } from './render-ssr';
|
|
97
|
+
export { renderSsr, ssrHeaders } from './render-ssr';
|
|
98
|
+
export type { StaticArtifact, StaticBuildOptions, StaticRenderFn } from './render-static';
|
|
99
|
+
export {
|
|
100
|
+
assertNoPerRequestState,
|
|
101
|
+
contentHash,
|
|
102
|
+
enumeratePrerender,
|
|
103
|
+
fillPath,
|
|
104
|
+
renderStatic,
|
|
105
|
+
staticHeaders,
|
|
106
|
+
staticResult,
|
|
107
|
+
} from './render-static';
|
|
108
|
+
export type { StreamHole, StreamOptions, StreamPlan } from './render-stream';
|
|
109
|
+
export {
|
|
110
|
+
collectStream,
|
|
111
|
+
holeId,
|
|
112
|
+
holeMarker,
|
|
113
|
+
REVEAL_SCRIPT,
|
|
114
|
+
renderStreamHtml,
|
|
115
|
+
revealChunk,
|
|
116
|
+
streamResult,
|
|
117
|
+
} from './render-stream';
|
|
118
|
+
export type {
|
|
119
|
+
HydrateStrategy,
|
|
120
|
+
OfflineStrategy,
|
|
121
|
+
PrerenderFn,
|
|
122
|
+
RenderMode,
|
|
123
|
+
RenderResult,
|
|
124
|
+
RevalidateConfig,
|
|
125
|
+
RouteBudget,
|
|
126
|
+
RouteConfig,
|
|
127
|
+
RouteData,
|
|
128
|
+
RouteDefinition,
|
|
129
|
+
RouteGuard,
|
|
130
|
+
RouteMetaAsyncFn,
|
|
131
|
+
RouteMetaFn,
|
|
132
|
+
RouteParams,
|
|
133
|
+
} from './route';
|
|
134
|
+
export {
|
|
135
|
+
defineRoute,
|
|
136
|
+
HYDRATE_STRATEGIES,
|
|
137
|
+
isRouteConfig,
|
|
138
|
+
OFFLINE_STRATEGIES,
|
|
139
|
+
tagKeys,
|
|
140
|
+
} from './route';
|
|
141
|
+
export type {
|
|
142
|
+
NavigateOptions,
|
|
143
|
+
NavigationGuard,
|
|
144
|
+
PrefetchContainer,
|
|
145
|
+
PrefetchLink,
|
|
146
|
+
ReactivePrimitives,
|
|
147
|
+
ResolvedRoute,
|
|
148
|
+
Router,
|
|
149
|
+
RouterHost,
|
|
150
|
+
RouterOptions,
|
|
151
|
+
RouterRoute,
|
|
152
|
+
} from './router-client';
|
|
153
|
+
export { createRouter } from './router-client';
|
|
154
|
+
export type {
|
|
155
|
+
BoundaryRule,
|
|
156
|
+
BoundaryViolation,
|
|
157
|
+
ImportGraph,
|
|
158
|
+
ImportRef,
|
|
159
|
+
Surface,
|
|
160
|
+
SurfaceSpec,
|
|
161
|
+
} from './surfaces';
|
|
162
|
+
export {
|
|
163
|
+
assertSurfaceBoundary,
|
|
164
|
+
checkSurfaceBoundary,
|
|
165
|
+
importGraph,
|
|
166
|
+
SURFACE_SPECS,
|
|
167
|
+
SURFACES,
|
|
168
|
+
surfaceAllows,
|
|
169
|
+
surfaceOf,
|
|
170
|
+
} from './surfaces';
|
package/src/islands.ts
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Island boundaries and the two separate bundle graphs. `site/` and `app/` never share a
|
|
3
|
+
* graph: that is the mechanical half of axiom 6 (the surface boundary in `surfaces.ts` is
|
|
4
|
+
* the other half). `site/` starts at a 0kb baseline and every byte after that is an
|
|
5
|
+
* opt-in, budgeted island.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { BudgetExceededError } from './errors';
|
|
9
|
+
import type { IslandDirective } from './hydrate';
|
|
10
|
+
import { hydrateRuntimeBytes } from './hydrate';
|
|
11
|
+
import type { RouteEntry } from './registry';
|
|
12
|
+
import type { HydrateStrategy } from './route';
|
|
13
|
+
import type { Surface } from './surfaces';
|
|
14
|
+
import { SURFACE_SPECS } from './surfaces';
|
|
15
|
+
|
|
16
|
+
/** A bundle graph is per surface. There are exactly two that ship JS: `site` and `app`. */
|
|
17
|
+
export type GraphName = 'site' | 'app';
|
|
18
|
+
|
|
19
|
+
export interface Island {
|
|
20
|
+
readonly id: string;
|
|
21
|
+
readonly file: string;
|
|
22
|
+
readonly graph: GraphName;
|
|
23
|
+
readonly strategy: HydrateStrategy;
|
|
24
|
+
/** Measured from the real bundle, gzip-before-brotli agnostic: raw bytes. */
|
|
25
|
+
readonly bytes: number;
|
|
26
|
+
/** The import chain that pulled the heaviest dependency in, for error messages. */
|
|
27
|
+
readonly heaviestChain?: readonly string[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface BundleGraph {
|
|
31
|
+
readonly name: GraphName;
|
|
32
|
+
readonly baselineBytes: number;
|
|
33
|
+
readonly islands: readonly Island[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function graphFor(surface: Surface, islands: readonly Island[]): BundleGraph {
|
|
37
|
+
const name: GraphName = surface === 'site' ? 'site' : 'app';
|
|
38
|
+
return {
|
|
39
|
+
name,
|
|
40
|
+
baselineBytes: SURFACE_SPECS[name].jsBaselineBytes,
|
|
41
|
+
islands: islands.filter((island) => island.graph === name && island.strategy !== 'never'),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const UNITS: Readonly<Record<string, number>> = { b: 1, kb: 1024, mb: 1024 * 1024 };
|
|
46
|
+
|
|
47
|
+
/** `'40kb'` → 40960. Throws nothing: an unparseable budget is `null` and skipped. */
|
|
48
|
+
export function parseByteBudget(budget: string | undefined): number | null {
|
|
49
|
+
if (budget === undefined) return null;
|
|
50
|
+
const match = /^(\d+(?:\.\d+)?)\s*(b|kb|mb)$/i.exec(budget.trim());
|
|
51
|
+
const amount = match?.[1];
|
|
52
|
+
const unit = match?.[2]?.toLowerCase();
|
|
53
|
+
if (amount === undefined || unit === undefined) return null;
|
|
54
|
+
const factor = UNITS[unit];
|
|
55
|
+
return factor === undefined ? null : Math.round(Number(amount) * factor);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function formatBytes(bytes: number): string {
|
|
59
|
+
if (bytes < 1024) return `${bytes}b`;
|
|
60
|
+
return `${Math.round((bytes / 1024) * 10) / 10}kb`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface RouteBytes {
|
|
64
|
+
readonly total: number;
|
|
65
|
+
readonly baseline: number;
|
|
66
|
+
readonly islandBytes: number;
|
|
67
|
+
readonly runtimeBytes: number;
|
|
68
|
+
readonly heaviest: Island | null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function routeJsBytes(
|
|
72
|
+
entry: RouteEntry,
|
|
73
|
+
islands: readonly Island[],
|
|
74
|
+
directives: readonly IslandDirective[] = [],
|
|
75
|
+
): RouteBytes {
|
|
76
|
+
const graph = graphFor(entry.surface, islands);
|
|
77
|
+
const onRoute = graph.islands.filter((island) => entry.islands.includes(island.id));
|
|
78
|
+
const islandBytes = onRoute.reduce((sum, island) => sum + island.bytes, 0);
|
|
79
|
+
const runtimeBytes = directives.length > 0 ? hydrateRuntimeBytes(directives) : 0;
|
|
80
|
+
const baseline = islandBytes === 0 && runtimeBytes === 0 ? 0 : graph.baselineBytes;
|
|
81
|
+
const heaviest = onRoute.reduce<Island | null>(
|
|
82
|
+
(max, island) => (max === null || island.bytes > max.bytes ? island : max),
|
|
83
|
+
null,
|
|
84
|
+
);
|
|
85
|
+
return {
|
|
86
|
+
total: baseline + islandBytes + runtimeBytes,
|
|
87
|
+
baseline,
|
|
88
|
+
islandBytes,
|
|
89
|
+
runtimeBytes,
|
|
90
|
+
heaviest,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface BudgetReport {
|
|
95
|
+
readonly path: string;
|
|
96
|
+
readonly measured: number;
|
|
97
|
+
readonly limit: number | null;
|
|
98
|
+
readonly ok: boolean;
|
|
99
|
+
readonly cause: string | null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The build gate. Failure names the *cause* — the island and the transitive import that
|
|
104
|
+
* added the bytes — because "bundle too big" without a chain is not an instruction.
|
|
105
|
+
*/
|
|
106
|
+
export function checkBudget(
|
|
107
|
+
entry: RouteEntry,
|
|
108
|
+
islands: readonly Island[],
|
|
109
|
+
directives: readonly IslandDirective[] = [],
|
|
110
|
+
): BudgetReport {
|
|
111
|
+
const bytes = routeJsBytes(entry, islands, directives);
|
|
112
|
+
const limit = parseByteBudget(entry.config.budget.js);
|
|
113
|
+
|
|
114
|
+
// site/ has a 0kb default: shipping JS there without declaring a budget is the failure.
|
|
115
|
+
if (limit === null && entry.surface === 'site' && bytes.total > 0) {
|
|
116
|
+
return {
|
|
117
|
+
path: entry.path,
|
|
118
|
+
measured: bytes.total,
|
|
119
|
+
limit: 0,
|
|
120
|
+
ok: false,
|
|
121
|
+
cause:
|
|
122
|
+
`${entry.path} is in site/ (0kb JS baseline) and ships ${formatBytes(bytes.total)} ` +
|
|
123
|
+
`with no budget.js${chainOf(bytes.heaviest)}`,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (limit === null) {
|
|
128
|
+
return { path: entry.path, measured: bytes.total, limit: null, ok: true, cause: null };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const ok = bytes.total <= limit;
|
|
132
|
+
return {
|
|
133
|
+
path: entry.path,
|
|
134
|
+
measured: bytes.total,
|
|
135
|
+
limit,
|
|
136
|
+
ok,
|
|
137
|
+
cause: ok
|
|
138
|
+
? null
|
|
139
|
+
: `${entry.path} js ${formatBytes(bytes.total)} > ${formatBytes(limit)}${chainOf(bytes.heaviest)}`,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function chainOf(island: Island | null): string {
|
|
144
|
+
if (island === null) return '';
|
|
145
|
+
const chain = island.heaviestChain;
|
|
146
|
+
if (chain === undefined || chain.length === 0) return ` (heaviest island: ${island.file})`;
|
|
147
|
+
return ` (${chain.join(' → ')})`;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function assertBudget(
|
|
151
|
+
entry: RouteEntry,
|
|
152
|
+
islands: readonly Island[],
|
|
153
|
+
directives: readonly IslandDirective[] = [],
|
|
154
|
+
): void {
|
|
155
|
+
const report = checkBudget(entry, islands, directives);
|
|
156
|
+
if (report.ok || report.cause === null) return;
|
|
157
|
+
throw new BudgetExceededError(
|
|
158
|
+
report.cause,
|
|
159
|
+
`raise budget.js in ${entry.file} deliberately, or remove the import that added the bytes`,
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** `x verify` prints every route, not just the first failure. */
|
|
164
|
+
export function checkBudgets(
|
|
165
|
+
entries: readonly RouteEntry[],
|
|
166
|
+
islands: readonly Island[],
|
|
167
|
+
): readonly BudgetReport[] {
|
|
168
|
+
return entries
|
|
169
|
+
.map((entry) => checkBudget(entry, islands))
|
|
170
|
+
.sort((a, b) => a.path.localeCompare(b.path));
|
|
171
|
+
}
|
package/src/modes.ts
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The five render modes as a table of invariants, checked at registration.
|
|
3
|
+
* Each mode has properties the framework can rely on; a config that contradicts one is
|
|
4
|
+
* rejected with `X_ROUTE_MODE_INVALID` and the exact edit that fixes it. A mode whose
|
|
5
|
+
* invariant is only documented is a mode that silently degrades in production.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { RouteModeInvalidError } from './errors';
|
|
9
|
+
import type { HydrateStrategy, RenderMode, RouteConfig } from './route';
|
|
10
|
+
import { HYDRATE_STRATEGIES } from './route';
|
|
11
|
+
import type { Surface } from './surfaces';
|
|
12
|
+
import { SURFACE_SPECS, surfaceAllows } from './surfaces';
|
|
13
|
+
|
|
14
|
+
export const RENDER_MODES = ['static', 'isr', 'ssr', 'stream', 'spa'] as const;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Everything about a route except its `meta`. Mode invariants never read metadata, and
|
|
18
|
+
* omitting it keeps these checks free of the route's data generic.
|
|
19
|
+
*/
|
|
20
|
+
export type RouteShape = Omit<RouteConfig, 'meta'>;
|
|
21
|
+
|
|
22
|
+
export interface ModeSpec {
|
|
23
|
+
readonly mode: RenderMode;
|
|
24
|
+
/** May the render function observe the request (actor, headers, cookies, geo)? */
|
|
25
|
+
readonly perRequestState: boolean;
|
|
26
|
+
/** May the route be built ahead of time via `prerender()`? */
|
|
27
|
+
readonly prerenderable: boolean;
|
|
28
|
+
/** Does the mode need a revalidation trigger (tags or TTL)? */
|
|
29
|
+
readonly needsRevalidate: boolean;
|
|
30
|
+
/** Does the mode need at least one `<Suspense>` boundary to mean anything? */
|
|
31
|
+
readonly needsSuspense: boolean;
|
|
32
|
+
/** Does the mode need a `policy` (it renders no data, so the shell must be gated)? */
|
|
33
|
+
readonly needsPolicy: boolean;
|
|
34
|
+
readonly description: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const MODE_SPECS: Readonly<Record<RenderMode, ModeSpec>> = Object.freeze({
|
|
38
|
+
static: {
|
|
39
|
+
mode: 'static',
|
|
40
|
+
perRequestState: false,
|
|
41
|
+
prerenderable: true,
|
|
42
|
+
needsRevalidate: false,
|
|
43
|
+
needsSuspense: false,
|
|
44
|
+
needsPolicy: false,
|
|
45
|
+
description: 'built once, served as a file',
|
|
46
|
+
},
|
|
47
|
+
isr: {
|
|
48
|
+
mode: 'isr',
|
|
49
|
+
perRequestState: false,
|
|
50
|
+
prerenderable: true,
|
|
51
|
+
needsRevalidate: true,
|
|
52
|
+
needsSuspense: false,
|
|
53
|
+
needsPolicy: false,
|
|
54
|
+
description: 'static + background regen on tag/TTL',
|
|
55
|
+
},
|
|
56
|
+
ssr: {
|
|
57
|
+
mode: 'ssr',
|
|
58
|
+
perRequestState: true,
|
|
59
|
+
prerenderable: false,
|
|
60
|
+
needsRevalidate: false,
|
|
61
|
+
needsSuspense: false,
|
|
62
|
+
needsPolicy: false,
|
|
63
|
+
description: 'per-request full render',
|
|
64
|
+
},
|
|
65
|
+
stream: {
|
|
66
|
+
mode: 'stream',
|
|
67
|
+
perRequestState: true,
|
|
68
|
+
prerenderable: false,
|
|
69
|
+
needsRevalidate: false,
|
|
70
|
+
needsSuspense: true,
|
|
71
|
+
needsPolicy: false,
|
|
72
|
+
description: 'static shell flushed instantly, holes streamed',
|
|
73
|
+
},
|
|
74
|
+
spa: {
|
|
75
|
+
mode: 'spa',
|
|
76
|
+
perRequestState: false,
|
|
77
|
+
prerenderable: true,
|
|
78
|
+
needsRevalidate: false,
|
|
79
|
+
needsSuspense: false,
|
|
80
|
+
needsPolicy: true,
|
|
81
|
+
description: 'shell only, client fetches',
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
/** Mode-local checks that need nothing but the config. Called by `defineRoute`. */
|
|
86
|
+
export function assertModeShape(config: RouteShape): void {
|
|
87
|
+
// Widened on purpose: JS callers reach `defineRoute` with unvalidated strings.
|
|
88
|
+
const spec: ModeSpec | undefined = MODE_SPECS[config.render];
|
|
89
|
+
if (spec === undefined) {
|
|
90
|
+
throw new RouteModeInvalidError(
|
|
91
|
+
`render: ${JSON.stringify(config.render)} is not a render mode`,
|
|
92
|
+
`use one of ${RENDER_MODES.join(' | ')}`,
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (!HYDRATE_STRATEGIES.includes(config.hydrate)) {
|
|
97
|
+
throw new RouteModeInvalidError(
|
|
98
|
+
`hydrate: ${JSON.stringify(config.hydrate)} is not a hydration strategy`,
|
|
99
|
+
`use one of ${HYDRATE_STRATEGIES.join(' | ')}`,
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// static: no per-request state. `policy` needs an actor and `revalidate` needs a
|
|
104
|
+
// request to regenerate on — both mean the page is not a file on disk.
|
|
105
|
+
if (config.render === 'static' && config.policy !== undefined) {
|
|
106
|
+
throw new RouteModeInvalidError(
|
|
107
|
+
"render: 'static' cannot read per-request state, but a `policy` was declared " +
|
|
108
|
+
`(${config.policy.permission} needs an actor)`,
|
|
109
|
+
"change render to 'ssr' (fresh, gated) or 'spa' (gated shell), or drop the policy",
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
if (config.render === 'static' && config.revalidate !== undefined) {
|
|
113
|
+
throw new RouteModeInvalidError(
|
|
114
|
+
"render: 'static' is built once and never regenerates, but `revalidate` was declared",
|
|
115
|
+
"change render to 'isr' to keep the revalidate trigger",
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// isr: needs a trigger, otherwise it is `static` wearing a costume.
|
|
120
|
+
if (config.render === 'isr' && !hasRevalidateTrigger(config)) {
|
|
121
|
+
throw new RouteModeInvalidError(
|
|
122
|
+
"render: 'isr' requires a regeneration trigger, but revalidate has neither tags nor ttl",
|
|
123
|
+
"add revalidate: { tags: [tag.post] } or revalidate: { ttl: '5m' }",
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ssr: cannot be prerendered — the whole point is that it runs per request.
|
|
128
|
+
if (config.render === 'ssr' && config.prerender !== undefined) {
|
|
129
|
+
throw new RouteModeInvalidError(
|
|
130
|
+
"render: 'ssr' renders per request and cannot be prerendered, but `prerender` was declared",
|
|
131
|
+
"change render to 'isr' to prerender and regenerate, or remove prerender",
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// spa: the shell carries no data, so authz has to live on the route itself.
|
|
136
|
+
if (config.render === 'spa' && config.policy === undefined) {
|
|
137
|
+
throw new RouteModeInvalidError(
|
|
138
|
+
"render: 'spa' ships a shell with no server-rendered data and requires a `policy`",
|
|
139
|
+
"add policy: can('dashboard:read') — or use 'stream' for a public page",
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function hasRevalidateTrigger(config: RouteShape): boolean {
|
|
145
|
+
const revalidate = config.revalidate;
|
|
146
|
+
if (revalidate === undefined) return false;
|
|
147
|
+
const hasTags = revalidate.tags !== undefined && revalidate.tags.length > 0;
|
|
148
|
+
const hasTtl = revalidate.ttl !== undefined && revalidate.ttl !== '';
|
|
149
|
+
return hasTags || hasTtl;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export interface ModeCheckContext {
|
|
153
|
+
readonly file: string;
|
|
154
|
+
readonly path: string;
|
|
155
|
+
readonly surface: Surface;
|
|
156
|
+
/** Counted from the route module's JSX by the build. `stream` needs at least one. */
|
|
157
|
+
readonly suspenseBoundaries: number;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Checks that need the surrounding module and surface. Called by `registerRoute`. */
|
|
161
|
+
export function assertModeInvariants(config: RouteShape, ctx: ModeCheckContext): void {
|
|
162
|
+
if (ctx.surface === 'api') {
|
|
163
|
+
throw new RouteModeInvalidError(
|
|
164
|
+
`${ctx.file} is in api/, which renders nothing, but declares render: '${config.render}'`,
|
|
165
|
+
`move ${ctx.file} into site/ or app/, or replace defineRoute with an action`,
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (!surfaceAllows(ctx.surface, config.render)) {
|
|
170
|
+
const allowed = SURFACE_SPECS[ctx.surface].allowedModes.join(' | ');
|
|
171
|
+
throw new RouteModeInvalidError(
|
|
172
|
+
`${ctx.file} is in ${ctx.surface}/ and declares render: '${config.render}', ` +
|
|
173
|
+
`which ${ctx.surface}/ does not allow`,
|
|
174
|
+
`use one of ${allowed} in ${ctx.file}`,
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// `budget` is always on the descriptor; `budget.js` is the field that stays optional,
|
|
179
|
+
// and its absence is the failure — site/ is 0kb until a route says otherwise, in bytes.
|
|
180
|
+
if (ctx.surface === 'site' && config.hydrate !== 'never' && config.budget.js === undefined) {
|
|
181
|
+
throw new RouteModeInvalidError(
|
|
182
|
+
`${ctx.file} is in site/ (0kb JS baseline) and opts into hydrate: '${config.hydrate}' ` +
|
|
183
|
+
'without a JS budget',
|
|
184
|
+
`add budget: { js: '10kb' } to ${ctx.file} — hydration on site/ is explicit and budgeted`,
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (config.render === 'stream' && ctx.suspenseBoundaries < 1) {
|
|
189
|
+
throw new RouteModeInvalidError(
|
|
190
|
+
`${ctx.file} declares render: 'stream' but has no <Suspense> boundary, so there is ` +
|
|
191
|
+
'nothing to stream — the whole page waits like ssr',
|
|
192
|
+
`wrap the data-dependent part of ${ctx.file} in <Suspense fallback={…}> ` +
|
|
193
|
+
"or change render to 'ssr'",
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (config.prerender !== undefined && !MODE_SPECS[config.render].prerenderable) {
|
|
198
|
+
throw new RouteModeInvalidError(
|
|
199
|
+
`${ctx.file} declares prerender with render: '${config.render}', which is not prerenderable`,
|
|
200
|
+
`remove prerender from ${ctx.file} or change render to 'static' | 'isr'`,
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Default hydration timing per surface — `site/` ships nothing unless asked. */
|
|
206
|
+
export function defaultHydrate(surface: Surface): HydrateStrategy {
|
|
207
|
+
return surface === 'site' ? 'never' : 'idle';
|
|
208
|
+
}
|