@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,164 @@
1
+ import { mkdir, writeFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import Ajv from 'ajv/dist/2020.js';
4
+ import standaloneCode from 'ajv/dist/standalone/index.js';
5
+ import { loadDocument } from './config.js';
6
+ import { compileRoutes } from './router.js';
7
+ import { assert } from './errors.js';
8
+ import { effectivePolicies, registry } from './policies.js';
9
+ import { resolveLists } from './agent-lists.js';
10
+ import { applySite } from './site.js';
11
+
12
+
13
+
14
+
15
+
16
+
17
+ // Handlers this target cannot serve, and why. Declarative routes only in this
18
+ // slice: assets need a platform binding rather than an inline copy, and the
19
+ // sandbox needs facilities the platform does not provide.
20
+ const unsupported = {
21
+ function:'isolated functions need worker threads and the WASM engine',
22
+ link:'stored live links need a durable writable store',
23
+ page:'pages need a static-asset binding',
24
+ static:'static directories need a static-asset binding',
25
+ download:'downloads need a static-asset binding',
26
+ } ;
27
+ const unsupportedHandlers = ['function','link','page','static','download'] ;
28
+
29
+ // The artifact is this runtime's build output, not a published contract: the
30
+ // format may change with any release, and the runtime refuses a version it does
31
+ // not recognise rather than guessing.
32
+ const FORMAT = 1;
33
+
34
+ // Policies this target can carry in the artifact. Anything else is refused at
35
+ // build time with the route named, like an unsupported handler: the Worker has
36
+ // no shared counters, no origin cache and the platform compresses itself.
37
+ const compilablePolicies = new Set(['agents','security']);
38
+ const isPolicyName = (name ) => Object.hasOwn(registry, name);
39
+ // The registry pairs each module with its own config type; the build works on
40
+ // the erased contract, the same one the compiled chain holds.
41
+ const policyModule = (name ) => registry[name];
42
+
43
+ // Ajv's standalone output hardcodes a CommonJS `require` for its runtime
44
+ // helpers even in ESM mode, and an ES module cannot evaluate that. Each helper
45
+ // is inlined from the installed Ajv instead of a hand-copied duplicate, so it
46
+ // can never drift from the validator that calls it. A helper that is not a
47
+ // self-contained function, or a `require` this does not recognise, fails the
48
+ // build rather than shipping a Worker that cannot start.
49
+ const runtimeModule = /require\("ajv\/dist\/runtime\/([\w-]+)(?:\.js)?"\)\.default/g;
50
+ async function linkRuntime(source ) {
51
+ const prelude = [], linked = [];
52
+ let index = 0;
53
+ for (const match of source.matchAll(runtimeModule)) {
54
+ const name = match[1] ?? '', local = `urlcodeRuntime_${name.replace(/\W/g,'_')}`;
55
+ if (!prelude.some(line => line.startsWith(`const ${local} `))) {
56
+ let helper ;
57
+ // CommonJS interop: the namespace default is the module's exports object.
58
+ try {
59
+ const module = (await import(`ajv/dist/runtime/${name}.js`) ).default; // trust boundary: a CommonJS module namespace
60
+ helper = typeof module === 'function' ? module : module !== null && typeof module === 'object' && 'default' in module ? module.default : undefined;
61
+ } catch { helper = undefined; }
62
+ assert(typeof helper === 'function' && !/^\s*(?:class|\w+\s*=>)/.test(helper.toString()),
63
+ `Ajv runtime helper "${name}" cannot be inlined; this schema is unsupported on this target`);
64
+ prelude.push(`const ${local} = ${helper.toString()};`);
65
+ }
66
+ linked.push(source.slice(index, match.index), local);
67
+ index = match.index + match[0].length;
68
+ }
69
+ linked.push(source.slice(index));
70
+ let out = linked.join('');
71
+ // Keep any leading directive first so the prelude cannot demote it.
72
+ const directive = out.match(/^\s*(["'])use strict\1;/)?.[0] ?? '';
73
+ out = directive + '\n' + prelude.join('\n') + (prelude.length ? '\n' : '') + out.slice(directive.length);
74
+ assert(!/\brequire\s*\(/.test(out), 'Generated validators still reference CommonJS; this schema is unsupported on this target');
75
+ return out;
76
+ }
77
+
78
+ export async function buildCloudflare(project , { out = 'dist/cloudflare', origin, log = () => {} } = {}) {
79
+ const loaded = await loadDocument(project);
80
+ // Generated site routes are built like declared ones; the ones that need
81
+ // an origin get it from --origin, exactly as the server does.
82
+ await applySite(loaded, { origin, log });
83
+ // No bindings are resolved: a build artifact must never carry a secret, and
84
+ // this target has no per-request operator policy to pin one to.
85
+ const compiled = await compileRoutes(loaded, {}, {}, undefined);
86
+ const routes = [...compiled.exact.values(), ...[...compiled.byLength.values()].flat(), ...compiled.mounts];
87
+
88
+ const refused = [];
89
+ for (const route of routes) {
90
+ for (const handler of unsupportedHandlers) {
91
+ if (route[handler]) refused.push(`${route.pattern}: ${unsupported[handler]}`);
92
+ }
93
+ if (route.middleware?.length) refused.push(`${route.pattern}: middleware needs the sandbox`);
94
+ const policies = effectivePolicies(loaded.document, route);
95
+ for (const name of Object.keys(policies)) {
96
+ assert(isPolicyName(name), `Unknown policy "${name}"`);
97
+ const support = policyModule(name).targets(policies[name]).cloudflare;
98
+ // The platform provides it: accepted and dropped, never carried.
99
+ if (support === 'delegated') { delete policies[name]; continue; }
100
+ if (!compilablePolicies.has(name) || support !== 'compiled') refused.push(`${route.pattern}: policies.${name} cannot be compiled for this target`);
101
+ }
102
+ // Project-relative agent lists are read here, once, so the artifact
103
+ // carries the patterns and the Worker never needs a filesystem.
104
+ if (policies.agents) {
105
+ const resolved = await resolveLists(policies.agents, loaded.root, route.pattern);
106
+ if (resolved) policies.agents = resolved; else delete policies.agents;
107
+ }
108
+ // Compiled policies validate now, at build time, so the Worker never
109
+ // evaluates a configuration the runtime would have rejected.
110
+ for (const name of Object.keys(policies)) if (isPolicyName(name) && compilablePolicies.has(name)) await policyModule(name).compile(policies[name], { route, shared: {}, target: 'cloudflare', document: loaded.document, root: loaded.root });
111
+ if (Object.keys(policies).length) route.compiledPolicies = policies; else delete route.compiledPolicies;
112
+ if (Object.keys(route.env).length || Object.keys(route.secrets).length) {
113
+ refused.push(`${route.pattern}: env and secret bindings would have to be baked into the artifact`);
114
+ }
115
+ }
116
+ assert(!refused.length, `This target serves declarative routes only:\n ${refused.join('\n ')}`);
117
+ assert(routes.length, 'No routes to build');
118
+
119
+ const ajv = new Ajv.default({ code:{ source:true, esm:true }, strict:false, allErrors:false });
120
+ const validators = {}, serialised = [];
121
+ for (const route of routes) {
122
+ const parameters = [];
123
+ for (const parameter of route.parameters) {
124
+ // One validator per distinct schema, named for the route and input it
125
+ // serves so a build failure points at the YAML that caused it.
126
+ const id = `v${Object.keys(validators).length}`;
127
+ const { default: _default, ...shape } = parameter.schema;
128
+ ajv.addSchema(shape, id);
129
+ validators[id] = id;
130
+ parameters.push({ name:parameter.name, in:parameter.in, required:parameter.required === true,
131
+ schema:parameter.schema, validator:id });
132
+ }
133
+ serialised.push({ pattern:route.pattern, parts:route.parts, names:route.names, methods:route.methods,
134
+ parameters, responseHeaders:route.responseHeaders,
135
+ ...(route.request ? { request:route.request } : {}),
136
+ ...(route.redirect ? { redirect:route.redirect } : {}),
137
+ ...(route.reply ? { reply:{ status:route.reply.status, headers:route.reply.headers, body:Buffer.from(route.reply.body).toString('utf8') } } : {}),
138
+ ...(route.enabled === false ? { enabled:false } : {}),
139
+ ...(route.expiresAt ? { expiresAt:route.expiresAt } : {}),
140
+ ...(route.compiledPolicies ? { policies:route.compiledPolicies } : {}) });
141
+ }
142
+
143
+ // Project-level security headers for the Worker's own 404 and error
144
+ // answers, the same headers the self-hosted server gives them.
145
+ const projectPolicies = effectivePolicies(loaded.document, {});
146
+ const errorPolicy = projectPolicies.security && registry.security.targets(projectPolicies.security).cloudflare === 'compiled'
147
+ ? { security: projectPolicies.security } : undefined;
148
+ if (errorPolicy) registry.security.compile(errorPolicy.security, { route: { pattern: '(project)' }, shared: {}, target: 'cloudflare', document: loaded.document });
149
+
150
+ const artifact = { format:FORMAT, version:loaded.version, routes:serialised, ...(errorPolicy ? { policies: errorPolicy } : {}) };
151
+ await mkdir(out, { recursive:true });
152
+ await writeFile(join(out,'validators.js'), await linkRuntime(standaloneCode.default(ajv, validators)));
153
+ await writeFile(join(out,'artifact.js'),
154
+ `// Generated by urlcode build. Do not edit; rebuild instead.\nexport default ${JSON.stringify(artifact,null,2)};\n`);
155
+ await writeFile(join(out,'index.js'), `// Generated by urlcode build. Do not edit; rebuild instead.
156
+ import { createFetchHandler } from '@jimhoyd/urlcode/cloudflare';
157
+ import artifact from './artifact.js';
158
+ import * as validators from './validators.js';
159
+
160
+ export default { fetch: createFetchHandler(artifact, validators) };
161
+ `);
162
+ return { out, format:FORMAT, version:loaded.version, routes:serialised.length,
163
+ validators:Object.keys(validators).length };
164
+ }
package/dist/cli.js ADDED
@@ -0,0 +1,185 @@
1
+ #!/usr/bin/env node
2
+ import { auditProject, benchmarkProject } from './readiness.js';
3
+
4
+ import { parseArgs } from 'node:util';
5
+ import { createRuntime } from './runtime.js';
6
+ import { startServer } from './server.js';
7
+
8
+ import {scaffoldProject} from './scaffold.js';
9
+ import { initProject, addRedirect } from './authoring.js';
10
+ import { runProjectTests } from './project-tests.js';
11
+ import { loadOperatorPolicy, prepareFunctionSnapshot, requestedPermissions } from './policy.js';
12
+ import { loadDocument } from './config.js';
13
+ import {parseLinkBinding,runLinkCommand,linkPoolOptions} from './link-cli.js';
14
+ import { ConfigError, HttpError } from './errors.js';
15
+ import {supportsConcurrentWal} from './sqlite-version.js';
16
+ import { registry as policyRegistry } from './policies.js';
17
+ import { loadComplianceRules, profileNames as complianceProfiles } from './compliance.js';
18
+
19
+ const usage = `URLCode 0.3.0 — local/self-hosted runtime
20
+ urlcode init <directory>
21
+ urlcode scaffold [--project directory] [--dry-run]
22
+ urlcode validate [--project directory] [--local] [--origin https://links.example] # origin: absolute URLs in site.* files
23
+ urlcode dev [--project directory] [--port 3000] [--host 127.0.0.1]
24
+ urlcode serve [--project directory] [--port 3000] [--host 127.0.0.1] [--origin https://links.example]
25
+ capacity: [--workers 2] [--function-timeout-ms 5000] [--max-response-bytes 1048576]
26
+ [--max-body-bytes 1048576] [--max-in-flight 64] [--max-in-flight-health 16]
27
+ logging: [--request-log minimal|detailed] [--trust-request-id] [--metrics] # metrics: GET /_urlcode/metrics, Prometheus text; keep internal
28
+ policies: [--trusted-proxies 10.0.0.0/8,fd00::/8] # peers allowed to set X-Forwarded-For for client policies
29
+ urlcode add <destination-url> [--alias short-code] [--project directory]
30
+ urlcode test [--project directory] [--origin https://links.example]
31
+ urlcode build --target cloudflare [--project directory] [--out dist/cloudflare] [--origin https://links.example]
32
+ urlcode routes [--project directory] [--origin https://links.example]
33
+ urlcode audit [--project directory] [--expect-routes 2]
34
+ compliance: [--compliance baseline|strict|privacy|none] [--compliance-rules /absolute/rules.mjs] [--compliance-ignore id,id]
35
+ [--compliance-warn] [--origin https://links.example] [--request-log minimal|detailed] # declare the deployment under review
36
+ urlcode benchmark [--project directory] [--requests 1000] [--concurrency 2] [--seconds 30] [--max-p95-ms 50]
37
+ [--warmup 50] [--target https://links.example] # target measures a running deployment, not a local snapshot
38
+ urlcode permissions [--project directory] # inspect requested bindings; grants nothing
39
+ urlcode links init|create|get|list|update|delete|export|import|api --store /absolute/links.sqlite [--collection links]
40
+ create/update: --destination https://example.com [--code abc] [--status 302] [--enabled true] [--expires UTC]
41
+ update/delete: --code abc --if-version N (update replaces all mutable fields)
42
+ export: consistent NDJSON snapshot to stdout [--collection links] [--page-size 100]
43
+ import: --input /absolute/export.ndjson restores into empty collections (versions are reassigned)
44
+ api: --auth-file /operator/management.json (or legacy --token-file /operator/token) --port 3001 (separate authenticated server)
45
+ urlcode doctor
46
+ serve/dev/validate/test/routes/audit/benchmark: --link-store links=/absolute/links.sqlite
47
+ Store pool controls: --link-readers 2 (1–8), --link-read-limit 32, --link-write-limit 32 (1–32 each)
48
+ Dev loads .env.local and watches; serve does neither. Functions run in WASM isolation; external bindings require --policy outside the project.
49
+ `;
50
+ const print = (value ) => process.stdout.write(typeof value === 'string' ? value : JSON.stringify(value) + '\n');
51
+ const options = {
52
+ project:{ type:'string', default:'.' },
53
+ port:{ type:'string' }, host:{ type:'string', default:'127.0.0.1' },
54
+ 'expect-routes':{type:'string'}, requests:{type:'string'}, concurrency:{type:'string'}, seconds:{type:'string'}, 'max-p95-ms':{type:'string'}, warmup:{type:'string'}, target:{type:'string'},
55
+ 'link-readers':{type:'string'}, 'link-read-limit':{type:'string'}, 'link-write-limit':{type:'string'},
56
+ workers:{type:'string'}, 'function-timeout-ms':{type:'string'}, 'max-response-bytes':{type:'string'}, 'max-body-bytes':{type:'string'},
57
+ 'max-in-flight':{type:'string'}, 'max-in-flight-health':{type:'string'}, 'request-log':{type:'string'}, 'trust-request-id':{type:'boolean'}, 'trusted-proxies':{type:'string'}, metrics:{type:'boolean'},
58
+ 'link-store':{type:'string'}, store:{type:'string'}, collection:{type:'string'}, code:{type:'string'}, destination:{type:'string'}, status:{type:'string'}, enabled:{type:'string'}, expires:{type:'string'}, 'if-version':{type:'string'}, limit:{type:'string'}, after:{type:'string'}, 'token-file':{type:'string'}, 'auth-file':{type:'string'}, input:{type:'string'}, 'page-size':{type:'string'},
59
+ out:{type:'string'}, 'dry-run':{type:'boolean'}, compliance:{type:'string'}, 'compliance-rules':{type:'string'}, 'compliance-ignore':{type:'string'}, 'compliance-warn':{type:'boolean'}, policy:{ type:'string' }, origin:{ type:'string' }, alias:{ type:'string' }, local:{ type:'boolean' }, help:{ type:'boolean', short:'h' },
60
+ } ;
61
+
62
+
63
+ // Deployment controls the container/CLI must be able to set; the embedding JS
64
+ // API is not reachable from `urlcode serve`.
65
+ const capacityFlags = [['workers','workers'],['function-timeout-ms','timeoutMs'],['max-response-bytes','maxBytes'],
66
+ ['max-body-bytes','maxBodyBytes'],['max-in-flight','maxInFlightRequests'],['max-in-flight-health','maxInFlightHealthRequests']] ;
67
+ function serverCapacity(values ) {
68
+ const options = {};
69
+ for (const [flag,key] of capacityFlags) {
70
+ const value = values[flag];
71
+ if (value === undefined) continue;
72
+ if (!/^\d{1,9}$/.test(value)) throw new ConfigError(`Invalid --${flag}`);
73
+ options[key] = Number(value);
74
+ }
75
+ if (values['request-log'] !== undefined) {
76
+ if (!['minimal','detailed'].includes(values['request-log'])) throw new ConfigError('Use --request-log minimal or detailed');
77
+ options.requestLog = values['request-log'];
78
+ }
79
+ if (values['trust-request-id']) options.trustRequestId = true;
80
+ if (values.metrics) options.metrics = true;
81
+ if (values['trusted-proxies'] !== undefined) options.trustedProxies = values['trusted-proxies'];
82
+ return options;
83
+ }
84
+ // Compliance flags for `audit`. Operator rules load like the binding policy:
85
+ // from an absolute path outside the project, as trusted host code. The origin
86
+ // and log level describe the deployment under review, not this audit process.
87
+ async function complianceOptions(values ) {
88
+ const flags=['compliance','compliance-rules','compliance-ignore','compliance-warn'] ;
89
+ if(flags.every(flag=>values[flag]===undefined))return undefined;
90
+ const profile=values.compliance ?? 'baseline';
91
+ if(!complianceProfiles.includes(profile))throw new ConfigError(`Use --compliance ${complianceProfiles.join('|')}`);
92
+ const ignore=(values['compliance-ignore'] ?? '').split(',').map(id=>id.trim()).filter(Boolean);
93
+ const operator=await loadComplianceRules(values['compliance-rules'],values.project);
94
+ const host={requestLog:values['request-log'] ?? 'minimal',linkEvents:false};
95
+ if(!['minimal','detailed'].includes(host.requestLog))throw new ConfigError('Use --request-log minimal or detailed');
96
+ return {profile,ignore,origin:values.origin,host,rules:operator?.rules ?? [],disable:operator?.disable ?? [],override:operator?.override ?? {}};
97
+ }
98
+ const errorMessages = { ERR_PARSE_ARGS_UNKNOWN_OPTION:'Unknown option; use --help', EEXIST:'Destination or edit lock already exists', ENOENT:'Required file or directory not found', EADDRINUSE:'Port is already in use', EACCES:'Permission denied' };
99
+ try {
100
+ const { values, positionals } = parseArgs({ allowPositionals:true, options });
101
+ const [command, arg, ...extra] = positionals;
102
+ values.port ??= command==='links' && arg==='api' ? '3001' : '3000';
103
+ if (values.help || !command) print(usage);
104
+ else {
105
+ if (extra.length || (!['init','add','links'].includes(command) && arg)) throw new ConfigError('Unexpected positional arguments');
106
+ if(command==='links'){await runLinkCommand(arg,values,print);}else{
107
+ const permissions = await loadOperatorPolicy(values.policy,values.project);
108
+ const linkStore=parseLinkBinding(values['link-store'],linkPoolOptions(values));
109
+ switch (command) {
110
+ case 'routes': case 'audit': case 'benchmark': {
111
+ const number = (key ,fallback ) => {
112
+ const value = values[key];
113
+ if(value===undefined)return fallback;
114
+ if(!/^\d+(?:\.\d+)?$/.test(value) || !Number.isFinite(Number(value)))throw new ConfigError('Invalid numeric option');
115
+ return Number(value);
116
+ };
117
+ const expected=number('expect-routes');
118
+ if(expected!==undefined && !Number.isSafeInteger(expected))throw new ConfigError('Expected route count must be an integer');
119
+ const compliance=command==='audit'?await complianceOptions(values):undefined;
120
+ const started=performance.now();
121
+ const app=await startServer({project:values.project,port:0,local:true,permissions,linkStore,origin:values.origin,log:()=>{}});
122
+ const startupMs=performance.now()-started;
123
+ try {
124
+ if(command==='routes') {
125
+ const plan=app.testPlan(); print({routes:plan.inventory.length,dynamicLinks:plan.dynamicLinks,inventory:plan.inventory,policies:plan.policies});
126
+ } else if(command==='audit') {
127
+ const report=await auditProject(app,{expectRoutes:expected,log:print,compliance});print(report);if(!report.ready)process.exitCode=1;
128
+ if(report.compliance && !report.compliance.pass && !values['compliance-warn'])process.exitCode=1;
129
+ } else {
130
+ const report=await benchmarkProject(app,{requests:number('requests',1000),concurrency:number('concurrency',2),seconds:number('seconds',30),maxP95Ms:number('max-p95-ms'),warmup:number('warmup',0),target:values.target});
131
+ // Local startup time is meaningless when the load went elsewhere.
132
+ print(values.target?report:{...report,startupMs});if(!report.pass)process.exitCode=1;
133
+ }
134
+ } finally {await app.close();}
135
+ break;
136
+ }
137
+ case 'build': {
138
+ if (values.target !== 'cloudflare') throw new ConfigError('Use --target cloudflare');
139
+ const { buildCloudflare } = await import('./build-cloudflare.js');
140
+ print({ event:'built', ...await buildCloudflare(values.project,{ out:values.out, origin:values.origin }) }); break;
141
+ }
142
+ case 'scaffold':
143
+ print(await scaffoldProject(values.project,{dryRun:values['dry-run']}));break;
144
+ case 'permissions': {
145
+ const loaded = await loadDocument(values.project);
146
+ print(requestedPermissions(loaded,await prepareFunctionSnapshot(loaded))); break;
147
+ }
148
+ case 'init':
149
+ if (!arg) throw new ConfigError('Provide a new project directory');
150
+ await initProject(arg); print({ event:'created' }); break;
151
+ case 'validate': {
152
+ const runtime = await createRuntime(values.project, { local:values.local, permissions, linkStore, origin:values.origin });
153
+ print({ event:'valid', dynamicLinks:runtime.testPlan().dynamicLinks, routes:runtime.count, version:runtime.version }); await runtime.close(); break;
154
+ }
155
+ case 'add':
156
+ if (!arg) throw new ConfigError('Provide an HTTP(S) destination URL');
157
+ print({ event:'added', path:await addRedirect(values.project,arg,values.alias) }); break;
158
+ case 'test': {
159
+ const result = await runProjectTests(values.project, { log:print, permissions, linkStore, origin:values.origin });
160
+ print(result); if (result.failed) process.exitCode = 1; break;
161
+ }
162
+ case 'doctor':
163
+ print({ node:process.version, sqlite:process.versions.sqlite, liveLinks:supportsConcurrentWal(process.versions.sqlite), platform:process.platform, architecture:process.arch, runtime:'node-process', functionSandbox:'quickjs-wasm', network:false, filesystem:false, providers:[], policies:Object.keys(policyRegistry), license:'Apache-2.0' }); break;
164
+ case 'dev': case 'serve': {
165
+ const port = Number(values.port);
166
+ if (!/^\d+$/.test(values.port) || !Number.isInteger(port) || port < 0 || port > 65535) throw new ConfigError('Invalid port');
167
+ if (command === 'serve' && values.local) throw new ConfigError('serve never reads local dotenv files');
168
+ const app = await startServer({ project:values.project, host:values.host, port,
169
+ local:command === 'dev', watch:command === 'dev', origin:values.origin, permissions, linkStore,
170
+ ...serverCapacity(values) });
171
+ print({ event:'listening', address:app.address.address, port:app.address.port, mode:command, origin:app.origin });
172
+ let stopping = false;
173
+ const stop = async () => { if (stopping) return; stopping = true; await app.close(); };
174
+ process.once('SIGINT',stop); process.once('SIGTERM',stop);
175
+ break;
176
+ }
177
+ default: throw new ConfigError('Unknown command; use --help');
178
+ }
179
+ }
180
+ }
181
+ } catch (error) {
182
+ const code = typeof error === 'object' && error !== null && 'code' in error && typeof error.code === 'string' ? error.code : undefined;
183
+ const message = (error instanceof ConfigError || error instanceof HttpError) ? error.message : ((code !== undefined ? errorMessages[code] : undefined) || 'Operation failed; check project files, module dependencies and command options');
184
+ process.stderr.write(JSON.stringify({ event:'error', message }) + '\n'); process.exitCode = 1;
185
+ }
@@ -0,0 +1,85 @@
1
+ import { isIP } from 'node:net';
2
+ import { assert } from './errors.js';
3
+
4
+ // Client identity for host policies. The socket peer is the truth unless the
5
+ // operator names the proxies allowed to speak for a client; then the first
6
+ // address to the left of the trusted chain in X-Forwarded-For is used, per
7
+ // the usual proxy-protocol convention and docs/RESILIENCE.md. Nothing here
8
+ // ever trusts a forwarded header from an address outside that set.
9
+
10
+
11
+
12
+ function toBytes(address ) {
13
+ const kind = isIP(address);
14
+ if (kind === 4) return Uint8Array.from(address.split('.').map(Number));
15
+ if (kind !== 6) return undefined;
16
+ // Mapped IPv4 in IPv6 keeps its v4 identity so one CIDR list covers both.
17
+ const mapped = address.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i);
18
+ if (mapped) return toBytes(mapped[1] );
19
+ // Any other embedded dotted quad (RFC 4291 §2.2) is two trailing groups.
20
+ const dotted = address.match(/^(.*:)(\d+\.\d+\.\d+\.\d+)$/);
21
+ if (dotted) {
22
+ const [a = 0, b = 0, c = 0, d = 0] = dotted[2] .split('.').map(Number);
23
+ address = dotted[1] + ((a << 8) | b).toString(16) + ':' + ((c << 8) | d).toString(16);
24
+ }
25
+ const [head, tail = ''] = address.split('::');
26
+ const parts = head ? head.split(':') : [], rest = tail ? tail.split(':') : [];
27
+ const groups = [...parts, ...Array(8 - parts.length - rest.length).fill('0'), ...rest].map(g => parseInt(g || '0', 16));
28
+ const bytes = new Uint8Array(16);
29
+ groups.forEach((g, i) => { bytes[i*2] = g >> 8; bytes[i*2+1] = g & 255; });
30
+ return bytes;
31
+ }
32
+
33
+ export function parseCidr(text ) {
34
+ const [address = '', prefixText] = String(text).trim().split('/');
35
+ const bytes = toBytes(address);
36
+ assert(bytes, `Invalid trusted proxy address "${text}"`);
37
+ const prefix = prefixText === undefined ? bytes.length * 8 : Number(prefixText);
38
+ assert(Number.isInteger(prefix) && prefix >= 0 && prefix <= bytes.length * 8, `Invalid trusted proxy prefix "${text}"`);
39
+ return { bytes, prefix };
40
+ }
41
+
42
+ export function compileTrustedProxies(list = []) {
43
+ const entries = Array.isArray(list) ? list : String(list).split(',').map(s => s.trim()).filter(Boolean);
44
+ assert(entries.length <= 256, 'At most 256 trusted proxy ranges');
45
+ return entries.map(parseCidr);
46
+ }
47
+
48
+ function within(address , { bytes, prefix } ) {
49
+ const target = toBytes(address);
50
+ if (!target || target.length !== bytes.length) return false;
51
+ for (let i = 0; i < bytes.length; i++) {
52
+ const bits = Math.min(8, Math.max(0, prefix - i*8));
53
+ if (!bits) return true;
54
+ const mask = (0xff << (8 - bits)) & 0xff;
55
+ if ((target[i] & mask) !== (bytes[i] & mask)) return false;
56
+ }
57
+ return true;
58
+ }
59
+
60
+ export function isTrustedProxy(address , trusted ) {
61
+ return trusted.some(range => within(address, range));
62
+ }
63
+
64
+ // Walk X-Forwarded-For from the right, skipping trusted hops; the first
65
+ // untrusted address is the client. A chain made entirely of trusted proxies
66
+ // yields the leftmost entry. A malformed entry is skipped; an IPv4 entry
67
+ // with a port keeps its address.
68
+ export function resolveClient(peer , forwarded , trusted ) {
69
+ const normalized = normalizeAddress(peer);
70
+ if (!trusted.length || !normalized || !isTrustedProxy(normalized, trusted)) return normalized;
71
+ const hops = (forwarded || '').split(',').map(s => normalizeAddress(s.trim())).filter((hop) => hop !== undefined);
72
+ if (!hops.length) return normalized;
73
+ for (let i = hops.length - 1; i >= 0; i--) if (!isTrustedProxy(hops[i] , trusted)) return hops[i];
74
+ return hops[0];
75
+ }
76
+
77
+ export function normalizeAddress(address ) {
78
+ if (typeof address !== 'string' || !address) return undefined;
79
+ let value = address;
80
+ if (value.startsWith('[')) value = value.slice(1, value.indexOf(']'));
81
+ else if (/^\d+\.\d+\.\d+\.\d+:\d+$/.test(value)) value = value.slice(0, value.indexOf(':'));
82
+ const mapped = value.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i);
83
+ if (mapped) value = mapped[1] ;
84
+ return isIP(value) ? value.toLowerCase() : undefined;
85
+ }
@@ -0,0 +1,165 @@
1
+ import { parseTarget, matchRoute, contextFor, redirectLocation } from './match.js';
2
+
3
+ import { checkRequest, decorateResponse } from './http-policy.js';
4
+
5
+ import { prepareResponse, errorResponse } from './http-response.js';
6
+
7
+ import { HttpError } from './errors.js';
8
+ // Only the two policies a Worker can carry. Both modules must stay free of
9
+ // Node imports; the build refuses every other policy with the route named.
10
+ import * as agents from './policies/agents.js';
11
+ import * as security from './policies/security.js';
12
+
13
+
14
+
15
+
16
+ // The artifact `urlcode build --target cloudflare` writes. Its shape is this
17
+ // runtime's own output, not a published contract (see build-cloudflare.ts).
18
+
19
+
20
+
21
+
22
+
23
+
24
+
25
+
26
+
27
+
28
+ /** A route's compiled policy chain on this target: the same hook pairs the Node runtime holds. */
29
+
30
+ /** A rehydrated route: MatchableRoute plus what the request path reads. The reply body is bytes, not a Buffer. */
31
+
32
+
33
+
34
+
35
+
36
+
37
+ // A Web-standard runtime for a compiled artifact. It shares the matching,
38
+ // request policy and response policy of every other host; only the transport
39
+ // differs. Nothing here touches the filesystem, a worker thread or a Node
40
+ // built-in, because the platform this targets has none of them.
41
+ const encoder = new TextEncoder();
42
+ const dict = () => Object.create(null) ;
43
+ const redirecting = (route ) => Boolean(route.redirect);
44
+
45
+ // The artifact stores what the compiler produced; validators arrive separately
46
+ // because a schema validator cannot be serialised and this platform forbids
47
+ // compiling one at runtime.
48
+ export function rehydrate(artifact , validators = {}) {
49
+ if (artifact?.format !== 1) throw new Error('Unsupported URLCode artifact; rebuild with this runtime version');
50
+ const exact = new Map (), byLength = new Map (), shared = { target: 'cloudflare' };
51
+ for (const route of artifact.routes) {
52
+ const prepared = { ...route,
53
+ parameters: (route.parameters || []).map((parameter) => {
54
+ const validate = validators[parameter.validator];
55
+ if (!validate) throw new Error(`Artifact is missing the validator for ${route.pattern} ${parameter.in}:${parameter.name}`);
56
+ return { ...parameter, validate };
57
+ }),
58
+ reply: route.reply ? { ...route.reply, body: encoder.encode(route.reply.body) } : undefined,
59
+ // Declarative routes carry no bindings; the artifact refuses to hold one.
60
+ env: dict(), secrets: dict(), middleware: [],
61
+ // The artifact carries the effective policy configuration the build
62
+ // validated; compile is synchronous for these modules by contract.
63
+ policy: null,
64
+ };
65
+ if (route.policies) {
66
+ const chain = { request: [], response: [] };
67
+ for (const [name, config] of Object.entries(route.policies)) {
68
+ const context = { route: prepared, shared, target: 'cloudflare' };
69
+ if (name === 'agents') {
70
+ const state = agents.compile(config , context); // trust boundary: the build validated this configuration
71
+ if (state instanceof Promise) throw new Error(`policies.${name} compile must be synchronous on this target`);
72
+ chain.request.push([agents, state]); chain.agents = state;
73
+ } else if (name === 'security') {
74
+ const state = security.compile(config, context);
75
+ chain.response.push([security, state]); chain.security = state;
76
+ } else throw new Error(`Artifact carries policies.${name}, which this target cannot enforce; rebuild`);
77
+ }
78
+ prepared.policy = chain;
79
+ }
80
+ if (!prepared.names.length) exact.set(prepared.pattern, prepared);
81
+ else {
82
+ let group = byLength.get(prepared.parts.length);
83
+ if (!group) { group = []; byLength.set(prepared.parts.length, group); }
84
+ group.push(prepared);
85
+ }
86
+ }
87
+ // Project-level security for answers that matched no route or threw.
88
+ const projectContext = { route: { pattern: '(project)' }, shared, target: 'cloudflare' };
89
+ const errorPolicy = artifact.policies?.security ? security.compile(artifact.policies.security, projectContext) : null;
90
+ // Compiled order is preserved: the artifact is emitted most specific first.
91
+ return { exact, byLength, mounts: [], errorPolicy };
92
+ }
93
+
94
+ export function createFetchHandler(artifact , validators ) {
95
+ const compiled = rehydrate(artifact, validators);
96
+ return async function fetch(request ) {
97
+ const requestId = crypto.randomUUID();
98
+ const method = request.method;
99
+ let matched , origin = 'http://localhost';
100
+ try {
101
+ const url = new URL(request.url);
102
+ origin = url.origin;
103
+ const parsed = parseTarget(url.pathname + url.search);
104
+ const match = matchRoute(compiled, parsed);
105
+ if (!match) throw new HttpError(404, 'Not found');
106
+ const { route, path } = match;
107
+ matched = route;
108
+ if (route.enabled === false) throw new HttpError(404, 'Not found');
109
+ if (route.expiresAt && Date.now() >= route.expiresAt) throw new HttpError(410, 'Gone');
110
+ // Same request shape and order as the Node runtime; the client identity
111
+ // is the platform's connecting address, never a client-supplied header.
112
+ const policyReq = route.policy ? { method, target: url.pathname + url.search, path: parsed.path, query: parsed.query, headers: request.headers,
113
+ headerCounts: {}, params: path, client: request.headers.get('cf-connecting-ip') ?? null, origin: url.origin, route: route.pattern, secrets: false } : null;
114
+ const finish = async (result , producer ) => {
115
+ let out = result;
116
+ for (const [module, state] of route.policy?.response || []) { if (module === producer || !policyReq) continue; out = await module.onResponse?.(state, policyReq, out) ?? out; }
117
+ return out;
118
+ };
119
+ for (const [module, state] of route.policy?.request || []) {
120
+ if (!policyReq) break;
121
+ const denied = await module.onRequest?.(state, policyReq);
122
+ if (denied) return respond(prepareResponse(await finish(denied, module), { requestId, method }), requestId, method);
123
+ }
124
+ if (!route.methods.includes(method)) {
125
+ // The same response policy as every other host: 405 skips the route's
126
+ // configured response headers but still gets length, nosniff and id,
127
+ // and passes through the response-phase policies like the Node runtime.
128
+ return respond(prepareResponse(await finish({ status:405, headers:[['allow',route.methods.join(', ')]],
129
+ body: encoder.encode('Method not allowed\n') }), { requestId, method }), requestId, method);
130
+ }
131
+ const body = route.request?.body
132
+ ? new Uint8Array(await request.arrayBuffer())
133
+ : new Uint8Array(0);
134
+ // Duplicate request headers are joined by the platform before this runs,
135
+ // so per-header counts are unavailable and the duplicate-scalar check
136
+ // cannot fire here. docs/CLOUDFLARE.md records the difference.
137
+ checkRequest(route, body, request.headers, {});
138
+ const context = contextFor(route, path, parsed.query, request.headers, {});
139
+ let native ;
140
+ if (redirecting(route)) native = { status: route.redirect.status || 302, headers:[['location',redirectLocation(route, context, parsed.query)]], body: new Uint8Array(0) };
141
+ else if (route.reply) native = { ...route.reply };
142
+ else throw new HttpError(502, 'Invalid function response');
143
+ return respond(prepareResponse(await finish(decorateResponse(route, native)), { requestId, method }), requestId, method);
144
+ } catch (error) {
145
+ if (!(error instanceof HttpError)) console.error(error);
146
+ // The matched route's security state when there is one, else the
147
+ // project's from the artifact: the same rule as the Node runtime.
148
+ const state = matched ? (matched.policy?.security ?? null) : compiled.errorPolicy;
149
+ const headers = state ? security.onResponse(state, { origin }, { status: 200, headers: [] }).headers : [];
150
+ const prepared = errorResponse(error, { requestId, method, headers });
151
+ return respond({ ...prepared, cookies: [], body: prepared.body === undefined ? undefined : encoder.encode(prepared.body) }, requestId, method);
152
+ }
153
+ };
154
+ }
155
+
156
+ function respond(prepared , requestId , method ) {
157
+ const headers = new Headers();
158
+ for (const [key,value] of prepared.headers) headers.append(key, value);
159
+ for (const cookie of prepared.cookies ?? []) headers.append('set-cookie', cookie);
160
+ if (!headers.has('x-request-id')) headers.set('x-request-id', requestId);
161
+ const empty = method === 'HEAD' || [204,205,304].includes(prepared.status);
162
+ // Bytes here always sit on a plain ArrayBuffer (the encoder or the platform made them); lib.dom's BodyInit only excludes shared memory.
163
+ const body = prepared.body ?? new Uint8Array(0);
164
+ return new Response(empty ? null : typeof body === 'string' ? body : body , { status: prepared.status, headers });
165
+ }