@jimhoyd/urlcode 0.3.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/CONTRIBUTING.md +80 -0
- package/LICENSE +202 -0
- package/NOTICE +20 -0
- package/README.md +342 -0
- package/ROADMAP.md +248 -0
- package/SECURITY.md +40 -0
- package/data/agents/LICENSES/ai-robots-txt.txt +24 -0
- package/data/agents/LICENSES/crawler-user-agents.txt +23 -0
- package/data/agents/ai-crawlers.json +1240 -0
- package/data/agents/crawlers.json +10515 -0
- package/data/agents/index.d.ts +3 -0
- package/data/agents/index.js +2453 -0
- package/data/agents/monitoring.json +1520 -0
- package/data/agents/seo.json +3865 -0
- package/dist/BUILD-MANIFEST.json +60 -0
- package/dist/adapters.js +59 -0
- package/dist/agent-lists.js +59 -0
- package/dist/assets.js +129 -0
- package/dist/authoring.js +68 -0
- package/dist/aws.js +110 -0
- package/dist/build-cloudflare.js +164 -0
- package/dist/cli.js +185 -0
- package/dist/client-address.js +85 -0
- package/dist/cloudflare.js +165 -0
- package/dist/compliance-rules/baseline.js +98 -0
- package/dist/compliance-rules/privacy.js +38 -0
- package/dist/compliance-rules/shared.js +62 -0
- package/dist/compliance-rules/strict.js +61 -0
- package/dist/compliance.js +219 -0
- package/dist/config-worker.js +9 -0
- package/dist/config.js +172 -0
- package/dist/errors.js +8 -0
- package/dist/function-sources.js +54 -0
- package/dist/function-worker.js +105 -0
- package/dist/functions.js +174 -0
- package/dist/guest-api.js +114 -0
- package/dist/header-validation.js +18 -0
- package/dist/http-policy.js +74 -0
- package/dist/http-response.js +84 -0
- package/dist/index.js +11 -0
- package/dist/link-api.js +136 -0
- package/dist/link-cli.js +141 -0
- package/dist/link-events.js +76 -0
- package/dist/link-records.js +31 -0
- package/dist/link-store-worker.js +150 -0
- package/dist/link-store.js +250 -0
- package/dist/logging.js +22 -0
- package/dist/management-policy.js +41 -0
- package/dist/match.js +124 -0
- package/dist/observability.js +242 -0
- package/dist/plugins.js +74 -0
- package/dist/policies/agents.js +248 -0
- package/dist/policies/cache.js +297 -0
- package/dist/policies/compression.js +187 -0
- package/dist/policies/security.js +0 -0
- package/dist/policies/throttle.js +131 -0
- package/dist/policies.js +142 -0
- package/dist/policy.js +55 -0
- package/dist/prerender.js +165 -0
- package/dist/project-tests.js +23 -0
- package/dist/readiness.js +216 -0
- package/dist/router.js +166 -0
- package/dist/runtime.js +244 -0
- package/dist/scaffold.js +0 -0
- package/dist/scripts/operational-drills.js +75 -0
- package/dist/server.js +270 -0
- package/dist/site.js +0 -0
- package/dist/sqlite-version.js +6 -0
- package/dist/types/adapters.d.ts +12 -0
- package/dist/types/agent-lists.d.ts +5 -0
- package/dist/types/assets.d.ts +13 -0
- package/dist/types/authoring.d.ts +2 -0
- package/dist/types/aws.d.ts +35 -0
- package/dist/types/build-cloudflare.d.ts +14 -0
- package/dist/types/cli.d.ts +2 -0
- package/dist/types/client-address.d.ts +9 -0
- package/dist/types/cloudflare.d.ts +75 -0
- package/dist/types/compliance-rules/baseline.d.ts +12 -0
- package/dist/types/compliance-rules/privacy.d.ts +6 -0
- package/dist/types/compliance-rules/shared.d.ts +24 -0
- package/dist/types/compliance-rules/strict.d.ts +9 -0
- package/dist/types/compliance.d.ts +119 -0
- package/dist/types/config-worker.d.ts +1 -0
- package/dist/types/config.d.ts +21 -0
- package/dist/types/errors.d.ts +7 -0
- package/dist/types/function-sources.d.ts +21 -0
- package/dist/types/function-worker.d.ts +1 -0
- package/dist/types/functions.d.ts +92 -0
- package/dist/types/guest-api.d.ts +16 -0
- package/dist/types/header-validation.d.ts +2 -0
- package/dist/types/http-policy.d.ts +39 -0
- package/dist/types/http-response.d.ts +43 -0
- package/dist/types/index.d.ts +11 -0
- package/dist/types/link-api.d.ts +30 -0
- package/dist/types/link-cli.d.ts +36 -0
- package/dist/types/link-events.d.ts +27 -0
- package/dist/types/link-records.d.ts +11 -0
- package/dist/types/link-store-worker.d.ts +1 -0
- package/dist/types/link-store.d.ts +130 -0
- package/dist/types/logging.d.ts +9 -0
- package/dist/types/management-policy.d.ts +9 -0
- package/dist/types/match.d.ts +81 -0
- package/dist/types/observability.d.ts +104 -0
- package/dist/types/plugins.d.ts +27 -0
- package/dist/types/policies/agents.d.ts +86 -0
- package/dist/types/policies/cache.d.ts +82 -0
- package/dist/types/policies/compression.d.ts +41 -0
- package/dist/types/policies/security.d.ts +37 -0
- package/dist/types/policies/throttle.d.ts +55 -0
- package/dist/types/policies.d.ts +36 -0
- package/dist/types/policy.d.ts +21 -0
- package/dist/types/prerender.d.ts +31 -0
- package/dist/types/project-tests.d.ts +13 -0
- package/dist/types/readiness.d.ts +121 -0
- package/dist/types/router.d.ts +11 -0
- package/dist/types/runtime.d.ts +91 -0
- package/dist/types/scaffold.d.ts +17 -0
- package/dist/types/server.d.ts +37 -0
- package/dist/types/site.d.ts +12 -0
- package/dist/types/sqlite-version.d.ts +1 -0
- package/dist/types/types.d.ts +332 -0
- package/dist/types/vercel.d.ts +12 -0
- package/dist/types.js +177 -0
- package/dist/vercel.js +65 -0
- package/docs/AI-AUTHORING.md +113 -0
- package/docs/ASSETS.md +106 -0
- package/docs/AWS.md +77 -0
- package/docs/BEST-PRACTICES.md +267 -0
- package/docs/CAPACITY.md +208 -0
- package/docs/CLOUDFLARE.md +110 -0
- package/docs/COMPLIANCE.md +242 -0
- package/docs/DYNAMIC-LINKS.md +561 -0
- package/docs/FUNCTION-SECURITY.md +113 -0
- package/docs/HTTP.md +129 -0
- package/docs/INSTALL.md +90 -0
- package/docs/LOAD-TESTING.md +91 -0
- package/docs/LOCAL-DEVELOPMENT.md +99 -0
- package/docs/MANAGEMENT-SECURITY.md +82 -0
- package/docs/MIDDLEWARE.md +83 -0
- package/docs/MONITORING.md +132 -0
- package/docs/OBSERVABILITY.md +229 -0
- package/docs/OPERATIONAL-PROOF.md +42 -0
- package/docs/OPERATIONS.md +207 -0
- package/docs/ORGANIZATION.md +135 -0
- package/docs/PERFORMANCE.md +72 -0
- package/docs/PLUGINS.md +234 -0
- package/docs/POLICIES.md +489 -0
- package/docs/PRERENDER.md +193 -0
- package/docs/PROJECT-DIRECTION.md +51 -0
- package/docs/READINESS.md +125 -0
- package/docs/README.md +58 -0
- package/docs/RELEASE-READINESS.md +95 -0
- package/docs/RELEASE-SECURITY.md +72 -0
- package/docs/RESILIENCE.md +160 -0
- package/docs/ROUTING.md +95 -0
- package/docs/SANDBOX-REVIEW.md +59 -0
- package/docs/SCAFFOLDING.md +72 -0
- package/docs/SECURITY-AUDIT.md +124 -0
- package/docs/SITE.md +150 -0
- package/docs/SPECIFICATION.md +232 -0
- package/docs/SPIKE-EXTENSIONS.md +475 -0
- package/docs/STANDARDS.md +303 -0
- package/docs/STARTERS.md +51 -0
- package/docs/TUNNELS.md +75 -0
- package/docs/TYPESCRIPT.md +102 -0
- package/docs/VERCEL.md +107 -0
- package/docs/YAML-GUIDE.md +521 -0
- package/docs/YAML-REFERENCE.md +328 -0
- package/docs/policies/agents.md +182 -0
- package/docs/policies/cache.md +139 -0
- package/docs/policies/compression.md +168 -0
- package/docs/policies/security.md +160 -0
- package/docs/policies/throttle.md +103 -0
- package/examples/assets/.env.example +1 -0
- package/examples/assets/.gitattributes +2 -0
- package/examples/assets/Makefile +30 -0
- package/examples/assets/README.md +18 -0
- package/examples/assets/functions/hello.mjs +3 -0
- package/examples/assets/gitignore.template +7 -0
- package/examples/assets/public/about.html +2 -0
- package/examples/assets/public/assets/example.txt +1 -0
- package/examples/assets/public/guide.txt +1 -0
- package/examples/assets/tests/requests.json +44 -0
- package/examples/assets/urlcode.yaml +27 -0
- package/examples/aws/README.md +13 -0
- package/examples/aws/handler.mjs +5 -0
- package/examples/aws/public/index.html +3 -0
- package/examples/aws/public/notes.txt +1 -0
- package/examples/aws/template.yaml +33 -0
- package/examples/aws/tests/requests.json +9 -0
- package/examples/aws/urlcode.yaml +19 -0
- package/examples/cloudflare/README.md +18 -0
- package/examples/cloudflare/package.json +14 -0
- package/examples/cloudflare/tests/requests.json +9 -0
- package/examples/cloudflare/urlcode.yaml +24 -0
- package/examples/cloudflare/wrangler.toml +5 -0
- package/examples/compliance/README.md +20 -0
- package/examples/compliance/rules.mjs +65 -0
- package/examples/cookbook/README.md +21 -0
- package/examples/cookbook/functions/choice.mjs +4 -0
- package/examples/cookbook/functions/echo.mjs +3 -0
- package/examples/cookbook/functions/hello.mjs +3 -0
- package/examples/cookbook/functions/text.mjs +3 -0
- package/examples/cookbook/middleware/headers.mjs +6 -0
- package/examples/cookbook/public/about.html +1 -0
- package/examples/cookbook/public/assets/index.html +1 -0
- package/examples/cookbook/public/assets/site.css +1 -0
- package/examples/cookbook/public/favicon.svg +1 -0
- package/examples/cookbook/public/guide.txt +1 -0
- package/examples/cookbook/public/llms.txt +6 -0
- package/examples/cookbook/routes/code.yaml +55 -0
- package/examples/cookbook/routes/files.yaml +17 -0
- package/examples/cookbook/routes/policies.yaml +28 -0
- package/examples/cookbook/routes/redirects.yaml +38 -0
- package/examples/cookbook/routes/responses.yaml +26 -0
- package/examples/cookbook/tests/requests.json +254 -0
- package/examples/cookbook/urlcode.yaml +22 -0
- package/examples/live-links/README.md +11 -0
- package/examples/live-links/tests/requests.json +6 -0
- package/examples/live-links/urlcode.yaml +16 -0
- package/examples/monitoring/blackbox-jobs.yaml +37 -0
- package/examples/monitoring/prometheus-rules.yaml +88 -0
- package/examples/monitoring/prometheus-scrape.yaml +20 -0
- package/examples/monitoring/vector.toml +65 -0
- package/examples/prerender/README.md +48 -0
- package/examples/prerender/functions/page.mjs +6 -0
- package/examples/prerender/middleware/template.mjs +29 -0
- package/examples/prerender/prerender.d.mts +4 -0
- package/examples/prerender/prerender.mjs +63 -0
- package/examples/prerender/tests/requests.json +39 -0
- package/examples/prerender/urlcode.yaml +50 -0
- package/examples/tunnel/dev-with-ngrok.sh +61 -0
- package/examples/vercel/README.md +13 -0
- package/examples/vercel/api/index.js +5 -0
- package/examples/vercel/package.json +8 -0
- package/examples/vercel/public/index.html +3 -0
- package/examples/vercel/public/notes.txt +1 -0
- package/examples/vercel/tests/requests.json +48 -0
- package/examples/vercel/urlcode.yaml +19 -0
- package/examples/vercel/vercel.json +10 -0
- package/llms.txt +46 -0
- package/package.json +123 -0
- package/schemas/urlcode.schema.json +1230 -0
- package/starters/default/.gitattributes +1 -0
- package/starters/default/Makefile +30 -0
- package/starters/default/README.md +34 -0
- package/starters/default/functions/hello.mjs +3 -0
- package/starters/default/gitignore.template +8 -0
- package/starters/default/middleware/headers.mjs +6 -0
- package/starters/default/routes/functions.yaml +20 -0
- package/starters/default/routes/marketing/links.yaml +7 -0
- package/starters/default/starter.json +5 -0
- package/starters/default/tests/requests.json +56 -0
- package/starters/default/urlcode.yaml +8 -0
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { assert, ConfigError } from '../errors.js';
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
// Named HTTP caching strategies (RFC 9111) chosen from a fixed catalogue so a
|
|
7
|
+
// browser, a CDN and this runtime's own origin cache read the same headers
|
|
8
|
+
// the same way. Each strategy names a pattern known outside this project:
|
|
9
|
+
// RFC 5861 stale-while-revalidate / stale-if-error, RFC 8246 immutable,
|
|
10
|
+
// RFC 9213 CDN-Cache-Control, the NGINX micro-cache. Explicit fields override
|
|
11
|
+
// what the strategy implies; the strategy never overrides explicit YAML
|
|
12
|
+
// response headers. The origin cache is one per runtime, bounded by entries
|
|
13
|
+
// and bytes, and dropped with the runtime, so a reload starts empty.
|
|
14
|
+
export const name = 'cache';
|
|
15
|
+
export const phases = ['request','response'];
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
/** A stored 200 representation: the result minus its body and headers, which are kept separately. */
|
|
23
|
+
|
|
24
|
+
/** The in-flight fill for one key; waiters share its promise up to MAX_WAITERS. */
|
|
25
|
+
|
|
26
|
+
/** The one origin cache per runtime, shared by every route with an origin-caching strategy. */
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
const strategies = ['no-store','revalidate','public','immutable','swr','sie','micro','cdn-only','private'];
|
|
40
|
+
const DEFAULT_STATUSES = [200,301,302,404,410];
|
|
41
|
+
const DEFAULT_MAX_BYTES = 1048576; // per entry
|
|
42
|
+
const DEFAULT_MAX_ENTRIES = 10000;
|
|
43
|
+
const STORE_BYTES = 64 * 1024 * 1024; // per runtime, all routes together
|
|
44
|
+
const MAX_WAITERS = 64; // coalesced requests per in-flight key
|
|
45
|
+
const YEAR = 31536000;
|
|
46
|
+
const MICRO_MAX = 5;
|
|
47
|
+
const hashedSegment = /[0-9a-f]{8,}/i;
|
|
48
|
+
const hashedParameter = /^\{[^}]*(?:hash|digest|sha|fingerprint|rev|version|build)[^}]*\}$/i;
|
|
49
|
+
|
|
50
|
+
// Per-instance memory: honest on node, per-instance on serverless (documented),
|
|
51
|
+
// impossible in a Worker that has no policy runtime at all.
|
|
52
|
+
export function targets() { return { node:'native', vercel:'native', aws:'native', cloudflare:'refused' }; }
|
|
53
|
+
|
|
54
|
+
function lower(list ) { return list.map(v => String(v).trim().toLowerCase()).filter(Boolean); }
|
|
55
|
+
function header(headers , key ) {
|
|
56
|
+
const found = headers.find(([k]) => String(k).toLowerCase() === key);
|
|
57
|
+
return found ? found[1] : undefined;
|
|
58
|
+
}
|
|
59
|
+
function without(headers , ...keys ) { return headers.filter(([k]) => !keys.includes(String(k).toLowerCase())); }
|
|
60
|
+
function directive(value , token ) { return new RegExp(`(?:^|,)\\s*${token}\\s*(?:=|,|$)`, 'i').test(value || ''); }
|
|
61
|
+
|
|
62
|
+
export async function compile(config , { route, shared } ) {
|
|
63
|
+
assert(config && typeof config === 'object', `policies.${name} on ${route.pattern} must be an object`);
|
|
64
|
+
const { strategy, maxAge, staleWhileRevalidate, staleIfError, cdnMaxAge, force = false } = config;
|
|
65
|
+
const where = `on ${route.pattern}`;
|
|
66
|
+
assert(strategies.includes(strategy), `policies.${name}.strategy ${where} must be one of ${strategies.join(', ')}`);
|
|
67
|
+
const seconds = (value , key , required ) => {
|
|
68
|
+
if (value === undefined) { if (required) throw new ConfigError(`policies.${name}.${key} ${where} is required by strategy ${strategy}`); return undefined; }
|
|
69
|
+
assert(Number.isInteger(value) && value >= 0, `policies.${name}.${key} ${where} must be a non-negative integer of seconds`);
|
|
70
|
+
return value;
|
|
71
|
+
};
|
|
72
|
+
const vary = lower(config.vary ?? []);
|
|
73
|
+
assert(vary.length <= 8 && new Set(vary).size === vary.length && vary.every(v => /^[!#$%&'*+.^_`|~0-9a-z-]+$/.test(v)), `policies.${name}.vary ${where} must list at most 8 distinct header names`);
|
|
74
|
+
const statuses = config.statuses ?? DEFAULT_STATUSES;
|
|
75
|
+
assert(Array.isArray(statuses) && statuses.every(s => Number.isInteger(s) && s >= 200 && s <= 599), `policies.${name}.statuses ${where} must list HTTP statuses`);
|
|
76
|
+
const maxBytes = config.maxBytes ?? DEFAULT_MAX_BYTES, maxEntries = config.maxEntries ?? DEFAULT_MAX_ENTRIES;
|
|
77
|
+
assert(Number.isInteger(maxBytes) && maxBytes >= 0, `policies.${name}.maxBytes ${where} must be a non-negative integer`);
|
|
78
|
+
assert(Number.isInteger(maxEntries) && maxEntries >= 1, `policies.${name}.maxEntries ${where} must be a positive integer`);
|
|
79
|
+
|
|
80
|
+
// What the strategy implies, then what the fields override. `age` is
|
|
81
|
+
// required (so defined) for public, private, swr and sie, `swr` for swr:
|
|
82
|
+
// seconds() has thrown otherwise, which is what the `!` below rest on.
|
|
83
|
+
let cacheControl = '', cdnCacheControl , fresh = 0, stale = 0, origin = false;
|
|
84
|
+
const age = seconds(maxAge, 'maxAge', ['public','private','swr','sie'].includes(strategy));
|
|
85
|
+
const swr = seconds(staleWhileRevalidate, 'staleWhileRevalidate', strategy === 'swr');
|
|
86
|
+
const sie = seconds(staleIfError, 'staleIfError', strategy === 'sie');
|
|
87
|
+
const cdn = seconds(cdnMaxAge, 'cdnMaxAge', strategy === 'cdn-only');
|
|
88
|
+
let originTtl = seconds(config.originTtl, 'originTtl', false);
|
|
89
|
+
switch (strategy) {
|
|
90
|
+
case 'no-store': cacheControl = 'no-store'; break;
|
|
91
|
+
case 'revalidate': cacheControl = 'no-cache'; break;
|
|
92
|
+
case 'public': cacheControl = `public, max-age=${age}`; origin = (originTtl ?? 0) > 0; fresh = originTtl ?? 0; break;
|
|
93
|
+
case 'private': cacheControl = `private, max-age=${age}`; break;
|
|
94
|
+
case 'immutable': {
|
|
95
|
+
const hashed = route.pattern.split('/').some(part => hashedSegment.test(part) || hashedParameter.test(part));
|
|
96
|
+
if (!hashed && !force) throw new ConfigError(`${route.pattern} declares policies.${name} strategy immutable on a path without a content hash; add a hashed segment or force: true`);
|
|
97
|
+
cacheControl = `public, max-age=${age ?? YEAR}, immutable`; break;
|
|
98
|
+
}
|
|
99
|
+
case 'swr': cacheControl = `public, max-age=${age}, stale-while-revalidate=${swr}`; origin = true; fresh = originTtl ?? age ; stale = swr ; break;
|
|
100
|
+
case 'sie':
|
|
101
|
+
cacheControl = `public, max-age=${age}${swr !== undefined ? `, stale-while-revalidate=${swr}` : ''}, stale-if-error=${sie}`;
|
|
102
|
+
origin = true; fresh = originTtl ?? age ; stale = swr ?? 0; break;
|
|
103
|
+
case 'micro':
|
|
104
|
+
originTtl ??= 1;
|
|
105
|
+
if (originTtl > MICRO_MAX && !force) throw new ConfigError(`policies.${name}.originTtl ${where} exceeds ${MICRO_MAX} seconds for strategy micro; use public or set force: true`);
|
|
106
|
+
cacheControl = 'no-store'; origin = originTtl > 0; fresh = originTtl; break;
|
|
107
|
+
case 'cdn-only': cacheControl = 'no-store'; cdnCacheControl = `max-age=${cdn}`; break;
|
|
108
|
+
}
|
|
109
|
+
// A route bound to secrets never enters the origin cache, whatever it says.
|
|
110
|
+
const secrets = Object.keys(route.secrets || {}).length > 0;
|
|
111
|
+
if (secrets || !(route.methods || ['GET','HEAD']).includes('GET')) origin = false;
|
|
112
|
+
const store = shared.cache ||= { entries: new Map(), pending: new Map(), bytes: 0, maxBytes: STORE_BYTES, now: shared.now || Date.now };
|
|
113
|
+
return {
|
|
114
|
+
strategy, cacheControl, cdnCacheControl, vary, statuses: new Set(statuses), maxBytes, maxEntries,
|
|
115
|
+
origin, freshMs: fresh * 1000, staleMs: stale * 1000, originTtl: origin ? fresh : null, staleIfError: sie ?? null,
|
|
116
|
+
route: route.pattern, secrets, log: shared.log, store,
|
|
117
|
+
// Who owns Cache-Control on this route: explicit YAML headers always,
|
|
118
|
+
// an asset handler's cacheControl unless the route itself names a policy.
|
|
119
|
+
yamlCacheControl: (route.responseHeaders || []).some(([k]) => k === 'cache-control'),
|
|
120
|
+
inheritedAsset: Boolean(route.asset) && !route.policies?.cache,
|
|
121
|
+
inflight: new WeakMap(),
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Key: route pattern, path, query and the values of the declared Vary
|
|
126
|
+
// headers. HEAD shares the GET entry (only GET results are stored), so the
|
|
127
|
+
// method is not part of the key.
|
|
128
|
+
function keyFor(state , req ) {
|
|
129
|
+
const varied = state.vary.map(h => req.headers.get(h) ?? '').join('\u0001');
|
|
130
|
+
return `${state.route}\u0000${req.path}\u0000${req.query?.toString?.() ?? ''}\u0000${varied}`;
|
|
131
|
+
}
|
|
132
|
+
function log(state , outcome ) {
|
|
133
|
+
try { state.log?.({ event: 'cache', route: state.route, outcome }); } catch { /* logging never changes the outcome */ }
|
|
134
|
+
}
|
|
135
|
+
function touch(store , key , entry ) {
|
|
136
|
+
store.entries.delete(key); store.entries.set(key, entry);
|
|
137
|
+
}
|
|
138
|
+
function served(entry , ageMs ) {
|
|
139
|
+
const headers = [...without(entry.headers, 'age'), ['age', String(Math.max(0, Math.floor(ageMs / 1000)))]];
|
|
140
|
+
// The body stays attached on HEAD, as the asset handler does: the response
|
|
141
|
+
// writer drops it, and a later policy can still pick the encoded variant.
|
|
142
|
+
return { ...entry.result, headers, contentLength: entry.body.length, body: entry.body };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const conditional = ['if-none-match','if-modified-since','if-match','if-unmodified-since','range'];
|
|
146
|
+
export async function onRequest(state , req ) {
|
|
147
|
+
if (!state.origin || req.secrets || (req.method !== 'GET' && req.method !== 'HEAD')) return undefined;
|
|
148
|
+
// A stored entry is a full 200 representation; the handler owns validators
|
|
149
|
+
// and ranges, so a conditional or partial request always reaches it.
|
|
150
|
+
if (conditional.some(name => req.headers.has(name))) return undefined;
|
|
151
|
+
const { store } = state, now = store.now(), key = keyFor(state, req);
|
|
152
|
+
const entry = store.entries.get(key);
|
|
153
|
+
if (entry) {
|
|
154
|
+
const age = now - entry.storedAt;
|
|
155
|
+
if (age < state.freshMs) { touch(store, key, entry); log(state, 'hit'); return served(entry, age); }
|
|
156
|
+
// Stale within the window: answer now and let the next request refresh.
|
|
157
|
+
// A policy has no handle to the handler, so this is the origin-side
|
|
158
|
+
// approximation of background revalidation; the flag makes it one-shot.
|
|
159
|
+
if (age < state.freshMs + state.staleMs && !entry.revalidating) { entry.revalidating = true; log(state, 'stale'); return served(entry, age); }
|
|
160
|
+
}
|
|
161
|
+
// Miss: the first request for a key reaches the handler; concurrent ones
|
|
162
|
+
// wait for its result up to a bounded count, beyond which they proceed.
|
|
163
|
+
const pending = store.pending.get(key);
|
|
164
|
+
if (pending && pending.waiters < MAX_WAITERS) {
|
|
165
|
+
pending.waiters++;
|
|
166
|
+
let stored ;
|
|
167
|
+
try { stored = await pending.promise; } catch { stored = null; }
|
|
168
|
+
if (stored) { log(state, 'hit'); return served(stored, store.now() - stored.storedAt); }
|
|
169
|
+
return undefined;
|
|
170
|
+
}
|
|
171
|
+
if (!pending && req.method === 'GET') {
|
|
172
|
+
let resolve , reject ;
|
|
173
|
+
const promise = new Promise ((res, rej) => { resolve = res; reject = rej; });
|
|
174
|
+
const flight = { waiters: 0, promise, resolve, reject };
|
|
175
|
+
flight.promise.catch(() => {});
|
|
176
|
+
store.pending.set(key, flight);
|
|
177
|
+
state.inflight.set(req, { key, flight });
|
|
178
|
+
}
|
|
179
|
+
log(state, 'miss');
|
|
180
|
+
return undefined;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function mergeVary(headers , names ) {
|
|
184
|
+
if (!names.length) return headers;
|
|
185
|
+
const index = headers.findIndex(([k]) => String(k).toLowerCase() === 'vary');
|
|
186
|
+
const present = index < 0 ? [] : headers[index] [1].split(',').map(v => v.trim()).filter(Boolean);
|
|
187
|
+
if (present.includes('*')) return headers;
|
|
188
|
+
const seen = new Set(present.map(v => v.toLowerCase()));
|
|
189
|
+
const merged = [...present, ...names.filter(n => !seen.has(n))];
|
|
190
|
+
if (index < 0) return [...headers, ['vary', merged.join(', ')]];
|
|
191
|
+
const out = [...headers]; out[index] = [headers[index] [0], merged.join(', ')]; return out;
|
|
192
|
+
}
|
|
193
|
+
function noneMatch(value , etag ) {
|
|
194
|
+
const strip = (tag ) => tag.trim().replace(/^W\//, '');
|
|
195
|
+
return value.split(',').some(tag => tag.trim() === '*' || strip(tag) === strip(etag));
|
|
196
|
+
}
|
|
197
|
+
function bodyOf(result ) { return result.body ? (Buffer.isBuffer(result.body) ? result.body : Buffer.from(result.body)) : Buffer.alloc(0); }
|
|
198
|
+
|
|
199
|
+
// Conditional requests for results the handler did not validate itself:
|
|
200
|
+
// assets answer 304 before this phase, so only 200 results are examined.
|
|
201
|
+
function revalidate(state , req , result ) {
|
|
202
|
+
if (result.status !== 200 || (req.method !== 'GET' && req.method !== 'HEAD')) return result;
|
|
203
|
+
let headers = result.headers, etag = header(headers, 'etag');
|
|
204
|
+
if (!etag) {
|
|
205
|
+
// A strong validator over the representation. A HEAD answer without a
|
|
206
|
+
// body (a function's) has nothing to hash and gets no validator, rather
|
|
207
|
+
// than one that would disagree with GET.
|
|
208
|
+
const body = bodyOf(result);
|
|
209
|
+
if (req.method === 'HEAD' && !body.length) return result;
|
|
210
|
+
etag = '"' + createHash('sha256').update(body).digest('hex') + '"'; headers = [...headers, ['etag', etag]];
|
|
211
|
+
}
|
|
212
|
+
const none = req.headers.get('if-none-match'), modified = header(headers, 'last-modified');
|
|
213
|
+
const since = req.headers.get('if-modified-since');
|
|
214
|
+
const matched = none ? noneMatch(none, etag) : Boolean(modified && since && Date.parse(modified) <= Date.parse(since));
|
|
215
|
+
if (!matched) return { ...result, headers };
|
|
216
|
+
// Content-Type stays so a later policy can still see what varied (RFC 9110 §15.4.5).
|
|
217
|
+
const kept = new Set(['etag','cache-control','cdn-cache-control','vary','last-modified','content-location','expires','date','content-type']);
|
|
218
|
+
const { contentLength: _length, ...rest } = result;
|
|
219
|
+
return { ...rest, status: 304, headers: headers.filter(([k]) => kept.has(String(k).toLowerCase())), body: Buffer.alloc(0) };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function evict(store , maxEntries ) {
|
|
223
|
+
while (store.entries.size > maxEntries || store.bytes > store.maxBytes) {
|
|
224
|
+
const [key, oldest] = store.entries.entries().next().value ;
|
|
225
|
+
store.entries.delete(key); store.bytes -= oldest.body.length;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export function onResponse(state , req , result ) {
|
|
230
|
+
const flight = state.inflight.get(req);
|
|
231
|
+
if (flight) state.inflight.delete(req);
|
|
232
|
+
const handlerControl = header(result.headers, 'cache-control');
|
|
233
|
+
// Explicit beats strategy: YAML headers, an inherited policy over an asset
|
|
234
|
+
// handler's own cacheControl, and a handler that asked for private or
|
|
235
|
+
// no-store (a personalized answer under a public route stays private).
|
|
236
|
+
const restrictive = directive(handlerControl, 'no-store') || directive(handlerControl, 'private');
|
|
237
|
+
const owned = !state.yamlCacheControl && !state.inheritedAsset && !restrictive;
|
|
238
|
+
let headers = result.headers;
|
|
239
|
+
if (owned) {
|
|
240
|
+
headers = [...without(headers, 'cache-control', 'cdn-cache-control'), ['cache-control', state.cacheControl]];
|
|
241
|
+
if (state.cdnCacheControl) headers.push(['cdn-cache-control', state.cdnCacheControl]);
|
|
242
|
+
}
|
|
243
|
+
// Only responses the cache could hold vary on the declared headers; a
|
|
244
|
+
// refusal produced ahead of the handler keeps its own headers.
|
|
245
|
+
if (flight || state.statuses.has(result.status)) headers = mergeVary(headers, state.vary);
|
|
246
|
+
let out = { ...result, headers };
|
|
247
|
+
if (state.strategy === 'revalidate') out = revalidate(state, req, out);
|
|
248
|
+
if (!flight) return out;
|
|
249
|
+
// Store decision for the request that reached the handler; waiters are
|
|
250
|
+
// released either way, with the entry or with nothing.
|
|
251
|
+
const { store } = state, body = bodyOf(out);
|
|
252
|
+
const storable = !req.secrets && state.statuses.has(out.status) && !restrictive && body.length <= state.maxBytes
|
|
253
|
+
&& !out.headers.some(([k]) => String(k).toLowerCase() === 'set-cookie');
|
|
254
|
+
let entry = null;
|
|
255
|
+
if (storable) {
|
|
256
|
+
const previous = store.entries.get(flight.key);
|
|
257
|
+
if (previous) { store.entries.delete(flight.key); store.bytes -= previous.body.length; }
|
|
258
|
+
const { body: _body, headers: _headers, ...rest } = out;
|
|
259
|
+
entry = { result: rest, headers: without(out.headers, 'age'), body, storedAt: store.now(), revalidating: false };
|
|
260
|
+
store.entries.set(flight.key, entry); store.bytes += body.length;
|
|
261
|
+
evict(store, state.maxEntries);
|
|
262
|
+
log(state, 'store');
|
|
263
|
+
}
|
|
264
|
+
store.pending.delete(flight.key);
|
|
265
|
+
flight.flight.resolve(entry);
|
|
266
|
+
return out;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export function onError(state , req , _error ) {
|
|
270
|
+
const flight = state.inflight.get(req);
|
|
271
|
+
if (!flight) return;
|
|
272
|
+
state.inflight.delete(req);
|
|
273
|
+
state.store.pending.delete(flight.key);
|
|
274
|
+
flight.flight.reject(new Error('cache fill failed'));
|
|
275
|
+
// A stale entry that failed to refresh may be served once more.
|
|
276
|
+
const entry = state.store.entries.get(flight.key);
|
|
277
|
+
if (entry) entry.revalidating = false;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export function describe(state ) {
|
|
281
|
+
const summary = { strategy: state.strategy, cacheControl: state.cacheControl, origin: state.origin, originTtl: state.originTtl, vary: state.vary };
|
|
282
|
+
if (state.cdnCacheControl) summary.cdnCacheControl = state.cdnCacheControl;
|
|
283
|
+
if (state.staleMs) summary.staleWhileRevalidate = state.staleMs / 1000;
|
|
284
|
+
if (state.staleIfError !== null) summary.staleIfError = state.staleIfError;
|
|
285
|
+
if (state.yamlCacheControl) summary.cacheControl = 'explicit response header';
|
|
286
|
+
else if (state.inheritedAsset) summary.cacheControl = 'asset handler';
|
|
287
|
+
if (state.secrets) summary.origin = false;
|
|
288
|
+
return summary;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
export async function close(shared ) {
|
|
292
|
+
const store = shared?.cache;
|
|
293
|
+
if (!store) return;
|
|
294
|
+
for (const flight of store.pending.values()) flight.reject(new Error('runtime closed'));
|
|
295
|
+
store.pending.clear(); store.entries.clear(); store.bytes = 0;
|
|
296
|
+
delete shared.cache;
|
|
297
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import zlib from 'node:zlib';
|
|
2
|
+
import { assert, ConfigError } from '../errors.js';
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
// Content-coding negotiation (RFC 9110 §12.5.3) enforced on the host. Runs
|
|
7
|
+
// last in the response phase so every header it reads (Content-Type,
|
|
8
|
+
// Cache-Control, Set-Cookie, ETag) is final. Two paths:
|
|
9
|
+
// assets variants are computed once in compile() and served by reference
|
|
10
|
+
// with a strong, suffixed ETag (the NGINX gzip_static model);
|
|
11
|
+
// dynamic bodies up to 1 MiB are compressed synchronously on the request
|
|
12
|
+
// path with a weakened ETag; larger bodies are sent as identity so
|
|
13
|
+
// the event loop is never held for a single response.
|
|
14
|
+
export const name = 'compression';
|
|
15
|
+
export const phases = ['response'];
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
const DYNAMIC_LIMIT = 1048576; // bytes; above this a body is never compressed on the request path
|
|
28
|
+
const PRECOMPRESSED_BUDGET = 64 * 1024 * 1024; // aggregate bytes of asset variants per runtime
|
|
29
|
+
const DEFAULT_TYPES = ['text/*','application/json','application/javascript','application/xml','image/svg+xml','application/manifest+json','application/ld+json'];
|
|
30
|
+
const SUFFIX = { br:'-br', gzip:'-gz', deflate:'-df', zstd:'-zs' };
|
|
31
|
+
const BODYLESS = new Set([204,205,304]);
|
|
32
|
+
|
|
33
|
+
// Codec table. `dynamic` levels favour latency, `stored` levels favour size
|
|
34
|
+
// because they are paid once at load. An explicit `level` (1–11) is mapped
|
|
35
|
+
// onto each codec's own scale so one number in YAML means "more" everywhere.
|
|
36
|
+
const codecs = {
|
|
37
|
+
br: { dynamic: 4, stored: 9, map: level => level,
|
|
38
|
+
compress: (body, quality) => zlib.brotliCompressSync(body, { params: { [zlib.constants.BROTLI_PARAM_QUALITY]: quality, [zlib.constants.BROTLI_PARAM_SIZE_HINT]: body.length } }) },
|
|
39
|
+
gzip: { dynamic: 6, stored: 9, map: level => Math.min(level, 9), compress: (body, level) => zlib.gzipSync(body, { level }) },
|
|
40
|
+
deflate: { dynamic: 6, stored: 9, map: level => Math.min(level, 9), compress: (body, level) => zlib.deflateSync(body, { level }) },
|
|
41
|
+
zstd: { dynamic: 3, stored: 12, map: level => Math.min(level * 2, 22),
|
|
42
|
+
compress: (body, level) => zlib.zstdCompressSync(body, { params: { [zlib.constants.ZSTD_c_compressionLevel]: level } }) },
|
|
43
|
+
};
|
|
44
|
+
export const zstdAvailable = typeof zlib.zstdCompressSync === 'function';
|
|
45
|
+
const isCoding = (value ) => typeof value === 'string' && Object.hasOwn(codecs, value);
|
|
46
|
+
|
|
47
|
+
export function targets() { return { node:'native', vercel:'delegated', aws:'delegated', cloudflare:'delegated' }; }
|
|
48
|
+
|
|
49
|
+
function typeMatcher(types ) {
|
|
50
|
+
const exact = new Set (), prefixes = [];
|
|
51
|
+
for (const raw of types) {
|
|
52
|
+
const type = raw.toLowerCase().trim();
|
|
53
|
+
assert(/^[\w.+-]+\/(?:\*|[\w.+-]+)$/.test(type), `policies.${name}.types entry "${raw}" is not a media type`);
|
|
54
|
+
if (type.endsWith('/*')) prefixes.push(type.slice(0, -1)); else exact.add(type);
|
|
55
|
+
}
|
|
56
|
+
return header => {
|
|
57
|
+
const type = (header || '').split(';')[0] .trim().toLowerCase();
|
|
58
|
+
return type ? exact.has(type) || prefixes.some(prefix => type.startsWith(prefix)) : false;
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
function eligibleAssets(asset ) {
|
|
62
|
+
if (!asset) return [];
|
|
63
|
+
return asset instanceof Map ? [...asset.values()] : [asset];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function compile(config , { route, shared } ) {
|
|
67
|
+
assert(config && typeof config === 'object', `policies.${name} on ${route.pattern} must be an object`);
|
|
68
|
+
const encodings = config.encodings ?? ['br','gzip'];
|
|
69
|
+
assert(Array.isArray(encodings) && encodings.length && encodings.every(isCoding), `policies.${name}.encodings on ${route.pattern} must list br, gzip, deflate or zstd`);
|
|
70
|
+
if (encodings.includes('zstd') && !zstdAvailable) throw new ConfigError(`${route.pattern} declares policies.${name}.encodings zstd, which node:zlib on Node ${process.version} does not provide`);
|
|
71
|
+
const minBytes = config.minBytes ?? 1024;
|
|
72
|
+
assert(Number.isInteger(minBytes) && minBytes >= 0, `policies.${name}.minBytes on ${route.pattern} must be a non-negative integer`);
|
|
73
|
+
const types = config.types ?? DEFAULT_TYPES;
|
|
74
|
+
assert(Array.isArray(types) && types.length, `policies.${name}.types on ${route.pattern} must be a non-empty array`);
|
|
75
|
+
const level = config.level;
|
|
76
|
+
assert(level === undefined || (Number.isInteger(level) && level >= 1 && level <= 11), `policies.${name}.level on ${route.pattern} must be 1–11`);
|
|
77
|
+
const levels = {};
|
|
78
|
+
for (const coding of encodings) {
|
|
79
|
+
const codec = codecs[coding];
|
|
80
|
+
levels[coding] = level === undefined ? { dynamic: codec.dynamic, stored: codec.stored } : { dynamic: codec.map(level), stored: codec.map(level) };
|
|
81
|
+
}
|
|
82
|
+
const state = { encodings, minBytes, types, matches: typeMatcher(types), level: level ?? null, levels, allowWithSecrets: config.allowWithSecrets === true, precompressed: 0 };
|
|
83
|
+
// Asset snapshots are immutable until the next reload, so their variants are
|
|
84
|
+
// computed here, once, and bounded: a variant that does not shrink the file
|
|
85
|
+
// is dropped and the aggregate across the runtime stops at the budget.
|
|
86
|
+
shared.compressionBytes ??= 0;
|
|
87
|
+
for (const asset of eligibleAssets(route.asset)) {
|
|
88
|
+
if (asset.body.length < minBytes || !state.matches(asset.type)) continue;
|
|
89
|
+
const encoded = asset.encoded ??= {};
|
|
90
|
+
for (const coding of encodings) {
|
|
91
|
+
if (encoded[coding] || shared.compressionBytes >= PRECOMPRESSED_BUDGET) continue;
|
|
92
|
+
const variant = codecs[coding].compress(asset.body, levels[coding] .stored);
|
|
93
|
+
if (variant.length >= asset.body.length || shared.compressionBytes + variant.length > PRECOMPRESSED_BUDGET) continue;
|
|
94
|
+
encoded[coding] = variant;
|
|
95
|
+
shared.compressionBytes += variant.length; state.precompressed++;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return state;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// RFC 9110 §12.5.3: pick the acceptable coding with the highest weight; the
|
|
102
|
+
// project's `encodings` order breaks ties. A coding absent from the field is
|
|
103
|
+
// acceptable only through `*`; identity is the fallback whenever nothing else
|
|
104
|
+
// is acceptable, including `identity;q=0` (sending 406 helps nobody).
|
|
105
|
+
export function negotiate (acceptEncoding , encodings ) {
|
|
106
|
+
if (acceptEncoding == null) return null;
|
|
107
|
+
const weights = new Map ();
|
|
108
|
+
for (const member of acceptEncoding.split(',')) {
|
|
109
|
+
const [token = '', ...params] = member.split(';');
|
|
110
|
+
const coding = token.trim().toLowerCase();
|
|
111
|
+
if (!coding) continue;
|
|
112
|
+
let q = 1;
|
|
113
|
+
for (const param of params) {
|
|
114
|
+
const [key, value] = param.split('=');
|
|
115
|
+
if (key?.trim().toLowerCase() === 'q') { q = Number.parseFloat(value ); if (!Number.isFinite(q)) q = 0; }
|
|
116
|
+
}
|
|
117
|
+
if (!weights.has(coding)) weights.set(coding, Math.min(Math.max(q, 0), 1));
|
|
118
|
+
}
|
|
119
|
+
const star = weights.get('*') ?? 0;
|
|
120
|
+
let best = null, bestQ = 0;
|
|
121
|
+
for (const coding of encodings) {
|
|
122
|
+
const q = weights.has(coding) ? weights.get(coding) : star;
|
|
123
|
+
if (q > bestQ) { best = coding; bestQ = q; }
|
|
124
|
+
}
|
|
125
|
+
return best;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function header(headers , key ) {
|
|
129
|
+
const found = headers.find(([k]) => k.toLowerCase() === key);
|
|
130
|
+
return found ? found[1] : undefined;
|
|
131
|
+
}
|
|
132
|
+
function withVary(headers ) {
|
|
133
|
+
const index = headers.findIndex(([k]) => k.toLowerCase() === 'vary');
|
|
134
|
+
if (index < 0) return [...headers, ['vary','Accept-Encoding']];
|
|
135
|
+
const values = headers[index] [1].split(',').map(v => v.trim()).filter(Boolean);
|
|
136
|
+
if (values.some(v => v === '*' || v.toLowerCase() === 'accept-encoding')) return headers;
|
|
137
|
+
const out = [...headers]; out[index] = [headers[index] [0], [...values,'Accept-Encoding'].join(', ')];
|
|
138
|
+
return out;
|
|
139
|
+
}
|
|
140
|
+
function etagFor(headers , coding , strong ) {
|
|
141
|
+
const value = header(headers, 'etag');
|
|
142
|
+
if (!value) return headers;
|
|
143
|
+
const tagged = strong ? value.replace(/"$/, SUFFIX[coding] + '"') : value.startsWith('W/') ? value : 'W/' + value;
|
|
144
|
+
return headers.map(([k,v]) => k.toLowerCase() === 'etag' ? [k,tagged] : [k,v]);
|
|
145
|
+
}
|
|
146
|
+
function noneMatch(value , etag ) {
|
|
147
|
+
return value.split(',').some(tag => { const t = tag.trim(); return t === '*' || t.replace(/^W\//,'') === etag.replace(/^W\//,''); });
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function onResponse(state , request , result ) {
|
|
151
|
+
const { status } = result;
|
|
152
|
+
if (BODYLESS.has(status) && status !== 304) return result;
|
|
153
|
+
const headers = result.headers;
|
|
154
|
+
if (header(headers, 'content-encoding') || !state.matches(header(headers, 'content-type'))) return result;
|
|
155
|
+
// The representation can vary from here on, whatever this response does.
|
|
156
|
+
const varied = { ...result, headers: withVary(headers) };
|
|
157
|
+
if (status === 304 || status === 206) return varied;
|
|
158
|
+
if (/(?:^|,)\s*no-transform\s*(?:,|$)/i.test(header(headers, 'cache-control') || '')) return varied;
|
|
159
|
+
if (!state.allowWithSecrets && (request.secrets || headers.some(([k]) => k.toLowerCase() === 'set-cookie'))) return varied;
|
|
160
|
+
const body = result.body ? (Buffer.isBuffer(result.body) ? result.body : Buffer.from(result.body)) : Buffer.alloc(0);
|
|
161
|
+
if (body.length < state.minBytes) return varied;
|
|
162
|
+
const coding = negotiate(request.headers.get('accept-encoding'), state.encodings);
|
|
163
|
+
if (!coding) return varied;
|
|
164
|
+
const asset = result.asset;
|
|
165
|
+
const stored = asset?.encoded?.[coding] && (asset.body === result.body || (request.method === 'HEAD' && result.asset === asset)) && status === 200 ? asset.encoded[coding] : undefined;
|
|
166
|
+
let encoded , strong ;
|
|
167
|
+
if (stored) { encoded = stored; strong = true; }
|
|
168
|
+
else {
|
|
169
|
+
// Dynamic bodies: HEAD would pay the whole compression for a number, and
|
|
170
|
+
// large bodies would stall every other request, so both stay identity.
|
|
171
|
+
if (request.method === 'HEAD' || body.length > DYNAMIC_LIMIT) return varied;
|
|
172
|
+
encoded = codecs[coding].compress(body, state.levels[coding] .dynamic);
|
|
173
|
+
if (encoded.length >= body.length) return varied;
|
|
174
|
+
strong = false;
|
|
175
|
+
}
|
|
176
|
+
const out = { ...varied, headers: [...etagFor(varied.headers, coding, strong).filter(([k]) => k.toLowerCase() !== 'content-encoding'), ['content-encoding', coding]] };
|
|
177
|
+
// The asset handler validated If-None-Match against the identity tag; the
|
|
178
|
+
// suffixed tag of an encoded variant is this module's to validate.
|
|
179
|
+
const etag = header(out.headers, 'etag'), revalidate = request.headers.get('if-none-match');
|
|
180
|
+
if (strong && etag && revalidate && noneMatch(revalidate, etag)) { const { contentLength: _length, ...rest } = out; return { ...rest, status: 304, body: Buffer.alloc(0) }; }
|
|
181
|
+
return { ...out, body: encoded, contentLength: encoded.length };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function describe(state ) {
|
|
185
|
+
return { encodings: state.encodings, minBytes: state.minBytes, types: state.types.length, level: state.level, precompressed: state.precompressed };
|
|
186
|
+
}
|
|
187
|
+
export async function close(shared ) { if (shared) shared.compressionBytes = 0; }
|
|
Binary file
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { assert } from '../errors.js';
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
// Sliding-window request budget, expressed in the vocabulary of the IETF
|
|
6
|
+
// httpapi RateLimit-Policy / RateLimit fields (draft-ietf-httpapi-
|
|
7
|
+
// ratelimit-headers) so a NGINX limit_req or a CDN rule can restate the
|
|
8
|
+
// same numbers. Two fixed windows are blended by elapsed fraction: cheaper
|
|
9
|
+
// than a log, smoother than a fixed window, and the reset time stays
|
|
10
|
+
// explainable in a header. Counters are per runtime, never per process
|
|
11
|
+
// group; multi-instance sharing is a plugin concern.
|
|
12
|
+
export const name = 'throttle';
|
|
13
|
+
export const phases = ['request','response'];
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
/** The one counter table per runtime, shared by every route that declares a throttle. */
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
const partitions = ['client','route','client-route'];
|
|
26
|
+
const bodies = { 429: 'Too many requests\n', 503: 'Service unavailable\n' };
|
|
27
|
+
|
|
28
|
+
// Per-instance counters are only honest where one instance sees the whole
|
|
29
|
+
// route; serverless targets fan a client across instances, so a client
|
|
30
|
+
// budget there would silently be quota × instances.
|
|
31
|
+
export function targets(config = {}) {
|
|
32
|
+
const perRoute = config.partition === 'route';
|
|
33
|
+
return { node: 'native', vercel: perRoute ? 'native' : 'refused', aws: perRoute ? 'native' : 'refused', cloudflare: 'refused' };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function compile(config , { route, shared } ) {
|
|
37
|
+
assert(config && typeof config === 'object', `policies.${name} on ${route.pattern} must be an object`);
|
|
38
|
+
const { quota, window, partition = 'client', status = 429, mode = 'enforce', maxKeys = 100000 } = config;
|
|
39
|
+
assert(Number.isInteger(quota) && quota >= 1, `policies.${name}.quota on ${route.pattern} must be a positive integer`);
|
|
40
|
+
assert(Number.isInteger(window) && window >= 1, `policies.${name}.window on ${route.pattern} must be a positive integer of seconds`);
|
|
41
|
+
assert(partitions.includes(partition), `policies.${name}.partition on ${route.pattern} must be one of ${partitions.join(', ')}`);
|
|
42
|
+
assert(Number.isInteger(status) && status >= 400 && status <= 599, `policies.${name}.status on ${route.pattern} must be a 4xx or 5xx status`);
|
|
43
|
+
assert(mode === 'enforce' || mode === 'report', `policies.${name}.mode on ${route.pattern} must be enforce or report`);
|
|
44
|
+
assert(Number.isInteger(maxKeys) && maxKeys >= 1, `policies.${name}.maxKeys on ${route.pattern} must be a positive integer`);
|
|
45
|
+
// One table per runtime so a client budget spans routes that share a
|
|
46
|
+
// policy; the widest maxKeys wins because the table is common.
|
|
47
|
+
const table = shared.throttle ||= { keys: new Map(), maxKeys: 0, now: shared.now || Date.now };
|
|
48
|
+
table.maxKeys = Math.max(table.maxKeys, maxKeys);
|
|
49
|
+
return { quota, window, windowMs: window * 1000, partition, status, mode, route: route.pattern, table, log: shared.log, pending: new WeakMap() };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Key by what the partition names, scoped to the budget itself so routes
|
|
53
|
+
// that restate the same quota share one client counter while a route that
|
|
54
|
+
// overrides it gets its own. An unresolved client (a caller that gave none,
|
|
55
|
+
// or an adapter without a peer) shares one bucket rather than being exempt,
|
|
56
|
+
// so a misconfigured proxy fails closed instead of open.
|
|
57
|
+
function keyFor(state , req ) {
|
|
58
|
+
const client = req.client ?? 'shared';
|
|
59
|
+
const budget = `${state.quota}/${state.window}`;
|
|
60
|
+
if (state.partition === 'route') return `${budget}|route|${req.route}`;
|
|
61
|
+
if (state.partition === 'client') return `${budget}|client|${client}`;
|
|
62
|
+
return `${budget}|client-route|${client}|${req.route}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function touch(state , key ) {
|
|
66
|
+
const { keys, maxKeys } = state.table;
|
|
67
|
+
let entry = keys.get(key);
|
|
68
|
+
if (entry) keys.delete(key); else entry = { start: 0, current: 0, previous: 0 };
|
|
69
|
+
keys.set(key, entry);
|
|
70
|
+
// Map preserves insertion order, so re-inserting on access makes the first
|
|
71
|
+
// key the least recently used.
|
|
72
|
+
while (keys.size > maxKeys) keys.delete(keys.keys().next().value );
|
|
73
|
+
return entry;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Roll the fixed windows forward, then weigh the previous one by how much of
|
|
77
|
+
// it still overlaps a window ending now.
|
|
78
|
+
function observe(state , entry , now ) {
|
|
79
|
+
const start = now - (now % state.windowMs);
|
|
80
|
+
if (start !== entry.start) {
|
|
81
|
+
entry.previous = start - entry.start === state.windowMs ? entry.current : 0;
|
|
82
|
+
entry.current = 0; entry.start = start;
|
|
83
|
+
}
|
|
84
|
+
const elapsed = (now - start) / state.windowMs;
|
|
85
|
+
return { used: entry.previous * (1 - elapsed) + entry.current, reset: Math.max(1, Math.ceil((start + state.windowMs - now) / 1000)) };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function headersFor(state , { remaining, reset } ) {
|
|
89
|
+
return [['ratelimit-policy', `"default";q=${state.quota};w=${state.window}`], ['ratelimit', `"default";r=${remaining};t=${reset}`]];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function withHeaders(result , added ) {
|
|
93
|
+
const names = new Set(added.map(([n]) => n));
|
|
94
|
+
return { ...result, headers: [...result.headers.filter(([n]) => !names.has(String(n).toLowerCase())), ...added] };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export async function onRequest(state , req ) {
|
|
98
|
+
const now = state.table.now();
|
|
99
|
+
const entry = touch(state, keyFor(state, req));
|
|
100
|
+
const { used, reset } = observe(state, entry, now);
|
|
101
|
+
const exceeded = used + 1 > state.quota;
|
|
102
|
+
// A refused request is not counted: it did no work, and counting it would
|
|
103
|
+
// let a retry loop keep its own window from ever clearing.
|
|
104
|
+
if (!exceeded || state.mode === 'report') entry.current++;
|
|
105
|
+
const remaining = Math.max(0, Math.floor(state.quota - used - (exceeded ? 0 : 1)));
|
|
106
|
+
const budget = { remaining, reset };
|
|
107
|
+
if (exceeded || state.mode === 'report') {
|
|
108
|
+
try { state.log?.({ event: 'throttle', route: state.route, outcome: exceeded ? 'exceeded' : 'allowed', remaining }); } catch { /* logging never changes the outcome */ }
|
|
109
|
+
}
|
|
110
|
+
if (!exceeded || state.mode === 'report') { state.pending.set(req, budget); return undefined; }
|
|
111
|
+
// The runtime skips the response phase of request-phase modules for an
|
|
112
|
+
// early result, so the refusal must carry its own headers.
|
|
113
|
+
return withHeaders({ status: state.status, headers: [['content-type', 'text/plain; charset=utf-8'], ['cache-control', 'no-store'], ['retry-after', String(reset)]],
|
|
114
|
+
body: Buffer.from(bodies[state.status] || 'Request refused\n') }, headersFor(state, budget));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function onResponse(state , req , result ) {
|
|
118
|
+
const budget = state.pending.get(req);
|
|
119
|
+
return budget ? withHeaders(result, headersFor(state, budget)) : result;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function describe(state ) {
|
|
123
|
+
const summary = { quota: state.quota, window: state.window, partition: state.partition, mode: state.mode, status: state.status };
|
|
124
|
+
if (state.partition !== 'route') summary.unresolvedClient = 'shared key';
|
|
125
|
+
return summary;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export async function close(shared ) {
|
|
129
|
+
shared.throttle?.keys.clear();
|
|
130
|
+
delete shared.throttle;
|
|
131
|
+
}
|