@rivetjs/router 0.0.0-stage → 0.1.0-alpha.1
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 +10 -2
- package/adapters/assemble.d.ts +32 -0
- package/adapters/assemble.js +32 -0
- package/adapters/build.d.ts +24 -0
- package/adapters/build.js +43 -0
- package/adapters/bun.d.ts +18 -0
- package/adapters/bun.js +44 -0
- package/adapters/cloudflare.d.ts +5 -0
- package/adapters/cloudflare.js +8 -0
- package/adapters/cluster.d.ts +7 -0
- package/adapters/cluster.js +130 -0
- package/adapters/csr.d.ts +12 -0
- package/adapters/csr.js +11 -0
- package/adapters/deno.d.ts +13 -0
- package/adapters/deno.js +35 -0
- package/adapters/fetch.d.ts +55 -0
- package/adapters/fetch.js +72 -0
- package/adapters/files.d.ts +20 -0
- package/adapters/files.js +155 -0
- package/adapters/idle-memory.d.ts +23 -0
- package/adapters/idle-memory.js +91 -0
- package/adapters/netlify.d.ts +13 -0
- package/adapters/netlify.js +15 -0
- package/adapters/node.d.ts +180 -0
- package/adapters/node.js +986 -0
- package/adapters/pack.d.ts +39 -0
- package/adapters/pack.js +360 -0
- package/adapters/server-target.d.ts +17 -0
- package/adapters/server-target.js +8 -0
- package/adapters/static.d.ts +84 -0
- package/adapters/static.js +231 -0
- package/adapters/target-build.d.ts +95 -0
- package/adapters/target-build.js +118 -0
- package/adapters/vercel-edge.d.ts +7 -0
- package/adapters/vercel-edge.js +9 -0
- package/adapters/vercel.d.ts +35 -0
- package/adapters/vercel.js +45 -0
- package/build/codegen.d.ts +97 -0
- package/build/codegen.js +1037 -0
- package/build/dev-errors.d.ts +23 -0
- package/build/dev-errors.js +247 -0
- package/build/lazy-rows.d.ts +19 -0
- package/build/lazy-rows.js +59 -0
- package/build/migrate.d.ts +31 -0
- package/build/migrate.js +385 -0
- package/build/reach.d.ts +60 -0
- package/build/reach.js +302 -0
- package/build/route-css.d.ts +54 -0
- package/build/route-css.js +216 -0
- package/build/routes.d.ts +55 -0
- package/build/routes.js +219 -0
- package/build/sync.d.ts +20 -0
- package/build/sync.js +16 -0
- package/build/vite.d.ts +208 -0
- package/build/vite.js +861 -0
- package/cli/rivet.d.ts +2 -0
- package/cli/rivet.js +101 -0
- package/client/active.d.ts +6 -0
- package/client/active.js +22 -0
- package/client/announce.d.ts +2 -0
- package/client/announce.js +21 -0
- package/client/dev.d.ts +41 -0
- package/client/dev.js +91 -0
- package/client/index.d.ts +156 -0
- package/client/index.js +714 -0
- package/client/layouts.d.ts +110 -0
- package/client/layouts.js +310 -0
- package/client/leave.d.ts +40 -0
- package/client/leave.js +75 -0
- package/client/link.d.ts +48 -0
- package/client/link.jsx +65 -0
- package/client/live.d.ts +57 -0
- package/client/live.js +170 -0
- package/client/navigate.d.ts +71 -0
- package/client/navigate.js +120 -0
- package/client/navigation.d.ts +28 -0
- package/client/navigation.js +14 -0
- package/client/params.d.ts +16 -0
- package/client/params.js +28 -0
- package/client/route-context.d.ts +32 -0
- package/client/route-context.js +21 -0
- package/client/scroll.d.ts +16 -0
- package/client/scroll.js +80 -0
- package/client/search.d.ts +8 -0
- package/client/search.js +17 -0
- package/client/tailwind.css +8 -0
- package/client/transition-helpers.d.ts +28 -0
- package/client/transition-helpers.js +63 -0
- package/client/transitions.css +90 -0
- package/client/transitions.d.ts +21 -0
- package/client/transitions.js +156 -0
- package/index.d.ts +135 -0
- package/index.js +8 -0
- package/package.json +156 -4
- package/server/app.d.ts +175 -0
- package/server/app.js +335 -0
- package/server/body.d.ts +20 -0
- package/server/body.js +8 -0
- package/server/deferred.d.ts +12 -0
- package/server/deferred.js +60 -0
- package/server/early-hints.d.ts +34 -0
- package/server/early-hints.js +63 -0
- package/server/handler.d.ts +240 -0
- package/server/handler.js +661 -0
- package/server/index.d.ts +9 -0
- package/server/index.js +6 -0
- package/server/inline-styles.d.ts +21 -0
- package/server/inline-styles.js +28 -0
- package/server/module.d.ts +14 -0
- package/server/module.js +96 -0
- package/server/render.d.ts +96 -0
- package/server/render.js +492 -0
- package/server/route-cache.d.ts +245 -0
- package/server/route-cache.js +813 -0
- package/server/security.d.ts +18 -0
- package/server/security.js +47 -0
- package/server/shell-build.d.ts +3 -0
- package/server/shell-build.js +2 -0
- package/server/shell-cache.d.ts +8 -0
- package/server/shell-cache.js +836 -0
- package/server/strict-csp.d.ts +7 -0
- package/server/strict-csp.js +18 -0
- package/server-only.d.ts +1 -0
- package/server-only.js +3 -0
- package/shared/actions.d.ts +10 -0
- package/shared/actions.js +18 -0
- package/shared/base.d.ts +8 -0
- package/shared/base.js +20 -0
- package/shared/cookie.d.ts +7 -0
- package/shared/cookie.js +32 -0
- package/shared/csp-directives.d.ts +9 -0
- package/shared/csp-directives.js +85 -0
- package/shared/csp-hash.d.ts +39 -0
- package/shared/csp-hash.js +121 -0
- package/shared/csp.d.ts +26 -0
- package/shared/csp.js +13 -0
- package/shared/data.d.ts +175 -0
- package/shared/data.js +22 -0
- package/shared/errors.d.ts +31 -0
- package/shared/errors.js +51 -0
- package/shared/head.d.ts +57 -0
- package/shared/head.js +242 -0
- package/shared/nonce.d.ts +2 -0
- package/shared/nonce.js +23 -0
- package/shared/prefetch-fallback.min.d.ts +1 -0
- package/shared/prefetch-fallback.min.js +2 -0
- package/shared/prefetch.d.ts +54 -0
- package/shared/prefetch.js +74 -0
- package/shared/redirect.d.ts +6 -0
- package/shared/redirect.js +25 -0
- package/shared/server-exports.d.ts +78 -0
- package/shared/server-exports.js +77 -0
- package/shared/slash.d.ts +8 -0
- package/shared/slash.js +24 -0
package/server/app.js
ADDED
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
import { headTags, rankMerge } from "../shared/head.js";
|
|
2
|
+
import { nonceCSP } from "../shared/csp.js";
|
|
3
|
+
import { inlineStylesFor } from "./inline-styles.js";
|
|
4
|
+
import { escapeJSON } from "@rivetjs/runtime/resume-format";
|
|
5
|
+
import { createHandler, _digest, encodeData } from "./handler.js";
|
|
6
|
+
import { renderDocument } from "./render.js";
|
|
7
|
+
import { SHELL_BUILD } from "./shell-build.js";
|
|
8
|
+
// `route-cache.ts` `ROUTE_CACHE` (same registered symbol; that module loads only with rules).
|
|
9
|
+
const ROUTE_CACHE = Symbol.for("rivet.routeCache");
|
|
10
|
+
// `early-hints.ts` `EARLY_HINTS` (same registered symbol; that module loads only with `earlyHints`).
|
|
11
|
+
const EARLY_HINTS = Symbol.for("rivet.earlyHints");
|
|
12
|
+
const RENDER_FAILED = Symbol.for("rivet.renderFailed");
|
|
13
|
+
/** Route export levels: layouts outermost first, then the page (undefined entries skipped). */
|
|
14
|
+
const ranked = (heads, defaults) => [
|
|
15
|
+
...(defaults ? [{ level: -1, phase: 0, seq: 0, head: defaults }] : []),
|
|
16
|
+
...heads.flatMap((head, level) => (head ? [{ level, phase: 0, seq: 0, head }] : [])),
|
|
17
|
+
];
|
|
18
|
+
/**
|
|
19
|
+
* Merge heads outer → inner: inner wins per key (title; meta by name/property/http-equiv/itemprop
|
|
20
|
+
* + media; one canonical; alternates by hreflang; other links by rel + href; scripts by id/src).
|
|
21
|
+
*/
|
|
22
|
+
export function mergeHeads(heads) {
|
|
23
|
+
return rankMerge(ranked(heads));
|
|
24
|
+
}
|
|
25
|
+
/** Component head collector of one request: entries until the hold releases, then late entries render in place. */
|
|
26
|
+
function createSink(nonce, late) {
|
|
27
|
+
let seq = 0, closed = false;
|
|
28
|
+
const entries = [];
|
|
29
|
+
return {
|
|
30
|
+
nonce,
|
|
31
|
+
entries,
|
|
32
|
+
close: () => void (closed = true),
|
|
33
|
+
push(level, head, component) {
|
|
34
|
+
if (!closed) {
|
|
35
|
+
entries.push({ level, phase: 1, seq: seq++, head });
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
// After the shell: only body-valid tags (links, scripts, styles) still apply, where they render.
|
|
39
|
+
if (head.title !== undefined || head.titleTemplate !== undefined || head.meta?.length || head.html || head.body || head.base)
|
|
40
|
+
late(`${component ?? "A head component"} rendered after the shell (inside a streamed Async boundary): title/meta/html changes are ignored there`);
|
|
41
|
+
return (headTags({ ...(head.links ? { links: head.links } : {}), ...(head.scripts ? { scripts: head.scripts } : {}), ...(head.styles ? { styles: head.styles } : {}) }, { nonce }) || undefined);
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
/** Create the app's request handler. */
|
|
46
|
+
export function createApp(config) {
|
|
47
|
+
const { entry, manifest, dev = false, styles = [], routeStyles } = config;
|
|
48
|
+
const inlineFor = config.inlineStyles ? inlineStylesFor(config.inlineStyles) : undefined;
|
|
49
|
+
const option = config.csp ?? nonceCSP;
|
|
50
|
+
let csp = typeof option === "object" ? (nonce) => nonceCSP(nonce, option) : option;
|
|
51
|
+
// `directives`: the merge module loads at startup (pay-per-use, dynamic import) and checks them once —
|
|
52
|
+
// a mistake rejects at startup (unhandled: the process fails like a synchronous throw would); requests
|
|
53
|
+
// arriving before it settles wait for it, later ones take the synchronous path.
|
|
54
|
+
let directives = typeof option === "object" && option.directives
|
|
55
|
+
? import("./strict-csp.js").then(({ strictCSP }) => {
|
|
56
|
+
strictCSP("", option);
|
|
57
|
+
csp = (nonce) => strictCSP(nonce, option);
|
|
58
|
+
directives = null;
|
|
59
|
+
})
|
|
60
|
+
: null;
|
|
61
|
+
// Route export contributions behind each merged head (rank merge with component entries).
|
|
62
|
+
const levels = new WeakMap();
|
|
63
|
+
// Early hints: the per-route Link values live in `early-hints.ts`, loaded at startup only with `earlyHints`.
|
|
64
|
+
const hintOption = dev ? undefined : config.earlyHints;
|
|
65
|
+
let hinter;
|
|
66
|
+
let hintLoading = hintOption
|
|
67
|
+
? import("./early-hints.js").then(({ createHinter }) => {
|
|
68
|
+
hinter = createHinter(hintOption, { styles, routeStyles, inlineFor, base: entry.base ?? "", runtime: manifest?.runtime });
|
|
69
|
+
hintLoading = null;
|
|
70
|
+
})
|
|
71
|
+
: null;
|
|
72
|
+
const warned = new Set();
|
|
73
|
+
const document = config.document ?? entry.document;
|
|
74
|
+
if (!document)
|
|
75
|
+
throw new TypeError("createApp: no document shell (pass `document` or generate the entry with one)");
|
|
76
|
+
const moveScript = (to, nonce) => to === undefined ? "" : `<script${scriptAttrs(nonce)}>location.replace(${JSON.stringify(to).replace(/</g, "\\u003c")})</script>`;
|
|
77
|
+
const scriptAttrs = (nonce) => (nonce !== undefined ? ` nonce="${nonce.replace(/[^\w+/=-]/g, "")}"` : "");
|
|
78
|
+
// Dev pages: what the data endpoint would answer for this URL, so the client mount needs no request.
|
|
79
|
+
const devAround = (page, status, nonce, locale, mode) => {
|
|
80
|
+
const list = typeof config.devScripts === "function" ? config.devScripts(locale) : (config.devScripts ?? []);
|
|
81
|
+
const scripts = list.map((src) => `<script type="module" src="${src}"${scriptAttrs(nonce)}></script>`).join("");
|
|
82
|
+
// Resumed dev pages: the dev entry only takes over (client render) on the first hot update.
|
|
83
|
+
if (mode === "resume")
|
|
84
|
+
return { start: `<div data-rivet-dev="resume" style="display:contents">`, end: `</div>${scripts}` };
|
|
85
|
+
const body = page.error
|
|
86
|
+
? { error: page.error }
|
|
87
|
+
: {
|
|
88
|
+
data: page.data,
|
|
89
|
+
status,
|
|
90
|
+
...(page.head ? { head: page.head } : {}),
|
|
91
|
+
...(page.action ? { action: page.action } : {}),
|
|
92
|
+
...(page.deps ? { deps: page.deps } : {}),
|
|
93
|
+
};
|
|
94
|
+
return {
|
|
95
|
+
start: `<div data-rivet-dev="" style="display:contents">`,
|
|
96
|
+
end: `</div><script type="application/json" id="rivet-dev-data">${escapeJSON(encodeData(body))}</script>${scripts}`,
|
|
97
|
+
};
|
|
98
|
+
};
|
|
99
|
+
const respond = (page, status, headers, nonce, request, locals, routeId) => {
|
|
100
|
+
if (directives || fragments || hintLoading)
|
|
101
|
+
return Promise.all([directives, fragments, hintLoading]).then(() => respond(page, status, headers, nonce, request, locals, routeId));
|
|
102
|
+
const extra = routeId === undefined || !routeStyles ? undefined : typeof routeStyles === "function" ? routeStyles(routeId) : routeStyles[routeId];
|
|
103
|
+
if (extra && "then" in extra)
|
|
104
|
+
return extra.then((list) => render(page, status, headers, nonce, request, locals, list, routeId));
|
|
105
|
+
return render(page, status, headers, nonce, request, locals, extra, routeId);
|
|
106
|
+
};
|
|
107
|
+
// Failures after the document started (the status is sent): reported with a digest, which the page's failure marker carries.
|
|
108
|
+
const report = config.onError ?? ((error, _request, info) => console.error("[rivet] digest " + info?.digest + ":", error));
|
|
109
|
+
const render = (page, status, headers, nonce, request, locals, extra, routeId) => {
|
|
110
|
+
const pageStyles = extra?.length ? [...styles, ...extra] : styles;
|
|
111
|
+
const inlineStyles = inlineFor?.(pageStyles);
|
|
112
|
+
let sink;
|
|
113
|
+
if (entry.headComponents) {
|
|
114
|
+
sink = createSink(nonce, (message) => {
|
|
115
|
+
if (dev)
|
|
116
|
+
console.error("[rivet] " + message);
|
|
117
|
+
// Once per message (component + text: bounded by the app's code), not per URL (unbounded).
|
|
118
|
+
else if (!warned.has(message))
|
|
119
|
+
(warned.add(message), console.warn("[rivet] " + message));
|
|
120
|
+
});
|
|
121
|
+
page = { ...page, headSink: sink };
|
|
122
|
+
}
|
|
123
|
+
const signal = request.signal;
|
|
124
|
+
if (hinter && routeId !== undefined) {
|
|
125
|
+
if (!sink)
|
|
126
|
+
hinter.learn(routeId, page.head);
|
|
127
|
+
const links = hinter.links(routeId);
|
|
128
|
+
if (links.length && !headers.has("link"))
|
|
129
|
+
headers.set("link", links.join(", "));
|
|
130
|
+
}
|
|
131
|
+
if (csp && nonce !== undefined && !headers.has("content-security-policy"))
|
|
132
|
+
headers.set("content-security-policy", csp(nonce));
|
|
133
|
+
const i18n = config.i18n;
|
|
134
|
+
const locale = i18n && typeof locals?.locale === "string" && i18n.locales.includes(locals.locale) ? locals.locale : undefined;
|
|
135
|
+
const variant = locale ? i18n.locales.indexOf(locale) : 0;
|
|
136
|
+
const built = (i18n && i18n.manifests?.[locale ?? i18n.locales[0]]) || manifest;
|
|
137
|
+
const devMode = built || !config.devResume ? undefined : typeof config.devResume.mode === "function" ? config.devResume.mode(locale) : config.devResume.mode;
|
|
138
|
+
const pageManifest = built || config.devResume?.manifest;
|
|
139
|
+
// Dev check mode streams nothing: a shell fill's deferred data waits in place there (not an in-order read).
|
|
140
|
+
if (page.shell && devMode === "check")
|
|
141
|
+
page.shell.plain = true;
|
|
142
|
+
const dir = locals?.dir === "rtl" || locals?.dir === "ltr" ? locals.dir : undefined;
|
|
143
|
+
const exports = page.head ? (levels.get(page.head) ?? ranked([page.head], config.head)) : ranked([], config.head);
|
|
144
|
+
// Root layout component entries stay in the browser (that layout never re-renders there).
|
|
145
|
+
const persistent = new Set();
|
|
146
|
+
const option = config.preload?.background ?? entry.preloadBackground;
|
|
147
|
+
const background = typeof option === "function" ? option(page.url) : option;
|
|
148
|
+
const leanOption = config.preload?.lean;
|
|
149
|
+
const lean = typeof leanOption === "function" ? leanOption(page.url) : leanOption;
|
|
150
|
+
const ahead = config.preload?.ahead ?? entry.fetchAhead;
|
|
151
|
+
const run = () => renderDocument(entry.page(page), {
|
|
152
|
+
head: page.head,
|
|
153
|
+
nonce,
|
|
154
|
+
url: page.url,
|
|
155
|
+
status,
|
|
156
|
+
styles: pageStyles,
|
|
157
|
+
...(inlineStyles ? { inlineStyles } : {}),
|
|
158
|
+
...(locale ? { locale } : {}),
|
|
159
|
+
...(dir ? { dir } : {}),
|
|
160
|
+
...(sink ? { managed: { persistent } } : {}),
|
|
161
|
+
}, {
|
|
162
|
+
document,
|
|
163
|
+
...(sink
|
|
164
|
+
? {
|
|
165
|
+
hold: {
|
|
166
|
+
head: () => {
|
|
167
|
+
sink.close();
|
|
168
|
+
for (const { level, head } of sink.entries)
|
|
169
|
+
if (level === 0)
|
|
170
|
+
for (const entry of [...(head.meta ?? []), ...(head.links ?? []), ...(head.scripts ?? [])])
|
|
171
|
+
persistent.add(entry);
|
|
172
|
+
const merged = rankMerge([...exports, ...sink.entries]);
|
|
173
|
+
if (routeId !== undefined)
|
|
174
|
+
hinter?.learn(routeId, merged);
|
|
175
|
+
return merged;
|
|
176
|
+
},
|
|
177
|
+
},
|
|
178
|
+
}
|
|
179
|
+
: {}),
|
|
180
|
+
...(pageManifest ? { manifest: pageManifest } : {}),
|
|
181
|
+
...(variant ? { variant } : {}),
|
|
182
|
+
// Deferred loaders: their boundaries stream out of order (shell first) even when the app set no `stream`.
|
|
183
|
+
// Shell routes (`routeRules` `shell`) always stream: the stored shell ends where the first boundary would stream.
|
|
184
|
+
...(config.stream ? { stream: config.stream } : page.deferred || page.shell ? { stream: {} } : {}),
|
|
185
|
+
...(background !== undefined || lean || ahead
|
|
186
|
+
? { preload: { ...(background !== undefined ? { background } : {}), ...(lean ? { lean } : {}), ...(ahead ? { ahead } : {}) } }
|
|
187
|
+
: {}),
|
|
188
|
+
signal,
|
|
189
|
+
...(nonce !== undefined ? { nonce } : {}),
|
|
190
|
+
onError: (error) => {
|
|
191
|
+
// Route rules: a page that rendered an error fallback is not stored (`route-cache.ts` `RENDER_FAILED`).
|
|
192
|
+
request[RENDER_FAILED] = true;
|
|
193
|
+
const digest = _digest();
|
|
194
|
+
report(error, request, { digest, phase: "render", ...(routeId !== undefined ? { route: routeId } : {}) });
|
|
195
|
+
return digest;
|
|
196
|
+
},
|
|
197
|
+
...(config.devScripts && !built ? { around: devAround(page, status, nonce, locale, devMode) } : {}),
|
|
198
|
+
// Deferred loaders' redirects (status already sent): the browser navigates at the document's end.
|
|
199
|
+
...(page.moved ? { epilogue: () => moveScript(page.moved(), nonce) } : {}),
|
|
200
|
+
...(devMode === "check" ? { discardState: true } : {}),
|
|
201
|
+
}, { status, headers });
|
|
202
|
+
return withCache
|
|
203
|
+
? withCache({
|
|
204
|
+
cache: config.renderCache,
|
|
205
|
+
...(variant ? { variant } : {}),
|
|
206
|
+
...(nonce !== undefined ? { nonce } : {}),
|
|
207
|
+
signal,
|
|
208
|
+
resumable: pageManifest !== undefined,
|
|
209
|
+
}, run)
|
|
210
|
+
: run();
|
|
211
|
+
};
|
|
212
|
+
// `renderCache`: the scope module (AsyncLocalStorage) loads at startup, pay-per-use like `directives`.
|
|
213
|
+
let withCache;
|
|
214
|
+
let fragments = config.renderCache
|
|
215
|
+
? import("@rivetjs/runtime/server-cache").then((module) => {
|
|
216
|
+
withCache = module.withRenderCache;
|
|
217
|
+
fragments = null;
|
|
218
|
+
})
|
|
219
|
+
: null;
|
|
220
|
+
const app = createHandler({
|
|
221
|
+
routes: entry.routes,
|
|
222
|
+
dev,
|
|
223
|
+
// Every locale's build shares one runtime; any manifest names the built-in decoders' chunk.
|
|
224
|
+
builtins: () => (manifest ?? (config.i18n?.manifests && Object.values(config.i18n.manifests)[0]) ?? config.devResume?.manifest)?.builtins,
|
|
225
|
+
...(config.onError ? { onError: config.onError } : {}),
|
|
226
|
+
...(config.maxBody !== undefined ? { maxBody: config.maxBody } : {}),
|
|
227
|
+
...(hintOption
|
|
228
|
+
? {
|
|
229
|
+
earlyHints(route, request) {
|
|
230
|
+
const send = request[EARLY_HINTS];
|
|
231
|
+
if (send) {
|
|
232
|
+
const links = hinter?.links(route.id) ?? [];
|
|
233
|
+
if (links.length)
|
|
234
|
+
send(links);
|
|
235
|
+
}
|
|
236
|
+
},
|
|
237
|
+
}
|
|
238
|
+
: {}),
|
|
239
|
+
async head(route, data, match) {
|
|
240
|
+
const modules = await entry.universal[route.id]?.();
|
|
241
|
+
if (!modules)
|
|
242
|
+
return config.head ? rankMerge(ranked([], config.head)) : undefined;
|
|
243
|
+
const heads = [];
|
|
244
|
+
for (const [index, module] of modules) {
|
|
245
|
+
const head = module.head;
|
|
246
|
+
heads[index] = typeof head === "function" ? head({ data: data[index], params: match.params, url: match.url }) : head;
|
|
247
|
+
}
|
|
248
|
+
const contributions = ranked(heads, config.head);
|
|
249
|
+
const merged = rankMerge(contributions);
|
|
250
|
+
if (merged)
|
|
251
|
+
levels.set(merged, contributions);
|
|
252
|
+
return merged;
|
|
253
|
+
},
|
|
254
|
+
// A layout whose `head` is a function of its data reruns its loader (the merged head needs it).
|
|
255
|
+
async keepable(route) {
|
|
256
|
+
const modules = await entry.universal[route.id]?.();
|
|
257
|
+
const out = [];
|
|
258
|
+
for (const [index, module] of modules ?? [])
|
|
259
|
+
if (typeof module.head === "function")
|
|
260
|
+
out[index] = false;
|
|
261
|
+
return out;
|
|
262
|
+
},
|
|
263
|
+
render: (input) => respond({
|
|
264
|
+
data: input.data,
|
|
265
|
+
// Shell fills: a URL whose named query reads outside the rule's key are recorded (`shell-cache.ts`).
|
|
266
|
+
url: input.shell?.url ?? input.url,
|
|
267
|
+
head: input.head,
|
|
268
|
+
...(input.action ? { action: input.action } : {}),
|
|
269
|
+
...(input.byURL ? { byURL: input.byURL } : {}),
|
|
270
|
+
...(input.deps ? { deps: input.deps } : {}),
|
|
271
|
+
...(input.deferred ? { deferred: input.deferred } : {}),
|
|
272
|
+
...(input.moved ? { moved: input.moved } : {}),
|
|
273
|
+
...(input.shell ? { shell: input.shell } : {}),
|
|
274
|
+
}, input.status, input.headers, input.nonce, input.request, input.locals, input.route.id),
|
|
275
|
+
renderError: async (input) => respond({
|
|
276
|
+
// The outermost layouts' data (error pages render inside the layouts that loaded).
|
|
277
|
+
data: input.layouts ?? [],
|
|
278
|
+
url: input.url,
|
|
279
|
+
head: { title: input.status === 404 ? "Not found" : "Error" },
|
|
280
|
+
error: { status: input.status, message: input.message },
|
|
281
|
+
}, input.status, input.headers, input.nonce, input.request, await errorLocals(input.locals, input.request, input.url)),
|
|
282
|
+
});
|
|
283
|
+
const given = config.routeRules ?? entry.routeRules;
|
|
284
|
+
if (!given || !Object.keys(given).length)
|
|
285
|
+
return app;
|
|
286
|
+
// Dev: headers only (a stored page would hide edits); `shell` rules probe (track reads, log why a shell would not be stored).
|
|
287
|
+
const rules = dev
|
|
288
|
+
? Object.fromEntries(Object.entries(given).map(([pattern, rule]) => [
|
|
289
|
+
pattern,
|
|
290
|
+
{ ...(rule.headers ? { headers: rule.headers } : {}), ...(rule.shell !== undefined ? { shell: devShell(rule) } : {}) },
|
|
291
|
+
]))
|
|
292
|
+
: given;
|
|
293
|
+
const shelled = Object.values(rules).some((rule) => rule.shell);
|
|
294
|
+
if (shelled && !SHELL_BUILD)
|
|
295
|
+
throw new TypeError("createApp: `shell` route rules need a build whose `rivetRouter({ routeRules })` has a `shell` rule (it bundles the shell cache)");
|
|
296
|
+
// Route rules: their module loads at startup (pay-per-use); malformed rules reject there (unhandled: the process
|
|
297
|
+
// fails like a synchronous throw would). Requests before it settles wait for it.
|
|
298
|
+
const policy = {
|
|
299
|
+
policy: (nonce) => (csp ? csp(nonce) : false),
|
|
300
|
+
// The default / `CSPOptions` nonce policy becomes hashes in stored pages; a `csp` function keeps its nonce per page.
|
|
301
|
+
hash: config.csp === undefined ? {} : typeof config.csp === "object" ? config.csp : null,
|
|
302
|
+
};
|
|
303
|
+
let cached = null;
|
|
304
|
+
// Shell rules: the shell cache module loads with the rules module (pay-per-use: only apps with `shell` rules).
|
|
305
|
+
const loading = Promise.all([import("./route-cache.js"), SHELL_BUILD && shelled ? SHELL_BUILD() : undefined]).then(([{ withRouteRules }, shells]) => (cached = withRouteRules(app, rules, policy, entry.base ?? "", config.routeCache, shells, {
|
|
306
|
+
dev,
|
|
307
|
+
build: manifest?.runtime ?? "",
|
|
308
|
+
})));
|
|
309
|
+
const handler = (request, context) => (cached ? cached(request, context) : loading.then((ready) => ready(request, context)));
|
|
310
|
+
// The node adapter's fast path (a stored page before a Request is built), once the module loaded.
|
|
311
|
+
Object.defineProperty(handler, ROUTE_CACHE, { get: () => (cached ? cached[ROUTE_CACHE] : undefined) });
|
|
312
|
+
return handler;
|
|
313
|
+
// Dev shell rules: keys only (the probe never stores, so no times and no fixed origin).
|
|
314
|
+
function devShell(rule) {
|
|
315
|
+
if (rule.shell === false)
|
|
316
|
+
return false;
|
|
317
|
+
const own = typeof rule.shell === "object" ? rule.shell : {};
|
|
318
|
+
const cache = typeof rule.cache === "object" ? rule.cache : {};
|
|
319
|
+
const query = own.query ?? cache.query;
|
|
320
|
+
const varies = own.varies ?? cache.varies;
|
|
321
|
+
return { maxAge: 0, ...(query !== undefined ? { query } : {}), ...(varies ? { varies } : {}) };
|
|
322
|
+
}
|
|
323
|
+
// Error pages without a settled locale: the app's resolver, else the URL's locale prefix.
|
|
324
|
+
async function errorLocals(locals, request, url) {
|
|
325
|
+
const i18n = config.i18n;
|
|
326
|
+
if (!i18n || (typeof locals.locale === "string" && i18n.locales.includes(locals.locale)))
|
|
327
|
+
return locals;
|
|
328
|
+
let locale = await i18n.resolve?.(request);
|
|
329
|
+
if (!locale || !i18n.locales.includes(locale)) {
|
|
330
|
+
const first = url.pathname.split("/")[1]?.toLowerCase();
|
|
331
|
+
locale = i18n.locales.find((l) => l.toLowerCase() === first);
|
|
332
|
+
}
|
|
333
|
+
return locale ? { ...locals, locale } : locals;
|
|
334
|
+
}
|
|
335
|
+
}
|
package/server/body.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** Where a directly attached page body goes. Web-standard: no Node types. */
|
|
2
|
+
export interface BodySink {
|
|
3
|
+
/**
|
|
4
|
+
* One chunk: a view into a pooled slab. Call `done` once its bytes are no longer needed (written out,
|
|
5
|
+
* copied, or dropped) — the slab is reused after every handed-off view of it is done. Returns a wait
|
|
6
|
+
* when the sink applies backpressure; throws once the sink is closed (the render then ends).
|
|
7
|
+
*/
|
|
8
|
+
write(chunk: Uint8Array, done: () => void): Promise<void> | undefined;
|
|
9
|
+
/** The body is complete. */
|
|
10
|
+
end(): void;
|
|
11
|
+
/** The render failed after the Response was handed out. */
|
|
12
|
+
error(error: unknown): void;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Own property of `renderPage` / `renderDocument` Responses: `(sink) => boolean`. True: every body byte
|
|
16
|
+
* (already rendered and later) goes to `sink` and the Response's `body` stream stays unread forever;
|
|
17
|
+
* false: the stream was already read (a copied Response, a consumer that started) — read the body.
|
|
18
|
+
*/
|
|
19
|
+
export declare const DIRECT_BODY: unique symbol;
|
|
20
|
+
export type DirectBody = (sink: BodySink) => boolean;
|
package/server/body.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// Direct page bodies: a host adapter (node) takes a rendered page's bytes straight from the renderer's
|
|
2
|
+
// slabs instead of reading the web `ReadableStream` (no queue, read promises or per-chunk races).
|
|
3
|
+
/**
|
|
4
|
+
* Own property of `renderPage` / `renderDocument` Responses: `(sink) => boolean`. True: every body byte
|
|
5
|
+
* (already rendered and later) goes to `sink` and the Response's `body` stream stays unread forever;
|
|
6
|
+
* false: the stream was already read (a copied Response, a consumer that started) — read the body.
|
|
7
|
+
*/
|
|
8
|
+
export const DIRECT_BODY = Symbol.for("rivet.body");
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { RequestEvent } from "./handler.js";
|
|
2
|
+
/**
|
|
3
|
+
* The `Deferred<T>` handle of a started deferred loader. Its only property (`then`) is non-enumerable: the resume encoder
|
|
4
|
+
* writes it as an empty object (a resumed page's layout data signal holds it; client navigations carry resolved values).
|
|
5
|
+
* Dev: a Proxy that throws `DeferredDataError` when code reads a field (`data.title`) instead of rendering the handle
|
|
6
|
+
* through a boundary.
|
|
7
|
+
*/
|
|
8
|
+
export declare function deferredHandle(promise: Promise<unknown>, dev: boolean): object;
|
|
9
|
+
/** Start deferred loader `i` now (with the request); a rejection is the boundary's, never an unhandled one. */
|
|
10
|
+
export declare function startDeferred(run: () => unknown): Promise<unknown>;
|
|
11
|
+
/** A deferred loader's event view: reads like its loader view, but `setHeader(s)` / `cookies.set` / `cookies.delete` throw. */
|
|
12
|
+
export declare function lateEvent(event: RequestEvent): RequestEvent;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// One handle per deferred slot per request: a frozen object whose non-enumerable `then` follows the loader's promise, so
|
|
2
|
+
// every await form works (`Async` / `resource` fetchers, `await data`, `Promise.all`). Ruling (PPR-T01): per request, not
|
|
3
|
+
// spec D3's request-independent handle resolved against the render scope — that one failed `await data` (the
|
|
4
|
+
// continuation runs outside the owner, the render scope is gone) and would need AsyncLocalStorage; shell-cache H-replay
|
|
5
|
+
// (T3) re-runs the page with live handles anyway, H-holes (T6) must swap a retained handle for the hit's (frame swap).
|
|
6
|
+
const DOCS = "https://rivetjs.severyn.pro/guides/routing/loading-data#deferred-loaders";
|
|
7
|
+
// The only string keys a handle answers in dev: what promise / boundary / encoder / JSON code reads.
|
|
8
|
+
const ALLOWED = new Set(["then", "toJSON", "constructor", "valueOf", "toString"]);
|
|
9
|
+
const devGet = (target, key, receiver) => {
|
|
10
|
+
if (typeof key === "symbol" || ALLOWED.has(key))
|
|
11
|
+
return Reflect.get(target, key, receiver);
|
|
12
|
+
const error = new TypeError("Deferred loader data read synchronously (`data." +
|
|
13
|
+
key +
|
|
14
|
+
"`): `load(fn, { defer: true })` data is a Deferred handle — render it through `<Async load={() => data}>` or `resource(() => data)` (" +
|
|
15
|
+
DOCS +
|
|
16
|
+
")");
|
|
17
|
+
error.name = "DeferredDataError";
|
|
18
|
+
throw error;
|
|
19
|
+
};
|
|
20
|
+
const DEV_TRAPS = { get: devGet };
|
|
21
|
+
/**
|
|
22
|
+
* The `Deferred<T>` handle of a started deferred loader. Its only property (`then`) is non-enumerable: the resume encoder
|
|
23
|
+
* writes it as an empty object (a resumed page's layout data signal holds it; client navigations carry resolved values).
|
|
24
|
+
* Dev: a Proxy that throws `DeferredDataError` when code reads a field (`data.title`) instead of rendering the handle
|
|
25
|
+
* through a boundary.
|
|
26
|
+
*/
|
|
27
|
+
export function deferredHandle(promise, dev) {
|
|
28
|
+
const handle = Object.freeze(Object.defineProperty({}, "then", { value: (ok, fail) => promise.then(ok, fail) }));
|
|
29
|
+
return dev ? new Proxy(handle, DEV_TRAPS) : handle;
|
|
30
|
+
}
|
|
31
|
+
/** Start deferred loader `i` now (with the request); a rejection is the boundary's, never an unhandled one. */
|
|
32
|
+
export function startDeferred(run) {
|
|
33
|
+
const promise = new Promise((resolve) => resolve(run()));
|
|
34
|
+
promise.catch(() => { });
|
|
35
|
+
return promise;
|
|
36
|
+
}
|
|
37
|
+
const late = (what) => () => {
|
|
38
|
+
throw new TypeError(`A deferred loader cannot ${what}: the response head is sent before it settles (use a non-deferred loader or a guard)`);
|
|
39
|
+
};
|
|
40
|
+
const lateCookies = (cookies) => ({
|
|
41
|
+
get: cookies.get,
|
|
42
|
+
getAll: cookies.getAll,
|
|
43
|
+
set: late("set cookies"),
|
|
44
|
+
delete: late("delete cookies"),
|
|
45
|
+
});
|
|
46
|
+
/** A deferred loader's event view: reads like its loader view, but `setHeader(s)` / `cookies.set` / `cookies.delete` throw. */
|
|
47
|
+
export function lateEvent(event) {
|
|
48
|
+
return new Proxy(event, {
|
|
49
|
+
get(target, key) {
|
|
50
|
+
if (key === "setHeader")
|
|
51
|
+
return late("set headers");
|
|
52
|
+
if (key === "setHeaders")
|
|
53
|
+
return late("set headers");
|
|
54
|
+
if (key === "cookies")
|
|
55
|
+
return lateCookies(target.cookies);
|
|
56
|
+
const value = Reflect.get(target, key, target);
|
|
57
|
+
return value;
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { Head } from "../shared/data.js";
|
|
2
|
+
/** `createApp({ earlyHints })`. */
|
|
3
|
+
export interface EarlyHintsOptions {
|
|
4
|
+
/** Also hint the resume runtime entry (`rel=modulepreload`): pages that start it early (visible preloads). Default false. */
|
|
5
|
+
readonly runtime?: boolean;
|
|
6
|
+
/** Extra `Link` values sent as given, e.g. `"<https://cdn.example>; rel=preconnect"`. */
|
|
7
|
+
readonly links?: readonly string[];
|
|
8
|
+
}
|
|
9
|
+
/** Registered symbol: a per-request `(links) => void` the node adapter puts on the Request it builds (one writer of 103s). */
|
|
10
|
+
export declare const EARLY_HINTS: unique symbol;
|
|
11
|
+
export type HintWriter = (links: readonly string[]) => void;
|
|
12
|
+
/** Whether `url` is a build asset below `base` (hashed file name, served immutable). */
|
|
13
|
+
export declare const isAsset: (url: string, base: string) => boolean;
|
|
14
|
+
/** `<url>; rel=preload; as=style` per linked (not inlined) build-asset stylesheet. */
|
|
15
|
+
export declare function styleLinks(urls: readonly string[], inline: Readonly<Record<string, string>> | undefined, base?: string): string[];
|
|
16
|
+
/**
|
|
17
|
+
* Build-asset font preloads of a merged head: build files are the same for every visitor; anything else in a head may
|
|
18
|
+
* come from loader data (preconnects, images) and is left out.
|
|
19
|
+
*/
|
|
20
|
+
export declare function headLinks(head: Head | undefined, base?: string): string[];
|
|
21
|
+
/** Per route id: its `Link` values, re-derived when the learned head links change. */
|
|
22
|
+
export interface Hinter {
|
|
23
|
+
links(routeId: string): readonly string[];
|
|
24
|
+
/** A render's merged head: its build-asset font preloads become the route's from now on. */
|
|
25
|
+
learn(routeId: string, head: Head | undefined): void;
|
|
26
|
+
}
|
|
27
|
+
/** `createApp`'s hinter: `styles` + the route's `routeStyles` (not inlined), learned head links, `links`, the runtime. */
|
|
28
|
+
export declare function createHinter(option: true | EarlyHintsOptions, app: {
|
|
29
|
+
readonly styles: readonly string[];
|
|
30
|
+
readonly routeStyles?: Readonly<Record<string, readonly string[]>> | ((routeId: string) => unknown);
|
|
31
|
+
readonly inlineFor?: (list: readonly string[]) => Readonly<Record<string, string>> | undefined;
|
|
32
|
+
readonly base: string;
|
|
33
|
+
readonly runtime?: string | undefined;
|
|
34
|
+
}): Hinter;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/** Registered symbol: a per-request `(links) => void` the node adapter puts on the Request it builds (one writer of 103s). */
|
|
2
|
+
export const EARLY_HINTS = Symbol.for("rivet.earlyHints");
|
|
3
|
+
// A URL written into a header value: no whitespace / controls, nothing that ends the `<…>` or splits the list.
|
|
4
|
+
const SAFE = /^[^\s\0-\x1f\x7f<>"',;\\]+$/;
|
|
5
|
+
const TOKEN = /^[\w./+-]+$/;
|
|
6
|
+
/** Whether `url` is a build asset below `base` (hashed file name, served immutable). */
|
|
7
|
+
export const isAsset = (url, base) => url.startsWith(base + "/assets/");
|
|
8
|
+
/** `<url>; rel=preload; as=style` per linked (not inlined) build-asset stylesheet. */
|
|
9
|
+
export function styleLinks(urls, inline, base = "") {
|
|
10
|
+
const out = [];
|
|
11
|
+
for (const url of urls)
|
|
12
|
+
if (!(inline && Object.hasOwn(inline, url)) && SAFE.test(url) && isAsset(url, base))
|
|
13
|
+
out.push(`<${url}>; rel=preload; as=style`);
|
|
14
|
+
return out;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Build-asset font preloads of a merged head: build files are the same for every visitor; anything else in a head may
|
|
18
|
+
* come from loader data (preconnects, images) and is left out.
|
|
19
|
+
*/
|
|
20
|
+
export function headLinks(head, base = "") {
|
|
21
|
+
const out = [];
|
|
22
|
+
for (const link of head?.links ?? []) {
|
|
23
|
+
const value = linkValue(link, base);
|
|
24
|
+
if (value)
|
|
25
|
+
out.push(value);
|
|
26
|
+
}
|
|
27
|
+
return out;
|
|
28
|
+
}
|
|
29
|
+
function linkValue(link, base) {
|
|
30
|
+
if (!SAFE.test(link.href))
|
|
31
|
+
return undefined;
|
|
32
|
+
const rel = link.rel.toLowerCase();
|
|
33
|
+
const cors = link.crossorigin === "use-credentials" ? "; crossorigin=use-credentials" : link.crossorigin ? "; crossorigin" : "";
|
|
34
|
+
if (rel === "preload" && link.as === "font" && isAsset(link.href, base))
|
|
35
|
+
return `<${link.href}>; rel=preload; as=font${link.type && TOKEN.test(link.type) ? "; type=" + link.type : ""}${cors}`;
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
/** `createApp`'s hinter: `styles` + the route's `routeStyles` (not inlined), learned head links, `links`, the runtime. */
|
|
39
|
+
export function createHinter(option, app) {
|
|
40
|
+
const extra = option === true
|
|
41
|
+
? []
|
|
42
|
+
: [...(option.links ?? []), ...(option.runtime && app.runtime && SAFE.test(app.runtime) ? [`<${app.runtime}>; rel=modulepreload`] : [])];
|
|
43
|
+
const learned = new Map();
|
|
44
|
+
const memo = new Map();
|
|
45
|
+
return {
|
|
46
|
+
links(routeId) {
|
|
47
|
+
let links = memo.get(routeId);
|
|
48
|
+
if (links)
|
|
49
|
+
return links;
|
|
50
|
+
const own = app.routeStyles && typeof app.routeStyles !== "function" ? app.routeStyles[routeId] : undefined;
|
|
51
|
+
const list = own?.length ? [...app.styles, ...own] : app.styles;
|
|
52
|
+
const head = learned.get(routeId);
|
|
53
|
+
links = [...styleLinks(list, app.inlineFor?.(list), app.base), ...(head ? head.split("\n") : []), ...extra];
|
|
54
|
+
memo.set(routeId, links);
|
|
55
|
+
return links;
|
|
56
|
+
},
|
|
57
|
+
learn(routeId, head) {
|
|
58
|
+
const value = headLinks(head, app.base).join("\n");
|
|
59
|
+
if (value !== (learned.get(routeId) ?? ""))
|
|
60
|
+
(value ? learned.set(routeId, value) : learned.delete(routeId), memo.delete(routeId));
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
}
|