@vouchington/edge 0.0.0-stage → 0.1.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 +9 -0
- package/README.md +44 -2
- package/dist/basic-auth-credentials.d.mts +23 -0
- package/dist/basic-auth-credentials.mjs +75 -0
- package/dist/basic-auth.d.mts +29 -0
- package/dist/basic-auth.mjs +60 -0
- package/dist/cache-policy.d.mts +41 -0
- package/dist/cache-policy.mjs +74 -0
- package/dist/cache-url.d.mts +9 -0
- package/dist/cache-url.mjs +34 -0
- package/dist/cache-vary.d.mts +8 -0
- package/dist/cache-vary.mjs +46 -0
- package/dist/cached-origin.d.mts +42 -0
- package/dist/cached-origin.mjs +74 -0
- package/dist/client-info.d.mts +33 -0
- package/dist/client-info.mjs +63 -0
- package/dist/cookies.d.mts +9 -0
- package/dist/cookies.mjs +26 -0
- package/dist/csp.d.mts +32 -0
- package/dist/csp.mjs +113 -0
- package/dist/env-validation.d.mts +42 -0
- package/dist/env-validation.mjs +58 -0
- package/dist/error-response.d.mts +9 -0
- package/dist/error-response.mjs +19 -0
- package/dist/headers.d.mts +8 -0
- package/dist/headers.mjs +34 -0
- package/dist/index.d.mts +19 -0
- package/dist/index.mjs +18 -0
- package/dist/nonce-rewrite.d.mts +14 -0
- package/dist/nonce-rewrite.mjs +104 -0
- package/dist/origin-fetch.d.mts +21 -0
- package/dist/origin-fetch.mjs +36 -0
- package/dist/proxy.d.mts +34 -0
- package/dist/proxy.mjs +92 -0
- package/dist/security-headers.d.mts +25 -0
- package/dist/security-headers.mjs +68 -0
- package/dist/sentry-dsn.d.mts +13 -0
- package/dist/sentry-dsn.mjs +38 -0
- package/dist/sentry-tunnel-diagnostics.d.mts +17 -0
- package/dist/sentry-tunnel-diagnostics.mjs +73 -0
- package/dist/sentry-tunnel.d.mts +17 -0
- package/dist/sentry-tunnel.mjs +104 -0
- package/package.json +50 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jonathan Ong
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
6
|
+
|
|
7
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,45 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @vouchington/edge
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Builders for Cloudflare Workers. The package exports functions, not a worker: the route table,
|
|
4
|
+
CSP origins, analytics and Sentry DSNs, origin hosts, and cache rules are injected by the
|
|
5
|
+
application.
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
import {
|
|
9
|
+
addSecurityHeaders,
|
|
10
|
+
buildCsp,
|
|
11
|
+
buildOriginRequest,
|
|
12
|
+
createBasicAuthGate,
|
|
13
|
+
createSentryTunnel,
|
|
14
|
+
fetchOriginResponse,
|
|
15
|
+
rewritePlaceholderNonce,
|
|
16
|
+
} from '@vouchington/edge'
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Builders
|
|
20
|
+
|
|
21
|
+
- Security headers: `addSecurityHeaders`, `buildSecurityHeaders`, `buildHsts`. The edge overwrites
|
|
22
|
+
origin values and preserves repeated `set-cookie` headers.
|
|
23
|
+
- CSP: `buildCsp` starts from strict defaults and takes injected `sources`, `directives`, a nonce, and
|
|
24
|
+
an asset origin. `serializeCsp` rejects sources that could inject another directive.
|
|
25
|
+
`normalizeAssetOrigin` and `parseOriginAllowlist` validate configuration values.
|
|
26
|
+
- Nonce rewriting: `rewritePlaceholderNonce` streams HTML and swaps a cache placeholder nonce for a
|
|
27
|
+
per-request nonce without buffering the document. It handles chunk boundaries and can drop
|
|
28
|
+
injected `<meta name>` tags.
|
|
29
|
+
- Basic auth: `createBasicAuthGate` with timing-safe credential matching, rotation lists, a
|
|
30
|
+
fail-closed malformed-list rule, and an injected exemption matcher
|
|
31
|
+
(`createBasicAuthExemptMatcher`).
|
|
32
|
+
- Proxying: `buildOriginRequest` retargets a request, strips client-supplied forwarding headers, owns
|
|
33
|
+
the request id and shared secret, and keeps redirects manual. `fetchOriginResponse` retries GET and
|
|
34
|
+
HEAD once after a dropped socket and maps failures to a no-store 502.
|
|
35
|
+
- Cache helpers: `bypassPolicy`, `cachedPolicy`, `buildCacheControlHeader`, `isCacheableResponse`,
|
|
36
|
+
`parseSafeCacheVary`, `mergeClientVary`, `normalizeCacheUrl`, `finalizeCachedOriginResponse`,
|
|
37
|
+
`stripDispatchOnlyHeaders`, and `purgeCacheTags`. The route-to-policy decision stays in the app.
|
|
38
|
+
- Environment checks: `createEnvWarner` (warn once per isolate), `warnIfSecretMisconfigured`,
|
|
39
|
+
`warnIfProductionValueInvalid`, `warnIfAssetOriginInvalid`, `warnIfDsnInvalid`, `isProductionMode`.
|
|
40
|
+
- Client info and cookies: `applyClientInfoHeaders` with injected header names, `replaceCookies`,
|
|
41
|
+
`stripCookies`. Cookie parsing comes from `@vouchington/utils/cookies`.
|
|
42
|
+
- Sentry tunnel: `createSentryTunnel` forwards envelopes only to injected trusted DSNs, caps the body,
|
|
43
|
+
and logs bounded diagnostics via `@vouchington/utils/sentry-envelope`.
|
|
44
|
+
|
|
45
|
+
`@cloudflare/workers-types` is an optional peer dependency used for the `cache.purge` context type.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compares every same-length credential with a byte-level XOR loop. Iterating the whole set avoids
|
|
3
|
+
* revealing which credential matched. Length remains observable from the request header itself.
|
|
4
|
+
*/
|
|
5
|
+
export declare function timingSafeCredentialMatch(input: string, credentials: ReadonlySet<string>): boolean;
|
|
6
|
+
export type BasicAuthCredentialList = {
|
|
7
|
+
status: 'absent';
|
|
8
|
+
} | {
|
|
9
|
+
status: 'configured';
|
|
10
|
+
source: string;
|
|
11
|
+
credentials: Set<string>;
|
|
12
|
+
hasMalformedEntries: boolean;
|
|
13
|
+
};
|
|
14
|
+
export type RequiredBasicAuthDecision = 'authorized' | 'misconfigured' | 'unauthorized';
|
|
15
|
+
/**
|
|
16
|
+
* Parses comma-separated `user:password` pairs (several entries support rotation). Usernames and
|
|
17
|
+
* passwords must not contain commas and whitespace is significant. Parsed lists are memoized.
|
|
18
|
+
*/
|
|
19
|
+
export declare function readBasicAuthCredentialList(envValue: string | undefined): BasicAuthCredentialList;
|
|
20
|
+
/** True when an `Authorization: Basic …` value decodes to one of `credentials`. */
|
|
21
|
+
export declare function basicAuthorizationMatches(authorization: string | null, credentials: ReadonlySet<string>): boolean;
|
|
22
|
+
/** Fail-closed decision for routes that require Basic auth: absent or malformed config is an error. */
|
|
23
|
+
export declare function requiredBasicAuthDecision(authorization: string | null, envValue: string | undefined): RequiredBasicAuthDecision;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
const encoder = new TextEncoder();
|
|
2
|
+
const MAX_CACHED_LISTS = 16;
|
|
3
|
+
/**
|
|
4
|
+
* Compares every same-length credential with a byte-level XOR loop. Iterating the whole set avoids
|
|
5
|
+
* revealing which credential matched. Length remains observable from the request header itself.
|
|
6
|
+
*/
|
|
7
|
+
export function timingSafeCredentialMatch(input, credentials) {
|
|
8
|
+
const inputBytes = encoder.encode(input);
|
|
9
|
+
let matched = false;
|
|
10
|
+
for (const credential of credentials) {
|
|
11
|
+
const credentialBytes = encoder.encode(credential);
|
|
12
|
+
if (inputBytes.length !== credentialBytes.length)
|
|
13
|
+
continue;
|
|
14
|
+
let diff = 0;
|
|
15
|
+
for (let i = 0; i < inputBytes.length; i++) {
|
|
16
|
+
diff |= inputBytes[i] ^ credentialBytes[i];
|
|
17
|
+
}
|
|
18
|
+
if (diff === 0)
|
|
19
|
+
matched = true;
|
|
20
|
+
}
|
|
21
|
+
return matched;
|
|
22
|
+
}
|
|
23
|
+
const credentialListCache = new Map();
|
|
24
|
+
/**
|
|
25
|
+
* Parses comma-separated `user:password` pairs (several entries support rotation). Usernames and
|
|
26
|
+
* passwords must not contain commas and whitespace is significant. Parsed lists are memoized.
|
|
27
|
+
*/
|
|
28
|
+
export function readBasicAuthCredentialList(envValue) {
|
|
29
|
+
if (!envValue || envValue.trim() === '')
|
|
30
|
+
return { status: 'absent' };
|
|
31
|
+
const cached = credentialListCache.get(envValue);
|
|
32
|
+
if (cached !== undefined)
|
|
33
|
+
return cached;
|
|
34
|
+
const credentials = new Set();
|
|
35
|
+
let hasMalformedEntries = false;
|
|
36
|
+
for (const entry of envValue.split(',')) {
|
|
37
|
+
const colonIndex = entry.indexOf(':');
|
|
38
|
+
if (entry === '' || colonIndex <= 0 || colonIndex === entry.length - 1) {
|
|
39
|
+
hasMalformedEntries = true;
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
credentials.add(entry);
|
|
43
|
+
}
|
|
44
|
+
const result = {
|
|
45
|
+
status: 'configured',
|
|
46
|
+
source: envValue,
|
|
47
|
+
credentials,
|
|
48
|
+
hasMalformedEntries,
|
|
49
|
+
};
|
|
50
|
+
if (credentialListCache.size >= MAX_CACHED_LISTS)
|
|
51
|
+
credentialListCache.clear();
|
|
52
|
+
credentialListCache.set(envValue, result);
|
|
53
|
+
return result;
|
|
54
|
+
}
|
|
55
|
+
/** True when an `Authorization: Basic …` value decodes to one of `credentials`. */
|
|
56
|
+
export function basicAuthorizationMatches(authorization, credentials) {
|
|
57
|
+
const match = authorization?.match(/^Basic\s+([A-Za-z0-9+/]+={0,2})$/i);
|
|
58
|
+
if (!match)
|
|
59
|
+
return false;
|
|
60
|
+
try {
|
|
61
|
+
const decoded = Uint8Array.from(atob(match[1]), (character) => character.charCodeAt(0));
|
|
62
|
+
return timingSafeCredentialMatch(new TextDecoder().decode(decoded), credentials);
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/** Fail-closed decision for routes that require Basic auth: absent or malformed config is an error. */
|
|
69
|
+
export function requiredBasicAuthDecision(authorization, envValue) {
|
|
70
|
+
const list = readBasicAuthCredentialList(envValue);
|
|
71
|
+
if (list.status === 'absent' || list.hasMalformedEntries || list.credentials.size === 0) {
|
|
72
|
+
return 'misconfigured';
|
|
73
|
+
}
|
|
74
|
+
return basicAuthorizationMatches(authorization, list.credentials) ? 'authorized' : 'unauthorized';
|
|
75
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export type BasicAuthExemptMatcher = (method: string, pathname: string) => boolean;
|
|
2
|
+
/**
|
|
3
|
+
* Builds a matcher from an injected `{ path: [methods] }` table. Paths compare lowercased with one
|
|
4
|
+
* trailing slash removed; methods compare uppercased. `extra` handles patterned paths.
|
|
5
|
+
*/
|
|
6
|
+
export declare function createBasicAuthExemptMatcher(methodsByPath: Readonly<Record<string, readonly string[]>>, extra?: BasicAuthExemptMatcher): BasicAuthExemptMatcher;
|
|
7
|
+
export type BasicAuthGateOptions = {
|
|
8
|
+
/** Comma-separated `user:password` list; unset or blank disables the gate. */
|
|
9
|
+
credentials: string | undefined;
|
|
10
|
+
realm: string;
|
|
11
|
+
isExempt?: BasicAuthExemptMatcher;
|
|
12
|
+
/**
|
|
13
|
+
* Header carrying Basic credentials when `authorization` holds a Bearer token for another
|
|
14
|
+
* layer. Only honored in that Bearer-plus-secondary combination.
|
|
15
|
+
*/
|
|
16
|
+
secondaryAuthorizationHeader?: string;
|
|
17
|
+
warn?: (message: string) => void;
|
|
18
|
+
};
|
|
19
|
+
export type BasicAuthGate = {
|
|
20
|
+
/** True when the gate is enabled and the request is not exempt. */
|
|
21
|
+
appliesTo: (request: Request) => boolean;
|
|
22
|
+
/** A 401 challenge, or null to pass the request through. */
|
|
23
|
+
check: (request: Request) => Response | null;
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* Staging-style Basic auth gate. A non-empty list with any malformed entry stays enabled and
|
|
27
|
+
* rejects every request (fail closed), warning once per gate.
|
|
28
|
+
*/
|
|
29
|
+
export declare function createBasicAuthGate(options: BasicAuthGateOptions): BasicAuthGate;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { readBasicAuthCredentialList, basicAuthorizationMatches, } from './basic-auth-credentials.mjs';
|
|
2
|
+
import { edgeErrorResponse } from './error-response.mjs';
|
|
3
|
+
/**
|
|
4
|
+
* Builds a matcher from an injected `{ path: [methods] }` table. Paths compare lowercased with one
|
|
5
|
+
* trailing slash removed; methods compare uppercased. `extra` handles patterned paths.
|
|
6
|
+
*/
|
|
7
|
+
export function createBasicAuthExemptMatcher(methodsByPath, extra) {
|
|
8
|
+
const table = new Map(Object.entries(methodsByPath).map(([path, methods]) => [
|
|
9
|
+
path.toLowerCase(),
|
|
10
|
+
new Set(methods.map((method) => method.toUpperCase())),
|
|
11
|
+
]));
|
|
12
|
+
return (method, pathname) => {
|
|
13
|
+
const trimmed = pathname.length > 1 && pathname.endsWith('/') ? pathname.slice(0, -1) : pathname;
|
|
14
|
+
const path = trimmed.toLowerCase();
|
|
15
|
+
return table.get(path)?.has(method.toUpperCase()) === true || extra?.(method, path) === true;
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Staging-style Basic auth gate. A non-empty list with any malformed entry stays enabled and
|
|
20
|
+
* rejects every request (fail closed), warning once per gate.
|
|
21
|
+
*/
|
|
22
|
+
export function createBasicAuthGate(options) {
|
|
23
|
+
if (/["\\\r\n]/u.test(options.realm))
|
|
24
|
+
throw new Error('Invalid Basic auth realm');
|
|
25
|
+
const warn = options.warn ?? console.warn;
|
|
26
|
+
let warned = false;
|
|
27
|
+
const credentials = () => {
|
|
28
|
+
const list = readBasicAuthCredentialList(options.credentials);
|
|
29
|
+
if (list.status === 'absent')
|
|
30
|
+
return null;
|
|
31
|
+
if (!list.hasMalformedEntries)
|
|
32
|
+
return list.credentials;
|
|
33
|
+
if (!warned) {
|
|
34
|
+
warned = true;
|
|
35
|
+
warn('[Basic Auth] Rejecting malformed credential list (username and password must both be non-empty)');
|
|
36
|
+
}
|
|
37
|
+
return new Set();
|
|
38
|
+
};
|
|
39
|
+
const exempt = (request) => options.isExempt?.(request.method, new URL(request.url).pathname) === true;
|
|
40
|
+
return {
|
|
41
|
+
appliesTo: (request) => credentials() !== null && !exempt(request),
|
|
42
|
+
check(request) {
|
|
43
|
+
const allowed = credentials();
|
|
44
|
+
if (!allowed || exempt(request))
|
|
45
|
+
return null;
|
|
46
|
+
const primary = request.headers.get('authorization');
|
|
47
|
+
const secondary = options.secondaryAuthorizationHeader
|
|
48
|
+
? request.headers.get(options.secondaryAuthorizationHeader)
|
|
49
|
+
: null;
|
|
50
|
+
const ordinaryBasic = primary !== null && /^Basic\s/i.test(primary) && secondary === null;
|
|
51
|
+
const dualBearer = primary !== null && /^Bearer\s+\S+$/i.test(primary) && secondary !== null;
|
|
52
|
+
const candidate = ordinaryBasic ? primary : dualBearer ? secondary : null;
|
|
53
|
+
if (basicAuthorizationMatches(candidate, allowed))
|
|
54
|
+
return null;
|
|
55
|
+
return edgeErrorResponse(401, 'Authentication required', 'UNAUTHORIZED', {
|
|
56
|
+
'www-authenticate': `Basic realm="${options.realm}", charset="UTF-8"`,
|
|
57
|
+
});
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
export type CacheMode = 'cache' | 'bypass';
|
|
2
|
+
export type CachePolicy<Audience extends string = string> = {
|
|
3
|
+
mode: CacheMode;
|
|
4
|
+
/** Which audience a cached response targets; null on bypass. Partition cache keys by it. */
|
|
5
|
+
audience: Audience | null;
|
|
6
|
+
/** True when a bypassed response must not stay browser or intermediary cacheable. */
|
|
7
|
+
forceNoStore: boolean;
|
|
8
|
+
ttlSeconds: number;
|
|
9
|
+
staleWhileRevalidateSeconds: number;
|
|
10
|
+
stripCookieNames: ReadonlySet<string>;
|
|
11
|
+
stripAllCookies: boolean;
|
|
12
|
+
};
|
|
13
|
+
/** Pass the request to the origin untouched by any cache. */
|
|
14
|
+
export declare function bypassPolicy<Audience extends string = string>(options?: {
|
|
15
|
+
forceNoStore?: boolean;
|
|
16
|
+
stripCookieNames?: ReadonlySet<string>;
|
|
17
|
+
}): CachePolicy<Audience>;
|
|
18
|
+
/** Serve an audience's response from the shared cache; cookies are stripped by default. */
|
|
19
|
+
export declare function cachedPolicy<Audience extends string>(audience: Audience, ttlSeconds: number, options?: {
|
|
20
|
+
stripCookieNames?: ReadonlySet<string>;
|
|
21
|
+
stripAllCookies?: boolean;
|
|
22
|
+
}): CachePolicy<Audience>;
|
|
23
|
+
/** Parses a positive TTL from configuration, flooring it; anything else yields `fallback`. */
|
|
24
|
+
export declare function toPositiveNumber(raw: string | undefined, fallback: number): number;
|
|
25
|
+
/** Parses a comma-separated path list and merges it into `defaults` (never replaces them). */
|
|
26
|
+
export declare function parsePathSet(raw: string | undefined, defaults?: Iterable<string>): Set<string>;
|
|
27
|
+
/** Case-insensitive match of an exact path or any path below a prefix. */
|
|
28
|
+
export declare function matchesPathPrefix(pathname: string, prefixes: readonly string[]): boolean;
|
|
29
|
+
export declare function canUseCache(request: Request, mode: CacheMode): boolean;
|
|
30
|
+
/** React Server Component fetch signal: `_rsc` param, `text/x-component` accept, or `rsc: 1`. */
|
|
31
|
+
export declare function isRscRequest(request: Request, url: URL): boolean;
|
|
32
|
+
export type CacheControlOptions = {
|
|
33
|
+
/** Multiplier applied to the TTL for `stale-while-revalidate`. Defaults to 2. */
|
|
34
|
+
staleWhileRevalidateFactor?: number;
|
|
35
|
+
/** Bound on serving stale content during origin errors. Defaults to one day. */
|
|
36
|
+
staleIfErrorSeconds?: number;
|
|
37
|
+
};
|
|
38
|
+
/** `cache-control` for a response whose only cache layer is the platform cache. */
|
|
39
|
+
export declare function buildCacheControlHeader(ttlSeconds: number, options?: CacheControlOptions): string;
|
|
40
|
+
/** A 2xx response without `set-cookie` or a private/no-store/no-cache directive. */
|
|
41
|
+
export declare function isCacheableResponse(response: Response): boolean;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
const EMPTY_SET = new Set();
|
|
2
|
+
/** Pass the request to the origin untouched by any cache. */
|
|
3
|
+
export function bypassPolicy(options = {}) {
|
|
4
|
+
return {
|
|
5
|
+
mode: 'bypass',
|
|
6
|
+
audience: null,
|
|
7
|
+
forceNoStore: options.forceNoStore ?? false,
|
|
8
|
+
ttlSeconds: 0,
|
|
9
|
+
staleWhileRevalidateSeconds: 0,
|
|
10
|
+
stripCookieNames: options.stripCookieNames ?? EMPTY_SET,
|
|
11
|
+
stripAllCookies: false,
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
/** Serve an audience's response from the shared cache; cookies are stripped by default. */
|
|
15
|
+
export function cachedPolicy(audience, ttlSeconds, options = {}) {
|
|
16
|
+
return {
|
|
17
|
+
mode: 'cache',
|
|
18
|
+
audience,
|
|
19
|
+
forceNoStore: false,
|
|
20
|
+
ttlSeconds,
|
|
21
|
+
staleWhileRevalidateSeconds: ttlSeconds * 2,
|
|
22
|
+
stripCookieNames: options.stripCookieNames ?? EMPTY_SET,
|
|
23
|
+
stripAllCookies: options.stripAllCookies ?? true,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
/** Parses a positive TTL from configuration, flooring it; anything else yields `fallback`. */
|
|
27
|
+
export function toPositiveNumber(raw, fallback) {
|
|
28
|
+
const value = Number(raw);
|
|
29
|
+
if (!Number.isFinite(value) || value <= 0)
|
|
30
|
+
return fallback;
|
|
31
|
+
return Math.floor(value);
|
|
32
|
+
}
|
|
33
|
+
/** Parses a comma-separated path list and merges it into `defaults` (never replaces them). */
|
|
34
|
+
export function parsePathSet(raw, defaults = []) {
|
|
35
|
+
const extra = (raw ?? '')
|
|
36
|
+
.split(',')
|
|
37
|
+
.map((path) => path.trim())
|
|
38
|
+
.filter(Boolean);
|
|
39
|
+
return new Set([...defaults, ...extra]);
|
|
40
|
+
}
|
|
41
|
+
/** Case-insensitive match of an exact path or any path below a prefix. */
|
|
42
|
+
export function matchesPathPrefix(pathname, prefixes) {
|
|
43
|
+
const normalized = pathname.toLowerCase();
|
|
44
|
+
return prefixes.some((prefix) => normalized === prefix || normalized.startsWith(`${prefix}/`));
|
|
45
|
+
}
|
|
46
|
+
export function canUseCache(request, mode) {
|
|
47
|
+
return mode === 'cache' && request.method === 'GET';
|
|
48
|
+
}
|
|
49
|
+
/** React Server Component fetch signal: `_rsc` param, `text/x-component` accept, or `rsc: 1`. */
|
|
50
|
+
export function isRscRequest(request, url) {
|
|
51
|
+
return (url.searchParams.has('_rsc') ||
|
|
52
|
+
(request.headers.get('accept') ?? '').toLowerCase().includes('text/x-component') ||
|
|
53
|
+
request.headers.get('rsc') === '1');
|
|
54
|
+
}
|
|
55
|
+
/** `cache-control` for a response whose only cache layer is the platform cache. */
|
|
56
|
+
export function buildCacheControlHeader(ttlSeconds, options = {}) {
|
|
57
|
+
const factor = options.staleWhileRevalidateFactor ?? 2;
|
|
58
|
+
const staleIfError = options.staleIfErrorSeconds ?? 86_400;
|
|
59
|
+
return `public, max-age=${ttlSeconds}, stale-while-revalidate=${ttlSeconds * factor}, stale-if-error=${staleIfError}`;
|
|
60
|
+
}
|
|
61
|
+
function hasDisqualifyingCacheControl(headers) {
|
|
62
|
+
return (headers.get('cache-control') ?? '')
|
|
63
|
+
.split(',')
|
|
64
|
+
.map((part) => part.trim().toLowerCase())
|
|
65
|
+
.some((directive) => directive === 'private' || directive === 'no-store' || directive === 'no-cache');
|
|
66
|
+
}
|
|
67
|
+
/** A 2xx response without `set-cookie` or a private/no-store/no-cache directive. */
|
|
68
|
+
export function isCacheableResponse(response) {
|
|
69
|
+
if (response.status < 200 || response.status >= 300)
|
|
70
|
+
return false;
|
|
71
|
+
if (hasDisqualifyingCacheControl(response.headers))
|
|
72
|
+
return false;
|
|
73
|
+
return !response.headers.has('set-cookie');
|
|
74
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** Marketing and ad click-id parameters that never change response content. */
|
|
2
|
+
export declare const DEFAULT_NO_VARY_SEARCH_PARAM_NAMES: readonly string[];
|
|
3
|
+
/**
|
|
4
|
+
* Builds the canonical URL used as a platform-cache key: drops parameters that do not change the
|
|
5
|
+
* response, then sorts the rest so key order never fragments the cache.
|
|
6
|
+
*/
|
|
7
|
+
export declare function normalizeCacheUrl(rawUrl: string, options?: {
|
|
8
|
+
stripParams?: Iterable<string>;
|
|
9
|
+
}): string;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/** Marketing and ad click-id parameters that never change response content. */
|
|
2
|
+
export const DEFAULT_NO_VARY_SEARCH_PARAM_NAMES = [
|
|
3
|
+
'utm_source',
|
|
4
|
+
'utm_medium',
|
|
5
|
+
'utm_campaign',
|
|
6
|
+
'utm_term',
|
|
7
|
+
'utm_content',
|
|
8
|
+
'gclid',
|
|
9
|
+
'gbraid',
|
|
10
|
+
'wbraid',
|
|
11
|
+
'dclid',
|
|
12
|
+
'fbclid',
|
|
13
|
+
'msclkid',
|
|
14
|
+
'ttclid',
|
|
15
|
+
'twclid',
|
|
16
|
+
'srsltid',
|
|
17
|
+
'igshid',
|
|
18
|
+
'igsh',
|
|
19
|
+
'_gl',
|
|
20
|
+
'mc_cid',
|
|
21
|
+
'mc_eid',
|
|
22
|
+
];
|
|
23
|
+
/**
|
|
24
|
+
* Builds the canonical URL used as a platform-cache key: drops parameters that do not change the
|
|
25
|
+
* response, then sorts the rest so key order never fragments the cache.
|
|
26
|
+
*/
|
|
27
|
+
export function normalizeCacheUrl(rawUrl, options = {}) {
|
|
28
|
+
const url = new URL(rawUrl);
|
|
29
|
+
for (const name of options.stripParams ?? DEFAULT_NO_VARY_SEARCH_PARAM_NAMES) {
|
|
30
|
+
url.searchParams.delete(name);
|
|
31
|
+
}
|
|
32
|
+
url.searchParams.sort();
|
|
33
|
+
return url.toString();
|
|
34
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export declare const DEFAULT_SAFE_CACHE_VARY_TOKENS: readonly string[];
|
|
2
|
+
/**
|
|
3
|
+
* Returns canonical safe tokens, or null when the `Vary` value must not enter a shared cache
|
|
4
|
+
* (any token outside `allowed`, including `*`). Tokens compare case-insensitively.
|
|
5
|
+
*/
|
|
6
|
+
export declare function parseSafeCacheVary(value: string | null, allowed?: readonly string[]): string[] | null;
|
|
7
|
+
/** Merges additions into a client-facing `Vary` value without respelling existing tokens. */
|
|
8
|
+
export declare function mergeClientVary(existing: string | null, additions: readonly string[]): string | null;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
export const DEFAULT_SAFE_CACHE_VARY_TOKENS = [
|
|
2
|
+
'Cookie',
|
|
3
|
+
'Authorization',
|
|
4
|
+
'Accept-Encoding',
|
|
5
|
+
'Accept-Language',
|
|
6
|
+
];
|
|
7
|
+
/**
|
|
8
|
+
* Returns canonical safe tokens, or null when the `Vary` value must not enter a shared cache
|
|
9
|
+
* (any token outside `allowed`, including `*`). Tokens compare case-insensitively.
|
|
10
|
+
*/
|
|
11
|
+
export function parseSafeCacheVary(value, allowed = DEFAULT_SAFE_CACHE_VARY_TOKENS) {
|
|
12
|
+
if (value === null)
|
|
13
|
+
return [];
|
|
14
|
+
const canonicalByKey = new Map(allowed.map((token) => [token.toLowerCase(), token]));
|
|
15
|
+
const tokens = [];
|
|
16
|
+
const seen = new Set();
|
|
17
|
+
for (const rawToken of value.split(',')) {
|
|
18
|
+
const key = rawToken.trim().toLowerCase();
|
|
19
|
+
if (!key)
|
|
20
|
+
continue;
|
|
21
|
+
const canonical = canonicalByKey.get(key);
|
|
22
|
+
if (!canonical)
|
|
23
|
+
return null;
|
|
24
|
+
if (seen.has(key))
|
|
25
|
+
continue;
|
|
26
|
+
seen.add(key);
|
|
27
|
+
tokens.push(canonical);
|
|
28
|
+
}
|
|
29
|
+
return tokens;
|
|
30
|
+
}
|
|
31
|
+
/** Merges additions into a client-facing `Vary` value without respelling existing tokens. */
|
|
32
|
+
export function mergeClientVary(existing, additions) {
|
|
33
|
+
const existingTokens = existing?.split(',').map((token) => token.trim()) ?? [];
|
|
34
|
+
if (existingTokens.some((token) => token === '*'))
|
|
35
|
+
return '*';
|
|
36
|
+
const merged = existingTokens.filter(Boolean);
|
|
37
|
+
const seen = new Set(merged.map((token) => token.toLowerCase()));
|
|
38
|
+
for (const token of additions) {
|
|
39
|
+
const key = token.toLowerCase();
|
|
40
|
+
if (seen.has(key))
|
|
41
|
+
continue;
|
|
42
|
+
seen.add(key);
|
|
43
|
+
merged.push(token);
|
|
44
|
+
}
|
|
45
|
+
return merged.length > 0 ? merged.join(', ') : null;
|
|
46
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { CacheContext } from '@cloudflare/workers-types';
|
|
2
|
+
import { type CacheControlOptions } from './cache-policy.mts';
|
|
3
|
+
/** Per-request headers that must not be frozen into bytes replayed to other clients. */
|
|
4
|
+
export declare const SHARED_CACHE_VOLATILE_HEADERS: readonly string[];
|
|
5
|
+
/** Copies headers for a shared cache entry, removing request-specific ones. */
|
|
6
|
+
export declare function stripCacheHeaders(headers: Headers, volatile?: readonly string[]): Headers;
|
|
7
|
+
export type FinalizeCachedOriginOptions = {
|
|
8
|
+
ttlSeconds: number;
|
|
9
|
+
/** Cache tags for the entry; used for purging. */
|
|
10
|
+
tags: readonly string[];
|
|
11
|
+
/**
|
|
12
|
+
* Header that carries the origin's `Vary` tokens inside the cache. The platform cache ignores
|
|
13
|
+
* `vary`, so the application re-reads this header on HIT to apply client-facing `Vary`.
|
|
14
|
+
*/
|
|
15
|
+
internalVaryHeader: string;
|
|
16
|
+
/** `Vary` tokens that are safe to cache. Any other token makes the response uncacheable. */
|
|
17
|
+
safeVaryTokens?: readonly string[];
|
|
18
|
+
cacheControl?: CacheControlOptions;
|
|
19
|
+
/** Appended to `tags` when it returns true for the origin's own `Cache-Tag` value. */
|
|
20
|
+
isValidOriginTag?: (tag: string) => boolean;
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* Prepares an origin response for the shared cache: uncacheable responses get `private, no-store`;
|
|
24
|
+
* cacheable ones lose `set-cookie` and volatile headers and gain `cache-control`, the internal
|
|
25
|
+
* vary header, and `Cache-Tag`.
|
|
26
|
+
*/
|
|
27
|
+
export declare function finalizeCachedOriginResponse(originResponse: Response, options: FinalizeCachedOriginOptions): Response;
|
|
28
|
+
/**
|
|
29
|
+
* Removes dispatch-only signals before building an origin request. A dispatch IP header is
|
|
30
|
+
* restored as `cf-connecting-ip` so downstream forwarding keeps the client address.
|
|
31
|
+
*/
|
|
32
|
+
export declare function stripDispatchOnlyHeaders(request: Request, options: {
|
|
33
|
+
dropHeaders: readonly string[];
|
|
34
|
+
ipHeader?: string;
|
|
35
|
+
}): Request;
|
|
36
|
+
/**
|
|
37
|
+
* Purges tags from the cache namespace of the current entrypoint. Throws when the execution
|
|
38
|
+
* context has no cache API, because a silent no-op would leave stale content serving.
|
|
39
|
+
*/
|
|
40
|
+
export declare function purgeCacheTags(context: {
|
|
41
|
+
cache?: CacheContext;
|
|
42
|
+
}, tags: string[]): Promise<import("@cloudflare/workers-types").CachePurgeResult>;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { buildCacheControlHeader, isCacheableResponse, } from './cache-policy.mjs';
|
|
2
|
+
import { DEFAULT_SAFE_CACHE_VARY_TOKENS, parseSafeCacheVary } from './cache-vary.mjs';
|
|
3
|
+
import { copyHeaders, withHeaders } from './headers.mjs';
|
|
4
|
+
const PRIVATE_NO_STORE = { 'cache-control': 'private, no-store' };
|
|
5
|
+
/** Per-request headers that must not be frozen into bytes replayed to other clients. */
|
|
6
|
+
export const SHARED_CACHE_VOLATILE_HEADERS = [
|
|
7
|
+
'content-security-policy',
|
|
8
|
+
'content-security-policy-report-only',
|
|
9
|
+
];
|
|
10
|
+
/** Copies headers for a shared cache entry, removing request-specific ones. */
|
|
11
|
+
export function stripCacheHeaders(headers, volatile = SHARED_CACHE_VOLATILE_HEADERS) {
|
|
12
|
+
const copied = copyHeaders(headers);
|
|
13
|
+
for (const name of volatile)
|
|
14
|
+
copied.delete(name);
|
|
15
|
+
return copied;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Prepares an origin response for the shared cache: uncacheable responses get `private, no-store`;
|
|
19
|
+
* cacheable ones lose `set-cookie` and volatile headers and gain `cache-control`, the internal
|
|
20
|
+
* vary header, and `Cache-Tag`.
|
|
21
|
+
*/
|
|
22
|
+
export function finalizeCachedOriginResponse(originResponse, options) {
|
|
23
|
+
if (!isCacheableResponse(originResponse))
|
|
24
|
+
return withHeaders(originResponse, PRIVATE_NO_STORE);
|
|
25
|
+
const varyTokens = parseSafeCacheVary(originResponse.headers.get('vary'), options.safeVaryTokens ?? DEFAULT_SAFE_CACHE_VARY_TOKENS);
|
|
26
|
+
const headers = stripCacheHeaders(originResponse.headers);
|
|
27
|
+
headers.delete(options.internalVaryHeader);
|
|
28
|
+
headers.delete('set-cookie');
|
|
29
|
+
const rebuild = () => new Response(originResponse.body, {
|
|
30
|
+
status: originResponse.status,
|
|
31
|
+
statusText: originResponse.statusText,
|
|
32
|
+
headers,
|
|
33
|
+
});
|
|
34
|
+
if (varyTokens === null) {
|
|
35
|
+
headers.set('cache-control', PRIVATE_NO_STORE['cache-control']);
|
|
36
|
+
return rebuild();
|
|
37
|
+
}
|
|
38
|
+
headers.delete('vary');
|
|
39
|
+
if (varyTokens.length > 0)
|
|
40
|
+
headers.set(options.internalVaryHeader, varyTokens.join(', '));
|
|
41
|
+
headers.set('cache-control', buildCacheControlHeader(options.ttlSeconds, options.cacheControl));
|
|
42
|
+
const tags = [...options.tags];
|
|
43
|
+
const originTag = originResponse.headers.get('Cache-Tag');
|
|
44
|
+
if (originTag && options.isValidOriginTag?.(originTag) && !tags.includes(originTag)) {
|
|
45
|
+
tags.push(originTag);
|
|
46
|
+
}
|
|
47
|
+
headers.set('Cache-Tag', tags.join(','));
|
|
48
|
+
return rebuild();
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Removes dispatch-only signals before building an origin request. A dispatch IP header is
|
|
52
|
+
* restored as `cf-connecting-ip` so downstream forwarding keeps the client address.
|
|
53
|
+
*/
|
|
54
|
+
export function stripDispatchOnlyHeaders(request, options) {
|
|
55
|
+
const headers = new Headers(request.headers);
|
|
56
|
+
const ip = options.ipHeader ? headers.get(options.ipHeader) : null;
|
|
57
|
+
for (const name of options.dropHeaders)
|
|
58
|
+
headers.delete(name);
|
|
59
|
+
if (options.ipHeader)
|
|
60
|
+
headers.delete(options.ipHeader);
|
|
61
|
+
if (ip)
|
|
62
|
+
headers.set('cf-connecting-ip', ip);
|
|
63
|
+
return new Request(request, { headers });
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Purges tags from the cache namespace of the current entrypoint. Throws when the execution
|
|
67
|
+
* context has no cache API, because a silent no-op would leave stale content serving.
|
|
68
|
+
*/
|
|
69
|
+
export async function purgeCacheTags(context, tags) {
|
|
70
|
+
if (!context.cache) {
|
|
71
|
+
throw new Error('Workers Cache purge API unavailable on this execution context');
|
|
72
|
+
}
|
|
73
|
+
return context.cache.purge({ tags });
|
|
74
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export type WebBrowserRequest = {
|
|
2
|
+
method: string;
|
|
3
|
+
/** The request URL's origin. */
|
|
4
|
+
requestOrigin: string;
|
|
5
|
+
};
|
|
6
|
+
export declare function hasBrowserFetchMetadata(headers: Headers): boolean;
|
|
7
|
+
/**
|
|
8
|
+
* Browser evidence that a request came from the web app on this origin, or from a top-level
|
|
9
|
+
* navigation. Forgeable by non-browser callers, so it only classifies telemetry.
|
|
10
|
+
*/
|
|
11
|
+
export declare function isWebBrowserRequest(headers: Headers, request: WebBrowserRequest): boolean;
|
|
12
|
+
export type ClientInfoPolicy = {
|
|
13
|
+
/** Header the edge owns to mark synthetic traffic such as bots or cache fills. */
|
|
14
|
+
requestKindHeader: string;
|
|
15
|
+
/** Every header a client may use to describe itself; cleared when the edge cannot vouch. */
|
|
16
|
+
clientInfoHeaderNames: readonly string[];
|
|
17
|
+
/** Header whose `value` identifies the web client; used to drop forged web claims. */
|
|
18
|
+
webClientHeader: {
|
|
19
|
+
name: string;
|
|
20
|
+
value: string;
|
|
21
|
+
};
|
|
22
|
+
/** Headers set when browser evidence confirms the web client. */
|
|
23
|
+
webClientHeaders: Readonly<Record<string, string>>;
|
|
24
|
+
};
|
|
25
|
+
export type ClientInfoOptions = WebBrowserRequest & {
|
|
26
|
+
requestKind?: string;
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* Normalizes client-identification headers before forwarding. Synthetic traffic gets only the
|
|
30
|
+
* request-kind header; browser-evidenced traffic gets the injected web headers; anything else
|
|
31
|
+
* keeps non-web client headers and loses forged web claims.
|
|
32
|
+
*/
|
|
33
|
+
export declare function applyClientInfoHeaders(headers: Headers, policy: ClientInfoPolicy, options: ClientInfoOptions): void;
|