@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.
Files changed (254) hide show
  1. package/CONTRIBUTING.md +80 -0
  2. package/LICENSE +202 -0
  3. package/NOTICE +20 -0
  4. package/README.md +342 -0
  5. package/ROADMAP.md +248 -0
  6. package/SECURITY.md +40 -0
  7. package/data/agents/LICENSES/ai-robots-txt.txt +24 -0
  8. package/data/agents/LICENSES/crawler-user-agents.txt +23 -0
  9. package/data/agents/ai-crawlers.json +1240 -0
  10. package/data/agents/crawlers.json +10515 -0
  11. package/data/agents/index.d.ts +3 -0
  12. package/data/agents/index.js +2453 -0
  13. package/data/agents/monitoring.json +1520 -0
  14. package/data/agents/seo.json +3865 -0
  15. package/dist/BUILD-MANIFEST.json +60 -0
  16. package/dist/adapters.js +59 -0
  17. package/dist/agent-lists.js +59 -0
  18. package/dist/assets.js +129 -0
  19. package/dist/authoring.js +68 -0
  20. package/dist/aws.js +110 -0
  21. package/dist/build-cloudflare.js +164 -0
  22. package/dist/cli.js +185 -0
  23. package/dist/client-address.js +85 -0
  24. package/dist/cloudflare.js +165 -0
  25. package/dist/compliance-rules/baseline.js +98 -0
  26. package/dist/compliance-rules/privacy.js +38 -0
  27. package/dist/compliance-rules/shared.js +62 -0
  28. package/dist/compliance-rules/strict.js +61 -0
  29. package/dist/compliance.js +219 -0
  30. package/dist/config-worker.js +9 -0
  31. package/dist/config.js +172 -0
  32. package/dist/errors.js +8 -0
  33. package/dist/function-sources.js +54 -0
  34. package/dist/function-worker.js +105 -0
  35. package/dist/functions.js +174 -0
  36. package/dist/guest-api.js +114 -0
  37. package/dist/header-validation.js +18 -0
  38. package/dist/http-policy.js +74 -0
  39. package/dist/http-response.js +84 -0
  40. package/dist/index.js +11 -0
  41. package/dist/link-api.js +136 -0
  42. package/dist/link-cli.js +141 -0
  43. package/dist/link-events.js +76 -0
  44. package/dist/link-records.js +31 -0
  45. package/dist/link-store-worker.js +150 -0
  46. package/dist/link-store.js +250 -0
  47. package/dist/logging.js +22 -0
  48. package/dist/management-policy.js +41 -0
  49. package/dist/match.js +124 -0
  50. package/dist/observability.js +242 -0
  51. package/dist/plugins.js +74 -0
  52. package/dist/policies/agents.js +248 -0
  53. package/dist/policies/cache.js +297 -0
  54. package/dist/policies/compression.js +187 -0
  55. package/dist/policies/security.js +0 -0
  56. package/dist/policies/throttle.js +131 -0
  57. package/dist/policies.js +142 -0
  58. package/dist/policy.js +55 -0
  59. package/dist/prerender.js +165 -0
  60. package/dist/project-tests.js +23 -0
  61. package/dist/readiness.js +216 -0
  62. package/dist/router.js +166 -0
  63. package/dist/runtime.js +244 -0
  64. package/dist/scaffold.js +0 -0
  65. package/dist/scripts/operational-drills.js +75 -0
  66. package/dist/server.js +270 -0
  67. package/dist/site.js +0 -0
  68. package/dist/sqlite-version.js +6 -0
  69. package/dist/types/adapters.d.ts +12 -0
  70. package/dist/types/agent-lists.d.ts +5 -0
  71. package/dist/types/assets.d.ts +13 -0
  72. package/dist/types/authoring.d.ts +2 -0
  73. package/dist/types/aws.d.ts +35 -0
  74. package/dist/types/build-cloudflare.d.ts +14 -0
  75. package/dist/types/cli.d.ts +2 -0
  76. package/dist/types/client-address.d.ts +9 -0
  77. package/dist/types/cloudflare.d.ts +75 -0
  78. package/dist/types/compliance-rules/baseline.d.ts +12 -0
  79. package/dist/types/compliance-rules/privacy.d.ts +6 -0
  80. package/dist/types/compliance-rules/shared.d.ts +24 -0
  81. package/dist/types/compliance-rules/strict.d.ts +9 -0
  82. package/dist/types/compliance.d.ts +119 -0
  83. package/dist/types/config-worker.d.ts +1 -0
  84. package/dist/types/config.d.ts +21 -0
  85. package/dist/types/errors.d.ts +7 -0
  86. package/dist/types/function-sources.d.ts +21 -0
  87. package/dist/types/function-worker.d.ts +1 -0
  88. package/dist/types/functions.d.ts +92 -0
  89. package/dist/types/guest-api.d.ts +16 -0
  90. package/dist/types/header-validation.d.ts +2 -0
  91. package/dist/types/http-policy.d.ts +39 -0
  92. package/dist/types/http-response.d.ts +43 -0
  93. package/dist/types/index.d.ts +11 -0
  94. package/dist/types/link-api.d.ts +30 -0
  95. package/dist/types/link-cli.d.ts +36 -0
  96. package/dist/types/link-events.d.ts +27 -0
  97. package/dist/types/link-records.d.ts +11 -0
  98. package/dist/types/link-store-worker.d.ts +1 -0
  99. package/dist/types/link-store.d.ts +130 -0
  100. package/dist/types/logging.d.ts +9 -0
  101. package/dist/types/management-policy.d.ts +9 -0
  102. package/dist/types/match.d.ts +81 -0
  103. package/dist/types/observability.d.ts +104 -0
  104. package/dist/types/plugins.d.ts +27 -0
  105. package/dist/types/policies/agents.d.ts +86 -0
  106. package/dist/types/policies/cache.d.ts +82 -0
  107. package/dist/types/policies/compression.d.ts +41 -0
  108. package/dist/types/policies/security.d.ts +37 -0
  109. package/dist/types/policies/throttle.d.ts +55 -0
  110. package/dist/types/policies.d.ts +36 -0
  111. package/dist/types/policy.d.ts +21 -0
  112. package/dist/types/prerender.d.ts +31 -0
  113. package/dist/types/project-tests.d.ts +13 -0
  114. package/dist/types/readiness.d.ts +121 -0
  115. package/dist/types/router.d.ts +11 -0
  116. package/dist/types/runtime.d.ts +91 -0
  117. package/dist/types/scaffold.d.ts +17 -0
  118. package/dist/types/server.d.ts +37 -0
  119. package/dist/types/site.d.ts +12 -0
  120. package/dist/types/sqlite-version.d.ts +1 -0
  121. package/dist/types/types.d.ts +332 -0
  122. package/dist/types/vercel.d.ts +12 -0
  123. package/dist/types.js +177 -0
  124. package/dist/vercel.js +65 -0
  125. package/docs/AI-AUTHORING.md +113 -0
  126. package/docs/ASSETS.md +106 -0
  127. package/docs/AWS.md +77 -0
  128. package/docs/BEST-PRACTICES.md +267 -0
  129. package/docs/CAPACITY.md +208 -0
  130. package/docs/CLOUDFLARE.md +110 -0
  131. package/docs/COMPLIANCE.md +242 -0
  132. package/docs/DYNAMIC-LINKS.md +561 -0
  133. package/docs/FUNCTION-SECURITY.md +113 -0
  134. package/docs/HTTP.md +129 -0
  135. package/docs/INSTALL.md +90 -0
  136. package/docs/LOAD-TESTING.md +91 -0
  137. package/docs/LOCAL-DEVELOPMENT.md +99 -0
  138. package/docs/MANAGEMENT-SECURITY.md +82 -0
  139. package/docs/MIDDLEWARE.md +83 -0
  140. package/docs/MONITORING.md +132 -0
  141. package/docs/OBSERVABILITY.md +229 -0
  142. package/docs/OPERATIONAL-PROOF.md +42 -0
  143. package/docs/OPERATIONS.md +207 -0
  144. package/docs/ORGANIZATION.md +135 -0
  145. package/docs/PERFORMANCE.md +72 -0
  146. package/docs/PLUGINS.md +234 -0
  147. package/docs/POLICIES.md +489 -0
  148. package/docs/PRERENDER.md +193 -0
  149. package/docs/PROJECT-DIRECTION.md +51 -0
  150. package/docs/READINESS.md +125 -0
  151. package/docs/README.md +58 -0
  152. package/docs/RELEASE-READINESS.md +95 -0
  153. package/docs/RELEASE-SECURITY.md +72 -0
  154. package/docs/RESILIENCE.md +160 -0
  155. package/docs/ROUTING.md +95 -0
  156. package/docs/SANDBOX-REVIEW.md +59 -0
  157. package/docs/SCAFFOLDING.md +72 -0
  158. package/docs/SECURITY-AUDIT.md +124 -0
  159. package/docs/SITE.md +150 -0
  160. package/docs/SPECIFICATION.md +232 -0
  161. package/docs/SPIKE-EXTENSIONS.md +475 -0
  162. package/docs/STANDARDS.md +303 -0
  163. package/docs/STARTERS.md +51 -0
  164. package/docs/TUNNELS.md +75 -0
  165. package/docs/TYPESCRIPT.md +102 -0
  166. package/docs/VERCEL.md +107 -0
  167. package/docs/YAML-GUIDE.md +521 -0
  168. package/docs/YAML-REFERENCE.md +328 -0
  169. package/docs/policies/agents.md +182 -0
  170. package/docs/policies/cache.md +139 -0
  171. package/docs/policies/compression.md +168 -0
  172. package/docs/policies/security.md +160 -0
  173. package/docs/policies/throttle.md +103 -0
  174. package/examples/assets/.env.example +1 -0
  175. package/examples/assets/.gitattributes +2 -0
  176. package/examples/assets/Makefile +30 -0
  177. package/examples/assets/README.md +18 -0
  178. package/examples/assets/functions/hello.mjs +3 -0
  179. package/examples/assets/gitignore.template +7 -0
  180. package/examples/assets/public/about.html +2 -0
  181. package/examples/assets/public/assets/example.txt +1 -0
  182. package/examples/assets/public/guide.txt +1 -0
  183. package/examples/assets/tests/requests.json +44 -0
  184. package/examples/assets/urlcode.yaml +27 -0
  185. package/examples/aws/README.md +13 -0
  186. package/examples/aws/handler.mjs +5 -0
  187. package/examples/aws/public/index.html +3 -0
  188. package/examples/aws/public/notes.txt +1 -0
  189. package/examples/aws/template.yaml +33 -0
  190. package/examples/aws/tests/requests.json +9 -0
  191. package/examples/aws/urlcode.yaml +19 -0
  192. package/examples/cloudflare/README.md +18 -0
  193. package/examples/cloudflare/package.json +14 -0
  194. package/examples/cloudflare/tests/requests.json +9 -0
  195. package/examples/cloudflare/urlcode.yaml +24 -0
  196. package/examples/cloudflare/wrangler.toml +5 -0
  197. package/examples/compliance/README.md +20 -0
  198. package/examples/compliance/rules.mjs +65 -0
  199. package/examples/cookbook/README.md +21 -0
  200. package/examples/cookbook/functions/choice.mjs +4 -0
  201. package/examples/cookbook/functions/echo.mjs +3 -0
  202. package/examples/cookbook/functions/hello.mjs +3 -0
  203. package/examples/cookbook/functions/text.mjs +3 -0
  204. package/examples/cookbook/middleware/headers.mjs +6 -0
  205. package/examples/cookbook/public/about.html +1 -0
  206. package/examples/cookbook/public/assets/index.html +1 -0
  207. package/examples/cookbook/public/assets/site.css +1 -0
  208. package/examples/cookbook/public/favicon.svg +1 -0
  209. package/examples/cookbook/public/guide.txt +1 -0
  210. package/examples/cookbook/public/llms.txt +6 -0
  211. package/examples/cookbook/routes/code.yaml +55 -0
  212. package/examples/cookbook/routes/files.yaml +17 -0
  213. package/examples/cookbook/routes/policies.yaml +28 -0
  214. package/examples/cookbook/routes/redirects.yaml +38 -0
  215. package/examples/cookbook/routes/responses.yaml +26 -0
  216. package/examples/cookbook/tests/requests.json +254 -0
  217. package/examples/cookbook/urlcode.yaml +22 -0
  218. package/examples/live-links/README.md +11 -0
  219. package/examples/live-links/tests/requests.json +6 -0
  220. package/examples/live-links/urlcode.yaml +16 -0
  221. package/examples/monitoring/blackbox-jobs.yaml +37 -0
  222. package/examples/monitoring/prometheus-rules.yaml +88 -0
  223. package/examples/monitoring/prometheus-scrape.yaml +20 -0
  224. package/examples/monitoring/vector.toml +65 -0
  225. package/examples/prerender/README.md +48 -0
  226. package/examples/prerender/functions/page.mjs +6 -0
  227. package/examples/prerender/middleware/template.mjs +29 -0
  228. package/examples/prerender/prerender.d.mts +4 -0
  229. package/examples/prerender/prerender.mjs +63 -0
  230. package/examples/prerender/tests/requests.json +39 -0
  231. package/examples/prerender/urlcode.yaml +50 -0
  232. package/examples/tunnel/dev-with-ngrok.sh +61 -0
  233. package/examples/vercel/README.md +13 -0
  234. package/examples/vercel/api/index.js +5 -0
  235. package/examples/vercel/package.json +8 -0
  236. package/examples/vercel/public/index.html +3 -0
  237. package/examples/vercel/public/notes.txt +1 -0
  238. package/examples/vercel/tests/requests.json +48 -0
  239. package/examples/vercel/urlcode.yaml +19 -0
  240. package/examples/vercel/vercel.json +10 -0
  241. package/llms.txt +46 -0
  242. package/package.json +123 -0
  243. package/schemas/urlcode.schema.json +1230 -0
  244. package/starters/default/.gitattributes +1 -0
  245. package/starters/default/Makefile +30 -0
  246. package/starters/default/README.md +34 -0
  247. package/starters/default/functions/hello.mjs +3 -0
  248. package/starters/default/gitignore.template +8 -0
  249. package/starters/default/middleware/headers.mjs +6 -0
  250. package/starters/default/routes/functions.yaml +20 -0
  251. package/starters/default/routes/marketing/links.yaml +7 -0
  252. package/starters/default/starter.json +5 -0
  253. package/starters/default/tests/requests.json +56 -0
  254. package/starters/default/urlcode.yaml +8 -0
@@ -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
+ }