@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
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The minimal client router. Vendored on purpose: the router is load-bearing for every
|
|
3
|
+
* app page, and a moving SolidStart alpha is not something a framework should depend on.
|
|
4
|
+
*
|
|
5
|
+
* No `solid-js` import — reactive primitives and the DOM are injected, so this file runs
|
|
6
|
+
* under `bun test` with no DOM and no framework runtime.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { CompiledPattern } from './registry';
|
|
10
|
+
import { compilePattern } from './registry';
|
|
11
|
+
import type { RouteParams } from './route';
|
|
12
|
+
|
|
13
|
+
export interface RouterRoute {
|
|
14
|
+
readonly path: string;
|
|
15
|
+
/** Module URL for the route chunk; used by `prefetch`. */
|
|
16
|
+
readonly chunk?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface ResolvedRoute {
|
|
20
|
+
readonly route: RouterRoute;
|
|
21
|
+
readonly params: RouteParams;
|
|
22
|
+
readonly pathname: string;
|
|
23
|
+
readonly search: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Injected instead of imported so there is no framework runtime dependency here. */
|
|
27
|
+
export interface ReactivePrimitives {
|
|
28
|
+
createSignal<T>(initial: T): readonly [() => T, (next: T) => void];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface RouterHost {
|
|
32
|
+
readonly pathname: () => string;
|
|
33
|
+
readonly search: () => string;
|
|
34
|
+
readonly pushState: (url: string) => void;
|
|
35
|
+
readonly replaceState: (url: string) => void;
|
|
36
|
+
readonly onPopState: (handler: () => void) => void;
|
|
37
|
+
readonly scrollTo: (x: number, y: number) => void;
|
|
38
|
+
readonly scrollY: () => number;
|
|
39
|
+
/** `document.startViewTransition` when present; falls back to running the update. */
|
|
40
|
+
readonly viewTransition?: (update: () => void) => void;
|
|
41
|
+
/** Warm a chunk. Defaults to a no-op host in tests. */
|
|
42
|
+
readonly preload?: (chunk: string) => void;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type NavigationGuard = (
|
|
46
|
+
to: ResolvedRoute,
|
|
47
|
+
from: ResolvedRoute | null,
|
|
48
|
+
) => boolean | Promise<boolean>;
|
|
49
|
+
|
|
50
|
+
export interface RouterOptions {
|
|
51
|
+
readonly routes: readonly RouterRoute[];
|
|
52
|
+
readonly host: RouterHost;
|
|
53
|
+
readonly primitives: ReactivePrimitives;
|
|
54
|
+
/** Hover dwell before prefetching, ms. */
|
|
55
|
+
readonly prefetchDelayMs?: number;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface NavigateOptions {
|
|
59
|
+
readonly replace?: boolean;
|
|
60
|
+
readonly scroll?: boolean;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface Router {
|
|
64
|
+
readonly current: () => ResolvedRoute | null;
|
|
65
|
+
readonly navigate: (to: string, options?: NavigateOptions) => Promise<boolean>;
|
|
66
|
+
readonly resolve: (url: string) => ResolvedRoute | null;
|
|
67
|
+
readonly prefetch: (to: string) => void;
|
|
68
|
+
readonly guard: (guard: NavigationGuard) => () => void;
|
|
69
|
+
/** Attach hover/visible prefetch + scroll restoration to a container element. */
|
|
70
|
+
readonly attach: (container: PrefetchContainer) => () => void;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Structural view of the DOM bits the router touches, so tests can fake them. */
|
|
74
|
+
export type DomEventHandler = (event: { target: unknown }) => void;
|
|
75
|
+
|
|
76
|
+
export interface PrefetchContainer {
|
|
77
|
+
querySelectorAll(selector: string): Iterable<PrefetchLink>;
|
|
78
|
+
addEventListener(type: string, handler: DomEventHandler, options?: unknown): void;
|
|
79
|
+
removeEventListener(type: string, handler: DomEventHandler, options?: unknown): void;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface PrefetchLink {
|
|
83
|
+
getAttribute(name: string): string | null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
interface CompiledRoute {
|
|
87
|
+
readonly route: RouterRoute;
|
|
88
|
+
readonly pattern: CompiledPattern;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function createRouter(options: RouterOptions): Router {
|
|
92
|
+
const compiled: readonly CompiledRoute[] = options.routes
|
|
93
|
+
.map((route) => ({ route, pattern: compilePattern(route.path) }))
|
|
94
|
+
.sort((a, b) => b.pattern.specificity - a.pattern.specificity);
|
|
95
|
+
|
|
96
|
+
const guards = new Set<NavigationGuard>();
|
|
97
|
+
const scrollPositions = new Map<string, number>();
|
|
98
|
+
const prefetched = new Set<string>();
|
|
99
|
+
const host = options.host;
|
|
100
|
+
|
|
101
|
+
const [current, setCurrent] = options.primitives.createSignal<ResolvedRoute | null>(
|
|
102
|
+
resolve(`${host.pathname()}${host.search()}`),
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
function resolve(url: string): ResolvedRoute | null {
|
|
106
|
+
const [rawPath = '/', rawSearch = ''] = splitUrl(url);
|
|
107
|
+
for (const candidate of compiled) {
|
|
108
|
+
const match = candidate.pattern.regex.exec(rawPath);
|
|
109
|
+
if (match === null) continue;
|
|
110
|
+
const params: Record<string, string> = {};
|
|
111
|
+
candidate.pattern.keys.forEach((key, index) => {
|
|
112
|
+
const value = match[index + 1];
|
|
113
|
+
if (value !== undefined) params[key] = decodeURIComponent(value);
|
|
114
|
+
});
|
|
115
|
+
return {
|
|
116
|
+
route: candidate.route,
|
|
117
|
+
params,
|
|
118
|
+
pathname: rawPath,
|
|
119
|
+
search: rawSearch === '' ? '' : `?${rawSearch}`,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function navigate(to: string, navOptions: NavigateOptions = {}): Promise<boolean> {
|
|
126
|
+
const next = resolve(to);
|
|
127
|
+
if (next === null) return false;
|
|
128
|
+
|
|
129
|
+
const from = current();
|
|
130
|
+
for (const guard of guards) {
|
|
131
|
+
// A guard that says no is a hard stop: no history entry, no scroll change.
|
|
132
|
+
if (!(await guard(next, from))) return false;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (from !== null) scrollPositions.set(keyOf(from), host.scrollY());
|
|
136
|
+
|
|
137
|
+
const commit = (): void => {
|
|
138
|
+
if (navOptions.replace === true) host.replaceState(to);
|
|
139
|
+
else host.pushState(to);
|
|
140
|
+
setCurrent(next);
|
|
141
|
+
if (navOptions.scroll !== false) restoreScroll(next);
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
if (host.viewTransition !== undefined) host.viewTransition(commit);
|
|
145
|
+
else commit();
|
|
146
|
+
return true;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function restoreScroll(route: ResolvedRoute): void {
|
|
150
|
+
host.scrollTo(0, scrollPositions.get(keyOf(route)) ?? 0);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function prefetch(to: string): void {
|
|
154
|
+
const target = resolve(to);
|
|
155
|
+
const chunk = target?.route.chunk;
|
|
156
|
+
if (chunk === undefined || prefetched.has(chunk)) return;
|
|
157
|
+
prefetched.add(chunk);
|
|
158
|
+
host.preload?.(chunk);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
host.onPopState(() => {
|
|
162
|
+
const next = resolve(`${host.pathname()}${host.search()}`);
|
|
163
|
+
if (next !== null) {
|
|
164
|
+
setCurrent(next);
|
|
165
|
+
restoreScroll(next);
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
current,
|
|
171
|
+
navigate,
|
|
172
|
+
resolve,
|
|
173
|
+
prefetch,
|
|
174
|
+
guard(guard) {
|
|
175
|
+
guards.add(guard);
|
|
176
|
+
return () => guards.delete(guard);
|
|
177
|
+
},
|
|
178
|
+
attach(container) {
|
|
179
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
180
|
+
const onEnter = (event: { target: unknown }): void => {
|
|
181
|
+
const href = hrefOf(event.target);
|
|
182
|
+
if (href === null) return;
|
|
183
|
+
if (timer !== null) clearTimeout(timer);
|
|
184
|
+
timer = setTimeout(() => prefetch(href), options.prefetchDelayMs ?? 80);
|
|
185
|
+
};
|
|
186
|
+
const onLeave = (): void => {
|
|
187
|
+
if (timer !== null) clearTimeout(timer);
|
|
188
|
+
timer = null;
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
container.addEventListener('mouseenter', onEnter, true);
|
|
192
|
+
container.addEventListener('mouseleave', onLeave, true);
|
|
193
|
+
|
|
194
|
+
// Visible prefetch: everything explicitly marked, warmed once it is on screen.
|
|
195
|
+
for (const link of container.querySelectorAll('a[data-prefetch="visible"]')) {
|
|
196
|
+
const href = link.getAttribute('href');
|
|
197
|
+
if (href !== null) prefetch(href);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return () => {
|
|
201
|
+
container.removeEventListener('mouseenter', onEnter, true);
|
|
202
|
+
container.removeEventListener('mouseleave', onLeave, true);
|
|
203
|
+
onLeave();
|
|
204
|
+
};
|
|
205
|
+
},
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function keyOf(route: ResolvedRoute): string {
|
|
210
|
+
return `${route.pathname}${route.search}`;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function splitUrl(url: string): readonly [string, string] {
|
|
214
|
+
const hashless = url.split('#')[0] ?? '/';
|
|
215
|
+
const index = hashless.indexOf('?');
|
|
216
|
+
if (index === -1) return [hashless === '' ? '/' : hashless, ''];
|
|
217
|
+
return [hashless.slice(0, index) || '/', hashless.slice(index + 1)];
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function hrefOf(target: unknown): string | null {
|
|
221
|
+
if (typeof target !== 'object' || target === null) return null;
|
|
222
|
+
if (!('getAttribute' in target)) return null;
|
|
223
|
+
const link = target as PrefetchLink;
|
|
224
|
+
return link.getAttribute('href');
|
|
225
|
+
}
|
package/src/surfaces.ts
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The three surfaces plus `shared/`, and the hard import boundary between them.
|
|
3
|
+
* `site/` must never reach `app/`, transitively, through any number of hops — that is
|
|
4
|
+
* what makes axiom 6 ("the static path never pays for the app path") real rather than
|
|
5
|
+
* aspirational. Violations are build errors, resolved through the whole chain.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { SurfaceBoundaryError } from './errors';
|
|
9
|
+
import type { RenderMode } from './route';
|
|
10
|
+
|
|
11
|
+
export type Surface = 'site' | 'app' | 'api' | 'shared';
|
|
12
|
+
|
|
13
|
+
export const SURFACES = ['site', 'app', 'api', 'shared'] as const;
|
|
14
|
+
|
|
15
|
+
export interface SurfaceSpec {
|
|
16
|
+
readonly surface: Surface;
|
|
17
|
+
readonly defaultMode: RenderMode | null;
|
|
18
|
+
readonly allowedModes: readonly RenderMode[];
|
|
19
|
+
/** Bytes of JS a route on this surface ships before any island opts in. */
|
|
20
|
+
readonly jsBaselineBytes: number;
|
|
21
|
+
/** Surfaces this one may import at runtime. */
|
|
22
|
+
readonly mayImport: readonly Surface[];
|
|
23
|
+
/** Surfaces this one may reference with `import type` only. */
|
|
24
|
+
readonly mayImportTypes: readonly Surface[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const SURFACE_SPECS: Readonly<Record<Surface, SurfaceSpec>> = Object.freeze({
|
|
28
|
+
site: {
|
|
29
|
+
surface: 'site',
|
|
30
|
+
defaultMode: 'static',
|
|
31
|
+
allowedModes: ['static', 'isr', 'ssr'],
|
|
32
|
+
jsBaselineBytes: 0,
|
|
33
|
+
mayImport: ['shared'],
|
|
34
|
+
mayImportTypes: ['shared'],
|
|
35
|
+
},
|
|
36
|
+
app: {
|
|
37
|
+
surface: 'app',
|
|
38
|
+
defaultMode: 'stream',
|
|
39
|
+
allowedModes: ['stream', 'spa', 'ssr'],
|
|
40
|
+
jsBaselineBytes: 14_336,
|
|
41
|
+
mayImport: ['shared'],
|
|
42
|
+
mayImportTypes: ['shared', 'api'],
|
|
43
|
+
},
|
|
44
|
+
api: {
|
|
45
|
+
surface: 'api',
|
|
46
|
+
defaultMode: null,
|
|
47
|
+
allowedModes: [],
|
|
48
|
+
jsBaselineBytes: 0,
|
|
49
|
+
mayImport: ['shared'],
|
|
50
|
+
mayImportTypes: ['shared'],
|
|
51
|
+
},
|
|
52
|
+
shared: {
|
|
53
|
+
surface: 'shared',
|
|
54
|
+
defaultMode: null,
|
|
55
|
+
allowedModes: [],
|
|
56
|
+
jsBaselineBytes: 0,
|
|
57
|
+
mayImport: [],
|
|
58
|
+
mayImportTypes: [],
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const SURFACE_SEGMENT = /(?:^|\/)(site|app|api|shared)\//;
|
|
63
|
+
|
|
64
|
+
/** `apps/web/site/pricing/page.tsx` → `site`. Returns null for files outside a surface. */
|
|
65
|
+
export function surfaceOf(file: string): Surface | null {
|
|
66
|
+
const match = SURFACE_SEGMENT.exec(normalize(file));
|
|
67
|
+
const found = match?.[1];
|
|
68
|
+
return found === undefined ? null : (found as Surface);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function normalize(file: string): string {
|
|
72
|
+
return file.replace(/\\/g, '/').replace(/^\.\//, '');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface ImportRef {
|
|
76
|
+
readonly file: string;
|
|
77
|
+
/** `import type` edges cost zero bytes, so they never carry the surface boundary. */
|
|
78
|
+
readonly type: boolean;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export type ImportGraph = ReadonlyMap<string, readonly ImportRef[]>;
|
|
82
|
+
|
|
83
|
+
/** Build a graph from a plain record; a bare string is a runtime (value) import. */
|
|
84
|
+
export function importGraph(
|
|
85
|
+
record: Readonly<Record<string, readonly (string | ImportRef)[]>>,
|
|
86
|
+
): ImportGraph {
|
|
87
|
+
const graph = new Map<string, readonly ImportRef[]>();
|
|
88
|
+
for (const [file, imports] of Object.entries(record)) {
|
|
89
|
+
graph.set(
|
|
90
|
+
normalize(file),
|
|
91
|
+
imports.map((i) =>
|
|
92
|
+
typeof i === 'string'
|
|
93
|
+
? { file: normalize(i), type: false }
|
|
94
|
+
: { ...i, file: normalize(i.file) },
|
|
95
|
+
),
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
return graph;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export type BoundaryRule = 'site-imports-app' | 'shared-is-a-leaf' | 'app-imports-api-at-runtime';
|
|
102
|
+
|
|
103
|
+
export interface BoundaryViolation {
|
|
104
|
+
readonly rule: BoundaryRule;
|
|
105
|
+
/** The file the traversal started from — the reviewed file. */
|
|
106
|
+
readonly entry: string;
|
|
107
|
+
/** The file that actually writes the offending import. */
|
|
108
|
+
readonly importer: string;
|
|
109
|
+
/** The file it imports. */
|
|
110
|
+
readonly imported: string;
|
|
111
|
+
/** Full resolved chain, `entry → … → imported`. */
|
|
112
|
+
readonly chain: readonly string[];
|
|
113
|
+
readonly cause: string;
|
|
114
|
+
readonly fix: string;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Walk value-import edges from every file and report every crossing of the boundary.
|
|
119
|
+
* Transitivity is the whole point: the import that costs you is three hops away from the
|
|
120
|
+
* file anyone reviewed, so a direct-imports-only check finds nothing.
|
|
121
|
+
*/
|
|
122
|
+
export function checkSurfaceBoundary(graph: ImportGraph): readonly BoundaryViolation[] {
|
|
123
|
+
const found = new Map<string, BoundaryViolation>();
|
|
124
|
+
|
|
125
|
+
for (const entry of graph.keys()) {
|
|
126
|
+
const entrySurface = surfaceOf(entry);
|
|
127
|
+
if (entrySurface === null) continue;
|
|
128
|
+
walk(graph, entry, entrySurface, found);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return [...found.values()].sort((a, b) => keyOf(a).localeCompare(keyOf(b)));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function keyOf(v: BoundaryViolation): string {
|
|
135
|
+
return `${v.rule}|${v.importer}|${v.imported}`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function walk(
|
|
139
|
+
graph: ImportGraph,
|
|
140
|
+
entry: string,
|
|
141
|
+
entrySurface: Surface,
|
|
142
|
+
found: Map<string, BoundaryViolation>,
|
|
143
|
+
): void {
|
|
144
|
+
const seen = new Set<string>([entry]);
|
|
145
|
+
const queue: string[][] = [[entry]];
|
|
146
|
+
|
|
147
|
+
while (queue.length > 0) {
|
|
148
|
+
const chain = queue.shift();
|
|
149
|
+
if (chain === undefined) break;
|
|
150
|
+
const current = chain[chain.length - 1];
|
|
151
|
+
if (current === undefined) continue;
|
|
152
|
+
|
|
153
|
+
for (const ref of graph.get(current) ?? []) {
|
|
154
|
+
const importerSurface = surfaceOf(current);
|
|
155
|
+
const importedSurface = surfaceOf(ref.file);
|
|
156
|
+
const nextChain = [...chain, ref.file];
|
|
157
|
+
|
|
158
|
+
const violation = classify({
|
|
159
|
+
entry,
|
|
160
|
+
entrySurface,
|
|
161
|
+
importer: current,
|
|
162
|
+
importerSurface,
|
|
163
|
+
imported: ref.file,
|
|
164
|
+
importedSurface,
|
|
165
|
+
typeOnly: ref.type,
|
|
166
|
+
chain: nextChain,
|
|
167
|
+
});
|
|
168
|
+
if (violation !== null) found.set(keyOf(violation), violation);
|
|
169
|
+
|
|
170
|
+
// Type-only edges are erased at build time, so they cannot carry bytes onward.
|
|
171
|
+
if (ref.type) continue;
|
|
172
|
+
if (seen.has(ref.file)) continue;
|
|
173
|
+
seen.add(ref.file);
|
|
174
|
+
queue.push(nextChain);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
interface ClassifyInput {
|
|
180
|
+
readonly entry: string;
|
|
181
|
+
readonly entrySurface: Surface;
|
|
182
|
+
readonly importer: string;
|
|
183
|
+
readonly importerSurface: Surface | null;
|
|
184
|
+
readonly imported: string;
|
|
185
|
+
readonly importedSurface: Surface | null;
|
|
186
|
+
readonly typeOnly: boolean;
|
|
187
|
+
readonly chain: readonly string[];
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function classify(i: ClassifyInput): BoundaryViolation | null {
|
|
191
|
+
const chainText = i.chain.join(' → ');
|
|
192
|
+
|
|
193
|
+
if (i.entrySurface === 'site' && i.importedSurface === 'app' && !i.typeOnly) {
|
|
194
|
+
return {
|
|
195
|
+
rule: 'site-imports-app',
|
|
196
|
+
entry: i.entry,
|
|
197
|
+
importer: i.importer,
|
|
198
|
+
imported: i.imported,
|
|
199
|
+
chain: i.chain,
|
|
200
|
+
cause: chainText,
|
|
201
|
+
fix: `x fix boundary ${i.entry} (or move ${i.imported} out of the shared graph)`,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (
|
|
206
|
+
i.importerSurface === 'shared' &&
|
|
207
|
+
(i.importedSurface === 'app' || i.importedSurface === 'site') &&
|
|
208
|
+
!i.typeOnly
|
|
209
|
+
) {
|
|
210
|
+
return {
|
|
211
|
+
rule: 'shared-is-a-leaf',
|
|
212
|
+
entry: i.entry,
|
|
213
|
+
importer: i.importer,
|
|
214
|
+
imported: i.imported,
|
|
215
|
+
chain: i.chain,
|
|
216
|
+
cause: `${chainText} (shared/ is a leaf — it may not import a surface)`,
|
|
217
|
+
fix: `move the shared part of ${i.imported} into shared/ and import it from ${i.importer}`,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (i.importerSurface === 'app' && i.importedSurface === 'api' && !i.typeOnly) {
|
|
222
|
+
return {
|
|
223
|
+
rule: 'app-imports-api-at-runtime',
|
|
224
|
+
entry: i.entry,
|
|
225
|
+
importer: i.importer,
|
|
226
|
+
imported: i.imported,
|
|
227
|
+
chain: i.chain,
|
|
228
|
+
cause: `${chainText} (app/ → api/ is types-only)`,
|
|
229
|
+
fix: `change to \`import type\` in ${i.importer} and call the typed client instead`,
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return null;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Build-time gate. `x verify` and the dev server both call this. */
|
|
237
|
+
export function assertSurfaceBoundary(graph: ImportGraph): void {
|
|
238
|
+
const violations = checkSurfaceBoundary(graph);
|
|
239
|
+
const first = violations[0];
|
|
240
|
+
if (first === undefined) return;
|
|
241
|
+
|
|
242
|
+
const extra = violations.length > 1 ? ` (+${violations.length - 1} more)` : '';
|
|
243
|
+
throw new SurfaceBoundaryError(`${first.cause}${extra}`, first.fix);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export function surfaceAllows(surface: Surface, mode: RenderMode): boolean {
|
|
247
|
+
return SURFACE_SPECS[surface].allowedModes.includes(mode);
|
|
248
|
+
}
|