@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
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/** A redirect, denial or HTTP failure ending the guard/loader chain. */
|
|
2
|
+
export declare class HttpError extends Error {
|
|
3
|
+
readonly status: number;
|
|
4
|
+
/** Redirect target for 3xx. */
|
|
5
|
+
readonly location: string | undefined;
|
|
6
|
+
/** Safe to show to users (4xx messages); 5xx details stay server-side. */
|
|
7
|
+
readonly expose: boolean;
|
|
8
|
+
constructor(status: number, message: string, location?: string);
|
|
9
|
+
}
|
|
10
|
+
/** Options of `redirect()`. */
|
|
11
|
+
export interface RedirectOptions {
|
|
12
|
+
status?: 301 | 302 | 303 | 307 | 308;
|
|
13
|
+
/** Allow an absolute `http(s):` URL on another origin (payment providers, SSO). */
|
|
14
|
+
external?: boolean;
|
|
15
|
+
}
|
|
16
|
+
type RedirectStatus = NonNullable<RedirectOptions["status"]>;
|
|
17
|
+
/**
|
|
18
|
+
* Redirect (default 302 Found, 303 See Other from actions; 307/308 keep the method). Same-origin
|
|
19
|
+
* only: relative targets, or absolute URLs on the request's origin; `{ external: true }` allows
|
|
20
|
+
* other origins. Protocol-relative (`//x`), backslash and control-character targets throw.
|
|
21
|
+
*/
|
|
22
|
+
export declare function redirect(location: string, status?: RedirectStatus | RedirectOptions, options?: RedirectOptions): HttpError;
|
|
23
|
+
/** Refuse access (default 403). */
|
|
24
|
+
export declare function deny(status?: 401 | 403, message?: string): HttpError;
|
|
25
|
+
/** The resource does not exist; renders the nearest `_404`. */
|
|
26
|
+
export declare function notFound(message?: string): HttpError;
|
|
27
|
+
/** Fail with an explicit status; renders the nearest `_error`. */
|
|
28
|
+
export declare function error(status: number, message: string): HttpError;
|
|
29
|
+
/** Whether a value is an `HttpError` thrown by `error()` / `notFound()`. */
|
|
30
|
+
export declare const isHttpError: (value: unknown) => value is HttpError;
|
|
31
|
+
export {};
|
package/shared/errors.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// Control-flow results for guards, loaders and actions: thrown or returned, never rendered as data.
|
|
2
|
+
import { unsafeTarget } from "./redirect.js";
|
|
3
|
+
/** A redirect, denial or HTTP failure ending the guard/loader chain. */
|
|
4
|
+
export class HttpError extends Error {
|
|
5
|
+
constructor(status, message, location) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.name = "HttpError";
|
|
8
|
+
this.status = status;
|
|
9
|
+
this.location = location;
|
|
10
|
+
this.expose = status < 500;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Redirect (default 302 Found, 303 See Other from actions; 307/308 keep the method). Same-origin
|
|
15
|
+
* only: relative targets, or absolute URLs on the request's origin; `{ external: true }` allows
|
|
16
|
+
* other origins. Protocol-relative (`//x`), backslash and control-character targets throw.
|
|
17
|
+
*/
|
|
18
|
+
export function redirect(location, status, options) {
|
|
19
|
+
const opts = typeof status === "object" ? status : options;
|
|
20
|
+
const code = typeof status === "number" ? status : opts?.status;
|
|
21
|
+
const external = opts?.external === true;
|
|
22
|
+
const unsafe = unsafeTarget(location);
|
|
23
|
+
if (unsafe)
|
|
24
|
+
throw new Error(`redirect(): unsafe target ${JSON.stringify(location)} (${unsafe})`);
|
|
25
|
+
if (external && !/^https?:\/\//i.test(location))
|
|
26
|
+
throw new Error(`redirect(): external target must be an absolute http(s) URL, got ${JSON.stringify(location)}`);
|
|
27
|
+
const error = new HttpError(code ?? 302, "Redirect", location);
|
|
28
|
+
// Actions answer a defaulted redirect with 303 (POST → GET); an explicit status is kept.
|
|
29
|
+
if (code === undefined)
|
|
30
|
+
Object.defineProperty(error, "defaultStatus", { value: true });
|
|
31
|
+
// The handler checks absolute targets against the request origin unless external.
|
|
32
|
+
if (external)
|
|
33
|
+
Object.defineProperty(error, "external", { value: true });
|
|
34
|
+
return error;
|
|
35
|
+
}
|
|
36
|
+
/** Refuse access (default 403). */
|
|
37
|
+
export function deny(status = 403, message = status === 401 ? "Unauthorized" : "Forbidden") {
|
|
38
|
+
return new HttpError(status, message);
|
|
39
|
+
}
|
|
40
|
+
/** The resource does not exist; renders the nearest `_404`. */
|
|
41
|
+
export function notFound(message = "Not found") {
|
|
42
|
+
return new HttpError(404, message);
|
|
43
|
+
}
|
|
44
|
+
/** Fail with an explicit status; renders the nearest `_error`. */
|
|
45
|
+
export function error(status, message) {
|
|
46
|
+
if (!Number.isInteger(status) || status < 400 || status > 599)
|
|
47
|
+
throw new RangeError("error() status must be 4xx or 5xx");
|
|
48
|
+
return new HttpError(status, message);
|
|
49
|
+
}
|
|
50
|
+
/** Whether a value is an `HttpError` thrown by `error()` / `notFound()`. */
|
|
51
|
+
export const isHttpError = (value) => value instanceof HttpError;
|
package/shared/head.d.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { Head, HeadLink, HeadMeta, HeadScript, InlineScript } from "./data.js";
|
|
2
|
+
/** One head contribution: `level` −1 document defaults, layouts 0…n−1, page n; `phase` 0 route export, 1 components. */
|
|
3
|
+
export interface RankedHead {
|
|
4
|
+
readonly level: number;
|
|
5
|
+
readonly phase: 0 | 1;
|
|
6
|
+
readonly seq: number;
|
|
7
|
+
readonly head: Head;
|
|
8
|
+
}
|
|
9
|
+
/** Brand static inline script code (kit `?script` imports, `unsafeInlineScript`). */
|
|
10
|
+
export declare function inlineScript(code: string): InlineScript;
|
|
11
|
+
export declare const isInlineScript: (value: unknown) => value is InlineScript;
|
|
12
|
+
export declare function metaKey(meta: HeadMeta): string;
|
|
13
|
+
export declare function linkKey(link: HeadLink): string;
|
|
14
|
+
export declare function scriptKey(script: HeadScript): string;
|
|
15
|
+
/**
|
|
16
|
+
* Merge contributions by rank `(level, phase, seq)`, later winning per key. The title template
|
|
17
|
+
* applies once to the winning title (the output's `title` is final; `titleTemplate` is kept).
|
|
18
|
+
*/
|
|
19
|
+
export declare function rankMerge(entries: readonly RankedHead[]): Head | undefined;
|
|
20
|
+
export interface HeadTagOptions {
|
|
21
|
+
/** Stylesheet URLs, linked before the head's own stylesheets (capo position 7). */
|
|
22
|
+
styles?: readonly string[];
|
|
23
|
+
/** Entries of `styles` written as an inline `<style>` (URL → CSS, with the nonce) instead of a link, in place. */
|
|
24
|
+
inlineStyles?: Readonly<Record<string, string>> | undefined;
|
|
25
|
+
/** CSP nonce for inline and external scripts. */
|
|
26
|
+
nonce?: string | undefined;
|
|
27
|
+
/**
|
|
28
|
+
* Mark managed tags `data-rh` (kit updates them on client navigation); entries in `persistent`
|
|
29
|
+
* (root layout components, never re-rendered in the browser) get `data-rh="0"` and are kept.
|
|
30
|
+
*/
|
|
31
|
+
managed?: boolean | HeadManaged | undefined;
|
|
32
|
+
}
|
|
33
|
+
export interface HeadManaged {
|
|
34
|
+
readonly persistent: ReadonlySet<object>;
|
|
35
|
+
}
|
|
36
|
+
export declare function linkTag(link: HeadLink, mark?: string): string;
|
|
37
|
+
export declare function metaTag(meta: HeadMeta, mark?: string): string;
|
|
38
|
+
export declare const jsonLdTag: (data: object, id?: string, mark?: string) => string;
|
|
39
|
+
export declare const BOOT_LIST_END = ",d=document,";
|
|
40
|
+
/**
|
|
41
|
+
* Bootstrap for `load` / `idle` / `interaction` scripts: `list` (escaped JSON array) inlined, code
|
|
42
|
+
* for only the strategies the page uses. Injected scripts are async by default.
|
|
43
|
+
*/
|
|
44
|
+
export declare function _scriptBootstrap(strategies: Iterable<string>, list?: string): string;
|
|
45
|
+
/**
|
|
46
|
+
* Tags for a merged head in capo.js order: base, title, preconnect, async scripts, blocking scripts,
|
|
47
|
+
* stylesheets (`styles` first), preloads, deferred scripts (+ bootstrap), prefetch, meta / SEO links,
|
|
48
|
+
* JSON-LD. Charset, viewport and the CSP meta belong to the shell.
|
|
49
|
+
*/
|
|
50
|
+
export declare function headTags(head: Head | undefined, options?: HeadTagOptions): string;
|
|
51
|
+
/** `<html>` attributes (leading space) for shells: the head's `html` over request defaults (i18n `locale`/`dir`). */
|
|
52
|
+
export declare function htmlAttributes(head: Head | undefined, defaults?: {
|
|
53
|
+
lang?: string | undefined;
|
|
54
|
+
dir?: string | undefined;
|
|
55
|
+
}): string;
|
|
56
|
+
/** `<body>` attributes (leading space). */
|
|
57
|
+
export declare const bodyAttributes: (head: Head | undefined) => string;
|
package/shared/head.js
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
// Head merging (rank + dedupe keys) and serialization (capo.js order), shared by server and kit.
|
|
2
|
+
import { escapeJSON } from "@rivetjs/runtime/resume-format";
|
|
3
|
+
import { escapeAttribute, escapeText } from "@rivetjs/runtime/escape";
|
|
4
|
+
const inline = new WeakSet();
|
|
5
|
+
/** Brand static inline script code (kit `?script` imports, `unsafeInlineScript`). */
|
|
6
|
+
export function inlineScript(code) {
|
|
7
|
+
const script = Object.freeze({ code });
|
|
8
|
+
inline.add(script);
|
|
9
|
+
return script;
|
|
10
|
+
}
|
|
11
|
+
export const isInlineScript = (value) => typeof value === "object" && value !== null && inline.has(value);
|
|
12
|
+
export function metaKey(meta) {
|
|
13
|
+
if (meta.key !== undefined)
|
|
14
|
+
return "k:" + meta.key;
|
|
15
|
+
const base = meta.name !== undefined
|
|
16
|
+
? "n:" + meta.name
|
|
17
|
+
: meta.property !== undefined
|
|
18
|
+
? "p:" + meta.property
|
|
19
|
+
: meta.httpEquiv !== undefined
|
|
20
|
+
? "h:" + meta.httpEquiv.toLowerCase()
|
|
21
|
+
: "i:" + meta.itemprop;
|
|
22
|
+
return meta.media !== undefined ? base + "|" + meta.media : base;
|
|
23
|
+
}
|
|
24
|
+
export function linkKey(link) {
|
|
25
|
+
if (link.key !== undefined)
|
|
26
|
+
return "k:" + link.key;
|
|
27
|
+
const rel = link.rel.toLowerCase();
|
|
28
|
+
if (rel === "canonical")
|
|
29
|
+
return "canonical";
|
|
30
|
+
// hreflang alternates may share an href (`en` and `x-default`); feeds differ by href.
|
|
31
|
+
if (rel === "alternate")
|
|
32
|
+
return link.hreflang !== undefined ? `alternate ${link.hreflang} ${link.type ?? ""}` : `alternate ${link.type ?? ""} ${link.href}`;
|
|
33
|
+
if (rel === "icon" || rel === "apple-touch-icon" || rel === "mask-icon")
|
|
34
|
+
return `${rel} ${link.sizes ?? ""} ${link.media ?? ""}`;
|
|
35
|
+
return `${rel} ${link.href || link.imagesrcset || ""} ${link.media ?? ""}`;
|
|
36
|
+
}
|
|
37
|
+
let jsonLdSeq = 0;
|
|
38
|
+
export function scriptKey(script) {
|
|
39
|
+
if (script.key !== undefined)
|
|
40
|
+
return "k:" + script.key;
|
|
41
|
+
if ("src" in script)
|
|
42
|
+
return "s:" + (script.id ?? script.src);
|
|
43
|
+
if ("inline" in script)
|
|
44
|
+
return "i:" + (script.id ?? script.inline.code);
|
|
45
|
+
// JSON-LD without an id is never deduplicated (several blocks are fine).
|
|
46
|
+
return script.id !== undefined ? "j:" + script.id : "j#" + jsonLdSeq++;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Merge contributions by rank `(level, phase, seq)`, later winning per key. The title template
|
|
50
|
+
* applies once to the winning title (the output's `title` is final; `titleTemplate` is kept).
|
|
51
|
+
*/
|
|
52
|
+
export function rankMerge(entries) {
|
|
53
|
+
if (!entries.length)
|
|
54
|
+
return undefined;
|
|
55
|
+
const sorted = [...entries].sort((a, b) => a.level - b.level || a.phase - b.phase || a.seq - b.seq);
|
|
56
|
+
let title, template, base;
|
|
57
|
+
let html, body;
|
|
58
|
+
const meta = new Map(), links = new Map(), scripts = new Map(), styles = new Map();
|
|
59
|
+
for (const { head } of sorted) {
|
|
60
|
+
if (head.title !== undefined)
|
|
61
|
+
title = head.title;
|
|
62
|
+
if (head.titleTemplate !== undefined)
|
|
63
|
+
template = head.titleTemplate;
|
|
64
|
+
if (head.base)
|
|
65
|
+
base = head.base;
|
|
66
|
+
if (head.html)
|
|
67
|
+
html = { ...html, ...head.html };
|
|
68
|
+
if (head.body)
|
|
69
|
+
body = { ...body, ...head.body };
|
|
70
|
+
for (const entry of head.meta ?? [])
|
|
71
|
+
meta.set(metaKey(entry), entry);
|
|
72
|
+
for (const entry of head.links ?? [])
|
|
73
|
+
links.set(linkKey(entry), entry);
|
|
74
|
+
for (const entry of head.scripts ?? [])
|
|
75
|
+
scripts.set(scriptKey(entry), entry);
|
|
76
|
+
for (const entry of head.styles ?? [])
|
|
77
|
+
styles.set(entry.key ?? entry.css, entry);
|
|
78
|
+
}
|
|
79
|
+
if (title !== undefined && template !== undefined)
|
|
80
|
+
title = template.replace("%s", title);
|
|
81
|
+
// The template stays alongside the final title: client head updates template component titles.
|
|
82
|
+
return {
|
|
83
|
+
...(title !== undefined ? { title } : {}),
|
|
84
|
+
...(template !== undefined ? { titleTemplate: template } : {}),
|
|
85
|
+
...(html ? { html } : {}),
|
|
86
|
+
...(body ? { body } : {}),
|
|
87
|
+
...(base ? { base } : {}),
|
|
88
|
+
...(meta.size ? { meta: [...meta.values()] } : {}),
|
|
89
|
+
...(links.size ? { links: [...links.values()] } : {}),
|
|
90
|
+
...(scripts.size ? { scripts: [...scripts.values()] } : {}),
|
|
91
|
+
...(styles.size ? { styles: [...styles.values()] } : {}),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
const attr = (name, value) => value === undefined || value === false ? "" : value === true ? " " + name : ` ${name}="${escapeAttribute(value)}"`;
|
|
95
|
+
const LINK_ATTRIBUTES = [
|
|
96
|
+
"rel",
|
|
97
|
+
"href",
|
|
98
|
+
"type",
|
|
99
|
+
"hreflang",
|
|
100
|
+
"media",
|
|
101
|
+
"sizes",
|
|
102
|
+
"as",
|
|
103
|
+
"crossorigin",
|
|
104
|
+
"imagesrcset",
|
|
105
|
+
"imagesizes",
|
|
106
|
+
"fetchpriority",
|
|
107
|
+
"title",
|
|
108
|
+
"color",
|
|
109
|
+
"integrity",
|
|
110
|
+
"referrerpolicy",
|
|
111
|
+
];
|
|
112
|
+
export function linkTag(link, mark = "") {
|
|
113
|
+
let tag = "<link";
|
|
114
|
+
for (const name of LINK_ATTRIBUTES)
|
|
115
|
+
tag += attr(name, link[name]);
|
|
116
|
+
return tag + mark + ">";
|
|
117
|
+
}
|
|
118
|
+
export function metaTag(meta, mark = "") {
|
|
119
|
+
const key = meta.name !== undefined
|
|
120
|
+
? attr("name", meta.name)
|
|
121
|
+
: meta.property !== undefined
|
|
122
|
+
? attr("property", meta.property)
|
|
123
|
+
: meta.httpEquiv !== undefined
|
|
124
|
+
? attr("http-equiv", meta.httpEquiv)
|
|
125
|
+
: meta.itemprop !== undefined
|
|
126
|
+
? attr("itemprop", meta.itemprop)
|
|
127
|
+
: "";
|
|
128
|
+
return key ? `<meta${key} content="${escapeAttribute(meta.content)}"${attr("media", meta.media)}${mark}>` : "";
|
|
129
|
+
}
|
|
130
|
+
export const jsonLdTag = (data, id, mark = "") => `<script type="application/ld+json"${attr("id", id)}${mark}>${escapeJSON(JSON.stringify(data))}</script>`;
|
|
131
|
+
const deferred = (s) => s.strategy === "load" || s.strategy === "idle" || s.strategy === "interaction";
|
|
132
|
+
// Script bootstrap (minified by hand), one inline script per page: `{let l=<list>,…}` (block scope).
|
|
133
|
+
// It runs while the head parses, before `load`. List entries: `{ u: src, k, …script properties }`,
|
|
134
|
+
// `k` = index in STRATEGY_CODES. `l` doubles as the policy's allowlist.
|
|
135
|
+
const STRATEGY_CODES = ["load", "idle", "interaction"];
|
|
136
|
+
export const BOOT_LIST_END = ",d=document,";
|
|
137
|
+
const BOOT_CORE = BOOT_LIST_END +
|
|
138
|
+
'E=addEventListener,p,r=k=>l.map(({u,k:s,...o})=>s==k&&d.head.append(Object.assign(d.createElement("script"),o,{src:p?.createScriptURL(u)??u})));' +
|
|
139
|
+
// The page's one `rivet-script` policy (unlisted URLs become ""; JSON-only scripts for kit JSON-LD), shared with kit's head manager.
|
|
140
|
+
'try{d.$rs={p:p=trustedTypes.createPolicy("rivet-script",{createScriptURL:u=>l.some(s=>s.u==u)?u:"",createScript:c=>JSON.parse(c)&&c}),l}}catch{}';
|
|
141
|
+
// Window-level scroll / wheel listeners are passive already; listeners stay attached as no-ops. Touch
|
|
142
|
+
// interaction arrives as pointerdown.
|
|
143
|
+
const BOOT_IDLE = 'let t,n,g=()=>n||(clearTimeout(t),t=setTimeout(()=>{n=1;(self.requestIdleCallback||setTimeout)(()=>r(1),{timeout:4e3})},150));E("scroll",g);g();';
|
|
144
|
+
const BOOT_INTERACTION = 'let i,x=()=>i||(i=r(2));"pointerdown keydown wheel".split(" ").map(e=>E(e,x,!0));';
|
|
145
|
+
/**
|
|
146
|
+
* Bootstrap for `load` / `idle` / `interaction` scripts: `list` (escaped JSON array) inlined, code
|
|
147
|
+
* for only the strategies the page uses. Injected scripts are async by default.
|
|
148
|
+
*/
|
|
149
|
+
export function _scriptBootstrap(strategies, list = "[]") {
|
|
150
|
+
const used = new Set(strategies);
|
|
151
|
+
const onload = (used.has("load") ? "r(0);" : "") + (used.has("idle") ? BOOT_IDLE : "");
|
|
152
|
+
return "{let l=" + list + BOOT_CORE + (onload ? `E("load",()=>{${onload}});` : "") + (used.has("interaction") ? BOOT_INTERACTION : "") + "}";
|
|
153
|
+
}
|
|
154
|
+
function scriptTag(script, nonce, mark) {
|
|
155
|
+
const module = script.type === "module";
|
|
156
|
+
const strategy = script.strategy ?? "defer";
|
|
157
|
+
const timing = strategy === "eager" ? " async" : strategy === "defer" && !module ? " defer" : "";
|
|
158
|
+
return `<script${module ? ' type="module"' : ""}${timing} src="${escapeAttribute(script.src)}"${attr("id", script.id)}${attr("integrity", script.integrity)}${attr("crossorigin", script.crossorigin)}${attr("referrerpolicy", script.referrerpolicy)}${nonce}${mark}></script>`;
|
|
159
|
+
}
|
|
160
|
+
/** Inline `<style>` (unmanaged: never `data-rh`). CSS never contains a closing tag. */
|
|
161
|
+
const styleTag = (style, nonce) => `<style${attr("media", style.media)}${nonce}>${style.css.replace(/<\/(style)/gi, "<\\/$1")}</style>`;
|
|
162
|
+
/** Inline script: its JSON data block (if any) right before it. Code never contains a closing tag. */
|
|
163
|
+
function inlineTag(script, nonce) {
|
|
164
|
+
if (!isInlineScript(script.inline))
|
|
165
|
+
throw new TypeError("head script `inline` must be an InlineScript (`?script` import or unsafeInlineScript)");
|
|
166
|
+
const data = script.data !== undefined ? `<script type="application/json">${escapeJSON(JSON.stringify(script.data))}</script>` : "";
|
|
167
|
+
return `${data}<script${attr("id", script.id)}${nonce}>${script.inline.code.replace(/<\/(script)/gi, "<\\/$1")}</script>`;
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Tags for a merged head in capo.js order: base, title, preconnect, async scripts, blocking scripts,
|
|
171
|
+
* stylesheets (`styles` first), preloads, deferred scripts (+ bootstrap), prefetch, meta / SEO links,
|
|
172
|
+
* JSON-LD. Charset, viewport and the CSP meta belong to the shell.
|
|
173
|
+
*/
|
|
174
|
+
export function headTags(head, options = {}) {
|
|
175
|
+
const managed = options.managed;
|
|
176
|
+
const markOf = (entry) => (!managed ? "" : typeof managed === "object" && managed.persistent.has(entry) ? ' data-rh="0"' : ' data-rh=""');
|
|
177
|
+
const nonce = options.nonce !== undefined ? ` nonce="${options.nonce.replace(/[^\w+/=-]/g, "")}"` : "";
|
|
178
|
+
// One string per capo.js group (index = order), appended in place.
|
|
179
|
+
const groups = ["", "", "", "", "", "", "", "", "", "", "", ""];
|
|
180
|
+
const sheets = options.inlineStyles;
|
|
181
|
+
for (const href of options.styles ?? []) {
|
|
182
|
+
const css = sheets && Object.hasOwn(sheets, href) ? sheets[href] : undefined;
|
|
183
|
+
groups[5] += css !== undefined ? styleTag({ css }, nonce) : `<link rel="stylesheet" href="${escapeAttribute(href)}">`;
|
|
184
|
+
}
|
|
185
|
+
// Inline styles after the stylesheets (same group): component rules (motion entrances) win ties over sheet rules.
|
|
186
|
+
const inlineStyles = head?.styles?.map((style) => styleTag(style, nonce)).join("") ?? "";
|
|
187
|
+
if (head?.base)
|
|
188
|
+
groups[0] += `<base href="${escapeAttribute(head.base.href)}"${attr("target", head.base.target)}>`;
|
|
189
|
+
if (head?.title !== undefined)
|
|
190
|
+
groups[1] += `<title>${escapeText(head.title)}</title>`;
|
|
191
|
+
for (const link of head?.links ?? []) {
|
|
192
|
+
const rel = link.rel.toLowerCase();
|
|
193
|
+
const group = rel === "preconnect"
|
|
194
|
+
? 2
|
|
195
|
+
: rel === "stylesheet"
|
|
196
|
+
? 5
|
|
197
|
+
: rel === "preload" || rel === "modulepreload"
|
|
198
|
+
? 6
|
|
199
|
+
: rel === "prefetch" || rel === "dns-prefetch" || rel === "prerender"
|
|
200
|
+
? 8
|
|
201
|
+
: 10;
|
|
202
|
+
groups[group] += linkTag(link, markOf(link));
|
|
203
|
+
}
|
|
204
|
+
groups[5] += inlineStyles;
|
|
205
|
+
const later = [];
|
|
206
|
+
for (const script of head?.scripts ?? []) {
|
|
207
|
+
if ("jsonLd" in script)
|
|
208
|
+
groups[11] += jsonLdTag(script.jsonLd, script.id, markOf(script));
|
|
209
|
+
else if ("inline" in script) {
|
|
210
|
+
const strategy = script.strategy ?? "defer";
|
|
211
|
+
groups[strategy === "eager" ? 3 : strategy === "blocking" ? 4 : 7] += inlineTag(script, nonce);
|
|
212
|
+
}
|
|
213
|
+
else if (deferred(script))
|
|
214
|
+
later.push(script);
|
|
215
|
+
else {
|
|
216
|
+
const strategy = script.strategy ?? "defer";
|
|
217
|
+
groups[strategy === "eager" ? 3 : strategy === "blocking" ? 4 : 7] += scriptTag(script, nonce, markOf(script));
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
if (later.length) {
|
|
221
|
+
const list = later.map((s) => ({
|
|
222
|
+
u: s.src,
|
|
223
|
+
k: STRATEGY_CODES.indexOf(s.strategy),
|
|
224
|
+
...(s.type === "module" ? { type: "module" } : {}),
|
|
225
|
+
...(s.id ? { id: s.id } : {}),
|
|
226
|
+
...(s.integrity ? { integrity: s.integrity } : {}),
|
|
227
|
+
...(s.crossorigin ? { crossOrigin: s.crossorigin } : {}),
|
|
228
|
+
...(s.referrerpolicy ? { referrerPolicy: s.referrerpolicy } : {}),
|
|
229
|
+
}));
|
|
230
|
+
groups[7] += `<script id="rivet-scripts"${nonce}>${_scriptBootstrap(later.map((s) => s.strategy), escapeJSON(JSON.stringify(list)))}</script>`;
|
|
231
|
+
}
|
|
232
|
+
for (const meta of head?.meta ?? [])
|
|
233
|
+
groups[9] += metaTag(meta, markOf(meta));
|
|
234
|
+
return groups.join("");
|
|
235
|
+
}
|
|
236
|
+
/** `<html>` attributes (leading space) for shells: the head's `html` over request defaults (i18n `locale`/`dir`). */
|
|
237
|
+
export function htmlAttributes(head, defaults = {}) {
|
|
238
|
+
const html = { ...defaults, ...head?.html };
|
|
239
|
+
return attr("lang", html.lang) + attr("dir", html.dir) + attr("class", head?.html?.class);
|
|
240
|
+
}
|
|
241
|
+
/** `<body>` attributes (leading space). */
|
|
242
|
+
export const bodyAttributes = (head) => attr("class", head?.body?.class);
|
package/shared/nonce.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// CSP nonces: the one generator behind the node and fetch adapters (Web Crypto, every runtime).
|
|
2
|
+
// 16 fresh CSPRNG bytes per nonce, cut from a pooled 4 KiB `getRandomValues` fill (every byte used once; the pool
|
|
3
|
+
// refills when it runs out) and base64-encoded by table: one `getRandomValues` + `btoa(String.fromCharCode(…))`
|
|
4
|
+
// per request cost ~0.8 µs, this ~0.1 µs.
|
|
5
|
+
const POOL = 4096;
|
|
6
|
+
const pool = new Uint8Array(POOL);
|
|
7
|
+
let at = POOL;
|
|
8
|
+
const DIGITS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
|
9
|
+
/** Fresh 128-bit CSP nonce (base64) for one response's inline scripts. */
|
|
10
|
+
export function nonce() {
|
|
11
|
+
if (at + 16 > POOL) {
|
|
12
|
+
crypto.getRandomValues(pool);
|
|
13
|
+
at = 0;
|
|
14
|
+
}
|
|
15
|
+
let out = "";
|
|
16
|
+
// 5 groups of 3 bytes → 20 digits, then the 16th byte → 2 digits + "==".
|
|
17
|
+
for (let end = at + 15; at < end; at += 3) {
|
|
18
|
+
const n = (pool[at] << 16) | (pool[at + 1] << 8) | pool[at + 2];
|
|
19
|
+
out += DIGITS[n >> 18] + DIGITS[(n >> 12) & 63] + DIGITS[(n >> 6) & 63] + DIGITS[n & 63];
|
|
20
|
+
}
|
|
21
|
+
const last = pool[at++];
|
|
22
|
+
return out + DIGITS[last >> 2] + DIGITS[(last & 3) << 4] + "==";
|
|
23
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const FALLBACK = "(function(m,g){const a=document;if(HTMLScriptElement.supports?.(\"speculationrules\"))return;const r=new Set,v=a.createElement(\"link\").relList.supports?.(\"prefetch\"),u=\"rivet:prefetch\",f={priority:\"low\"},y={capture:!0,passive:!0},s=(t,e)=>a.addEventListener(t,e,y);let i,c=null,n=2;try{n=sessionStorage.getItem(u)||2}catch{}const h=t=>{const e=t.target?.closest?.(m);return e&&g?.test(e.pathname)&&!e.matches(\"[data-rivet-reload],[rel~=external]\")?null:e},p=t=>{const e=navigator.connection;if(!t||e?.saveData||/2g/.test(e?.effectiveType))return;const o=t.href.split(\"#\")[0];if(t.origin!==location.origin||o===location.href.split(\"#\")[0]||r.has(o)||n<1)return;r.size>99&&r.delete(r.values().next().value),r.add(o);const d=()=>v?a.head.append(Object.assign(a.createElement(\"link\"),{rel:\"prefetch\",as:\"document\",href:o})):fetch(o,f).catch(()=>{});if(n==\"1\")return void d();n=\"\",fetch(o,{...f,cache:\"no-store\"}).then(l=>{l.body?.cancel();const T=l.headers.get(\"cache-control\")+\",\"+l.headers.get(\"pragma\");n=/no-(store|cache)|^(?!.*stale-while).*max-age=0\\b/.test(T)?\"0\":\"1\";try{sessionStorage.setItem(u,n)}catch{}n==\"1\"&&d()},()=>{n=2,r.delete(o)})};s(\"pointerover\",t=>{const e=h(t)||null;e!==c&&(clearTimeout(i),c=e,e&&(i=setTimeout(p,65,e)))}),s(\"pointerout\",t=>{c&&!c.contains(t.relatedTarget)&&(clearTimeout(i),c=null)});for(const t of[\"focusin\",\"touchstart\",\"pointerdown\"])s(t,e=>p(h(e)))})";
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
// Generated by build/prefetch-script.ts from client/prefetch-fallback.ts. Do not edit.
|
|
2
|
+
export const FALLBACK = "(function(m,g){const a=document;if(HTMLScriptElement.supports?.(\"speculationrules\"))return;const r=new Set,v=a.createElement(\"link\").relList.supports?.(\"prefetch\"),u=\"rivet:prefetch\",f={priority:\"low\"},y={capture:!0,passive:!0},s=(t,e)=>a.addEventListener(t,e,y);let i,c=null,n=2;try{n=sessionStorage.getItem(u)||2}catch{}const h=t=>{const e=t.target?.closest?.(m);return e&&g?.test(e.pathname)&&!e.matches(\"[data-rivet-reload],[rel~=external]\")?null:e},p=t=>{const e=navigator.connection;if(!t||e?.saveData||/2g/.test(e?.effectiveType))return;const o=t.href.split(\"#\")[0];if(t.origin!==location.origin||o===location.href.split(\"#\")[0]||r.has(o)||n<1)return;r.size>99&&r.delete(r.values().next().value),r.add(o);const d=()=>v?a.head.append(Object.assign(a.createElement(\"link\"),{rel:\"prefetch\",as:\"document\",href:o})):fetch(o,f).catch(()=>{});if(n==\"1\")return void d();n=\"\",fetch(o,{...f,cache:\"no-store\"}).then(l=>{l.body?.cancel();const T=l.headers.get(\"cache-control\")+\",\"+l.headers.get(\"pragma\");n=/no-(store|cache)|^(?!.*stale-while).*max-age=0\\b/.test(T)?\"0\":\"1\";try{sessionStorage.setItem(u,n)}catch{}n==\"1\"&&d()},()=>{n=2,r.delete(o)})};s(\"pointerover\",t=>{const e=h(t)||null;e!==c&&(clearTimeout(i),c=e,e&&(i=setTimeout(p,65,e)))}),s(\"pointerout\",t=>{c&&!c.contains(t.relatedTarget)&&(clearTimeout(i),c=null)});for(const t of[\"focusin\",\"touchstart\",\"pointerdown\"])s(t,e=>p(h(e)))})";
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { DocumentShell } from "../server/render.js";
|
|
2
|
+
/** What a link loads ahead of a document navigation: the HTML (`document`) or the whole page in the background (`prerender`). */
|
|
3
|
+
export type DocumentPrefetch = "document" | "prerender";
|
|
4
|
+
/**
|
|
5
|
+
* When Chromium acts on the rules (Speculation Rules `eagerness`): `conservative` on pointer / touch
|
|
6
|
+
* down, `moderate` also after the pointer rests on a link ~200 ms, `eager` sooner still (on desktop:
|
|
7
|
+
* a short hover; Chromium decides the exact heuristics).
|
|
8
|
+
*/
|
|
9
|
+
export type PrefetchEagerness = "conservative" | "moderate" | "eager";
|
|
10
|
+
/** Document prefetch options (Speculation Rules with a fallback). */
|
|
11
|
+
export interface DocumentPrefetchOptions {
|
|
12
|
+
/**
|
|
13
|
+
* App default (`rivetRouter({ prefetch })`): links without their own `data-rivet-prefetch` choice
|
|
14
|
+
* (plain `<a>` too) prefetch this way. Absent: only links marked `document` / `prerender`.
|
|
15
|
+
*/
|
|
16
|
+
readonly default?: DocumentPrefetch;
|
|
17
|
+
/**
|
|
18
|
+
* The client router intercepts same-app links (every server / static app with more than one
|
|
19
|
+
* route): those navigations never load a document (the router prefetches their data + page chunk
|
|
20
|
+
* on intent itself), so only links that load documents take part: `data-rivet-reload` /
|
|
21
|
+
* `rel=external` links, and — with `routes` — links whose path no route matches.
|
|
22
|
+
*/
|
|
23
|
+
readonly routed?: boolean;
|
|
24
|
+
/**
|
|
25
|
+
* RegExp source over `URL.pathname.slice(1)` matching every path a route of the app (page or
|
|
26
|
+
* endpoint) matches (generated: `routePathRegExp` in `build/codegen.ts`). Links to other paths load
|
|
27
|
+
* documents and take part; endpoint paths never do (only when marked `reload`). Implies `routed`.
|
|
28
|
+
*/
|
|
29
|
+
readonly routes?: string;
|
|
30
|
+
/** Speculation Rules eagerness (`rivetRouter({ prefetchEagerness })`); default `moderate`. The fallback keeps its own intent timing. */
|
|
31
|
+
readonly eagerness?: PrefetchEagerness;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* CSS selector of the links prefetched with `kind` (`"all"`: either kind). Routed apps: only
|
|
35
|
+
* `data-rivet-reload` / `rel=external` links — with `routes`, path-based detection is not a selector
|
|
36
|
+
* (see `speculationRules`), so the selector leaves it out.
|
|
37
|
+
*/
|
|
38
|
+
export declare function prefetchSelector(kind: DocumentPrefetch | "all", options?: DocumentPrefetchOptions): string;
|
|
39
|
+
/**
|
|
40
|
+
* Speculation Rules (Chromium) for a page at `url`: document rules over the prefetch selectors,
|
|
41
|
+
* same origin only (`/*`), not the page itself (either trailing-slash form), `moderate` eagerness by default (pointer resting ~200 ms on a
|
|
42
|
+
* link, or pointer down). The browser applies them to links added later too (no registry needed).
|
|
43
|
+
*/
|
|
44
|
+
export declare function speculationRules(url: URL, options?: DocumentPrefetchOptions): string;
|
|
45
|
+
/** Head tags: the rules (Chromium) and the inline fallback (`<link rel=prefetch>` / `fetch` on intent elsewhere). */
|
|
46
|
+
export declare function prefetchTags(url: URL, nonce: string | undefined, options?: DocumentPrefetchOptions): string;
|
|
47
|
+
/** Wrap a document shell: every page gets the prefetch tags right before `</head>` (generated `entry.server.tsx`). */
|
|
48
|
+
export declare function documentPrefetch(shell: DocumentShell, options?: DocumentPrefetchOptions): DocumentShell;
|
|
49
|
+
/**
|
|
50
|
+
* Run `fn` now, or — on a page the browser is prerendering (`<Link prefetch="prerender">`) — once the
|
|
51
|
+
* user actually navigates to it (`prerenderingchange`). For page-view analytics and other "the user
|
|
52
|
+
* saw this page" side effects. No-op outside a browser.
|
|
53
|
+
*/
|
|
54
|
+
export declare function whenActivated(fn: () => void): void;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { FALLBACK } from "./prefetch-fallback.min.js";
|
|
2
|
+
/** Links the browser would navigate in this tab (no download, no other target). */
|
|
3
|
+
const SAME_TAB = ":not([download],[target]:not([target=_self]))";
|
|
4
|
+
/** Links of a client-routed app that load a document whatever their path (the router never intercepts them). */
|
|
5
|
+
const RELOAD = "[data-rivet-reload],[rel~=external]";
|
|
6
|
+
/**
|
|
7
|
+
* CSS selector of the links prefetched with `kind` (`"all"`: either kind). Routed apps: only
|
|
8
|
+
* `data-rivet-reload` / `rel=external` links — with `routes`, path-based detection is not a selector
|
|
9
|
+
* (see `speculationRules`), so the selector leaves it out.
|
|
10
|
+
*/
|
|
11
|
+
export function prefetchSelector(kind, options = {}) {
|
|
12
|
+
const marked = kind === "all" ? "a[data-rivet-prefetch=document],a[data-rivet-prefetch=prerender]" : `a[data-rivet-prefetch=${kind}]`;
|
|
13
|
+
const any = options.default && (kind === "all" || options.default === kind) ? ",a:not([data-rivet-prefetch])" : "";
|
|
14
|
+
return `:is(${marked}${any})[href]${options.routed && options.routes === undefined ? `:is(${RELOAD})` : ""}${SAME_TAB}`;
|
|
15
|
+
}
|
|
16
|
+
/** URLPattern-escape a path (`:` `*` `?` `+` `(` `)` `{` `}` `\` are syntax). */
|
|
17
|
+
const pattern = (path) => path.replace(/[\\:*?+(){}]/g, "\\$&");
|
|
18
|
+
/** Script text is JSON: `<` escaped so no `</script` can close it. */
|
|
19
|
+
const json = (value) => JSON.stringify(value).replace(/</g, "\\u003c");
|
|
20
|
+
/**
|
|
21
|
+
* Speculation Rules (Chromium) for a page at `url`: document rules over the prefetch selectors,
|
|
22
|
+
* same origin only (`/*`), not the page itself (either trailing-slash form), `moderate` eagerness by default (pointer resting ~200 ms on a
|
|
23
|
+
* link, or pointer down). The browser applies them to links added later too (no registry needed).
|
|
24
|
+
*/
|
|
25
|
+
export function speculationRules(url, options = {}) {
|
|
26
|
+
// The page's own path with or without its trailing slash (`/a` and `/a/` are the same page by default).
|
|
27
|
+
const path = url.pathname.replace(/\/+$/, "");
|
|
28
|
+
const self = path ? pattern(path) + "{/}?" : "/";
|
|
29
|
+
// Routed app with its route paths: a reload link, or a path no route matches (URLPattern regexp group).
|
|
30
|
+
const routes = options.routes;
|
|
31
|
+
const documents = routes === undefined ? [] : [{ or: [{ selector_matches: RELOAD }, { not: { href_matches: routes ? "/(" + routes + ")" : "/" } }] }];
|
|
32
|
+
const rule = (kind) => [
|
|
33
|
+
{
|
|
34
|
+
source: "document",
|
|
35
|
+
where: { and: [{ href_matches: "/*" }, { not: { href_matches: self } }, { selector_matches: prefetchSelector(kind, options) }, ...documents] },
|
|
36
|
+
eagerness: options.eagerness ?? "moderate",
|
|
37
|
+
},
|
|
38
|
+
];
|
|
39
|
+
return json({ prefetch: rule("document"), prerender: rule("prerender") });
|
|
40
|
+
}
|
|
41
|
+
/** Head tags: the rules (Chromium) and the inline fallback (`<link rel=prefetch>` / `fetch` on intent elsewhere). */
|
|
42
|
+
export function prefetchTags(url, nonce, options = {}) {
|
|
43
|
+
const attr = nonce !== undefined ? ` nonce="${nonce.replace(/[^\w+/=-]/g, "")}"` : "";
|
|
44
|
+
const selector = prefetchSelector("all", options);
|
|
45
|
+
// Routed app with its route paths: the fallback applies the same path rule (a RegExp literal: the source
|
|
46
|
+
// escapes every `/`, and `<` / line terminators are percent-encoded in it).
|
|
47
|
+
const routes = options.routes === undefined ? "" : ",/^\\/(?:" + options.routes + ")$/";
|
|
48
|
+
return (`<script type="speculationrules"${attr}>${speculationRules(url, options)}</script>` + `<script${attr}>${FALLBACK}(${json(selector)}${routes})</script>`);
|
|
49
|
+
}
|
|
50
|
+
/** Wrap a document shell: every page gets the prefetch tags right before `</head>` (generated `entry.server.tsx`). */
|
|
51
|
+
export function documentPrefetch(shell, options = {}) {
|
|
52
|
+
// Written into the page as a RegExp literal: no `<` (`</script`), no line terminator, no unescaped `/`.
|
|
53
|
+
if (options.routes !== undefined && /[<\n\r\u2028\u2029]|(^|[^\\])(\\\\)*\//.test(options.routes))
|
|
54
|
+
throw new TypeError("documentPrefetch: invalid routes source");
|
|
55
|
+
return (input) => {
|
|
56
|
+
const parts = shell(input);
|
|
57
|
+
const tags = prefetchTags(input.url, input.nonce, options);
|
|
58
|
+
const at = parts.start.lastIndexOf("</head>");
|
|
59
|
+
return at < 0 ? { start: parts.start + tags, end: parts.end } : { start: parts.start.slice(0, at) + tags + parts.start.slice(at), end: parts.end };
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Run `fn` now, or — on a page the browser is prerendering (`<Link prefetch="prerender">`) — once the
|
|
64
|
+
* user actually navigates to it (`prerenderingchange`). For page-view analytics and other "the user
|
|
65
|
+
* saw this page" side effects. No-op outside a browser.
|
|
66
|
+
*/
|
|
67
|
+
export function whenActivated(fn) {
|
|
68
|
+
if (typeof document === "undefined")
|
|
69
|
+
return;
|
|
70
|
+
if (document.prerendering)
|
|
71
|
+
document.addEventListener("prerenderingchange", () => fn(), { once: true });
|
|
72
|
+
else
|
|
73
|
+
fn();
|
|
74
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** Absolute URL (has a scheme). */
|
|
2
|
+
export declare const ABSOLUTE: RegExp;
|
|
3
|
+
/** Why `location` cannot be a same-origin redirect target as written, or undefined when it can. */
|
|
4
|
+
export declare function unsafeTarget(location: string): string | undefined;
|
|
5
|
+
/** `target` when it is a same-origin path (`/a/b?c#d`), else `fallback`. Never an absolute or protocol-relative URL. */
|
|
6
|
+
export declare function safeRedirect(target: unknown, fallback?: string): string;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// Open-redirect guards: `redirect()` targets and user-supplied targets (`?redirectTo=`).
|
|
2
|
+
/** Control characters, backslashes or a leading `//` can make browsers resolve a path to another origin. */
|
|
3
|
+
const UNSAFE = /[\u0000-\u001f\u007f\\]|^\s|^\/\//;
|
|
4
|
+
/** Absolute URL (has a scheme). */
|
|
5
|
+
export const ABSOLUTE = /^[a-z][a-z\d+.-]*:/i;
|
|
6
|
+
/** Why `location` cannot be a same-origin redirect target as written, or undefined when it can. */
|
|
7
|
+
export function unsafeTarget(location) {
|
|
8
|
+
if (UNSAFE.test(location))
|
|
9
|
+
return "protocol-relative, backslash or control characters";
|
|
10
|
+
return undefined;
|
|
11
|
+
}
|
|
12
|
+
/** `target` when it is a same-origin path (`/a/b?c#d`), else `fallback`. Never an absolute or protocol-relative URL. */
|
|
13
|
+
export function safeRedirect(target, fallback = "/") {
|
|
14
|
+
if (typeof target !== "string" || !target.startsWith("/") || unsafeTarget(target))
|
|
15
|
+
return fallback;
|
|
16
|
+
const base = "http://rivet.invalid";
|
|
17
|
+
let url;
|
|
18
|
+
try {
|
|
19
|
+
url = new URL(target, base);
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return fallback;
|
|
23
|
+
}
|
|
24
|
+
return url.origin === base ? url.pathname + url.search + url.hash : fallback;
|
|
25
|
+
}
|