@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
package/dist/policies.js
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import * as agents from './policies/agents.js';
|
|
2
|
+
import * as throttle from './policies/throttle.js';
|
|
3
|
+
import * as cache from './policies/cache.js';
|
|
4
|
+
import * as security from './policies/security.js';
|
|
5
|
+
import * as compression from './policies/compression.js';
|
|
6
|
+
import { assert, ConfigError } from './errors.js';
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
// Host-side behavior declared in YAML and enforced outside the sandbox. Every
|
|
13
|
+
// module here follows one contract so a first-party policy and an operator
|
|
14
|
+
// plugin share a code path (PolicyModule in src/types.ts):
|
|
15
|
+
//
|
|
16
|
+
// name the YAML key under `policies`
|
|
17
|
+
// phases 'request' | 'response' | both; fixed order below
|
|
18
|
+
// targets(cfg) {node, vercel, aws, cloudflare} → 'native' | 'compiled' | 'delegated' | 'refused'
|
|
19
|
+
// delegated: the platform already provides it, so the policy is
|
|
20
|
+
// accepted and dropped rather than refusing the deployment
|
|
21
|
+
// compile(cfg, {route, shared, target, document, root}) → state, or throws ConfigError
|
|
22
|
+
// onRequest(state, req) → result to short-circuit, or undefined
|
|
23
|
+
// onResponse(state, req, result) → result (same or replaced)
|
|
24
|
+
// onError(state, req, error) → result to answer with instead, or undefined
|
|
25
|
+
// describe(state) → JSON summary for audit/inventory
|
|
26
|
+
// close(shared) → release cross-request state
|
|
27
|
+
//
|
|
28
|
+
// Request order: agents (cheapest denial first), throttle, cache lookup.
|
|
29
|
+
// Response order: cache store, throttle headers (after the store, so a cached
|
|
30
|
+
// copy is never stamped with one client's remaining budget), security headers,
|
|
31
|
+
// compression last so every header it depends on is already final. YAML response.headers are applied by
|
|
32
|
+
// the runtime before this phase, so explicit headers beat profile defaults.
|
|
33
|
+
/** The registry, typed per policy so `registry.cache.compile` returns a CacheState; erased to PolicyModule where iterated. */
|
|
34
|
+
|
|
35
|
+
export const registry = { agents, throttle, cache, security, compression };
|
|
36
|
+
export const requestOrder = ['agents','throttle','cache'];
|
|
37
|
+
export const responseOrder = ['cache','throttle','security','compression'];
|
|
38
|
+
export const targets = ['node','vercel','aws','cloudflare'];
|
|
39
|
+
const isPolicyName = (name ) => Object.hasOwn(registry, name);
|
|
40
|
+
|
|
41
|
+
// What a profile is: a policies object without `profile`. The built-in one is
|
|
42
|
+
// a starting point that reads in one place, not a claim about any workload.
|
|
43
|
+
export const builtinProfiles = Object.freeze({
|
|
44
|
+
hardened: Object.freeze ({
|
|
45
|
+
security: { headers: 'oshp' },
|
|
46
|
+
agents: { deny: ['ai-crawlers'], status: 403 },
|
|
47
|
+
throttle: { quota: 120, window: 60, partition: 'client', status: 429 },
|
|
48
|
+
compression: { encodings: ['br','gzip'], minBytes: 1024 },
|
|
49
|
+
cache: { strategy: 'revalidate' },
|
|
50
|
+
}),
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
function resolveProfile(name , document ) {
|
|
54
|
+
if (!name) return {};
|
|
55
|
+
const custom = document.profiles?.[name];
|
|
56
|
+
if (custom) return custom;
|
|
57
|
+
const builtin = builtinProfiles[name];
|
|
58
|
+
assert(builtin, `Unknown policy profile "${name}"`);
|
|
59
|
+
return builtin;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Effective configuration for one route: profile ← project keys ← route
|
|
63
|
+
// profile ← route keys. `false` disables a policy at any level; an object
|
|
64
|
+
// merges shallowly over what is below it, so a route can tighten one number
|
|
65
|
+
// without restating the rest.
|
|
66
|
+
export function effectivePolicies(document , routeConfig ) {
|
|
67
|
+
const layers = [];
|
|
68
|
+
const project = document.policies || {};
|
|
69
|
+
layers.push(resolveProfile(project.profile, document), project);
|
|
70
|
+
const route = routeConfig?.policies || {};
|
|
71
|
+
if (route.profile) layers.push(resolveProfile(route.profile, document));
|
|
72
|
+
layers.push(route);
|
|
73
|
+
const effective = {};
|
|
74
|
+
for (const layer of layers) for (const [key, value] of Object.entries(layer)) {
|
|
75
|
+
if (key === 'profile') continue;
|
|
76
|
+
if (value === false) { delete effective[key]; continue; }
|
|
77
|
+
effective[key] = { ...(effective[key] || {}), ...value };
|
|
78
|
+
}
|
|
79
|
+
return effective ; // the schema admits only the five policy keys in a layer
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Compiles every declared policy for a route into ordered request/response
|
|
83
|
+
// chains. A target that cannot honour a policy refuses the whole activation
|
|
84
|
+
// with the route and policy named, the same rule adapters apply to handlers.
|
|
85
|
+
export async function compilePolicies(document , routeConfig , { route, shared, target = 'node', root } ) {
|
|
86
|
+
const effective = effectivePolicies(document, routeConfig);
|
|
87
|
+
const chain = { request: [], response: [], error: [], describe: {} };
|
|
88
|
+
const states = chain;
|
|
89
|
+
// Generic over the name so the module, its config and its state stay correlated.
|
|
90
|
+
async function compileOne (name , config ) {
|
|
91
|
+
const module = registry[name];
|
|
92
|
+
const support = module.targets(config)[target];
|
|
93
|
+
if (support === 'refused') throw new ConfigError(`${route.pattern} declares policies.${name}, which the ${target} target cannot enforce`);
|
|
94
|
+
if (support === 'delegated') { chain.describe[name] = { target: support }; return; }
|
|
95
|
+
const state = await module.compile(config, { route, shared, target, document, ...(root === undefined ? {} : { root }) });
|
|
96
|
+
const described = module.describe?.(state) ?? {};
|
|
97
|
+
chain.describe[name] = { ...described, target: support };
|
|
98
|
+
states[name] = state;
|
|
99
|
+
}
|
|
100
|
+
for (const name of Object.keys(effective)) {
|
|
101
|
+
assert(isPolicyName(name), `Unknown policy "${name}"`);
|
|
102
|
+
await compileOne(name, effective[name] );
|
|
103
|
+
}
|
|
104
|
+
const entry = (name ) => [registry[name], chain[name]];
|
|
105
|
+
for (const name of requestOrder) if (chain[name] && registry[name].onRequest) chain.request.push(entry(name));
|
|
106
|
+
for (const name of responseOrder) if (chain[name] && registry[name].onResponse) chain.response.push(entry(name));
|
|
107
|
+
for (const name of Object.keys(effective)) if (isPolicyName(name) && registry[name].onError) chain.error.push(entry(name));
|
|
108
|
+
return chain;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// The security headers an error response gets. A thrown error has no route
|
|
112
|
+
// result to decorate, so the host asks for the header list instead: the
|
|
113
|
+
// matched route's own security state when there is one, otherwise the
|
|
114
|
+
// project-level policy compiled once here. Nothing else applies to errors:
|
|
115
|
+
// their bodies are fixed text and nothing may cache or count them twice.
|
|
116
|
+
export function compileErrorPolicy(document , { target = 'node' } = {}) {
|
|
117
|
+
const effective = effectivePolicies(document, {});
|
|
118
|
+
const config = effective.security;
|
|
119
|
+
if (!config || registry.security.targets(config)[target] === 'refused') return null;
|
|
120
|
+
return security.compile(config, { route: { pattern: '(project)' } });
|
|
121
|
+
}
|
|
122
|
+
export function errorHeaders(state , origin ) {
|
|
123
|
+
if (!state) return [];
|
|
124
|
+
return security.onResponse(state, { origin }, { status: 200, headers: [] }).headers;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export async function closePolicies(shared ) {
|
|
128
|
+
for (const module of Object.values(registry) ) await module.close?.(shared);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// The request a policy sees. Nothing here is parsed twice: headers and target
|
|
132
|
+
// are the runtime's own objects, the client identity is what the host resolved
|
|
133
|
+
// through its trusted-proxy setting (never a raw forwarded header) and the
|
|
134
|
+
// route is the configured pattern, not request text.
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
export function policyRequest({ method, target, path, params = {}, query, headers, headerCounts, client, origin, route } ) {
|
|
141
|
+
return { method, target, path, params, query, headers, headerCounts, client: client ?? null, origin, route: route.pattern, secrets: Object.keys(route.secrets || {}).length > 0 };
|
|
142
|
+
}
|
package/dist/policy.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { readFile, realpath, stat } from 'node:fs/promises';
|
|
2
|
+
import { relative, isAbsolute, sep } from 'node:path';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { functionFile } from './config.js';
|
|
5
|
+
import { collectFunctionSources, routeFunctions } from './function-sources.js';
|
|
6
|
+
|
|
7
|
+
import { assert } from './errors.js';
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
/** The operator's binding grants: which env and secret names each route may read, pinned to a project hash. */
|
|
11
|
+
|
|
12
|
+
/** The function snapshot plus the hash operator grants are pinned to. */
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
export async function prepareFunctionSnapshot(loaded ) {
|
|
16
|
+
const definitions = [];
|
|
17
|
+
for (const [pattern,route] of Object.entries(loaded.routes)) for (const definition of routeFunctions(route)) definitions.push({pattern,function:{
|
|
18
|
+
source:await functionFile(loaded.root,definition.source),export:definition.export || 'default',
|
|
19
|
+
}});
|
|
20
|
+
const collected = await collectFunctionSources(definitions,loaded.root);
|
|
21
|
+
const sources = Object.fromEntries(Object.entries(collected.sources).sort(([a],[b])=>a < b ? -1 : a > b ? 1 : 0));
|
|
22
|
+
// Generated site routes carry no bindings and depend on the origin, so they
|
|
23
|
+
// stay out of the hash that operator grants are pinned to.
|
|
24
|
+
const declared = Object.fromEntries(Object.entries(loaded.routes).filter(([,route])=>!route.generated));
|
|
25
|
+
const snapshot = { ...collected, projectSha256: createHash('sha256').update(JSON.stringify({routes:declared,...(loaded.document.dynamicLinks===true?{dynamicLinks:true}:{}),sources})).digest('hex') };
|
|
26
|
+
return snapshot;
|
|
27
|
+
}
|
|
28
|
+
export function validatePolicy(value ) {
|
|
29
|
+
assert(value && typeof value === 'object' && !Array.isArray(value), 'Invalid operator policy');
|
|
30
|
+
const policy = value ; // trust boundary: operator JSON, checked field by field below
|
|
31
|
+
assert(Object.keys(policy).every(k=>['version','projectSha256','routes'].includes(k)) && policy.version === 1 && typeof policy.projectSha256 === 'string' && /^[a-f0-9]{64}$/.test(policy.projectSha256), 'Policy requires version 1 and projectSha256');
|
|
32
|
+
const routes = policy.routes;
|
|
33
|
+
assert(routes && typeof routes === 'object' && !Array.isArray(routes), 'Policy requires route grants');
|
|
34
|
+
for (const [path,grant] of Object.entries(routes)) {
|
|
35
|
+
assert(path.startsWith('/') && grant && typeof grant === 'object' && !Array.isArray(grant), 'Invalid route grant');
|
|
36
|
+
assert(Object.keys(grant).every(k=>['env','secrets'].includes(k)), 'Unsupported policy capability');
|
|
37
|
+
for (const list of Object.values(grant)) assert(Array.isArray(list) && list.length <= 64 && list.every(n=>typeof n === 'string' && /^[A-Za-z_][A-Za-z0-9_]*$/.test(n)), 'Invalid binding grant');
|
|
38
|
+
}
|
|
39
|
+
return value ; // every field was just checked
|
|
40
|
+
}
|
|
41
|
+
export async function loadOperatorPolicy(file , project ) {
|
|
42
|
+
if (!file) return undefined;
|
|
43
|
+
const root = await realpath(project), path = await realpath(file);
|
|
44
|
+
const rel = relative(root,path);
|
|
45
|
+
assert(isAbsolute(rel) || rel === '..' || rel.startsWith('..' + sep), 'Operator policy must be outside the application project');
|
|
46
|
+
assert((await stat(path)).size <= 65536, 'Policy exceeds 64 KiB');
|
|
47
|
+
return validatePolicy(JSON.parse(await readFile(path,'utf8')));
|
|
48
|
+
}
|
|
49
|
+
export function requestedPermissions(loaded , snapshot ) {
|
|
50
|
+
return {version:1,projectSha256:snapshot.projectSha256,routes:Object.fromEntries(Object.entries(loaded.routes).flatMap(([path,route]) => {
|
|
51
|
+
const env = Object.values(route.env || {}).flatMap(ref=>ref.env ? [ref.env] : []);
|
|
52
|
+
const secrets = Object.values(route.secrets || {}).map(ref=>ref.secret);
|
|
53
|
+
return env.length || secrets.length ? [[path,{env:[...new Set(env)],secrets:[...new Set(secrets)]}]] : [];
|
|
54
|
+
}))};
|
|
55
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import {mkdir, writeFile} from 'node:fs/promises';
|
|
2
|
+
import {resolve, relative, isAbsolute, join, dirname, sep} from 'node:path';
|
|
3
|
+
import {createRuntime} from './runtime.js';
|
|
4
|
+
import {ConfigError, assert} from './errors.js';
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
// Operator-side build tooling for rendering a project's function and middleware
|
|
9
|
+
// routes once, ahead of serving, into HTML files that native page routes can
|
|
10
|
+
// publish with no guest execution on the request path.
|
|
11
|
+
//
|
|
12
|
+
// This is trusted build code, not runtime code: it runs in Node, it is never
|
|
13
|
+
// reachable from route YAML, and it gives the sandbox nothing. It owns the
|
|
14
|
+
// orchestration that is easy to get wrong — runtime lifecycle, response
|
|
15
|
+
// validation, budgets, byte fidelity and output-path safety — and deliberately
|
|
16
|
+
// owns nothing site-specific. Callers assemble their own project or generated
|
|
17
|
+
// include from the metadata it returns. See docs/PRERENDER.md.
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
// Names a static tree refuses to publish, rejected here so a bad route path
|
|
28
|
+
// fails the build instead of the deployment.
|
|
29
|
+
const PROTECTED = new Set(['urlcode.yaml', 'urlcode.yml', 'package.json', 'package-lock.json']);
|
|
30
|
+
const SEGMENT = /^[A-Za-z0-9._-]+$/;
|
|
31
|
+
// A published page name: no path separator, and no leading dot, which a static
|
|
32
|
+
// tree skips as a hidden entry.
|
|
33
|
+
const FILE = /^[A-Za-z0-9][A-Za-z0-9._~-]*\.html$/;
|
|
34
|
+
const overlaps = (first , second ) => {
|
|
35
|
+
const rel = relative(first, second);
|
|
36
|
+
return rel === '' || !(isAbsolute(rel) || rel === '..' || rel.startsWith('..' + sep));
|
|
37
|
+
};
|
|
38
|
+
const isCode = (error , code ) => error instanceof Error && 'code' in error && error.code === code;
|
|
39
|
+
|
|
40
|
+
// One route path to one flat filename, deterministically. `~` cannot occur in a
|
|
41
|
+
// route segment, so it separates segments unambiguously: `/a/b` and `/a-b` are
|
|
42
|
+
// distinct names rather than a silent collision. The root becomes `index`, and a
|
|
43
|
+
// literal `/index` route takes the otherwise unreachable `index~`, so the
|
|
44
|
+
// mapping stays injective for every accepted path. A flat name contains no path
|
|
45
|
+
// separator and no leading dot, so no output can escape its directory.
|
|
46
|
+
// A path that can become a file at all: absolute, literal, no parameter,
|
|
47
|
+
// wildcard, dot segment or empty segment. Checked for every rendered route
|
|
48
|
+
// whatever `fileName` does with it, so a custom hook cannot accept a route the
|
|
49
|
+
// default scheme would refuse.
|
|
50
|
+
export function assertLiteralRoutePath(path ) {
|
|
51
|
+
assert(typeof path === 'string' && path.startsWith('/'), 'Route path must be absolute');
|
|
52
|
+
const segments = path === '/' ? [] : path.slice(1).split('/');
|
|
53
|
+
assert(segments.every(segment => SEGMENT.test(segment) && segment !== '.' && segment !== '..'),
|
|
54
|
+
`Cannot derive a filename from nonliteral route ${path}; prerender literal paths only`);
|
|
55
|
+
return segments;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function pageFileName(path ) {
|
|
59
|
+
const segments = assertLiteralRoutePath(path);
|
|
60
|
+
const name = segments.length === 1 && segments[0] === 'index' ? 'index~'
|
|
61
|
+
: segments.length ? segments.join('~') : 'index';
|
|
62
|
+
return `${name}.html`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Prove a project cannot execute guest code to answer a request: every route is
|
|
66
|
+
// one of the allowed native handlers and none carries middleware. Callers run it
|
|
67
|
+
// on a final assembled site, which normally also serves static files and
|
|
68
|
+
// downloads, not only on a page-only artifact.
|
|
69
|
+
export async function assertNativeProject(project , {allow = ['page', 'static', 'download'], log = () => {}} = {}) {
|
|
70
|
+
assert(Array.isArray(allow) && allow.length && allow.every(handler => typeof handler === 'string'), 'Allowed handlers must be a nonempty string array');
|
|
71
|
+
const runtime = await createRuntime(project, {log: () => {}});
|
|
72
|
+
try {
|
|
73
|
+
const inventory = runtime.testPlan().inventory;
|
|
74
|
+
assert(inventory.length, 'Project declares no routes');
|
|
75
|
+
const executable = inventory.filter(route => route.handler === undefined || !allow.includes(route.handler) || route.middleware);
|
|
76
|
+
assert(!executable.length,
|
|
77
|
+
`Project must contain only native ${allow.join('/')} routes without middleware: ${executable.map(route => `${route.path} (${route.handler}${route.middleware ? ' + middleware' : ''})`).join(', ')}`);
|
|
78
|
+
log({event: 'native-project', routes: inventory.length, handlers: allow});
|
|
79
|
+
return inventory;
|
|
80
|
+
} finally { await runtime.close(); }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function prerenderPages(project , output , {
|
|
84
|
+
origin = 'http://localhost', fileName = pageFileName, ignoreUnrenderable = false,
|
|
85
|
+
maxPages = 500, maxPageBytes = 512 * 1024, maxTotalBytes = 32 * 1024 * 1024, log = () => {},
|
|
86
|
+
} = {}) {
|
|
87
|
+
for (const [value, name] of [[maxPages, 'maxPages'], [maxPageBytes, 'maxPageBytes'], [maxTotalBytes, 'maxTotalBytes']] )
|
|
88
|
+
assert(Number.isSafeInteger(value) && value > 0, `Build budget ${name} must be a positive integer`);
|
|
89
|
+
assert(typeof fileName === 'function', 'fileName must be a function');
|
|
90
|
+
assert(typeof ignoreUnrenderable === 'boolean', 'ignoreUnrenderable must be a boolean');
|
|
91
|
+
let parsed ;
|
|
92
|
+
try { parsed = new URL(origin); } catch { assert(false, 'Invalid render origin'); }
|
|
93
|
+
assert(['http:', 'https:'].includes(parsed.protocol) && parsed.origin === origin, 'Render origin must be HTTP(S) without path or credentials');
|
|
94
|
+
const source = resolve(project), directory = resolve(output);
|
|
95
|
+
// Rendering into the reviewed source, or reading a source nested in the
|
|
96
|
+
// output, lets a build consume or overwrite what it just produced.
|
|
97
|
+
assert(!overlaps(source, directory) && !overlaps(directory, source), 'Source and output directories must not overlap');
|
|
98
|
+
|
|
99
|
+
const pages = [];
|
|
100
|
+
let bytes = 0;
|
|
101
|
+
const runtime = await createRuntime(source, {log: () => {}});
|
|
102
|
+
try {
|
|
103
|
+
const inventory = runtime.testPlan().inventory;
|
|
104
|
+
const renderable = (route ) => route.handler === 'function' && route.state === 'active' && route.methods.includes('GET');
|
|
105
|
+
const targets = inventory.filter(renderable);
|
|
106
|
+
// Silently skipping a route publishes an incomplete site that looks whole.
|
|
107
|
+
if (!ignoreUnrenderable) {
|
|
108
|
+
const skipped = inventory.filter(route => !renderable(route));
|
|
109
|
+
assert(!skipped.length,
|
|
110
|
+
`Source project has routes this build would not render: ${skipped.map(route => `${route.path} (${route.handler}, ${route.state})`).join(', ')}. Pass ignoreUnrenderable to allow it`);
|
|
111
|
+
}
|
|
112
|
+
assert(targets.length, 'Source project has no active GET function routes to prerender');
|
|
113
|
+
assert(targets.length <= maxPages, `Source project page count ${targets.length} exceeds maxPages ${maxPages}`);
|
|
114
|
+
// Case-insensitive: on macOS and Windows two names differing only in case
|
|
115
|
+
// are one file, so the second render would silently replace the first.
|
|
116
|
+
const taken = new Map ();
|
|
117
|
+
for (const route of targets) {
|
|
118
|
+
assertLiteralRoutePath(route.path);
|
|
119
|
+
const file = fileName(route.path);
|
|
120
|
+
assert(typeof file === 'string' && FILE.test(file) && !PROTECTED.has(file), `Unsafe output filename ${file} for route ${route.path}`);
|
|
121
|
+
const key = file.toLowerCase();
|
|
122
|
+
assert(!taken.has(key), `Routes ${taken.get(key)} and ${route.path} both render ${file}`);
|
|
123
|
+
taken.set(key, route.path);
|
|
124
|
+
const result = await runtime.handle({target: route.path, method: 'GET', origin});
|
|
125
|
+
assert(result.status === 200, `${route.path} rendered ${result.status}; expected 200`);
|
|
126
|
+
const type = result.headers.find(([name]) => name.toLowerCase() === 'content-type')?.[1] ?? '';
|
|
127
|
+
assert(/^text\/html\s*(?:;|$)/i.test(type), `${route.path} rendered ${type || 'no content type'}; expected text/html`);
|
|
128
|
+
// The response body is bytes. Keeping it as a Buffer is what preserves a
|
|
129
|
+
// multi-byte character exactly, through the file and its fixture alike.
|
|
130
|
+
const body = typeof result.body === 'string' ? Buffer.from(result.body) : Buffer.from(result.body ?? []);
|
|
131
|
+
assert(body.length > 0 && body.length <= maxPageBytes,
|
|
132
|
+
`${route.path} rendered ${body.length} bytes; expected 1..${maxPageBytes} (maxPageBytes)`);
|
|
133
|
+
bytes += body.length;
|
|
134
|
+
assert(bytes <= maxTotalBytes, `Rendered pages exceed the total budget of ${maxTotalBytes} bytes (maxTotalBytes)`);
|
|
135
|
+
pages.push({path: route.path, file, bytes: body.length, body});
|
|
136
|
+
log({event: 'prerendered', path: route.path, file, bytes: body.length});
|
|
137
|
+
}
|
|
138
|
+
} finally {
|
|
139
|
+
// The runtime owns worker threads. Close it whether or not the render
|
|
140
|
+
// succeeded, so a failing build exits instead of hanging.
|
|
141
|
+
await runtime.close();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Nothing is written until every page has rendered, and the directory itself
|
|
145
|
+
// must be new: a failed or partial build never damages an existing artifact.
|
|
146
|
+
// Parents are created; only the leaf carries that guarantee.
|
|
147
|
+
await mkdir(dirname(directory), {recursive: true});
|
|
148
|
+
try { await mkdir(directory); }
|
|
149
|
+
catch (error) {
|
|
150
|
+
if (!isCode(error, 'EEXIST')) throw error;
|
|
151
|
+
// Keep the code so a caller can branch on it, with a message that says why.
|
|
152
|
+
throw Object.assign(new ConfigError(`Output directory ${directory} already exists; remove it before prerendering`), {code: 'EEXIST'});
|
|
153
|
+
}
|
|
154
|
+
for (const page of pages) await writeFile(join(directory, page.file), page.body);
|
|
155
|
+
return {
|
|
156
|
+
count: pages.length, bytes, directory,
|
|
157
|
+
pages: pages.map(({path, file, bytes: size}) => ({path, file, bytes: size})),
|
|
158
|
+
// Byte-for-byte GET plus empty HEAD, ready to concatenate with a caller's
|
|
159
|
+
// own fixtures. `.html` is served as text/html; charset=utf-8.
|
|
160
|
+
fixtures: pages.flatMap(page => [
|
|
161
|
+
{path: page.path, status: 200, expectHeaders: {'content-type': 'text/html; charset=utf-8'}, expectBody: page.body.toString('utf8')},
|
|
162
|
+
{path: page.path, method: 'HEAD', status: 200, expectBody: ''},
|
|
163
|
+
]),
|
|
164
|
+
};
|
|
165
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { realpath } from 'node:fs/promises';
|
|
2
|
+
import { Agent } from 'node:http';
|
|
3
|
+
import { startServer } from './server.js';
|
|
4
|
+
|
|
5
|
+
import { readCases, hit } from './readiness.js';
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
export async function runProjectTests(project , { log = () => {}, permissions, linkStore, origin } = {}) {
|
|
12
|
+
const root = await realpath(project), cases = await readCases(root);
|
|
13
|
+
const app = await startServer({ project, port: 0, local: true, log, permissions, linkStore, origin });
|
|
14
|
+
const agent = new Agent({keepAlive:true,maxSockets:1}); let failed = 0;
|
|
15
|
+
try {
|
|
16
|
+
for (const [i,test] of cases.entries()) {
|
|
17
|
+
const result=await hit(app,test,agent);
|
|
18
|
+
if(!result.pass)failed++;
|
|
19
|
+
log({event:'test',case:i+1,pass:result.pass,status:result.status});
|
|
20
|
+
}
|
|
21
|
+
} finally {agent.destroy();await app.close();}
|
|
22
|
+
return {total:cases.length,failed};
|
|
23
|
+
}
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import { request, Agent } from 'node:http';
|
|
2
|
+
|
|
3
|
+
import { request as secureRequest, Agent as SecureAgent } from 'node:https';
|
|
4
|
+
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { readFile, lstat } from 'node:fs/promises';
|
|
7
|
+
import { safeFile } from './config.js';
|
|
8
|
+
import { assert } from './errors.js';
|
|
9
|
+
import { parseTarget, matchRoute, contextFor, redirectLocation } from './router.js';
|
|
10
|
+
import { runCompliance } from './compliance.js';
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
/** One configured route as the inventory reports it: a PlanInventoryEntry with the handler kind named. */
|
|
18
|
+
|
|
19
|
+
/** One request case: a generated probe or a `tests/requests.json` fixture. */
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
/** A started server as the audit and benchmark see it. structural: the real type is startServer's result in src/server.ts. */
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
const handlers = ['redirect','function','page','static','download','respond','link'] ;
|
|
45
|
+
/** Narrows a compiled route to one that redirects, so redirectLocation can read its spec. */
|
|
46
|
+
export const hasRedirect = (route ) => Boolean(route.redirect);
|
|
47
|
+
const isRecord = (value ) => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
48
|
+
// Probes identify themselves so an agents policy that denies an empty
|
|
49
|
+
// User-Agent does not fail every generated case; fixtures may override it.
|
|
50
|
+
export const probeAgent = 'Mozilla/5.0 (compatible; RouteProbe/0.1)';
|
|
51
|
+
export function projectPlan(compiled ) {
|
|
52
|
+
const routes = [...compiled.exact.values(), ...[...compiled.byLength.values()].flat(), ...compiled.mounts];
|
|
53
|
+
const now = Date.now();
|
|
54
|
+
const inventory = routes.map(route => ({ path:route.pattern, handler:handlers.find(key => route[key]), methods:route.methods, middleware:route.middleware?.length || 0,
|
|
55
|
+
policies:route.policy ? Object.keys(route.policy.describe) : [],
|
|
56
|
+
...(route.generated ? { generated:route.generated } : {}),
|
|
57
|
+
state:route.enabled === false ? 'disabled' : route.expiresAt && now >= route.expiresAt ? 'expired' : 'active' }));
|
|
58
|
+
const cases = [];
|
|
59
|
+
for (const [i,route] of routes.entries()) {
|
|
60
|
+
const entry = inventory[i];
|
|
61
|
+
if (!entry) continue;
|
|
62
|
+
if (entry.state !== 'active') {
|
|
63
|
+
if(!route.names.length && !route.static) cases.push({path:route.pattern,method:'GET',status:entry.state==='disabled'?404:410});
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
if (route.function || route.link || route.middleware?.length || route.names.length) continue;
|
|
67
|
+
// Required inputs need intentional fixtures; never invent business data.
|
|
68
|
+
let context ;
|
|
69
|
+
try { context = contextFor(route,{},new URLSearchParams(),new Headers()); } catch { continue; }
|
|
70
|
+
if (route.request?.body?.required) continue;
|
|
71
|
+
const files = route.asset instanceof Map ? route.asset : undefined;
|
|
72
|
+
const prefix = route.prefix ?? '';
|
|
73
|
+
const paths = route.static && files ? [...files.keys()].map(key => prefix + key.split('/').map(encodeURIComponent).join('/')) : [route.pattern];
|
|
74
|
+
for (const path of paths) for (const method of route.methods) {
|
|
75
|
+
if (!['GET','HEAD'].includes(method)) continue;
|
|
76
|
+
const test = {path,method,status:(route.redirect ? route.redirect.status || 302 : route.reply?.status || 200)};
|
|
77
|
+
if (hasRedirect(route)) test.expectHeaders = {location:redirectLocation(route,context,new URLSearchParams())};
|
|
78
|
+
const asset=route.static ? files?.get(decodeURIComponent(path.slice(prefix.length))) : route.asset instanceof Map ? undefined : route.asset;
|
|
79
|
+
if(asset) test.expectHeaders={'content-type':asset.type,etag:asset.etag,'content-length':String(asset.body.length)};
|
|
80
|
+
if (route.reply && method !== 'HEAD') test.expectBody = Buffer.from(route.reply.body).toString('utf8');
|
|
81
|
+
if (method === 'HEAD') test.expectBody = '';
|
|
82
|
+
cases.push(test);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return {inventory,cases,resolve:path => matchRoute(compiled,parseTarget(path))?.route.pattern};
|
|
86
|
+
}
|
|
87
|
+
export async function readCases(root , optional = false) {
|
|
88
|
+
if(optional) {
|
|
89
|
+
try {await lstat(join(root,'tests/requests.json'));}
|
|
90
|
+
catch(error){if(error instanceof Error && 'code' in error && error.code==='ENOENT')return [];throw error;}
|
|
91
|
+
}
|
|
92
|
+
const file=await safeFile(root,'tests/requests.json');
|
|
93
|
+
const bytes = await readFile(file);
|
|
94
|
+
assert(bytes.length <= 16*1024*1024, 'Request fixture file exceeds 16 MiB');
|
|
95
|
+
const cases = JSON.parse(bytes.toString('utf8'));
|
|
96
|
+
assert(Array.isArray(cases) && cases.length <= 10000 && (optional || cases.length), 'Request tests must be an array (maximum 10000)');
|
|
97
|
+
for (const test of cases ) {
|
|
98
|
+
assert(isRecord(test), 'Invalid request test');
|
|
99
|
+
assert(typeof test.path === 'string' && test.path.startsWith('/') && !test.path.startsWith('//') && !/[\r\n]/.test(test.path), 'Test path must be local');
|
|
100
|
+
assert(Number.isInteger(test.status) && typeof test.status === 'number' && test.status >= 200 && test.status <= 599, 'Test must declare an HTTP status');
|
|
101
|
+
assert(!test.method || (typeof test.method === 'string' && ['GET','HEAD','POST','PUT','PATCH','DELETE','OPTIONS'].includes(test.method)), 'Invalid test method');
|
|
102
|
+
assert(test.body === undefined || typeof test.body === 'string', 'Test body must be text');
|
|
103
|
+
assert(test.expectBody === undefined || typeof test.expectBody === 'string', 'Expected body must be text');
|
|
104
|
+
for (const headers of [test.headers,test.expectHeaders]) assert(headers === undefined || (isRecord(headers) && Object.values(headers).every(v => typeof v === 'string')), 'Test headers must be string mappings');
|
|
105
|
+
}
|
|
106
|
+
return cases ; // trust boundary: fixture JSON, validated field by field above
|
|
107
|
+
}
|
|
108
|
+
export function benchmarkTarget(value ) {
|
|
109
|
+
let url ;
|
|
110
|
+
try { url=new URL(value); } catch { assert(false,'Target must be an absolute HTTP(S) origin'); }
|
|
111
|
+
assert(['http:','https:'].includes(url.protocol) && url.origin===value.replace(/\/$/,'') && !url.username && !url.password,
|
|
112
|
+
'Target must be a bare HTTP(S) origin without path or credentials');
|
|
113
|
+
return {protocol:url.protocol,hostname:url.hostname,port:url.port || (url.protocol==='https:'?443:80)};
|
|
114
|
+
}
|
|
115
|
+
export function hit(app ,test ,agent ,target ) {
|
|
116
|
+
return new Promise(resolve => {
|
|
117
|
+
const began=performance.now();
|
|
118
|
+
const fail=()=>resolve({pass:false,status:0,durationMs:performance.now()-began,error:'transport'});
|
|
119
|
+
let req ;
|
|
120
|
+
try {
|
|
121
|
+
const send=target?.protocol==='https:' ? secureRequest : request;
|
|
122
|
+
const options =target
|
|
123
|
+
? {host:target.hostname,port:target.port,path:test.path,method:test.method || 'GET',headers:{host:target.hostname,'user-agent':probeAgent,...(test.headers || {})},agent,timeout:10000}
|
|
124
|
+
: {host:'127.0.0.1',port:app.address.port,path:test.path,method:test.method || 'GET',headers:{'user-agent':probeAgent,...(test.headers || {})},agent,timeout:10000};
|
|
125
|
+
req=send(options,(res )=>{
|
|
126
|
+
let size=0;const chunks =[];
|
|
127
|
+
res.on('data',(chunk )=>{size+=chunk.length;if(size>16*1024*1024)res.destroy(new Error('Response limit'));else if(test.expectBody!==undefined)chunks.push(chunk);});
|
|
128
|
+
res.on('error',fail);
|
|
129
|
+
res.on('end',()=>resolve({status:res.statusCode ?? 0,durationMs:performance.now()-began,
|
|
130
|
+
pass:res.statusCode===test.status && Object.entries(test.expectHeaders || {}).every(([k,v])=>res.headers[k.toLowerCase()]===v) && (test.expectBody===undefined || Buffer.concat(chunks).toString()===test.expectBody)}));
|
|
131
|
+
});
|
|
132
|
+
req.on('error',fail);req.on('timeout',()=>req?.destroy(new Error('Timeout')));req.end(test.body);
|
|
133
|
+
} catch { req?.destroy();fail(); }
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
// `compliance` is the option object for runCompliance (profile, rules, ignore,
|
|
137
|
+
// origin, host); absent, the report carries `compliance: null` and readiness
|
|
138
|
+
// is unchanged. A compliance verdict is reported beside readiness, never
|
|
139
|
+
// folded into it: the exit code decision belongs to the caller.
|
|
140
|
+
export async function auditProject(app , {expectRoutes,log=()=>{},compliance} = {}) {
|
|
141
|
+
const began=performance.now();
|
|
142
|
+
const plan=app.testPlan(), fixtures=await readCases(app.root,true);
|
|
143
|
+
const metadata=new Map(plan.inventory.map(r=>[r.path,r]));
|
|
144
|
+
const cases=[...plan.cases,...fixtures], covered=new Set (), unassertedCases =[];let passed=0,failed=0;
|
|
145
|
+
const agent=new Agent({keepAlive:true,maxSockets:1});
|
|
146
|
+
try {
|
|
147
|
+
for (const [i,test] of cases.entries()) {
|
|
148
|
+
const result=await hit(app,test,agent);const method=test.method || 'GET';
|
|
149
|
+
let route ;try {route=plan.resolve(test.path);} catch { /* Invalid-path negative fixture. */ }
|
|
150
|
+
const meta=route===undefined?undefined:metadata.get(route);
|
|
151
|
+
// Error-only fixtures cannot prove a function's normal path works.
|
|
152
|
+
const assertsResponse=test.expectBody!==undefined || Object.keys(test.expectHeaders || {}).length>0;
|
|
153
|
+
if(result.pass && meta?.state==='active' && result.status<400 && !assertsResponse)unassertedCases.push(i+1);
|
|
154
|
+
if(result.pass && assertsResponse && meta?.state==='active' && (result.status<400 || (meta.handler==='respond' && i<plan.cases.length)))covered.add(JSON.stringify([route,method]));
|
|
155
|
+
if(result.pass)passed++;else failed++;
|
|
156
|
+
log({event:'check',case:i+1,source:i<plan.cases.length?'generated':'fixture',pass:result.pass,status:result.status,expectedStatus:test.status});
|
|
157
|
+
}
|
|
158
|
+
} finally {agent.destroy();}
|
|
159
|
+
const uncovered=plan.inventory.filter(r=>r.state==='active').flatMap(r=>r.methods.filter(m=>!covered.has(JSON.stringify([r.path,m]))).map(method=>({route:r.path,method})));
|
|
160
|
+
const counts ={configured:plan.inventory.length,active:0,disabled:0,expired:0,byHandler:{}};
|
|
161
|
+
for(const route of plan.inventory){counts[route.state]++;const handler=String(route.handler);counts.byHandler[handler]=(counts.byHandler[handler]||0)+1;}
|
|
162
|
+
const countMatches=expectRoutes===undefined || counts.configured===expectRoutes;
|
|
163
|
+
// The per-route capability table: which policies apply and whether this
|
|
164
|
+
// host enforces, compiles or delegates each one. Refusals never get here.
|
|
165
|
+
return {dynamicLinks:plan.dynamicLinks,elapsedMs:performance.now()-began,ready:countMatches && !failed && !uncovered.length && counts.active>0,counts,expectedRoutes:expectRoutes ?? null,countMatches,checks:cases.length,passed,failed,coveredRouteMethods:covered.size,unassertedCases,uncovered,policies:plan.policies ?? {},compliance:compliance?await runCompliance(app,compliance):null};
|
|
166
|
+
}
|
|
167
|
+
export async function benchmarkProject(app ,{requests=1000,concurrency=2,maxP95Ms,seconds=30,warmup=0,target} ={}) {
|
|
168
|
+
assert(Number.isInteger(requests)&&requests>=1&&requests<=100000,'Requests must be 1–100000');
|
|
169
|
+
assert(Number.isInteger(concurrency)&&concurrency>=1&&concurrency<=32,'Concurrency must be 1–32');
|
|
170
|
+
assert(Number.isInteger(seconds)&&seconds>=1&&seconds<=300,'Seconds must be 1–300');
|
|
171
|
+
assert(Number.isInteger(warmup)&&warmup>=0&&warmup<=10000,'Warmup must be 0–10000 requests');
|
|
172
|
+
assert(maxP95Ms===undefined || (Number.isFinite(maxP95Ms)&&maxP95Ms>0),'Latency budget must be positive');
|
|
173
|
+
const destination=target?benchmarkTarget(target):undefined;
|
|
174
|
+
const plan=app.testPlan();const fixtures=await readCases(app.root,true);
|
|
175
|
+
const cases=[...plan.cases,...fixtures].filter(c=>['GET','HEAD'].includes(c.method||'GET')&&c.status<400);
|
|
176
|
+
assert(cases.length>0,'No GET/HEAD workload: add representative successful request fixtures');
|
|
177
|
+
const workload=(index ) =>{const found=cases[index%cases.length];assert(found,'Empty workload');return found;};
|
|
178
|
+
// A deployment behind TLS or a proxy is a different system from a local
|
|
179
|
+
// snapshot; the workload is the same, the measurement is not interchangeable.
|
|
180
|
+
const agent=destination?.protocol==='https:'
|
|
181
|
+
? new SecureAgent({keepAlive:true,maxSockets:concurrency})
|
|
182
|
+
: new Agent({keepAlive:true,maxSockets:concurrency});
|
|
183
|
+
const times =[],statuses ={};
|
|
184
|
+
let next=0,failed=0,transportErrors=0,elapsedMs ;
|
|
185
|
+
try {
|
|
186
|
+
// Warm-up requests are sent and discarded: a cold snapshot, an empty
|
|
187
|
+
// connection pool and a just-started worker are not what a budget is about.
|
|
188
|
+
let warmed=0;
|
|
189
|
+
await Promise.all(Array.from({length:Math.min(concurrency,Math.max(warmup,1))},async()=>{
|
|
190
|
+
while(warmed<warmup){const index=warmed++;await hit(app,workload(index),agent,destination);}
|
|
191
|
+
}));
|
|
192
|
+
const began=performance.now();
|
|
193
|
+
await Promise.all(Array.from({length:concurrency},async()=>{
|
|
194
|
+
while(next<requests && performance.now()-began<seconds*1000){
|
|
195
|
+
const index=next++;const result=await hit(app,workload(index),agent,destination);
|
|
196
|
+
times.push(result.durationMs);
|
|
197
|
+
if(!result.pass){failed++;if(result.status===0)transportErrors++;}
|
|
198
|
+
statuses[result.status]=(statuses[result.status]||0)+1;
|
|
199
|
+
}
|
|
200
|
+
}));
|
|
201
|
+
elapsedMs=performance.now()-began;
|
|
202
|
+
} finally {agent.destroy();}
|
|
203
|
+
times.sort((a,b)=>a-b);
|
|
204
|
+
const percentile=(q )=>times[Math.max(0,Math.ceil(times.length*q)-1)] ?? null;
|
|
205
|
+
const p95Ms=percentile(.95), complete=times.length===requests;
|
|
206
|
+
const shed=Object.entries(statuses).filter(([status])=>['503','504'].includes(status)).reduce((n,[,count])=>n+count,0);
|
|
207
|
+
return {pass:complete&&!failed&&(maxP95Ms===undefined||(p95Ms!==null&&p95Ms<=maxP95Ms)),requested:requests,completed:times.length,complete,failed,transportErrors,shedResponses:shed,concurrency,
|
|
208
|
+
workloadCases:cases.length,exercisedWorkloadCases:Math.min(times.length,cases.length),
|
|
209
|
+
workload:`${destination?'remote':'local'} GET/HEAD only; redirects not followed`,
|
|
210
|
+
target:destination?`${destination.protocol}//${destination.hostname}:${destination.port}`:null,
|
|
211
|
+
warmupRequests:warmup,elapsedMs,requestsPerSecond:times.length/elapsedMs*1000,
|
|
212
|
+
p50Ms:percentile(.5),p95Ms,p99Ms:percentile(.99),maxP95Ms:maxP95Ms??null,statuses,
|
|
213
|
+
// In target mode this process is the load generator, not the server: its
|
|
214
|
+
// memory says nothing about the deployment under test.
|
|
215
|
+
rssMiB:destination?null:process.memoryUsage().rss/2**20,node:process.version,platform:process.platform};
|
|
216
|
+
}
|