@ultimat3/render 5.0.0 → 6.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/CLAUDE.md +9 -8
- package/README.md +10 -9
- package/package.json +5 -5
- package/src/index.ts +1 -16
- package/src/modes.ts +4 -27
- package/src/registry.ts +3 -3
- package/src/render-html.ts +7 -0
- package/src/route.ts +1 -1
- package/src/surfaces.ts +1 -1
- package/src/render-spa.ts +0 -79
- package/src/router-client.ts +0 -236
package/CLAUDE.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# @ultimat3/render — boundary
|
|
2
2
|
|
|
3
|
-
Owns: the `route` primitive, the
|
|
4
|
-
islands + budgets, hydration directives,
|
|
5
|
-
|
|
3
|
+
Owns: the `route` primitive, the four render modes, the route table, the surface boundary,
|
|
4
|
+
islands + budgets, hydration directives, `<head>` merge, **the server JSX runtime and the two Bun
|
|
5
|
+
loaders that make an app's `.tsx` and `.scss` runnable**.
|
|
6
6
|
|
|
7
7
|
`island()` is a **factory over the route's own `hydrate`**, not a ninth primitive and not a
|
|
8
8
|
second render mode — the same rule `llm()` and `backfill()` follow. It adds no key to
|
|
@@ -24,7 +24,7 @@ Tier 4. May import tiers 0–3: `core`, `schema`, `i18n`, `money`, `time`, `cach
|
|
|
24
24
|
| Descriptor `meta` / `load` | always `(x) => Promise<…>`. Authors may declare either sync; consumers never branch. |
|
|
25
25
|
| Descriptor `budget` | always an object, `{}` when undeclared. Its *fields* stay optional — `budget.js === undefined` is the site/ hydration failure. |
|
|
26
26
|
| No `describe()` on a route | `describeRoutes()` is the one route list. A per-route projection would be a second one. |
|
|
27
|
-
| Mode invariants | `modes.ts` only. Never inline a mode check in a render-\* file. |
|
|
27
|
+
| Mode invariants | `modes.ts` only. Never inline a mode check in a render-\* file. **Four modes, and every one renders the route's component** — `spa` did not, and `renderSpa` never read `entry.component`, so a `spa` route served `<div id="x-root"></div>` in every app that ever declared one. A gated page is `ssr`; a body that belongs in the browser is an `island({ src })`. A fifth mode has to name the function that renders it. |
|
|
28
28
|
| Island declaration | `island({ src })` — a **specifier**, never an import. That is the whole boundary: a string has no scope to close over and no edge for a bundler or `checkSurfaceBoundary` to follow, so a `static` page's graph cannot grow the island's dependencies. Never add an overload that takes a component. |
|
|
29
29
|
| Island filename | `*.island.tsx`, `ISLAND_EXTENSION` — one spelling, same rule as `page.tsx`: a module ships to the browser iff its name says so. Never widen it to accept a second, and never make it optional. |
|
|
30
30
|
| Island timing | the route's `hydrate` and nothing else — derived from the declaration, never declared on the island. An island declaring its own strategy would be a second way to say "this route hydrates" (axiom 1) and a second thing `budget.js` would have to chase. `hydrate: 'never'` + an island is still `X_ISLAND_NOT_HYDRATED`, and so is an `island()` call *below* the `defineRoute` that drains it. The `fix:` names exactly ONE of the two edits — `islandNeverDrained(spec)` tells the causes apart at the throw site, and a message offering both makes half the instruction wrong for every reader. |
|
|
@@ -52,17 +52,18 @@ Tier 4. May import tiers 0–3: `core`, `schema`, `i18n`, `money`, `time`, `cach
|
|
|
52
52
|
| `X_ROUTE_LOAD_FAILED` computes its pathname BEFORE the try | `new URL(ctx.url)` inside the catch made a relative `ctx.url` — what a prerender pass, `x build` and every test harness hand in — throw a bare `TypeError` on the one path whose job is a coded error. `pathnameOf` never throws. |
|
|
53
53
|
| ISR store bound | `memoryIsrStore()` caps at `DEFAULT_ISR_MAX_ENTRIES` (1,000), least recently generated first — a route table supports `:params` and `*`, so `/blog/:slug` retains one full HTML string per slug ever requested, 404-shaped ones included. |
|
|
54
54
|
| Route path from file | ONE reader of the surface segment: `locateSurface()` answers which surface AND where it starts. `registry.ts` sliced at `indexOf('app/')` instead, which matched inside `myapp/`, so every route under `apps/myapp/app/` served at `/app/…`. Never re-derive the offset from the surface NAME. |
|
|
55
|
-
| An undecodable path segment | not a match, never a throw
|
|
55
|
+
| An undecodable path segment | not a match, never a throw — `decodeSegment` in `registry.ts` is the one reader. `decodeURIComponent('%zz')` is a bare `URIError`, so a typo in a path segment was a 500 where `@ultimat3/http`'s router already answers "this branch does not match". A literal route matching the same text still wins. `router-client.ts` was the second reader and went with `createRouter`. |
|
|
56
56
|
| ISR registration | reconciled against `store.paths()` after every generation (`forgetEvictedPaths`). The store is bounded and evicts silently; `registered` and the cache graph behind it only ever grew, one edge per slug ever requested. Never a store callback — a custom `IsrStore` has none. |
|
|
57
57
|
| Stream hole deadline | `DEFAULT_HOLE_TIMEOUT_MS` (15s), `holeTimeoutMs: null` to opt out. A hole is app code the framework `await`s and nothing else bounds it; one that never settles held the response and its whole closure open for the life of the process. A hole reveals exactly once — its promise, its rejection, or its deadline, whichever lands first. |
|
|
58
58
|
| Errors | `errors.ts` subclasses only. Never a bare `Error`, never a bare `TODO`. |
|
|
59
59
|
| Policy | render checks *presence* only. Evaluation belongs to `@ultimat3/policy`. |
|
|
60
|
-
| A gated route is never a cached one | `modes.ts` refuses `policy` on BOTH `static` and `isr` (`X_ROUTE_MODE_INVALID`, `modes.test.ts`). `isr` was not refused until 2026-08 and `dev-render.ts` keys the cache on `url.pathname` alone — no actor, no query string — so a gated ISR route rendered the first actor's document and served it to every later actor who passed the same policy. Keying on more is a trap, not a fix: the key would have to enumerate everything a `policy` and a `load` can read. `ssr` is
|
|
60
|
+
| A gated route is never a cached one | `modes.ts` refuses `policy` on BOTH `static` and `isr` (`X_ROUTE_MODE_INVALID`, `modes.test.ts`). `isr` was not refused until 2026-08 and `dev-render.ts` keys the cache on `url.pathname` alone — no actor, no query string — so a gated ISR route rendered the first actor's document and served it to every later actor who passed the same policy. Keying on more is a trap, not a fix: the key would have to enumerate everything a `policy` and a `load` can read. `ssr` is the one gated mode there is. |
|
|
61
61
|
| Responses | return `RenderResult`. `@ultimat3/http` builds the `Response`. |
|
|
62
|
-
| Solid | no `solid-js` import anywhere in this package — `type-pins.tsx` satisfies its `JSX.Element` structurally, through `jsxImportSource`, and never names it.
|
|
62
|
+
| Solid | no `solid-js` import anywhere in this package — `type-pins.tsx` satisfies its `JSX.Element` structurally, through `jsxImportSource`, and never names it. The JSX factory in `jsx.ts` builds inert nodes — it is not a Solid renderer and must never become one. The client half runs in an island chunk, which `@ultimat3/cli`'s `solid-loader.ts` compiles with `babel-preset-solid`: Solid's reactivity is a COMPILE-time contract, so nothing this package could inject would substitute for it. `router-client.ts` was the one file built on that premise ("inject primitives") and it never had a caller. |
|
|
63
|
+
| Root element | `ROOT_ELEMENT_ID` (`render-html.ts`), the id every document's body wraps its component in. It was `SPA_ROOT_ID` in `render-spa.ts`, naming a mode that never used it and that no longer exists. |
|
|
63
64
|
| The loaders | `module-loader.ts` installs them at `index.ts` module scope, once. A plugin only affects modules loaded after it, so a second install point is a page that renders in one entry point and not another. |
|
|
64
65
|
| `<head>` baseline | `documentBaseline()` in `head.ts` — charset, viewport, `color-scheme` — merged FIRST so a route can still override any of them. Absent until `As of 2026-08`, and the missing `viewport` is why every deployed app rendered zoomed-out on a phone whatever its CSS said. |
|
|
65
|
-
| Escaping | `html.ts` only, and that now includes `render-stream.ts` (`holeMarker`'s attribute, and `revealChunk`'s `$X(...)` argument via `JSON.stringify` — an id containing `")` closed the call and ran the rest)
|
|
66
|
+
| Escaping | `html.ts` only, and that now includes `render-stream.ts` (`holeMarker`'s attribute, and `revealChunk`'s `$X(...)` argument via `JSON.stringify` — an id containing `")` closed the call and ran the rest) and `head.ts`'s `themeScript` (`storageKey`/`attribute` as JS string LITERALS). All author-controlled today — the identical status `emitIslandAttributes` had before the last sweep. `render-spa.ts` was the third entry here and went with the mode. A second escaper is how one of them ends up missing a character, and a missing character in an attribute is an injection. `head.ts` and `hydrate.ts` each had a private copy; both now import — `escapeAttribute` for every attribute value (`emitIslandAttributes` interpolated all five of its own raw until 2026-08, while this row already claimed otherwise) and `escapeJsonContent` for a JSON script body. |
|
|
66
67
|
| Script and style CONTENT | never emitted raw. Three rules, one choice: HTML text (`escapeText`), raw text for code (`escapeRawTextContent`: `</` → `<\/`, `<!--` → `<\!--`), and the total JSON rule for a `type` ending in `json` (`escapeJsonContent`: `<`, `>`, `&`, U+2028/9 → `\uXXXX`, still valid JSON). `meta.ld` is built from route data, and it was emitted VERBATIM until `As of 2026-08` — a title could close the element. Never HTML-escape a script body: a character reference is not decoded there, so `<` corrupts the code AND leaves the hole. |
|
|
67
68
|
| Which export is the page | `route-component.ts`, one precedence: `Page` → a single `…Page` → a single capitalised function. Never a per-generator name table. |
|
|
68
69
|
| Stylesheets | compiled by `css-modules.ts` and served **inlined** per surface. `sass` is this package's only third-party dependency and its only reason to exist here. |
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# 🖼 @ultimat3/render
|
|
2
2
|
|
|
3
|
-
The `route` primitive and the
|
|
3
|
+
The `route` primitive and the four render modes.
|
|
4
4
|
|
|
5
5
|
| Mode | Behavior | Use |
|
|
6
6
|
|---|---|---|
|
|
@@ -8,11 +8,10 @@ The `route` primitive and the five render modes.
|
|
|
8
8
|
| `isr` | static + background regen on tag/TTL | catalogs, profiles |
|
|
9
9
|
| `ssr` | per-request full render | fresh SEO pages |
|
|
10
10
|
| `stream` | static shell flushed instantly, holes streamed | **default for app pages** |
|
|
11
|
-
| `spa` | shell only, client fetches | dashboards behind auth |
|
|
12
11
|
|
|
13
12
|
```ts
|
|
14
13
|
export const config = defineRoute({
|
|
15
|
-
render: 'isr', // static | isr | ssr | stream
|
|
14
|
+
render: 'isr', // static | isr | ssr | stream
|
|
16
15
|
revalidate: { tags: [tag.post] },
|
|
17
16
|
prerender: () => db.posts.slugs(),
|
|
18
17
|
offline: 'precache', // precache | runtime | network-only
|
|
@@ -104,9 +103,8 @@ a hydrating `site/` route below.
|
|
|
104
103
|
| `isr` | needs a trigger: `revalidate.tags` or `revalidate.ttl`; **no `policy`** — one cached document per URL cannot answer two actors | `X_ROUTE_MODE_INVALID` |
|
|
105
104
|
| `ssr` | cannot be prerendered | `X_ROUTE_MODE_INVALID` |
|
|
106
105
|
| `stream` | at least one `<Suspense>` boundary | `X_ROUTE_MODE_INVALID` |
|
|
107
|
-
| `spa` | requires a `policy` (authed dashboards only) | `X_ROUTE_MODE_INVALID` |
|
|
108
106
|
|
|
109
|
-
Plus surface rules: `site/` allows `static | isr | ssr`, `app/` allows `stream |
|
|
107
|
+
Plus surface rules: `site/` allows `static | isr | ssr`, `app/` allows `stream | ssr`,
|
|
110
108
|
`api/` renders nothing, and a `site/` route that opts into hydration without a `budget.js`
|
|
111
109
|
is a build error.
|
|
112
110
|
|
|
@@ -284,10 +282,9 @@ would paper over the contradiction.
|
|
|
284
282
|
| `checkSurfaceBoundary`, `assertSurfaceBoundary`, `surfaceOf` | the hard boundary |
|
|
285
283
|
| `renderStatic`, `enumeratePrerender` | build-time render, content hashing |
|
|
286
284
|
| `createIsrController`, `invalidateAndRevalidate` | SWR + single-flight + tag triggers |
|
|
287
|
-
| `renderSsr`, `streamResult
|
|
285
|
+
| `renderSsr`, `streamResult` | the per-request modes |
|
|
288
286
|
| `emitIslandAttributes`, `hydrateRuntime` | the four hydration strategies |
|
|
289
287
|
| `graphFor`, `checkBudget`, `assertBudget` | two bundle graphs, per-route budgets |
|
|
290
|
-
| `createRouter` | the vendored client router |
|
|
291
288
|
| `mergeHead`, `renderHead`, `themeScript` | `<head>` merge + the one inlined script |
|
|
292
289
|
|
|
293
290
|
## Notes
|
|
@@ -311,7 +308,11 @@ would paper over the contradiction.
|
|
|
311
308
|
- **`hydrate: 'never'`** emits no attributes beyond the marker and no runtime — the `site/`
|
|
312
309
|
0kb default is mechanical, not aspirational. A page that renders an island anyway is
|
|
313
310
|
`X_ISLAND_NOT_HYDRATED`, not a silently dead button.
|
|
314
|
-
- **
|
|
315
|
-
|
|
311
|
+
- **A gated page is `ssr`**, never a client-rendered shell. `spa` and `createRouter` were
|
|
312
|
+
deleted `As of 2026-08-20`: `renderSpa` never read the route's component and no build ever produced the
|
|
313
|
+
`chunks` it preloaded, so every `spa` route served an empty `<div id="x-root">`, and the router
|
|
314
|
+
that shell would have needed had no caller in the framework or in either tracked app. A page
|
|
315
|
+
whose body belongs in the browser declares `island({ src })` — one interactivity model, one
|
|
316
|
+
bundler entry point, one budget measured against real bytes.
|
|
316
317
|
- `@ultimat3/http`'s `html()` / `stream()` turn a `RenderResult` into a `Response`; render
|
|
317
318
|
never constructs one.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/render",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "6.0.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",
|
|
@@ -31,10 +31,10 @@
|
|
|
31
31
|
"test": "bun test"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@ultimat3/cache": "
|
|
35
|
-
"@ultimat3/core": "
|
|
36
|
-
"@ultimat3/i18n": "
|
|
37
|
-
"@ultimat3/seo": "
|
|
34
|
+
"@ultimat3/cache": "6.0.0",
|
|
35
|
+
"@ultimat3/core": "6.0.0",
|
|
36
|
+
"@ultimat3/i18n": "6.0.0",
|
|
37
|
+
"@ultimat3/seo": "6.0.0",
|
|
38
38
|
"sass": "1.102.0"
|
|
39
39
|
}
|
|
40
40
|
}
|
package/src/index.ts
CHANGED
|
@@ -120,7 +120,7 @@ export {
|
|
|
120
120
|
routePathFromFile,
|
|
121
121
|
} from './registry';
|
|
122
122
|
export type { RenderHtmlOptions } from './render-html';
|
|
123
|
-
export { renderComponent, renderToHtml } from './render-html';
|
|
123
|
+
export { ROOT_ELEMENT_ID, renderComponent, renderToHtml } from './render-html';
|
|
124
124
|
export type {
|
|
125
125
|
IsrController,
|
|
126
126
|
IsrControllerOptions,
|
|
@@ -138,8 +138,6 @@ export {
|
|
|
138
138
|
isrKey,
|
|
139
139
|
memoryIsrStore,
|
|
140
140
|
} from './render-isr';
|
|
141
|
-
export type { SpaShell, SpaShellInput } from './render-spa';
|
|
142
|
-
export { renderSpa, renderSpaShell, SPA_ROOT_ID } from './render-spa';
|
|
143
141
|
export type { SsrOptions, SsrRenderFn, SsrRenderInput } from './render-ssr';
|
|
144
142
|
export { renderSsr, ssrHeaders } from './render-ssr';
|
|
145
143
|
export type { StaticArtifact, StaticBuildOptions, StaticRenderFn } from './render-static';
|
|
@@ -195,19 +193,6 @@ export {
|
|
|
195
193
|
export type { RouteComponent } from './route-component';
|
|
196
194
|
export { pageComponentOf } from './route-component';
|
|
197
195
|
export { metaContextFor, routeDataFor } from './route-data';
|
|
198
|
-
export type {
|
|
199
|
-
NavigateOptions,
|
|
200
|
-
NavigationGuard,
|
|
201
|
-
PrefetchContainer,
|
|
202
|
-
PrefetchLink,
|
|
203
|
-
ReactivePrimitives,
|
|
204
|
-
ResolvedRoute,
|
|
205
|
-
Router,
|
|
206
|
-
RouterHost,
|
|
207
|
-
RouterOptions,
|
|
208
|
-
RouterRoute,
|
|
209
|
-
} from './router-client';
|
|
210
|
-
export { createRouter } from './router-client';
|
|
211
196
|
export type {
|
|
212
197
|
BoundaryRule,
|
|
213
198
|
BoundaryViolation,
|
package/src/modes.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The
|
|
2
|
+
* The four render modes as a table of invariants, checked at registration.
|
|
3
3
|
* Each mode has properties the framework can rely on; a config that contradicts one is
|
|
4
4
|
* rejected with `X_ROUTE_MODE_INVALID` and the exact edit that fixes it. A mode whose
|
|
5
5
|
* invariant is only documented is a mode that silently degrades in production.
|
|
@@ -12,7 +12,7 @@ import { HYDRATE_STRATEGIES } from './route';
|
|
|
12
12
|
import type { Surface } from './surfaces';
|
|
13
13
|
import { SURFACE_SPECS, surfaceAllows } from './surfaces';
|
|
14
14
|
|
|
15
|
-
export const RENDER_MODES = ['static', 'isr', 'ssr', 'stream'
|
|
15
|
+
export const RENDER_MODES = ['static', 'isr', 'ssr', 'stream'] as const;
|
|
16
16
|
|
|
17
17
|
/**
|
|
18
18
|
* Everything about a route except the two keys carrying its data generic. Mode invariants never
|
|
@@ -35,8 +35,6 @@ export interface ModeSpec {
|
|
|
35
35
|
readonly needsRevalidate: boolean;
|
|
36
36
|
/** Does the mode need at least one `<Suspense>` boundary to mean anything? */
|
|
37
37
|
readonly needsSuspense: boolean;
|
|
38
|
-
/** Does the mode need a `policy` (it renders no data, so the shell must be gated)? */
|
|
39
|
-
readonly needsPolicy: boolean;
|
|
40
38
|
readonly description: string;
|
|
41
39
|
}
|
|
42
40
|
|
|
@@ -47,7 +45,6 @@ export const MODE_SPECS: Readonly<Record<RenderMode, ModeSpec>> = Object.freeze(
|
|
|
47
45
|
prerenderable: true,
|
|
48
46
|
needsRevalidate: false,
|
|
49
47
|
needsSuspense: false,
|
|
50
|
-
needsPolicy: false,
|
|
51
48
|
description: 'built once, served as a file',
|
|
52
49
|
},
|
|
53
50
|
isr: {
|
|
@@ -56,7 +53,6 @@ export const MODE_SPECS: Readonly<Record<RenderMode, ModeSpec>> = Object.freeze(
|
|
|
56
53
|
prerenderable: true,
|
|
57
54
|
needsRevalidate: true,
|
|
58
55
|
needsSuspense: false,
|
|
59
|
-
needsPolicy: false,
|
|
60
56
|
description: 'static + background regen on tag/TTL',
|
|
61
57
|
},
|
|
62
58
|
ssr: {
|
|
@@ -65,7 +61,6 @@ export const MODE_SPECS: Readonly<Record<RenderMode, ModeSpec>> = Object.freeze(
|
|
|
65
61
|
prerenderable: false,
|
|
66
62
|
needsRevalidate: false,
|
|
67
63
|
needsSuspense: false,
|
|
68
|
-
needsPolicy: false,
|
|
69
64
|
description: 'per-request full render',
|
|
70
65
|
},
|
|
71
66
|
stream: {
|
|
@@ -74,18 +69,8 @@ export const MODE_SPECS: Readonly<Record<RenderMode, ModeSpec>> = Object.freeze(
|
|
|
74
69
|
prerenderable: false,
|
|
75
70
|
needsRevalidate: false,
|
|
76
71
|
needsSuspense: true,
|
|
77
|
-
needsPolicy: false,
|
|
78
72
|
description: 'static shell flushed instantly, holes streamed',
|
|
79
73
|
},
|
|
80
|
-
spa: {
|
|
81
|
-
mode: 'spa',
|
|
82
|
-
perRequestState: false,
|
|
83
|
-
prerenderable: true,
|
|
84
|
-
needsRevalidate: false,
|
|
85
|
-
needsSuspense: false,
|
|
86
|
-
needsPolicy: true,
|
|
87
|
-
description: 'shell only, client fetches',
|
|
88
|
-
},
|
|
89
74
|
});
|
|
90
75
|
|
|
91
76
|
/** Mode-local checks that need nothing but the config. Called by `defineRoute`. */
|
|
@@ -116,7 +101,7 @@ export function assertModeShape(config: RouteShape): void {
|
|
|
116
101
|
throw new RouteModeInvalidError(
|
|
117
102
|
"render: 'static' cannot read per-request state, but a `policy` was declared " +
|
|
118
103
|
`(${config.policy.permission} needs an actor)`,
|
|
119
|
-
"change render to 'ssr'
|
|
104
|
+
"change render to 'ssr' — per-request and gated — or drop the policy",
|
|
120
105
|
);
|
|
121
106
|
}
|
|
122
107
|
if (config.render === 'static' && config.revalidate !== undefined) {
|
|
@@ -135,7 +120,7 @@ export function assertModeShape(config: RouteShape): void {
|
|
|
135
120
|
throw new RouteModeInvalidError(
|
|
136
121
|
"render: 'isr' caches one document per URL and cannot be gated, but a `policy` was " +
|
|
137
122
|
`declared (${config.policy.permission} varies the answer per actor)`,
|
|
138
|
-
"change render to 'ssr'
|
|
123
|
+
"change render to 'ssr' — per-request and gated — or drop the policy",
|
|
139
124
|
);
|
|
140
125
|
}
|
|
141
126
|
|
|
@@ -154,14 +139,6 @@ export function assertModeShape(config: RouteShape): void {
|
|
|
154
139
|
"change render to 'isr' to prerender and regenerate, or remove prerender",
|
|
155
140
|
);
|
|
156
141
|
}
|
|
157
|
-
|
|
158
|
-
// spa: the shell carries no data, so authz has to live on the route itself.
|
|
159
|
-
if (config.render === 'spa' && config.policy === undefined) {
|
|
160
|
-
throw new RouteModeInvalidError(
|
|
161
|
-
"render: 'spa' ships a shell with no server-rendered data and requires a `policy`",
|
|
162
|
-
"add policy: can('dashboard:read') — or use 'stream' for a public page",
|
|
163
|
-
);
|
|
164
|
-
}
|
|
165
142
|
}
|
|
166
143
|
|
|
167
144
|
function hasRevalidateTrigger(config: RouteShape): boolean {
|
package/src/registry.ts
CHANGED
|
@@ -370,9 +370,9 @@ export function matchRoute(pathname: string): RouteMatch | null {
|
|
|
370
370
|
* `decodeURIComponent('%zz')` throws a bare `URIError` — no code, no fix line — which escaped
|
|
371
371
|
* `matchRoute` as a 500 and an error-monitor page for somebody's typo.
|
|
372
372
|
*
|
|
373
|
-
*
|
|
374
|
-
*
|
|
375
|
-
*
|
|
373
|
+
* Still exported after `router-client.ts` went with `createRouter`: it is the one answer to "is
|
|
374
|
+
* this segment decodable?" on this side of the wire, and a second copy of it is how one of the two
|
|
375
|
+
* ends up throwing where the other 404s.
|
|
376
376
|
*/
|
|
377
377
|
export function decodeSegment(value: string): string | undefined {
|
|
378
378
|
try {
|
package/src/render-html.ts
CHANGED
|
@@ -20,6 +20,13 @@ import { islandWithoutCollector } from './island-collector';
|
|
|
20
20
|
import type { JsxComponent, JsxProps } from './jsx';
|
|
21
21
|
import { isJsxNode } from './jsx';
|
|
22
22
|
|
|
23
|
+
/**
|
|
24
|
+
* The id of the element every document wraps its route component in — one name, because a service
|
|
25
|
+
* worker, a test and a client entry all address the same element. It was `SPA_ROOT_ID` in
|
|
26
|
+
* `render-spa.ts` and named a mode that no longer exists, while every mode has always used it.
|
|
27
|
+
*/
|
|
28
|
+
export const ROOT_ELEMENT_ID = 'x-root';
|
|
29
|
+
|
|
23
30
|
/** Depth is bounded so a component that renders itself fails with a cause instead of a stack trace. */
|
|
24
31
|
const MAX_DEPTH = 500;
|
|
25
32
|
|
package/src/route.ts
CHANGED
|
@@ -21,7 +21,7 @@ import type { IslandSpec } from './island';
|
|
|
21
21
|
import { drainDeclaredIslands } from './island';
|
|
22
22
|
import { assertModeShape } from './modes';
|
|
23
23
|
|
|
24
|
-
export type RenderMode = 'static' | 'isr' | 'ssr' | 'stream'
|
|
24
|
+
export type RenderMode = 'static' | 'isr' | 'ssr' | 'stream';
|
|
25
25
|
export type OfflineStrategy = 'precache' | 'runtime' | 'network-only';
|
|
26
26
|
export type HydrateStrategy = 'idle' | 'visible' | 'interaction' | 'never';
|
|
27
27
|
|
package/src/surfaces.ts
CHANGED
|
@@ -36,7 +36,7 @@ export const SURFACE_SPECS: Readonly<Record<Surface, SurfaceSpec>> = Object.free
|
|
|
36
36
|
app: {
|
|
37
37
|
surface: 'app',
|
|
38
38
|
defaultMode: 'stream',
|
|
39
|
-
allowedModes: ['stream', '
|
|
39
|
+
allowedModes: ['stream', 'ssr'],
|
|
40
40
|
jsBaselineBytes: 14_336,
|
|
41
41
|
mayImport: ['shared'],
|
|
42
42
|
mayImportTypes: ['shared', 'api'],
|
package/src/render-spa.ts
DELETED
|
@@ -1,79 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* `spa` — shell only, client fetches everything. For dashboards behind auth, where the
|
|
3
|
-
* shell is identical for every actor and therefore cacheable, and the data is not.
|
|
4
|
-
* `modes.ts` requires a `policy` on this mode: nothing is server-rendered, so the route
|
|
5
|
-
* itself is the only place authz can live.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import { RouteModeInvalidError } from './errors';
|
|
9
|
-
import { escapeAttribute } from './html';
|
|
10
|
-
import type { RouteEntry } from './registry';
|
|
11
|
-
import { contentHash } from './render-static';
|
|
12
|
-
import type { RenderResult } from './route';
|
|
13
|
-
|
|
14
|
-
export const SPA_ROOT_ID = 'x-root';
|
|
15
|
-
|
|
16
|
-
export interface SpaShellInput {
|
|
17
|
-
readonly entry: RouteEntry;
|
|
18
|
-
readonly buildId: string;
|
|
19
|
-
/** Everything inside `<head>`, already merged by `head.ts`. */
|
|
20
|
-
readonly head: string;
|
|
21
|
-
/** Build-id-immutable chunk URLs to preload; order is preserved for determinism. */
|
|
22
|
-
readonly chunks: readonly string[];
|
|
23
|
-
readonly rootId?: string;
|
|
24
|
-
readonly lang: string;
|
|
25
|
-
readonly dir?: 'ltr' | 'rtl';
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export interface SpaShell {
|
|
29
|
-
readonly html: string;
|
|
30
|
-
readonly hash: string;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
export function renderSpaShell(input: SpaShellInput): SpaShell {
|
|
34
|
-
if (input.entry.config.policy === undefined) {
|
|
35
|
-
throw new RouteModeInvalidError(
|
|
36
|
-
`${input.entry.file} declares render: 'spa' with no policy, so the shell would be public`,
|
|
37
|
-
`add policy: can('…') to ${input.entry.file}`,
|
|
38
|
-
);
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
// Every attribute value through `html.ts`'s ONE escaper — the package's own escaping rule, and
|
|
42
|
-
// the same repair `emitIslandAttributes` took. `head` is the exception by construction: it is
|
|
43
|
-
// already-merged markup from `head.ts`, which escaped it on the way in.
|
|
44
|
-
const rootId = escapeAttribute(input.rootId ?? SPA_ROOT_ID);
|
|
45
|
-
const preloads = input.chunks
|
|
46
|
-
.map((chunk) => `<link rel="modulepreload" href="${escapeAttribute(chunk)}">`)
|
|
47
|
-
.join('');
|
|
48
|
-
const scripts = input.chunks
|
|
49
|
-
.map((chunk) => `<script type="module" src="${escapeAttribute(chunk)}"></script>`)
|
|
50
|
-
.join('');
|
|
51
|
-
|
|
52
|
-
const html =
|
|
53
|
-
`<!doctype html><html lang="${escapeAttribute(input.lang)}" ` +
|
|
54
|
-
`dir="${escapeAttribute(input.dir ?? 'ltr')}">` +
|
|
55
|
-
`<head>${input.head}${preloads}` +
|
|
56
|
-
`<meta name="x-ultimate-build" content="${escapeAttribute(input.buildId)}">` +
|
|
57
|
-
`</head><body><div id="${rootId}"></div>${scripts}</body></html>`;
|
|
58
|
-
|
|
59
|
-
return { html, hash: contentHash(html) };
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
/**
|
|
63
|
-
* The shell is identical for every actor, so it is cache-first in `sw.js` and
|
|
64
|
-
* revalidate-on-navigation over HTTP. The build id in the document is what
|
|
65
|
-
* `@ultimat3/pwa`'s skew detection compares against the server's.
|
|
66
|
-
*/
|
|
67
|
-
export function renderSpa(input: SpaShellInput): RenderResult {
|
|
68
|
-
const shell = renderSpaShell(input);
|
|
69
|
-
return {
|
|
70
|
-
status: 200,
|
|
71
|
-
headers: {
|
|
72
|
-
'content-type': 'text/html; charset=utf-8',
|
|
73
|
-
'cache-control': 'private, max-age=0, must-revalidate',
|
|
74
|
-
etag: `"${shell.hash}"`,
|
|
75
|
-
'x-ultimate-build': input.buildId,
|
|
76
|
-
},
|
|
77
|
-
body: shell.html,
|
|
78
|
-
};
|
|
79
|
-
}
|
package/src/router-client.ts
DELETED
|
@@ -1,236 +0,0 @@
|
|
|
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, decodeSegment } 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
|
-
let undecodable = false;
|
|
112
|
-
candidate.pattern.keys.forEach((key, index) => {
|
|
113
|
-
const value = match[index + 1];
|
|
114
|
-
if (value === undefined) return;
|
|
115
|
-
// `decodeSegment`, the server router's own reader: `decodeURIComponent('%zz')` is a bare
|
|
116
|
-
// `URIError`, and `resolve` is called from the signal initialiser, from `navigate`, from a
|
|
117
|
-
// `mouseenter` prefetch and from the popstate handler — so a typo in the address bar took
|
|
118
|
-
// the whole SPA down at boot instead of failing this one branch.
|
|
119
|
-
const decoded = decodeSegment(value);
|
|
120
|
-
if (decoded === undefined) undecodable = true;
|
|
121
|
-
else params[key] = decoded;
|
|
122
|
-
});
|
|
123
|
-
// Fails only the branch that would have decoded it, exactly as `matchRoute` answers: a
|
|
124
|
-
// literal route matching the same text still wins, and an unclaimed pathname is a non-match.
|
|
125
|
-
if (undecodable) continue;
|
|
126
|
-
return {
|
|
127
|
-
route: candidate.route,
|
|
128
|
-
params,
|
|
129
|
-
pathname: rawPath,
|
|
130
|
-
search: rawSearch === '' ? '' : `?${rawSearch}`,
|
|
131
|
-
};
|
|
132
|
-
}
|
|
133
|
-
return null;
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
async function navigate(to: string, navOptions: NavigateOptions = {}): Promise<boolean> {
|
|
137
|
-
const next = resolve(to);
|
|
138
|
-
if (next === null) return false;
|
|
139
|
-
|
|
140
|
-
const from = current();
|
|
141
|
-
for (const guard of guards) {
|
|
142
|
-
// A guard that says no is a hard stop: no history entry, no scroll change.
|
|
143
|
-
if (!(await guard(next, from))) return false;
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
if (from !== null) scrollPositions.set(keyOf(from), host.scrollY());
|
|
147
|
-
|
|
148
|
-
const commit = (): void => {
|
|
149
|
-
if (navOptions.replace === true) host.replaceState(to);
|
|
150
|
-
else host.pushState(to);
|
|
151
|
-
setCurrent(next);
|
|
152
|
-
if (navOptions.scroll !== false) restoreScroll(next);
|
|
153
|
-
};
|
|
154
|
-
|
|
155
|
-
if (host.viewTransition !== undefined) host.viewTransition(commit);
|
|
156
|
-
else commit();
|
|
157
|
-
return true;
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
function restoreScroll(route: ResolvedRoute): void {
|
|
161
|
-
host.scrollTo(0, scrollPositions.get(keyOf(route)) ?? 0);
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
function prefetch(to: string): void {
|
|
165
|
-
const target = resolve(to);
|
|
166
|
-
const chunk = target?.route.chunk;
|
|
167
|
-
if (chunk === undefined || prefetched.has(chunk)) return;
|
|
168
|
-
prefetched.add(chunk);
|
|
169
|
-
host.preload?.(chunk);
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
host.onPopState(() => {
|
|
173
|
-
const next = resolve(`${host.pathname()}${host.search()}`);
|
|
174
|
-
if (next !== null) {
|
|
175
|
-
setCurrent(next);
|
|
176
|
-
restoreScroll(next);
|
|
177
|
-
}
|
|
178
|
-
});
|
|
179
|
-
|
|
180
|
-
return {
|
|
181
|
-
current,
|
|
182
|
-
navigate,
|
|
183
|
-
resolve,
|
|
184
|
-
prefetch,
|
|
185
|
-
guard(guard) {
|
|
186
|
-
guards.add(guard);
|
|
187
|
-
return () => guards.delete(guard);
|
|
188
|
-
},
|
|
189
|
-
attach(container) {
|
|
190
|
-
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
191
|
-
const onEnter = (event: { target: unknown }): void => {
|
|
192
|
-
const href = hrefOf(event.target);
|
|
193
|
-
if (href === null) return;
|
|
194
|
-
if (timer !== null) clearTimeout(timer);
|
|
195
|
-
timer = setTimeout(() => prefetch(href), options.prefetchDelayMs ?? 80);
|
|
196
|
-
};
|
|
197
|
-
const onLeave = (): void => {
|
|
198
|
-
if (timer !== null) clearTimeout(timer);
|
|
199
|
-
timer = null;
|
|
200
|
-
};
|
|
201
|
-
|
|
202
|
-
container.addEventListener('mouseenter', onEnter, true);
|
|
203
|
-
container.addEventListener('mouseleave', onLeave, true);
|
|
204
|
-
|
|
205
|
-
// Visible prefetch: everything explicitly marked, warmed once it is on screen.
|
|
206
|
-
for (const link of container.querySelectorAll('a[data-prefetch="visible"]')) {
|
|
207
|
-
const href = link.getAttribute('href');
|
|
208
|
-
if (href !== null) prefetch(href);
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
return () => {
|
|
212
|
-
container.removeEventListener('mouseenter', onEnter, true);
|
|
213
|
-
container.removeEventListener('mouseleave', onLeave, true);
|
|
214
|
-
onLeave();
|
|
215
|
-
};
|
|
216
|
-
},
|
|
217
|
-
};
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
function keyOf(route: ResolvedRoute): string {
|
|
221
|
-
return `${route.pathname}${route.search}`;
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
function splitUrl(url: string): readonly [string, string] {
|
|
225
|
-
const hashless = url.split('#')[0] ?? '/';
|
|
226
|
-
const index = hashless.indexOf('?');
|
|
227
|
-
if (index === -1) return [hashless === '' ? '/' : hashless, ''];
|
|
228
|
-
return [hashless.slice(0, index) || '/', hashless.slice(index + 1)];
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
function hrefOf(target: unknown): string | null {
|
|
232
|
-
if (typeof target !== 'object' || target === null) return null;
|
|
233
|
-
if (!('getAttribute' in target)) return null;
|
|
234
|
-
const link = target as PrefetchLink;
|
|
235
|
-
return link.getAttribute('href');
|
|
236
|
-
}
|