@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,242 @@
|
|
|
1
|
+
import { assert } from './errors.js';
|
|
2
|
+
|
|
3
|
+
// Operator observability: the catalogue of events the runtime emits, a sink
|
|
4
|
+
// that fans each event out to operator observers, in-process counters and a
|
|
5
|
+
// Prometheus renderer. Observers mirror host plugins (src/plugins.js): they
|
|
6
|
+
// are passed by the embedding application, never named in YAML, and run in
|
|
7
|
+
// the host process with the host's privileges.
|
|
8
|
+
//
|
|
9
|
+
// { name, version, onEvent(event)?, onMetrics(snapshot)?, onClose()? }
|
|
10
|
+
//
|
|
11
|
+
// Every field an event may carry is listed here. Nothing carries a request
|
|
12
|
+
// URL, query string, header, body, client address, User-Agent string, binding
|
|
13
|
+
// or secret; `route` is always the configured pattern.
|
|
14
|
+
export const events = Object.freeze({
|
|
15
|
+
request: Object.freeze(['event', 'requestId', 'status', 'durationMs', 'method', 'route']),
|
|
16
|
+
reload: Object.freeze(['event', 'status', 'version', 'routes']),
|
|
17
|
+
watch: Object.freeze(['event', 'status']),
|
|
18
|
+
function_worker: Object.freeze(['event', 'status', 'slot', 'attempt', 'delayMs']),
|
|
19
|
+
link_store_worker: Object.freeze(['event', 'status', 'readOnly', 'attempt', 'delayMs']),
|
|
20
|
+
link_request: Object.freeze(['event', 'requestId', 'collection', 'route', 'code', 'method', 'status', 'outcome', 'durationMs']),
|
|
21
|
+
link_observer: Object.freeze(['event', 'status', 'reason', 'dropped', 'queued', 'delivered', 'failed', 'timedOut', 'closed']),
|
|
22
|
+
logs_dropped: Object.freeze(['event', 'count']),
|
|
23
|
+
observer: Object.freeze(['event', 'status', 'name']),
|
|
24
|
+
management_request: Object.freeze(['event', 'timestamp', 'requestId', 'collection', 'action', 'authenticated', 'principal', 'status', 'outcome', 'durationMs']),
|
|
25
|
+
throttle: Object.freeze(['event', 'route', 'outcome', 'remaining']),
|
|
26
|
+
agents: Object.freeze(['event', 'route', 'list', 'outcome']),
|
|
27
|
+
cache: Object.freeze(['event', 'route', 'outcome']),
|
|
28
|
+
listening: Object.freeze(['event', 'address', 'port', 'mode', 'origin']),
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
/** One log record: a flat object whose `event` key names the kind (see `events`). */
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
/** What the event does not say: the route when the request log is minimal, and whether this was a probe. */
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
/** The log sink the runtime writes to: callable like a logger, with the counters and observers hanging off it. */
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
const hookNames = ['onEvent', 'onMetrics', 'onClose'] ;
|
|
69
|
+
const namePattern = /^[a-z][a-z0-9-]{0,63}$/;
|
|
70
|
+
|
|
71
|
+
export function validateObservers(observers = []) {
|
|
72
|
+
assert(Array.isArray(observers) && observers.length <= 32, 'Observers must be an array of at most 32 entries');
|
|
73
|
+
const seen = new Set ();
|
|
74
|
+
for (const candidate of observers ) {
|
|
75
|
+
assert(candidate && typeof candidate === 'object', 'Observer must be an object');
|
|
76
|
+
const observer = candidate ; // trust boundary: operator code, checked field by field
|
|
77
|
+
assert(typeof observer.name === 'string' && namePattern.test(observer.name), 'Observer name must be lowercase kebab-case');
|
|
78
|
+
assert(!seen.has(observer.name), `Duplicate observer "${observer.name}"`); seen.add(observer.name);
|
|
79
|
+
assert(typeof observer.version === 'string' && observer.version.length <= 64, `Observer "${observer.name}" needs a version string`);
|
|
80
|
+
for (const hook of hookNames) assert(observer[hook] === undefined || typeof observer[hook] === 'function', `Observer "${observer.name}" hook ${hook} must be a function`);
|
|
81
|
+
assert(hookNames.some(hook => observer[hook]), `Observer "${observer.name}" declares no hooks`);
|
|
82
|
+
}
|
|
83
|
+
return observers ; // every entry was just checked
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export const SNAPSHOT_VERSION = 1;
|
|
87
|
+
const statusClasses = ['2xx', '3xx', '4xx', '5xx'];
|
|
88
|
+
const MAX_ROUTES = 10000;
|
|
89
|
+
const outcomes = {
|
|
90
|
+
throttle: ['allowed', 'exceeded'],
|
|
91
|
+
agents: ['denied', 'reported'],
|
|
92
|
+
cache: ['hit', 'stale', 'miss', 'store'],
|
|
93
|
+
link_request: ['completed', 'aborted', 'missing', 'disabled', 'expired', 'invalid_code', 'invalid_record', 'unavailable'],
|
|
94
|
+
};
|
|
95
|
+
const zeroed = (keys ) => Object.fromEntries(keys.map(key => [key, 0]));
|
|
96
|
+
|
|
97
|
+
// Counters and gauges derived from the events that pass through a sink, plus
|
|
98
|
+
// the few facts only the server knows (admission, shedding). Numbers only; the
|
|
99
|
+
// only keyed series is per configured route pattern, capped so a reviewed but
|
|
100
|
+
// large configuration cannot grow it without bound.
|
|
101
|
+
export function createMetrics() {
|
|
102
|
+
const started = Date.now();
|
|
103
|
+
const requests = { total: 0, byStatusClass: zeroed(statusClasses), inFlight: 0, byRoute: Object.create(null) };
|
|
104
|
+
const health = { total: 0, byStatusClass: zeroed(statusClasses), inFlight: 0 };
|
|
105
|
+
const shed = { requests: 0, health: 0 };
|
|
106
|
+
const reloads = { ok: 0, rejected: 0 };
|
|
107
|
+
const watch = { failed: 0 };
|
|
108
|
+
const functionWorkers = { started: 0, restarts: 0 };
|
|
109
|
+
const linkStoreWorkers = { started: 0, restarts: 0 };
|
|
110
|
+
const policies = { throttle: zeroed(outcomes.throttle), agents: zeroed(outcomes.agents), cache: zeroed(outcomes.cache) };
|
|
111
|
+
const linkRequests = zeroed(outcomes.link_request);
|
|
112
|
+
const linkObserver = { failed: 0, dropped: 0 };
|
|
113
|
+
let logsDropped = 0, observerErrors = 0;
|
|
114
|
+
const count = (table , key ) => { if (typeof key === 'string' && Object.hasOwn(table, key)) table[key] ++; };
|
|
115
|
+
function countRequest(target , status , route ) {
|
|
116
|
+
target.total++;
|
|
117
|
+
const cls = `${Math.floor(Number(status) / 100)}xx`;
|
|
118
|
+
if (Object.hasOwn(target.byStatusClass, cls)) target.byStatusClass[cls] ++;
|
|
119
|
+
if (route && target.byRoute) {
|
|
120
|
+
const key = String(route);
|
|
121
|
+
if (Object.hasOwn(target.byRoute, key)) target.byRoute[key] ++;
|
|
122
|
+
else if (Object.keys(target.byRoute).length < MAX_ROUTES) target.byRoute[key] = 1;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return {
|
|
126
|
+
// `context` carries what the event does not: the route when the request
|
|
127
|
+
// log is minimal, and whether this was a probe.
|
|
128
|
+
record(event, context = {}) {
|
|
129
|
+
if (!event || typeof event !== 'object') return;
|
|
130
|
+
const record = event ; // any object is read as a record; unknown keys are ignored
|
|
131
|
+
switch (record.event) {
|
|
132
|
+
case 'request': countRequest(context.probe ? health : requests, record.status, context.probe ? null : (record.route ?? context.route)); break;
|
|
133
|
+
case 'reload': count(reloads, record.status); break;
|
|
134
|
+
case 'watch': if (record.status === 'failed') watch.failed++; break;
|
|
135
|
+
case 'function_worker': if (record.status === 'restarting') functionWorkers.restarts++; else if (record.status === 'started') functionWorkers.started++; break;
|
|
136
|
+
case 'link_store_worker': if (record.status === 'restarting') linkStoreWorkers.restarts++; else if (record.status === 'started') linkStoreWorkers.started++; break;
|
|
137
|
+
case 'throttle': case 'agents': case 'cache': count(policies[record.event], record.outcome); break;
|
|
138
|
+
case 'link_request': count(linkRequests, record.outcome); break;
|
|
139
|
+
case 'link_observer': if (record.status === 'failed') linkObserver.failed++; else if (record.status === 'dropped' || record.status === 'closed') linkObserver.dropped = Math.max(linkObserver.dropped, Number(record.dropped) || 0); break;
|
|
140
|
+
case 'logs_dropped': logsDropped += Number(record.count) || 0; break;
|
|
141
|
+
case 'observer': if (record.status === 'failed') observerErrors++; break;
|
|
142
|
+
default: break;
|
|
143
|
+
}
|
|
144
|
+
},
|
|
145
|
+
inFlight(kind, delta) { if (kind === 'health') health.inFlight += delta; else requests.inFlight += delta; },
|
|
146
|
+
shed(kind) { shed[kind === 'health' ? 'health' : 'requests']++; },
|
|
147
|
+
observerError() { observerErrors++; },
|
|
148
|
+
snapshot(extra = {}) {
|
|
149
|
+
return {
|
|
150
|
+
version: SNAPSHOT_VERSION,
|
|
151
|
+
uptimeSeconds: Math.round((Date.now() - started) / 1000),
|
|
152
|
+
rssBytes: process.memoryUsage.rss(),
|
|
153
|
+
requests: { total: requests.total, byStatusClass: { ...requests.byStatusClass }, inFlight: requests.inFlight, byRoute: { ...requests.byRoute } },
|
|
154
|
+
health: { total: health.total, byStatusClass: { ...health.byStatusClass }, inFlight: health.inFlight },
|
|
155
|
+
shed: { ...shed },
|
|
156
|
+
reloads: { ...reloads },
|
|
157
|
+
watch: { ...watch },
|
|
158
|
+
functionWorkers: { ...functionWorkers, healthySlots: 0, slots: 0 },
|
|
159
|
+
linkStoreWorkers: { ...linkStoreWorkers },
|
|
160
|
+
policies: { throttle: { ...policies.throttle }, agents: { ...policies.agents }, cache: { ...policies.cache } },
|
|
161
|
+
linkRequests: { ...linkRequests },
|
|
162
|
+
linkObserver: { ...linkObserver },
|
|
163
|
+
logsDropped,
|
|
164
|
+
observers: { errors: observerErrors },
|
|
165
|
+
...extra,
|
|
166
|
+
};
|
|
167
|
+
},
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Fan-out to the default sink first, then each observer in array order. An
|
|
172
|
+
// observer that throws is counted and reported to the default sink only, so a
|
|
173
|
+
// bad observer can neither fail a request nor recurse through the others.
|
|
174
|
+
export function createObserverSink(observers = [], fallbackLog = () => {}, metrics = createMetrics()) {
|
|
175
|
+
const checked = validateObservers(observers);
|
|
176
|
+
const report = (event ) => { try { fallbackLog(event); } catch { /* Logging cannot fail requests. */ } };
|
|
177
|
+
const settle = (result , observer ) => {
|
|
178
|
+
if (result && typeof (result ).then === 'function') (result ).then(undefined, () => sink.fail(observer));
|
|
179
|
+
};
|
|
180
|
+
const sink = Object.assign((event , context ) => {
|
|
181
|
+
metrics.record(event, context);
|
|
182
|
+
report(event);
|
|
183
|
+
for (const observer of checked) {
|
|
184
|
+
if (!observer.onEvent) continue;
|
|
185
|
+
try { settle(observer.onEvent(event), observer); } catch { sink.fail(observer); }
|
|
186
|
+
}
|
|
187
|
+
}, {
|
|
188
|
+
fail(observer ) { metrics.observerError(); report({ event: 'observer', status: 'failed', name: observer.name }); },
|
|
189
|
+
metrics,
|
|
190
|
+
observers: checked,
|
|
191
|
+
publish(snapshot ) {
|
|
192
|
+
for (const observer of checked) {
|
|
193
|
+
if (!observer.onMetrics) continue;
|
|
194
|
+
try { settle(observer.onMetrics(snapshot), observer); } catch { sink.fail(observer); }
|
|
195
|
+
}
|
|
196
|
+
},
|
|
197
|
+
async close() {
|
|
198
|
+
for (let i = checked.length - 1; i >= 0; i--) { try { await checked[i] .onClose?.(); } catch { sink.fail(checked[i] ); } }
|
|
199
|
+
},
|
|
200
|
+
});
|
|
201
|
+
return sink;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Prometheus text exposition, version 0.0.4. Labels are limited to
|
|
205
|
+
// status_class, route (a configured pattern) and outcome (a fixed vocabulary).
|
|
206
|
+
const escapeLabel = (value ) => String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n');
|
|
207
|
+
|
|
208
|
+
export function renderPrometheus(snapshot ) {
|
|
209
|
+
const lines = [];
|
|
210
|
+
const metric = (name , type , help , samples ) => {
|
|
211
|
+
lines.push(`# HELP urlcode_${name} ${help}`, `# TYPE urlcode_${name} ${type}`);
|
|
212
|
+
for (const [labels, value] of samples) {
|
|
213
|
+
const label = Object.entries(labels || {}).map(([k, v]) => `${k}="${escapeLabel(v)}"`).join(',');
|
|
214
|
+
lines.push(`urlcode_${name}${label ? `{${label}}` : ''} ${typeof value === 'number' && Number.isFinite(value) ? value : 0}`);
|
|
215
|
+
}
|
|
216
|
+
};
|
|
217
|
+
const byKey = (table , label ) => Object.entries(table || {}).map(([key, value]) => [{ [label]: key }, value]);
|
|
218
|
+
metric('requests_total', 'counter', 'Application requests answered since start, by status class.', byKey(snapshot.requests?.byStatusClass, 'status_class'));
|
|
219
|
+
metric('route_requests_total', 'counter', 'Application requests answered since start, by configured route pattern.', byKey(snapshot.requests?.byRoute, 'route'));
|
|
220
|
+
metric('requests_in_flight', 'gauge', 'Application requests currently admitted.', [[{}, snapshot.requests?.inFlight]]);
|
|
221
|
+
metric('health_requests_total', 'counter', 'Probe and metrics requests answered since start, by status class.', byKey(snapshot.health?.byStatusClass, 'status_class'));
|
|
222
|
+
metric('health_requests_in_flight', 'gauge', 'Probe and metrics requests currently admitted.', [[{}, snapshot.health?.inFlight]]);
|
|
223
|
+
metric('shed_total', 'counter', 'Requests refused with 503 because an admission budget was full.', byKey(snapshot.shed, 'outcome'));
|
|
224
|
+
metric('reloads_total', 'counter', 'Configuration reloads since start.', byKey(snapshot.reloads, 'outcome'));
|
|
225
|
+
metric('watch_failures_total', 'counter', 'Development watcher failures.', [[{}, snapshot.watch?.failed]]);
|
|
226
|
+
metric('function_worker_restarts_total', 'counter', 'Function worker replacements scheduled.', [[{}, snapshot.functionWorkers?.restarts]]);
|
|
227
|
+
metric('function_worker_healthy_slots', 'gauge', 'Function worker slots ready to serve.', [[{}, snapshot.functionWorkers?.healthySlots]]);
|
|
228
|
+
metric('function_worker_slots', 'gauge', 'Function worker slots configured.', [[{}, snapshot.functionWorkers?.slots]]);
|
|
229
|
+
metric('link_store_worker_restarts_total', 'counter', 'Link-store worker replacements scheduled.', [[{}, snapshot.linkStoreWorkers?.restarts]]);
|
|
230
|
+
metric('throttle_total', 'counter', 'Throttle policy decisions.', byKey(snapshot.policies?.throttle, 'outcome'));
|
|
231
|
+
metric('agents_total', 'counter', 'Agents policy decisions.', byKey(snapshot.policies?.agents, 'outcome'));
|
|
232
|
+
metric('cache_total', 'counter', 'Cache policy outcomes.', byKey(snapshot.policies?.cache, 'outcome'));
|
|
233
|
+
metric('link_requests_total', 'counter', 'Dynamic link requests by outcome.', byKey(snapshot.linkRequests, 'outcome'));
|
|
234
|
+
metric('link_observer_failures_total', 'counter', 'Link event collector failures.', [[{}, snapshot.linkObserver?.failed]]);
|
|
235
|
+
metric('link_observer_dropped_total', 'counter', 'Link events dropped under overload.', [[{}, snapshot.linkObserver?.dropped]]);
|
|
236
|
+
metric('logs_dropped_total', 'counter', 'Log records the JSON logger shed.', [[{}, snapshot.logsDropped]]);
|
|
237
|
+
metric('observer_errors_total', 'counter', 'Observer hooks that threw or rejected.', [[{}, snapshot.observers?.errors]]);
|
|
238
|
+
metric('uptime_seconds', 'gauge', 'Seconds since the process started serving.', [[{}, snapshot.uptimeSeconds]]);
|
|
239
|
+
metric('process_rss_bytes', 'gauge', 'Resident set size of the process.', [[{}, snapshot.rssBytes]]);
|
|
240
|
+
metric('metrics_snapshot_version', 'gauge', 'Version of the metrics snapshot shape.', [[{}, snapshot.version]]);
|
|
241
|
+
return lines.join('\n') + '\n';
|
|
242
|
+
}
|
package/dist/plugins.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { assert, ConfigError } from './errors.js';
|
|
2
|
+
import { targets as knownTargets } from './policies.js';
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
// Operator-supplied host plugins. They are not part of the project format: an
|
|
9
|
+
// application passes them to startServer/createRuntime, so the YAML stays
|
|
10
|
+
// portable and the trust boundary stays with the operator. Same hook names as
|
|
11
|
+
// first-party policies, so both are tested through one seam.
|
|
12
|
+
//
|
|
13
|
+
// { name, version, targets: ['node', …],
|
|
14
|
+
// onActivate(runtime)?, onRequest(req)?, onResponse(req, result)?,
|
|
15
|
+
// onError(req, error)?, onClose()? }
|
|
16
|
+
//
|
|
17
|
+
// onRequest may return a result to short-circuit; onResponse returns the
|
|
18
|
+
// result to send. No hook can reach the guest, extend a deadline or read
|
|
19
|
+
// bindings: the request object carries none of those.
|
|
20
|
+
/** What onActivate receives: the started runtime's public facts, never its handlers. */
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
const hookNames = ['onActivate','onRequest','onResponse','onError','onClose'] ;
|
|
31
|
+
const namePattern = /^[a-z][a-z0-9-]{0,63}$/;
|
|
32
|
+
|
|
33
|
+
export function validatePlugins(plugins = [], target = 'node') {
|
|
34
|
+
assert(Array.isArray(plugins) && plugins.length <= 32, 'Plugins must be an array of at most 32 entries');
|
|
35
|
+
const seen = new Set ();
|
|
36
|
+
for (const candidate of plugins ) {
|
|
37
|
+
assert(candidate && typeof candidate === 'object', 'Plugin must be an object');
|
|
38
|
+
const plugin = candidate ; // trust boundary: operator code, checked field by field
|
|
39
|
+
assert(typeof plugin.name === 'string' && namePattern.test(plugin.name), 'Plugin name must be lowercase kebab-case');
|
|
40
|
+
assert(!seen.has(plugin.name), `Duplicate plugin "${plugin.name}"`); seen.add(plugin.name);
|
|
41
|
+
assert(typeof plugin.version === 'string' && plugin.version.length <= 64, `Plugin "${plugin.name}" needs a version string`);
|
|
42
|
+
assert(Array.isArray(plugin.targets) && plugin.targets.every(t => (knownTargets ).includes(t)), `Plugin "${plugin.name}" must list its supported targets`);
|
|
43
|
+
if (!(plugin.targets ).includes(target)) throw new ConfigError(`Plugin "${plugin.name}" does not support the ${target} target`);
|
|
44
|
+
for (const hook of hookNames) assert(plugin[hook] === undefined || typeof plugin[hook] === 'function', `Plugin "${plugin.name}" hook ${hook} must be a function`);
|
|
45
|
+
assert(hookNames.some(hook => plugin[hook]), `Plugin "${plugin.name}" declares no hooks`);
|
|
46
|
+
}
|
|
47
|
+
return plugins ; // every entry was just checked
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function activatePlugins(plugins , runtime ) {
|
|
51
|
+
for (const plugin of plugins) await plugin.onActivate?.(runtime);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function pluginsRequest(plugins , request ) {
|
|
55
|
+
for (const plugin of plugins) {
|
|
56
|
+
const early = await plugin.onRequest?.(request);
|
|
57
|
+
if (early) return early;
|
|
58
|
+
}
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Reverse order, so the plugin that saw the request first sees the response last.
|
|
63
|
+
export async function pluginsResponse(plugins , request , result ) {
|
|
64
|
+
for (let i = plugins.length - 1; i >= 0; i--) result = (await plugins[i] .onResponse?.(request, result)) ?? result;
|
|
65
|
+
return result;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function pluginsError(plugins , request , error ) {
|
|
69
|
+
for (let i = plugins.length - 1; i >= 0; i--) { try { await plugins[i] .onError?.(request, error); } catch { /* an observer cannot change the outcome */ } }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function closePlugins(plugins ) {
|
|
73
|
+
for (let i = plugins.length - 1; i >= 0; i--) { try { await plugins[i] .onClose?.(); } catch { /* best effort */ } }
|
|
74
|
+
}
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import { assert, ConfigError } from '../errors.js';
|
|
2
|
+
import { lists as bundled } from '../../data/agents/index.js';
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
// User-Agent policy. Contract in src/policies.ts. This module also runs inside
|
|
6
|
+
// the Cloudflare Worker, so it has no Node imports and never touches the
|
|
7
|
+
// filesystem: bundled lists arrive through the generated data/agents/index.js
|
|
8
|
+
// and project-relative list files arrive already loaded (src/agent-lists.ts
|
|
9
|
+
// on Node, or a `resolved` map the build step attaches for the Worker).
|
|
10
|
+
export const name = 'agents';
|
|
11
|
+
export const phases = ['request'];
|
|
12
|
+
export function targets() { return { node:'native', vercel:'native', aws:'native', cloudflare:'compiled' }; }
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
export const bundledLists = Object.freeze(Object.keys(bundled));
|
|
38
|
+
export const MAX_PATTERN_BYTES = 256;
|
|
39
|
+
export const MAX_REPEAT = 64;
|
|
40
|
+
|
|
41
|
+
// The pattern subset: anchors, literals, `.`, escapes, character classes,
|
|
42
|
+
// groups, alternation, and quantifiers on a single atom only. Nothing here
|
|
43
|
+
// can make a backtracking engine super-linear on the input: no backreferences,
|
|
44
|
+
// no lookaround, no quantifier on a group that itself repeats, no unbounded
|
|
45
|
+
// counted repetition. A pattern outside the subset is a ConfigError, so a
|
|
46
|
+
// project cannot turn the matcher into a ReDoS vector by editing YAML.
|
|
47
|
+
const escapeClass = /^[dDwWsSbBtnrfv0]$/;
|
|
48
|
+
export function validatePattern(pattern ) {
|
|
49
|
+
if (typeof pattern !== 'string' || !pattern.length) return 'pattern must be a non-empty string';
|
|
50
|
+
if (new TextEncoder().encode(pattern).length > MAX_PATTERN_BYTES) return `pattern exceeds ${MAX_PATTERN_BYTES} bytes`;
|
|
51
|
+
let i = 0;
|
|
52
|
+
const p = pattern;
|
|
53
|
+
const peek = () => p[i];
|
|
54
|
+
function escape() { // after the backslash
|
|
55
|
+
const c = p[i++];
|
|
56
|
+
if (c === undefined) return 'trailing backslash';
|
|
57
|
+
if (/[1-9]/.test(c) || c === 'k') return 'backreferences are not allowed';
|
|
58
|
+
if (c === 'p' || c === 'P') return 'unicode property escapes are not allowed';
|
|
59
|
+
if (c === 'x') { if (!/^[0-9a-fA-F]{2}$/.test(p.slice(i, i + 2))) return 'bad \\x escape'; i += 2; return; }
|
|
60
|
+
if (c === 'u') { if (!/^[0-9a-fA-F]{4}$/.test(p.slice(i, i + 4))) return 'bad \\u escape'; i += 4; return; }
|
|
61
|
+
if (c === 'c') return 'control escapes are not allowed';
|
|
62
|
+
if (escapeClass.test(c) || /[^A-Za-z0-9]/.test(c)) return;
|
|
63
|
+
return `unknown escape \\${c}`;
|
|
64
|
+
}
|
|
65
|
+
function charClass() { // after '['
|
|
66
|
+
if (peek() === '^') i++;
|
|
67
|
+
let first = true;
|
|
68
|
+
for (;;) {
|
|
69
|
+
const c = p[i++];
|
|
70
|
+
if (c === undefined) return 'unterminated character class';
|
|
71
|
+
if (c === ']' && !first) return;
|
|
72
|
+
first = false;
|
|
73
|
+
if (c === '[') return 'nested character classes are not allowed';
|
|
74
|
+
if (c === '\\') { const err = escape(); if (err) return err; }
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
function quantifier() { // returns {err} or {none} or {}
|
|
78
|
+
const c = peek();
|
|
79
|
+
if (c === '*' || c === '+' || c === '?') { i++; }
|
|
80
|
+
else if (c === '{') {
|
|
81
|
+
const m = /^\{(\d{1,3})(?:,(\d{1,3}))?\}/.exec(p.slice(i));
|
|
82
|
+
if (!m) return { err: 'counted repetition must be {n} or {n,m}' };
|
|
83
|
+
const lo = Number(m[1]), hi = m[2] === undefined ? lo : Number(m[2]);
|
|
84
|
+
if (hi < lo) return { err: 'repetition upper bound below lower bound' };
|
|
85
|
+
if (hi > MAX_REPEAT) return { err: `repetition bound above ${MAX_REPEAT}` };
|
|
86
|
+
i += m[0].length;
|
|
87
|
+
} else return { none: true };
|
|
88
|
+
if (/[*+?{]/.test(peek() ?? '')) return { err: 'stacked or lazy quantifiers are not allowed' };
|
|
89
|
+
return {};
|
|
90
|
+
}
|
|
91
|
+
// Parses one alternation; returns {err} or {quantified: boolean} where
|
|
92
|
+
// quantified says whether anything inside carries a quantifier.
|
|
93
|
+
function alternation(depth ) {
|
|
94
|
+
let quantified = false;
|
|
95
|
+
for (;;) {
|
|
96
|
+
const c = p[i];
|
|
97
|
+
if (c === undefined || c === '|' || c === ')') return { quantified, end: c };
|
|
98
|
+
i++;
|
|
99
|
+
let atom = 'single';
|
|
100
|
+
if (c === '\\') { const err = escape(); if (err) return { err }; }
|
|
101
|
+
else if (c === '[') { const err = charClass(); if (err) return { err }; }
|
|
102
|
+
else if (c === '(') {
|
|
103
|
+
if (peek() === '?') {
|
|
104
|
+
if (p[i + 1] !== ':') return { err: 'lookaround and named groups are not allowed' };
|
|
105
|
+
i += 2;
|
|
106
|
+
}
|
|
107
|
+
let inner = false;
|
|
108
|
+
for (;;) {
|
|
109
|
+
const r = alternation(depth + 1);
|
|
110
|
+
if (r.err) return r;
|
|
111
|
+
inner = inner || r.quantified;
|
|
112
|
+
if (r.end === '|') { i++; continue; }
|
|
113
|
+
if (r.end === ')') { i++; break; }
|
|
114
|
+
return { err: 'unterminated group' };
|
|
115
|
+
}
|
|
116
|
+
atom = inner ? 'group-quantified' : 'group';
|
|
117
|
+
}
|
|
118
|
+
else if (c === ')') return { err: 'unbalanced parenthesis' };
|
|
119
|
+
else if (c === '^' || c === '$') atom = 'anchor';
|
|
120
|
+
else if (c === '*' || c === '+' || c === '?' || c === '{') {
|
|
121
|
+
if (c === '{') continue; // a literal brace where no quantifier can apply
|
|
122
|
+
return { err: 'quantifier without a preceding atom' };
|
|
123
|
+
}
|
|
124
|
+
const q = quantifier();
|
|
125
|
+
if (q.err) return { err: q.err };
|
|
126
|
+
if (q.none) continue;
|
|
127
|
+
if (atom === 'anchor') return { err: 'quantifier on an anchor' };
|
|
128
|
+
if (atom === 'group-quantified') return { err: 'nested quantifiers are not allowed' };
|
|
129
|
+
if (atom === 'group' && p[i - 1] !== '?') return { err: 'only ? may quantify a group; put * + {n,m} on a single atom' };
|
|
130
|
+
quantified = true;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
for (;;) {
|
|
134
|
+
const r = alternation(0);
|
|
135
|
+
if (r.err) return r.err;
|
|
136
|
+
if (r.end === '|') { i++; continue; }
|
|
137
|
+
if (r.end === ')') return 'unbalanced parenthesis';
|
|
138
|
+
break;
|
|
139
|
+
}
|
|
140
|
+
try { new RegExp(pattern, 'i'); } catch (error) { return `invalid regular expression: ${(error ).message}`; }
|
|
141
|
+
return undefined;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function checkPatterns(entries , where ) {
|
|
145
|
+
for (const entry of entries) {
|
|
146
|
+
const problem = validatePattern(entry.pattern);
|
|
147
|
+
if (problem) throw new ConfigError(`${where}: pattern ${JSON.stringify(String(entry.pattern))} rejected (${problem})`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// One RegExp per list, alternated: a single pass over the header per list
|
|
152
|
+
// instead of one per pattern, and the list name still known on a hit.
|
|
153
|
+
function compileList(label , entries ) {
|
|
154
|
+
if (!entries.length) return null;
|
|
155
|
+
const regexp = new RegExp(entries.map(({ pattern }) => `(?:${pattern})`).join('|'), 'i');
|
|
156
|
+
return { label, count: entries.length, regexp };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function isBundled(reference ) { return Object.hasOwn(bundled, reference); }
|
|
160
|
+
export function isListFile(reference ) { return typeof reference === 'string' && reference.endsWith('.json'); }
|
|
161
|
+
|
|
162
|
+
// Resolves one `deny`/`allow` reference to {label, entries, source}. Bundled
|
|
163
|
+
// names come from the generated index; a `.json` reference must be in
|
|
164
|
+
// `loaded` (Node: read by src/agent-lists.ts; Worker: attached at build).
|
|
165
|
+
function resolveReference(reference , loaded , routePattern ) {
|
|
166
|
+
if (isBundled(reference)) {
|
|
167
|
+
const list = bundled[reference] ;
|
|
168
|
+
return { label: reference, entries: list.patterns.map(([entryName, pattern]) => ({ name: entryName, pattern })), source: list.source, revision: list.revision };
|
|
169
|
+
}
|
|
170
|
+
if (isListFile(reference)) {
|
|
171
|
+
const entries = loaded?.[reference];
|
|
172
|
+
assert(Array.isArray(entries), `${routePattern}: policies.agents list ${JSON.stringify(reference)} is not loaded on this target; bundled names are ${bundledLists.join(', ')}`);
|
|
173
|
+
return { label: reference, entries, source: 'project', revision: 'project' };
|
|
174
|
+
}
|
|
175
|
+
throw new ConfigError(`${routePattern}: policies.agents references unknown list ${JSON.stringify(reference)}; bundled names are ${bundledLists.join(', ')}, or give a project-relative path ending in .json`);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function build(config , { route, shared, loaded } ) {
|
|
179
|
+
assert(config && typeof config === 'object', `policies.${name} on ${route.pattern} must be an object`);
|
|
180
|
+
const where = `${route.pattern} policies.agents`;
|
|
181
|
+
const mode = config.mode ?? 'enforce';
|
|
182
|
+
assert(mode === 'enforce' || mode === 'report', `${where}.mode must be enforce or report`);
|
|
183
|
+
const status = config.status ?? 403;
|
|
184
|
+
assert(Number.isInteger(status) && status >= 400 && status <= 599, `${where}.status must be an integer between 400 and 599`);
|
|
185
|
+
const lists = { deny: [], allow: [] };
|
|
186
|
+
const used = { deny: [], allow: [] };
|
|
187
|
+
for (const side of ['deny', 'allow'] ) {
|
|
188
|
+
for (const reference of config[side] ?? []) {
|
|
189
|
+
const list = resolveReference(reference, loaded, route.pattern);
|
|
190
|
+
checkPatterns(list.entries, `${where}.${side} list ${JSON.stringify(reference)}`);
|
|
191
|
+
lists[side].push(compileList(list.label, list.entries));
|
|
192
|
+
used[side].push({ name: list.label, patterns: list.entries.length, source: list.source, revision: list.revision });
|
|
193
|
+
}
|
|
194
|
+
const patterns = (config[`${side}Patterns` ] ?? []).map(pattern => ({ pattern }));
|
|
195
|
+
checkPatterns(patterns, `${where}.${side}Patterns`);
|
|
196
|
+
const compiled = compileList('pattern', patterns);
|
|
197
|
+
if (compiled) lists[side].push(compiled);
|
|
198
|
+
}
|
|
199
|
+
const compiled = (list ) => Boolean(list);
|
|
200
|
+
const state = {
|
|
201
|
+
route: route.pattern, mode, status, denyEmpty: config.denyEmpty === true,
|
|
202
|
+
deny: lists.deny.filter(compiled), allow: lists.allow.filter(compiled),
|
|
203
|
+
used, patternCounts: { deny: config.denyPatterns?.length ?? 0, allow: config.allowPatterns?.length ?? 0 },
|
|
204
|
+
log: shared?.log,
|
|
205
|
+
};
|
|
206
|
+
return state;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Synchronous on the Worker (it is called without await there); on Node the
|
|
210
|
+
// project-relative list files are read first, so the result is a promise.
|
|
211
|
+
export function compile(config , context ) {
|
|
212
|
+
const { target, root } = context;
|
|
213
|
+
const files = [...(config?.deny ?? []), ...(config?.allow ?? [])].filter(isListFile);
|
|
214
|
+
if (target === 'cloudflare' || !files.length) return build(config, { ...context, loaded: config?.resolved });
|
|
215
|
+
// Non-literal specifier on purpose: a Worker bundler must not follow this
|
|
216
|
+
// Node-only import; the Worker never reaches this line.
|
|
217
|
+
const loader = '../agent-lists.js';
|
|
218
|
+
return import(loader).then(async ({ loadListFiles } ) => build(config, { ...context, loaded: await loadListFiles(files, root, context.route.pattern) }));
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const encoder = new TextEncoder();
|
|
222
|
+
function firstMatch(lists , agent ) {
|
|
223
|
+
for (const list of lists) if (list.regexp.test(agent)) return list.label;
|
|
224
|
+
return undefined;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export function onRequest(state , request ) {
|
|
228
|
+
const agent = request.headers.get('user-agent');
|
|
229
|
+
let list ;
|
|
230
|
+
if (agent === null || agent.trim() === '') { if (state.denyEmpty) list = 'empty'; }
|
|
231
|
+
else if (firstMatch(state.allow, agent) === undefined) list = firstMatch(state.deny, agent);
|
|
232
|
+
if (list === undefined) return undefined;
|
|
233
|
+
const denied = state.mode === 'enforce';
|
|
234
|
+
// The list name is logged, never the header: a User-Agent is attacker text.
|
|
235
|
+
try { state.log?.({ event: 'agents', route: state.route, list, outcome: denied ? 'denied' : 'reported' }); } catch { /* logging cannot fail a request */ }
|
|
236
|
+
if (!denied) return undefined;
|
|
237
|
+
return { status: state.status, headers: [['content-type','text/plain; charset=utf-8'],['cache-control','no-store']], body: encoder.encode('Forbidden\n') };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export function describe(state ) {
|
|
241
|
+
const total = (side ) => state[side].reduce((sum, list) => sum + list.count, 0);
|
|
242
|
+
return { mode: state.mode, status: state.status, denyEmpty: state.denyEmpty,
|
|
243
|
+
deny: state.used.deny, allow: state.used.allow,
|
|
244
|
+
denyPatterns: state.patternCounts.deny, allowPatterns: state.patternCounts.allow,
|
|
245
|
+
patterns: { deny: total('deny'), allow: total('allow') } };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export async function close() {}
|