@rivetjs/router 0.0.0-stage → 0.1.0-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +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 +7 -0
- package/adapters/files.js +103 -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 +163 -0
- package/adapters/node.js +940 -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 +92 -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 +33 -0
- package/adapters/vercel.js +45 -0
- package/build/codegen.d.ts +84 -0
- package/build/codegen.js +920 -0
- package/build/dev-errors.d.ts +19 -0
- package/build/dev-errors.js +236 -0
- package/build/migrate.d.ts +15 -0
- package/build/migrate.js +146 -0
- package/build/route-css.d.ts +54 -0
- package/build/route-css.js +216 -0
- package/build/routes.d.ts +50 -0
- package/build/routes.js +202 -0
- package/build/sync.d.ts +20 -0
- package/build/sync.js +16 -0
- package/build/vite.d.ts +195 -0
- package/build/vite.js +703 -0
- package/cli/rivet.d.ts +2 -0
- package/cli/rivet.js +100 -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 +74 -0
- package/client/index.d.ts +152 -0
- package/client/index.js +680 -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 +138 -0
- package/index.js +7 -0
- package/package.json +156 -4
- package/server/app.d.ts +169 -0
- package/server/app.js +302 -0
- package/server/body.d.ts +20 -0
- package/server/body.js +8 -0
- package/server/early-hints.d.ts +34 -0
- package/server/early-hints.js +63 -0
- package/server/handler.d.ts +196 -0
- package/server/handler.js +623 -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 +78 -0
- package/server/render.d.ts +94 -0
- package/server/render.js +489 -0
- package/server/route-cache.d.ts +175 -0
- package/server/route-cache.js +742 -0
- package/server/security.d.ts +18 -0
- package/server/security.js +47 -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/slash.d.ts +8 -0
- package/shared/slash.js +24 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reject cross-site unsafe requests: `Origin` must equal the app origin; without it `Sec-Fetch-Site`
|
|
3
|
+
* must be `same-origin`; with neither, form content types are rejected outside dev (browsers send at
|
|
4
|
+
* least one header; non-browser JSON clients send none; dev keeps `curl -d` working).
|
|
5
|
+
*/
|
|
6
|
+
export declare function crossSite(request: Request, origin: string, dev: boolean): boolean;
|
|
7
|
+
/** Request body limit: bytes for every body, or per kind (`multipart` uploads usually get more). */
|
|
8
|
+
export type BodyLimit = number | {
|
|
9
|
+
readonly default?: number;
|
|
10
|
+
readonly multipart?: number;
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* The request with its body limited to `limit` bytes: 413 at once for a larger `Content-Length`,
|
|
14
|
+
* else the body stream errors (and `state.exceeded` is set) once it passes the limit.
|
|
15
|
+
*/
|
|
16
|
+
export declare function limitBody(request: Request, limit: BodyLimit | undefined, state: {
|
|
17
|
+
exceeded: boolean;
|
|
18
|
+
}): Request;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Request hardening the handler applies to state-changing requests: cross-site request rejection
|
|
2
|
+
// (Origin / Fetch Metadata) and a request body size limit.
|
|
3
|
+
import { HttpError } from "../shared/errors.js";
|
|
4
|
+
/** Content types a cross-site HTML form (or a CORS "simple" request) can send. */
|
|
5
|
+
const FORM_TYPES = new Set(["application/x-www-form-urlencoded", "multipart/form-data", "text/plain"]);
|
|
6
|
+
const MiB = 1024 * 1024;
|
|
7
|
+
/**
|
|
8
|
+
* Reject cross-site unsafe requests: `Origin` must equal the app origin; without it `Sec-Fetch-Site`
|
|
9
|
+
* must be `same-origin`; with neither, form content types are rejected outside dev (browsers send at
|
|
10
|
+
* least one header; non-browser JSON clients send none; dev keeps `curl -d` working).
|
|
11
|
+
*/
|
|
12
|
+
export function crossSite(request, origin, dev) {
|
|
13
|
+
const header = request.headers.get("origin");
|
|
14
|
+
if (header !== null)
|
|
15
|
+
return header !== origin;
|
|
16
|
+
const site = request.headers.get("sec-fetch-site");
|
|
17
|
+
if (site !== null)
|
|
18
|
+
return site !== "same-origin";
|
|
19
|
+
return !dev && FORM_TYPES.has(mediaType(request));
|
|
20
|
+
}
|
|
21
|
+
const mediaType = (request) => (request.headers.get("content-type") ?? "").split(";", 1)[0].trim().toLowerCase();
|
|
22
|
+
const TOO_LARGE = "Payload too large";
|
|
23
|
+
/**
|
|
24
|
+
* The request with its body limited to `limit` bytes: 413 at once for a larger `Content-Length`,
|
|
25
|
+
* else the body stream errors (and `state.exceeded` is set) once it passes the limit.
|
|
26
|
+
*/
|
|
27
|
+
export function limitBody(request, limit, state) {
|
|
28
|
+
const max = typeof limit === "number" ? limit : mediaType(request) === "multipart/form-data" ? (limit?.multipart ?? 10 * MiB) : (limit?.default ?? MiB);
|
|
29
|
+
const declared = request.headers.get("content-length");
|
|
30
|
+
if (declared !== null && Number(declared) > max) {
|
|
31
|
+
state.exceeded = true;
|
|
32
|
+
throw new HttpError(413, TOO_LARGE);
|
|
33
|
+
}
|
|
34
|
+
if (!request.body || max === Infinity)
|
|
35
|
+
return request;
|
|
36
|
+
let size = 0;
|
|
37
|
+
const body = request.body.pipeThrough(new TransformStream({
|
|
38
|
+
transform(chunk, controller) {
|
|
39
|
+
size += chunk.byteLength;
|
|
40
|
+
if (size <= max)
|
|
41
|
+
return controller.enqueue(chunk);
|
|
42
|
+
state.exceeded = true;
|
|
43
|
+
controller.error(new HttpError(413, TOO_LARGE));
|
|
44
|
+
},
|
|
45
|
+
}));
|
|
46
|
+
return new Request(request, { body, duplex: "half" });
|
|
47
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { type CSPOptions } from "../shared/csp.js";
|
|
2
|
+
/**
|
|
3
|
+
* Strict CSP: only nonce'd scripts and what they load run; no plugins, no `<base>` hijacking.
|
|
4
|
+
* `{ trustedTypes: true }` also enforces Trusted Types (`csp: (nonce) => strictCSP(nonce, { trustedTypes: true })`);
|
|
5
|
+
* `{ directives }` merges extra directives (`cspPolicy`).
|
|
6
|
+
*/
|
|
7
|
+
export declare const strictCSP: (nonce: string, options?: CSPOptions) => string;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// `strictCSP` with `directives`: own module so `createApp` loads the directive merge only when an app passes
|
|
2
|
+
// directives (dynamic import); the default policy is `nonceCSP` (shared/csp.ts).
|
|
3
|
+
import { nonceCSP } from "../shared/csp.js";
|
|
4
|
+
import { cspPolicy } from "../shared/csp-directives.js";
|
|
5
|
+
/**
|
|
6
|
+
* Strict CSP: only nonce'd scripts and what they load run; no plugins, no `<base>` hijacking.
|
|
7
|
+
* `{ trustedTypes: true }` also enforces Trusted Types (`csp: (nonce) => strictCSP(nonce, { trustedTypes: true })`);
|
|
8
|
+
* `{ directives }` merges extra directives (`cspPolicy`).
|
|
9
|
+
*/
|
|
10
|
+
export const strictCSP = (nonce, options) => options?.directives
|
|
11
|
+
? cspPolicy([
|
|
12
|
+
["script-src", [`'nonce-${nonce}'`, "'strict-dynamic'"]],
|
|
13
|
+
["object-src", ["'none'"]],
|
|
14
|
+
["base-uri", ["'none'"]],
|
|
15
|
+
], options, false,
|
|
16
|
+
// Inline `<style>`s (inlined stylesheets, head styles) carry the nonce: allowed by a `style-src` the app sets.
|
|
17
|
+
[`'nonce-${nonce}'`])
|
|
18
|
+
: nonceCSP(nonce, options);
|
package/server-only.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/server-only.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** A failed action: HTTP `status` plus the data the page (or the enhanced submit) receives. */
|
|
2
|
+
export declare class ActionFailure<T = unknown> {
|
|
3
|
+
readonly status: number;
|
|
4
|
+
readonly data: T;
|
|
5
|
+
constructor(status: number, data: T);
|
|
6
|
+
}
|
|
7
|
+
/** Fail an action with a 4xx status (e.g. 400 for invalid input) and the data to render. */
|
|
8
|
+
export declare function fail<T>(status: number, data: T): ActionFailure<T>;
|
|
9
|
+
/** Whether a value is an `ActionFailure` returned by `fail()`. */
|
|
10
|
+
export declare const isActionFailure: (value: unknown) => value is ActionFailure;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// Action results that are not success: returned by an action to answer with a 4xx status and data
|
|
2
|
+
// (validation failures). The page still renders (loaders run) and receives the data as the action
|
|
3
|
+
// result; enhanced (JSON) submits get `{ action: { name, result, status } }` with that status.
|
|
4
|
+
/** A failed action: HTTP `status` plus the data the page (or the enhanced submit) receives. */
|
|
5
|
+
export class ActionFailure {
|
|
6
|
+
constructor(status, data) {
|
|
7
|
+
this.status = status;
|
|
8
|
+
this.data = data;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
/** Fail an action with a 4xx status (e.g. 400 for invalid input) and the data to render. */
|
|
12
|
+
export function fail(status, data) {
|
|
13
|
+
if (!Number.isInteger(status) || status < 400 || status > 499)
|
|
14
|
+
throw new RangeError("fail() status must be 4xx");
|
|
15
|
+
return new ActionFailure(status, data);
|
|
16
|
+
}
|
|
17
|
+
/** Whether a value is an `ActionFailure` returned by `fail()`. */
|
|
18
|
+
export const isActionFailure = (value) => value instanceof ActionFailure;
|
package/shared/base.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** Normalize a base (`/app/`, `/app`, `/`, `./`) to `""` (root) or `/app` (no trailing slash). */
|
|
2
|
+
export declare function normalizeBase(base: string | undefined): string;
|
|
3
|
+
/** The app's base path without trailing slash; `""` at the root. */
|
|
4
|
+
export declare const BASE: string;
|
|
5
|
+
/** An app path (`/items/1?q`) as a URL path under the base; other forms (full, relative URLs) unchanged. */
|
|
6
|
+
export declare function withBase(to: string, base?: string): string;
|
|
7
|
+
/** Whether a URL path lies under the base (the app's routes can serve it). */
|
|
8
|
+
export declare function underBase(pathname: string, base?: string): boolean;
|
package/shared/base.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// App base path (`rivetRouter({ base: "/app" })` or Vite's `base`): Vite replaces
|
|
2
|
+
// `import.meta.env.BASE_URL` in every module it builds (client and server bundles); outside Vite
|
|
3
|
+
// (plain Node) it is the root. Route patterns in the generated files already carry the base;
|
|
4
|
+
// these helpers serve code that takes app paths (`<Link to>`, `navigate()`).
|
|
5
|
+
/** Normalize a base (`/app/`, `/app`, `/`, `./`) to `""` (root) or `/app` (no trailing slash). */
|
|
6
|
+
export function normalizeBase(base) {
|
|
7
|
+
if (!base || !base.startsWith("/"))
|
|
8
|
+
return "";
|
|
9
|
+
return base.replace(/\/+$/, "");
|
|
10
|
+
}
|
|
11
|
+
/** The app's base path without trailing slash; `""` at the root. */
|
|
12
|
+
export const BASE = normalizeBase(import.meta.env?.BASE_URL);
|
|
13
|
+
/** An app path (`/items/1?q`) as a URL path under the base; other forms (full, relative URLs) unchanged. */
|
|
14
|
+
export function withBase(to, base = BASE) {
|
|
15
|
+
return base && to.startsWith("/") && !to.startsWith("//") ? base + to : to;
|
|
16
|
+
}
|
|
17
|
+
/** Whether a URL path lies under the base (the app's routes can serve it). */
|
|
18
|
+
export function underBase(pathname, base = BASE) {
|
|
19
|
+
return !base || pathname === base || pathname.startsWith(base + "/");
|
|
20
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse a `Cookie` request header (RFC 6265 §5.4 shape): `name=value` pairs split on `;`, names and
|
|
3
|
+
* values trimmed, one pair of surrounding double quotes removed, values percent-decoded (a value that
|
|
4
|
+
* is not valid percent-encoding is kept as written). The first occurrence of a name wins (browsers
|
|
5
|
+
* send the most specific path first); pairs without `=` or with an empty name are skipped.
|
|
6
|
+
*/
|
|
7
|
+
export declare function parseCookies(header: string | null | undefined): Map<string, string>;
|
package/shared/cookie.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// Cookie header parsing: the one implementation behind `event.cookies` (server/handler.ts) and
|
|
2
|
+
// `@rivetjs/i18n` locale resolution (`requestSource`).
|
|
3
|
+
/**
|
|
4
|
+
* Parse a `Cookie` request header (RFC 6265 §5.4 shape): `name=value` pairs split on `;`, names and
|
|
5
|
+
* values trimmed, one pair of surrounding double quotes removed, values percent-decoded (a value that
|
|
6
|
+
* is not valid percent-encoding is kept as written). The first occurrence of a name wins (browsers
|
|
7
|
+
* send the most specific path first); pairs without `=` or with an empty name are skipped.
|
|
8
|
+
*/
|
|
9
|
+
export function parseCookies(header) {
|
|
10
|
+
const out = new Map();
|
|
11
|
+
if (!header)
|
|
12
|
+
return out;
|
|
13
|
+
for (const part of header.split(";")) {
|
|
14
|
+
const at = part.indexOf("=");
|
|
15
|
+
if (at < 0)
|
|
16
|
+
continue;
|
|
17
|
+
const name = part.slice(0, at).trim();
|
|
18
|
+
if (!name || out.has(name))
|
|
19
|
+
continue;
|
|
20
|
+
const raw = part
|
|
21
|
+
.slice(at + 1)
|
|
22
|
+
.trim()
|
|
23
|
+
.replace(/^"(.*)"$/, "$1");
|
|
24
|
+
try {
|
|
25
|
+
out.set(name, decodeURIComponent(raw));
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
out.set(name, raw);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { type CSPOptions } from "./csp.js";
|
|
2
|
+
/**
|
|
3
|
+
* The policy string for `generated` (directive → sources, in order) merged with `options.directives`
|
|
4
|
+
* and the Trusted Types directives. `meta`: the policy goes into a `<meta>` (header-only directives throw).
|
|
5
|
+
* `styles`: the sources of the page's own `<style>` elements (nonce / hashes: inlined stylesheets, head styles), added
|
|
6
|
+
* to a `style-src` / `style-src-elem` the options set (none set: inline styles are not restricted). A list with
|
|
7
|
+
* `'unsafe-inline'` is left as given (a nonce or hash would turn it off).
|
|
8
|
+
*/
|
|
9
|
+
export declare function cspPolicy(generated: readonly (readonly [string, readonly string[]])[], options: CSPOptions | undefined, meta?: boolean, styles?: readonly string[]): string;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// CSP directive merge + validation (`directives` option): loaded only by apps that pass directives
|
|
2
|
+
// (`createApp` dynamic import, `strictCSP` / static `hashCSP`), so the default policy carries none of it.
|
|
3
|
+
import { trustedTypesDirectives } from "./csp.js";
|
|
4
|
+
/** CSP Level 3 directive names (+ Trusted Types, `upgrade-insecure-requests`, reporting). */
|
|
5
|
+
const DIRECTIVES = new Set([
|
|
6
|
+
"default-src",
|
|
7
|
+
"script-src",
|
|
8
|
+
"script-src-elem",
|
|
9
|
+
"script-src-attr",
|
|
10
|
+
"style-src",
|
|
11
|
+
"style-src-elem",
|
|
12
|
+
"style-src-attr",
|
|
13
|
+
"img-src",
|
|
14
|
+
"font-src",
|
|
15
|
+
"connect-src",
|
|
16
|
+
"media-src",
|
|
17
|
+
"object-src",
|
|
18
|
+
"child-src",
|
|
19
|
+
"frame-src",
|
|
20
|
+
"worker-src",
|
|
21
|
+
"manifest-src",
|
|
22
|
+
"fenced-frame-src",
|
|
23
|
+
"base-uri",
|
|
24
|
+
"form-action",
|
|
25
|
+
"frame-ancestors",
|
|
26
|
+
"sandbox",
|
|
27
|
+
"upgrade-insecure-requests",
|
|
28
|
+
"report-uri",
|
|
29
|
+
"report-to",
|
|
30
|
+
"webrtc",
|
|
31
|
+
]);
|
|
32
|
+
/** Ignored by browsers in a `<meta>` policy (CSP3 §3.3): header only. */
|
|
33
|
+
const HEADER_ONLY = new Set(["frame-ancestors", "report-uri", "report-to", "sandbox"]);
|
|
34
|
+
// One source expression: no whitespace, no directive / policy separators, nothing that breaks out of an attribute.
|
|
35
|
+
const SOURCE = /^[^\s;,"'<>\\]+$|^'[A-Za-z0-9+/=_-]+'$/;
|
|
36
|
+
/**
|
|
37
|
+
* The policy string for `generated` (directive → sources, in order) merged with `options.directives`
|
|
38
|
+
* and the Trusted Types directives. `meta`: the policy goes into a `<meta>` (header-only directives throw).
|
|
39
|
+
* `styles`: the sources of the page's own `<style>` elements (nonce / hashes: inlined stylesheets, head styles), added
|
|
40
|
+
* to a `style-src` / `style-src-elem` the options set (none set: inline styles are not restricted). A list with
|
|
41
|
+
* `'unsafe-inline'` is left as given (a nonce or hash would turn it off).
|
|
42
|
+
*/
|
|
43
|
+
export function cspPolicy(generated, options, meta = false, styles = []) {
|
|
44
|
+
const policy = new Map(generated.map(([name, sources]) => [name, [...sources]]));
|
|
45
|
+
const extra = options?.directives;
|
|
46
|
+
if (extra) {
|
|
47
|
+
for (const [raw, value] of Object.entries(extra)) {
|
|
48
|
+
const name = raw.toLowerCase();
|
|
49
|
+
if (name === "require-trusted-types-for" || name === "trusted-types")
|
|
50
|
+
throw new TypeError(`CSP directive "${raw}": use the trustedTypes option`);
|
|
51
|
+
if (!DIRECTIVES.has(name))
|
|
52
|
+
throw new TypeError(`Unknown CSP directive "${raw}"`);
|
|
53
|
+
if (meta && HEADER_ONLY.has(name))
|
|
54
|
+
throw new TypeError(`CSP directive "${name}" is ignored in a <meta> policy: send it as a header from your host`);
|
|
55
|
+
const sources = value === true ? [] : typeof value === "string" ? value.split(/\s+/).filter(Boolean) : [...value];
|
|
56
|
+
for (const source of sources)
|
|
57
|
+
if (typeof source !== "string" || !SOURCE.test(source))
|
|
58
|
+
throw new TypeError(`Invalid CSP source ${JSON.stringify(source)} in "${name}"`);
|
|
59
|
+
if (name === "script-src" || name === "script-src-elem") {
|
|
60
|
+
// Additive: the generated sources (nonce / hashes) are what lets the page's own scripts run.
|
|
61
|
+
const list = policy.get(name) ?? (name === "script-src-elem" ? [...(policy.get("script-src") ?? [])] : []);
|
|
62
|
+
for (const source of sources)
|
|
63
|
+
if (!list.includes(source))
|
|
64
|
+
list.push(source);
|
|
65
|
+
policy.set(name, list);
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
policy.delete(name);
|
|
69
|
+
policy.set(name, sources);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if (styles.length)
|
|
74
|
+
for (const name of ["style-src", "style-src-elem"]) {
|
|
75
|
+
const list = policy.get(name);
|
|
76
|
+
if (list && !list.includes("'unsafe-inline'"))
|
|
77
|
+
for (const source of styles)
|
|
78
|
+
if (!list.includes(source))
|
|
79
|
+
list.push(source);
|
|
80
|
+
}
|
|
81
|
+
let out = "";
|
|
82
|
+
for (const [name, sources] of policy)
|
|
83
|
+
out += (out ? "; " : "") + (sources.length ? `${name} ${sources.join(" ")}` : name);
|
|
84
|
+
return out + trustedTypesDirectives(options);
|
|
85
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { CSPOptions } from "./csp.js";
|
|
2
|
+
/** A page's inline code a hash policy must allow. */
|
|
3
|
+
export interface InlineSources {
|
|
4
|
+
/** Bodies of the executable inline scripts, in page order (duplicates kept: callers dedupe the hashes). */
|
|
5
|
+
readonly scripts: string[];
|
|
6
|
+
/** Bodies of the `<style>` elements (only when asked: `styles`). */
|
|
7
|
+
readonly styles: string[];
|
|
8
|
+
/** Origins of third-party script files (`<script src>`, the kit `#rivet-scripts` bootstrap list). */
|
|
9
|
+
readonly origins: Set<string>;
|
|
10
|
+
}
|
|
11
|
+
/** Collect what a hash policy for `html` allows; `styles`: also the `<style>` bodies (the options set a `style-src`). */
|
|
12
|
+
export declare function inlineSources(html: string, styles: boolean): InlineSources;
|
|
13
|
+
/** Byte ranges (`[start, end)`) of what a stored page's hash policy allows, and where its nonce attributes are. */
|
|
14
|
+
export interface NoncedSources {
|
|
15
|
+
/** Bodies of the executable inline scripts that carry the nonce. */
|
|
16
|
+
readonly scripts: [number, number][];
|
|
17
|
+
/** Bodies of the `<style>` elements that carry the nonce (only when asked: `styles`). */
|
|
18
|
+
readonly styles: [number, number][];
|
|
19
|
+
/** Origins of third-party script files whose `<script src>` carries the nonce (and of the kit `#rivet-scripts` list in a nonce'd bootstrap). */
|
|
20
|
+
readonly origins: Set<string>;
|
|
21
|
+
/** Start offsets of each ` nonce="…"` attribute (`' nonce="'.length + nonce.length + 1` long). */
|
|
22
|
+
readonly attrs: number[];
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Collect what the hash policy of a nonce-rendered page allows: only `<script>` / `<style>` / `<link>` start tags that
|
|
26
|
+
* carry ` nonce="<nonce>"` (the renderer's own elements). `text`: the page as one character per byte (latin1), so
|
|
27
|
+
* offsets are byte offsets; `utf8(start, end)`: those bytes as text (script URLs, the bootstrap list). Null — the page
|
|
28
|
+
* must not be stored — when the nonce appears anywhere else (script text, an attribute value, a malformed tag): a
|
|
29
|
+
* stored copy would hand one nonce to every visitor, or the tag is not one the renderer wrote.
|
|
30
|
+
*/
|
|
31
|
+
export declare function noncedSources(text: string, nonce: string, styles: boolean, utf8: (start: number, end: number) => string): NoncedSources | null;
|
|
32
|
+
/**
|
|
33
|
+
* The hash policy: `script-src` = the scripts' hashes + `'self'` + script origins, `object-src` / `base-uri` `'none'`,
|
|
34
|
+
* merged with `options` (`cspPolicy`; `meta`: header-only directives throw). Hashes are CSP sources (`'sha256-…'`).
|
|
35
|
+
* (`'strict-dynamic'` would not do: it trusts the scripts a trusted script inserts with `createElement("script")`, but
|
|
36
|
+
* an `import()` is fetched with the importing script's nonce, and a hash-allowed script has none — Chromium and Firefox
|
|
37
|
+
* block it (WebKit allows it); the resume loader imports its chunks. Pinned in `tests/route-cache-csp.test.ts`.)
|
|
38
|
+
*/
|
|
39
|
+
export declare function hashPolicy(scripts: Iterable<string>, origins: Iterable<string>, styles: readonly string[], options: CSPOptions | undefined, meta: boolean): string;
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { cspPolicy } from "./csp-directives.js";
|
|
2
|
+
import { BOOT_LIST_END } from "./head.js";
|
|
3
|
+
const SCRIPT = /<script\b([^>]*)>([\s\S]*?)<\/script\s*>/gi;
|
|
4
|
+
const STYLE = /<style\b[^>]*>([\s\S]*?)<\/style\s*>/gi;
|
|
5
|
+
// Inline scripts script-src governs: no `src`, and no type, a JavaScript one or `speculationrules` (router document prefetch).
|
|
6
|
+
const executable = (attrs) => {
|
|
7
|
+
if (/\ssrc\s*=/i.test(attrs))
|
|
8
|
+
return false;
|
|
9
|
+
const type = /\stype\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/i.exec(attrs);
|
|
10
|
+
const value = (type?.[1] ?? type?.[2] ?? type?.[3] ?? "").trim().toLowerCase();
|
|
11
|
+
return value === "" || value === "module" || value === "text/javascript" || value === "application/javascript" || value === "speculationrules";
|
|
12
|
+
};
|
|
13
|
+
/** Collect what a hash policy for `html` allows; `styles`: also the `<style>` bodies (the options set a `style-src`). */
|
|
14
|
+
export function inlineSources(html, styles) {
|
|
15
|
+
const scripts = [];
|
|
16
|
+
const origins = new Set();
|
|
17
|
+
for (const [, attrs = "", body = ""] of html.matchAll(SCRIPT)) {
|
|
18
|
+
if (executable(attrs))
|
|
19
|
+
scripts.push(body);
|
|
20
|
+
const src = /\ssrc\s*=\s*"([^"]*)"/i.exec(attrs)?.[1];
|
|
21
|
+
if (src)
|
|
22
|
+
addOrigin(origins, src.replaceAll("&", "&"));
|
|
23
|
+
else if (/\sid\s*=\s*"rivet-scripts"/i.test(attrs))
|
|
24
|
+
bootOrigins(body, origins);
|
|
25
|
+
}
|
|
26
|
+
const out = [];
|
|
27
|
+
if (styles)
|
|
28
|
+
for (const [, body = ""] of html.matchAll(STYLE))
|
|
29
|
+
out.push(body);
|
|
30
|
+
return { scripts, styles: out, origins };
|
|
31
|
+
}
|
|
32
|
+
const NONCE_LEAD = ' nonce="';
|
|
33
|
+
// A whole start tag the renderer writes: `<script|style|link` + attributes (quoted values without `<`) + `>`.
|
|
34
|
+
const NONCED_TAG = /^<(script|style|link)((?:\s+[^\s"'<>/=]+(?:\s*=\s*(?:"[^"<]*"|'[^'<]*'|[^\s"'=<>`]+))?)*)\s*\/?>$/i;
|
|
35
|
+
/**
|
|
36
|
+
* Collect what the hash policy of a nonce-rendered page allows: only `<script>` / `<style>` / `<link>` start tags that
|
|
37
|
+
* carry ` nonce="<nonce>"` (the renderer's own elements). `text`: the page as one character per byte (latin1), so
|
|
38
|
+
* offsets are byte offsets; `utf8(start, end)`: those bytes as text (script URLs, the bootstrap list). Null — the page
|
|
39
|
+
* must not be stored — when the nonce appears anywhere else (script text, an attribute value, a malformed tag): a
|
|
40
|
+
* stored copy would hand one nonce to every visitor, or the tag is not one the renderer wrote.
|
|
41
|
+
*/
|
|
42
|
+
export function noncedSources(text, nonce, styles, utf8) {
|
|
43
|
+
const scripts = [];
|
|
44
|
+
const out = [];
|
|
45
|
+
const origins = new Set();
|
|
46
|
+
const attrs = [];
|
|
47
|
+
if (!nonce)
|
|
48
|
+
return null;
|
|
49
|
+
const attr = NONCE_LEAD + nonce + '"';
|
|
50
|
+
for (let at = text.indexOf(nonce); at >= 0; at = text.indexOf(nonce, at + nonce.length)) {
|
|
51
|
+
const start = at - NONCE_LEAD.length;
|
|
52
|
+
if (start < 0 || !text.startsWith(attr, start))
|
|
53
|
+
return null;
|
|
54
|
+
const open = text.lastIndexOf("<", start);
|
|
55
|
+
const close = text.indexOf(">", at);
|
|
56
|
+
const tag = open < 0 || close < 0 ? null : NONCED_TAG.exec(text.slice(open, close + 1));
|
|
57
|
+
if (!tag)
|
|
58
|
+
return null;
|
|
59
|
+
attrs.push(start);
|
|
60
|
+
const name = tag[1].toLowerCase();
|
|
61
|
+
if (name === "link")
|
|
62
|
+
continue;
|
|
63
|
+
const end = text.indexOf(name === "script" ? "</script" : "</style", close + 1);
|
|
64
|
+
if (end < 0)
|
|
65
|
+
return null;
|
|
66
|
+
if (name === "style") {
|
|
67
|
+
if (styles)
|
|
68
|
+
out.push([close + 1, end]);
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
const attributes = tag[2];
|
|
72
|
+
if (executable(attributes))
|
|
73
|
+
scripts.push([close + 1, end]);
|
|
74
|
+
const src = /\ssrc\s*=\s*"([^"]*)"/i.exec(attributes);
|
|
75
|
+
if (src) {
|
|
76
|
+
// The value's bytes (`"` excluded): the attributes start right after `<script`.
|
|
77
|
+
const end = open + 1 + name.length + src.index + src[0].length - 1;
|
|
78
|
+
addOrigin(origins, utf8(end - src[1].length, end).replaceAll("&", "&"));
|
|
79
|
+
}
|
|
80
|
+
else if (/\sid\s*=\s*"rivet-scripts"/i.test(attributes))
|
|
81
|
+
bootOrigins(utf8(close + 1, end), origins);
|
|
82
|
+
}
|
|
83
|
+
return { scripts, styles: out, origins, attrs };
|
|
84
|
+
}
|
|
85
|
+
function addOrigin(origins, src) {
|
|
86
|
+
const url = URL.canParse(src) ? new URL(src) : null;
|
|
87
|
+
if (url && (url.protocol === "https:" || url.protocol === "http:"))
|
|
88
|
+
origins.add(url.origin);
|
|
89
|
+
}
|
|
90
|
+
/** Kit script bootstrap: `{let l=<JSON list>,d=document,…`; the list ends at the first cut that parses. */
|
|
91
|
+
function bootOrigins(body, origins) {
|
|
92
|
+
if (!body.startsWith("{let l="))
|
|
93
|
+
return;
|
|
94
|
+
for (let at = body.indexOf(BOOT_LIST_END); at > 0; at = body.indexOf(BOOT_LIST_END, at + 1)) {
|
|
95
|
+
let list;
|
|
96
|
+
try {
|
|
97
|
+
list = JSON.parse(body.slice(7, at));
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
for (const entry of list)
|
|
103
|
+
if (typeof entry.u === "string")
|
|
104
|
+
addOrigin(origins, entry.u);
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* The hash policy: `script-src` = the scripts' hashes + `'self'` + script origins, `object-src` / `base-uri` `'none'`,
|
|
110
|
+
* merged with `options` (`cspPolicy`; `meta`: header-only directives throw). Hashes are CSP sources (`'sha256-…'`).
|
|
111
|
+
* (`'strict-dynamic'` would not do: it trusts the scripts a trusted script inserts with `createElement("script")`, but
|
|
112
|
+
* an `import()` is fetched with the importing script's nonce, and a hash-allowed script has none — Chromium and Firefox
|
|
113
|
+
* block it (WebKit allows it); the resume loader imports its chunks. Pinned in `tests/route-cache-csp.test.ts`.)
|
|
114
|
+
*/
|
|
115
|
+
export function hashPolicy(scripts, origins, styles, options, meta) {
|
|
116
|
+
return cspPolicy([
|
|
117
|
+
["script-src", [...new Set(scripts), "'self'", ...origins]],
|
|
118
|
+
["object-src", ["'none'"]],
|
|
119
|
+
["base-uri", ["'none'"]],
|
|
120
|
+
], options, meta, [...new Set(styles)]);
|
|
121
|
+
}
|
package/shared/csp.d.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extra CSP directives, merged with the generated policy. A value is a source list (string split on
|
|
3
|
+
* whitespace, or an array); `true` / `[]` / `""` is a directive without sources (`upgrade-insecure-requests`).
|
|
4
|
+
*/
|
|
5
|
+
export type CSPDirectives = Readonly<Record<string, string | readonly string[] | true>>;
|
|
6
|
+
/** Content Security Policy options: directives, nonces and Trusted Types. */
|
|
7
|
+
export interface CSPOptions {
|
|
8
|
+
/**
|
|
9
|
+
* Enforce Trusted Types: `require-trusted-types-for 'script'` with the runtime's `rivet` policy
|
|
10
|
+
* (template HTML) allowed. Pass policy names to allow your own as well.
|
|
11
|
+
*/
|
|
12
|
+
readonly trustedTypes?: boolean | readonly string[];
|
|
13
|
+
/**
|
|
14
|
+
* Extra directives (`{ "script-src": "'wasm-unsafe-eval'", "worker-src": ["'self'", "blob:"] }`).
|
|
15
|
+
* `script-src` (and `script-src-elem`, when given) sources are added to the generated ones (nonce /
|
|
16
|
+
* hashes, `'self'`); `object-src` / `base-uri` replace the generated `'none'`; other directives are
|
|
17
|
+
* appended. Names are checked (unknown → `TypeError`); Trusted Types go through `trustedTypes`.
|
|
18
|
+
* Nothing is added implicitly (no `'unsafe-inline'`).
|
|
19
|
+
*/
|
|
20
|
+
readonly directives?: CSPDirectives;
|
|
21
|
+
}
|
|
22
|
+
export { cspPolicy } from "./csp-directives.js";
|
|
23
|
+
/** The default strict nonce policy (+ Trusted Types): what `strictCSP` returns without `directives`. */
|
|
24
|
+
export declare const nonceCSP: (nonce: string, options?: CSPOptions) => string;
|
|
25
|
+
/** The Trusted Types directives for `options` (empty when off), each prefixed with `; `. */
|
|
26
|
+
export declare function trustedTypesDirectives(options: CSPOptions | undefined): string;
|
package/shared/csp.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// CSP options shared by the server (nonce policy) and the static adapter (hash policy).
|
|
2
|
+
// The directive merge + validation lives in its own module (`csp-directives.ts`): apps without
|
|
3
|
+
// `directives` never load it (`createApp` imports it dynamically; `strictCSP` / `hashCSP` statically).
|
|
4
|
+
export { cspPolicy } from "./csp-directives.js";
|
|
5
|
+
/** The default strict nonce policy (+ Trusted Types): what `strictCSP` returns without `directives`. */
|
|
6
|
+
export const nonceCSP = (nonce, options) => `script-src 'nonce-${nonce}' 'strict-dynamic'; object-src 'none'; base-uri 'none'` + trustedTypesDirectives(options);
|
|
7
|
+
/** The Trusted Types directives for `options` (empty when off), each prefixed with `; `. */
|
|
8
|
+
export function trustedTypesDirectives(options) {
|
|
9
|
+
const names = options?.trustedTypes;
|
|
10
|
+
if (!names)
|
|
11
|
+
return "";
|
|
12
|
+
return `; require-trusted-types-for 'script'; trusted-types ${["rivet", ...(names === true ? [] : names)].join(" ")}`;
|
|
13
|
+
}
|