@jimhoyd/urlcode 0.3.0 → 0.4.0-alpha.1

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 (326) hide show
  1. package/.claude/skills/urlcode-authoring/SKILL.md +106 -0
  2. package/.claude/skills/urlcode-operations/SKILL.md +114 -0
  3. package/.claude-plugin/marketplace.json +18 -0
  4. package/CONTRIBUTING.md +30 -2
  5. package/README.md +157 -230
  6. package/ROADMAP.md +85 -2
  7. package/SECURITY.md +8 -1
  8. package/dist/BUILD-MANIFEST.json +62 -32
  9. package/dist/adapters.js +4 -23
  10. package/dist/agent-lists.js +1 -1
  11. package/dist/agents-guide.js +113 -0
  12. package/dist/authoring-files.js +60 -0
  13. package/dist/authoring.js +11 -1
  14. package/dist/aws.js +4 -3
  15. package/dist/build-cloudflare.js +11 -24
  16. package/dist/bulk.js +37 -0
  17. package/dist/capabilities.js +208 -0
  18. package/dist/capability-query.js +72 -0
  19. package/dist/catalog.js +105 -0
  20. package/dist/cli.js +154 -15
  21. package/dist/client-address.js +1 -1
  22. package/dist/compliance-rules/baseline.js +8 -8
  23. package/dist/compliance-rules/privacy.js +3 -3
  24. package/dist/compliance-rules/strict.js +5 -5
  25. package/dist/conditions.js +88 -0
  26. package/dist/config.js +60 -4
  27. package/dist/context.js +156 -0
  28. package/dist/ecosystem-cli.js +88 -0
  29. package/dist/egress.js +98 -0
  30. package/dist/examples.js +92 -0
  31. package/dist/explain-cli.js +64 -0
  32. package/dist/explain.js +132 -0
  33. package/dist/extensions.js +172 -0
  34. package/dist/function-sources.js +15 -3
  35. package/dist/index.js +37 -0
  36. package/dist/init-with.js +165 -0
  37. package/dist/interchange-cli.js +42 -0
  38. package/dist/interchange.js +189 -0
  39. package/dist/link-cli.js +1 -1
  40. package/dist/management-policy.js +0 -1
  41. package/dist/manifest.js +111 -0
  42. package/dist/match.js +2 -2
  43. package/dist/mcp-authoring.js +147 -0
  44. package/dist/mcp.js +97 -0
  45. package/dist/observability.js +6 -0
  46. package/dist/operator-host.js +29 -0
  47. package/dist/plugins.js +12 -0
  48. package/dist/policies/agents.js +2 -2
  49. package/dist/policies/compression.js +2 -1
  50. package/dist/policies/security.js +0 -0
  51. package/dist/policies.js +1 -1
  52. package/dist/policy.js +29 -7
  53. package/dist/prerender.js +100 -41
  54. package/dist/project-tests.js +3 -3
  55. package/dist/provider-verification.js +92 -0
  56. package/dist/proxy.js +44 -0
  57. package/dist/readiness.js +5 -5
  58. package/dist/recipes.js +41 -0
  59. package/dist/route-diff.js +106 -0
  60. package/dist/router.js +42 -2
  61. package/dist/runtime.js +95 -14
  62. package/dist/schema-query.js +62 -0
  63. package/dist/signals.js +24 -0
  64. package/dist/site.js +0 -0
  65. package/dist/tooling.js +96 -0
  66. package/dist/types/adapters.d.ts +7 -4
  67. package/dist/types/agent-lists.d.ts +0 -1
  68. package/dist/types/agents-guide.d.ts +17 -0
  69. package/dist/types/authoring-files.d.ts +10 -0
  70. package/dist/types/aws.d.ts +3 -1
  71. package/dist/types/build-cloudflare.d.ts +1 -0
  72. package/dist/types/bulk.d.ts +27 -0
  73. package/dist/types/capabilities.d.ts +55 -0
  74. package/dist/types/capability-query.d.ts +24 -0
  75. package/dist/types/catalog.d.ts +65 -0
  76. package/dist/types/client-address.d.ts +0 -1
  77. package/dist/types/compliance-rules/baseline.d.ts +1 -9
  78. package/dist/types/compliance-rules/privacy.d.ts +1 -4
  79. package/dist/types/compliance-rules/strict.d.ts +0 -5
  80. package/dist/types/conditions.d.ts +19 -0
  81. package/dist/types/config.d.ts +20 -2
  82. package/dist/types/context.d.ts +65 -0
  83. package/dist/types/ecosystem-cli.d.ts +17 -0
  84. package/dist/types/egress.d.ts +46 -0
  85. package/dist/types/examples.d.ts +50 -0
  86. package/dist/types/explain-cli.d.ts +11 -0
  87. package/dist/types/explain.d.ts +95 -0
  88. package/dist/types/extensions.d.ts +122 -0
  89. package/dist/types/function-sources.d.ts +5 -0
  90. package/dist/types/index.d.ts +33 -0
  91. package/dist/types/init-with.d.ts +30 -0
  92. package/dist/types/interchange-cli.d.ts +16 -0
  93. package/dist/types/interchange.d.ts +42 -0
  94. package/dist/types/link-cli.d.ts +2 -1
  95. package/dist/types/management-policy.d.ts +0 -1
  96. package/dist/types/manifest.d.ts +81 -0
  97. package/dist/types/match.d.ts +1 -0
  98. package/dist/types/mcp-authoring.d.ts +92 -0
  99. package/dist/types/mcp.d.ts +12 -0
  100. package/dist/types/observability.d.ts +2 -0
  101. package/dist/types/operator-host.d.ts +8 -0
  102. package/dist/types/plugins.d.ts +2 -0
  103. package/dist/types/policies/agents.d.ts +0 -2
  104. package/dist/types/policies/compression.d.ts +2 -0
  105. package/dist/types/policies/security.d.ts +0 -1
  106. package/dist/types/policy.d.ts +15 -4
  107. package/dist/types/project-tests.d.ts +3 -1
  108. package/dist/types/provider-verification.d.ts +53 -0
  109. package/dist/types/proxy.d.ts +21 -0
  110. package/dist/types/readiness.d.ts +1 -1
  111. package/dist/types/recipes.d.ts +30 -0
  112. package/dist/types/route-diff.d.ts +27 -0
  113. package/dist/types/runtime.d.ts +11 -0
  114. package/dist/types/schema-query.d.ts +12 -0
  115. package/dist/types/signals.d.ts +25 -0
  116. package/dist/types/site.d.ts +0 -1
  117. package/dist/types/tooling.d.ts +115 -0
  118. package/dist/types/types.d.ts +57 -0
  119. package/dist/types/typescript-authoring.d.ts +12 -0
  120. package/dist/types/vercel.d.ts +3 -1
  121. package/dist/types/verify-deployment.d.ts +47 -0
  122. package/dist/types.js +21 -2
  123. package/dist/typescript-authoring.js +104 -0
  124. package/dist/vercel.js +4 -3
  125. package/dist/verify-deployment.js +270 -0
  126. package/docs/AI-AUTHORING.md +130 -8
  127. package/docs/BULK.md +79 -0
  128. package/docs/CAPABILITIES.md +179 -0
  129. package/docs/CAPACITY.md +1 -1
  130. package/docs/CI.md +142 -0
  131. package/docs/CONDITIONS.md +74 -0
  132. package/docs/DEPLOYMENT-CHECKS.md +108 -0
  133. package/docs/DYNAMIC-LINKS.md +18 -518
  134. package/docs/EGRESS.md +125 -0
  135. package/docs/EXTENSIONS.md +226 -0
  136. package/docs/FRAMEWORK.md +182 -0
  137. package/docs/INSTALL.md +45 -7
  138. package/docs/INTERCHANGE.md +134 -0
  139. package/docs/MIDDLEWARE-EXAMPLES.md +75 -0
  140. package/docs/MIDDLEWARE.md +2 -0
  141. package/docs/NEXT-PHASE-PLAN.md +90 -0
  142. package/docs/NEXT-STEPS.md +415 -0
  143. package/docs/OBSERVABILITY.md +4 -2
  144. package/docs/OPERATIONAL-PROOF.md +4 -1
  145. package/docs/OPERATIONS.md +6 -3
  146. package/docs/PLUGINS.md +37 -0
  147. package/docs/POLICIES.md +12 -309
  148. package/docs/PRERENDER.md +40 -0
  149. package/docs/PROJECT-DIRECTION.md +42 -0
  150. package/docs/PROVIDER-VERIFICATION.md +84 -0
  151. package/docs/READINESS.md +21 -1
  152. package/docs/README.md +82 -31
  153. package/docs/RECIPES.md +99 -0
  154. package/docs/RELEASE-READINESS.md +11 -9
  155. package/docs/RELEASE-SECURITY.md +27 -4
  156. package/docs/SECURITY-AUDIT.md +1 -1
  157. package/docs/SPECIFICATION.md +95 -8
  158. package/docs/SPIKE-BUSINESS-SUITE.md +1013 -0
  159. package/docs/SPIKE-EXTENSION-MODEL.md +419 -0
  160. package/docs/SPIKE-EXTENSIONS.md +1 -0
  161. package/docs/SPIKE-LAMBDA-COMPILE.md +199 -0
  162. package/docs/STANDARDS.md +150 -142
  163. package/docs/STARTERS.md +21 -1
  164. package/docs/TOOLING.md +291 -0
  165. package/docs/TYPESCRIPT-AUTHORING.md +67 -0
  166. package/docs/TYPESCRIPT.md +1 -1
  167. package/docs/USABILITY-REVIEW.md +123 -0
  168. package/docs/YAML-GUIDE.md +18 -479
  169. package/docs/YAML-REFERENCE.md +127 -16
  170. package/docs/links/cli.md +110 -0
  171. package/docs/links/limits.md +175 -0
  172. package/docs/links/management-api.md +80 -0
  173. package/docs/links/pools.md +75 -0
  174. package/docs/links/setup.md +135 -0
  175. package/docs/policies/agents.md +1 -1
  176. package/docs/policies/contract.md +52 -0
  177. package/docs/policies/hardened.md +56 -0
  178. package/docs/policies/interoperability.md +169 -0
  179. package/docs/policies/operations.md +45 -0
  180. package/docs/yaml/assets.md +36 -0
  181. package/docs/yaml/conditions.md +20 -0
  182. package/docs/yaml/functions.md +160 -0
  183. package/docs/yaml/links.md +30 -0
  184. package/docs/yaml/middleware.md +29 -0
  185. package/docs/yaml/organization.md +74 -0
  186. package/docs/yaml/policies.md +37 -0
  187. package/docs/yaml/redirects.md +64 -0
  188. package/docs/yaml/responses.md +57 -0
  189. package/docs/yaml/site.md +24 -0
  190. package/examples/assets/example.yaml +17 -0
  191. package/examples/aws/example.yaml +20 -0
  192. package/examples/cloudflare/example.yaml +19 -0
  193. package/examples/compliance/example.yaml +11 -0
  194. package/examples/conditions/README.md +12 -0
  195. package/examples/conditions/example.yaml +19 -0
  196. package/examples/conditions/tests/requests.json +13 -0
  197. package/examples/conditions/urlcode.yaml +24 -0
  198. package/examples/cookbook/README.md +8 -4
  199. package/examples/cookbook/example.yaml +17 -0
  200. package/examples/cookbook/functions/catalog.mjs +3 -0
  201. package/examples/cookbook/functions/fail.mjs +4 -0
  202. package/examples/cookbook/functions/items.mjs +3 -0
  203. package/examples/cookbook/functions/profile.mjs +3 -0
  204. package/examples/cookbook/functions/resource.mjs +3 -0
  205. package/examples/cookbook/functions/status.mjs +3 -0
  206. package/examples/cookbook/middleware/auth.mjs +48 -0
  207. package/examples/cookbook/middleware/body.mjs +15 -0
  208. package/examples/cookbook/middleware/bucket.mjs +19 -0
  209. package/examples/cookbook/middleware/cors.mjs +21 -0
  210. package/examples/cookbook/middleware/debug.mjs +13 -0
  211. package/examples/cookbook/middleware/envelope.mjs +11 -0
  212. package/examples/cookbook/middleware/errors.mjs +11 -0
  213. package/examples/cookbook/middleware/etag.mjs +18 -0
  214. package/examples/cookbook/middleware/locale.mjs +16 -0
  215. package/examples/cookbook/middleware/maintenance.mjs +10 -0
  216. package/examples/cookbook/middleware/methods.mjs +15 -0
  217. package/examples/cookbook/middleware/negotiate.mjs +20 -0
  218. package/examples/cookbook/middleware/referer.mjs +12 -0
  219. package/examples/cookbook/middleware/request-id.mjs +16 -0
  220. package/examples/cookbook/route-index.json +676 -0
  221. package/examples/cookbook/routes/middleware.yaml +126 -0
  222. package/examples/cookbook/tests/requests.json +526 -0
  223. package/examples/cookbook/urlcode.yaml +1 -0
  224. package/examples/egress/README.md +22 -0
  225. package/examples/egress/example.yaml +19 -0
  226. package/examples/egress/urlcode.yaml +19 -0
  227. package/examples/extensions/README.md +7 -0
  228. package/examples/extensions/example.yaml +21 -0
  229. package/examples/extensions/urlcode.yaml +25 -0
  230. package/examples/live-links/example.yaml +21 -0
  231. package/examples/monitoring/example.yaml +8 -0
  232. package/examples/prerender/example.yaml +16 -0
  233. package/examples/provider-conformance/README.md +12 -0
  234. package/examples/provider-conformance/example.yaml +14 -0
  235. package/examples/provider-conformance/urlcode.yaml +34 -0
  236. package/examples/tunnel/example.yaml +8 -0
  237. package/examples/vercel/example.yaml +19 -0
  238. package/llms-full.txt +2709 -0
  239. package/llms.txt +48 -19
  240. package/package.json +29 -7
  241. package/packaging/claude-plugin/.claude-plugin/plugin.json +19 -0
  242. package/packaging/claude-plugin/skills/urlcode-authoring/SKILL.md +106 -0
  243. package/packaging/claude-plugin/skills/urlcode-operations/SKILL.md +114 -0
  244. package/recipes/authenticated-json-api/README.md +51 -0
  245. package/recipes/authenticated-json-api/functions/profile.mjs +5 -0
  246. package/recipes/authenticated-json-api/recipe.yaml +34 -0
  247. package/recipes/authenticated-json-api/tests/requests.json +39 -0
  248. package/recipes/authenticated-json-api/urlcode.yaml +12 -0
  249. package/recipes/contact-form/README.md +25 -0
  250. package/recipes/contact-form/functions/contact.mjs +17 -0
  251. package/recipes/contact-form/recipe.yaml +33 -0
  252. package/recipes/contact-form/tests/requests.json +47 -0
  253. package/recipes/contact-form/urlcode.yaml +18 -0
  254. package/recipes/cors-api/README.md +16 -0
  255. package/recipes/cors-api/functions/items.mjs +3 -0
  256. package/recipes/cors-api/middleware/cors.mjs +21 -0
  257. package/recipes/cors-api/recipe.yaml +26 -0
  258. package/recipes/cors-api/tests/requests.json +65 -0
  259. package/recipes/cors-api/urlcode.yaml +12 -0
  260. package/recipes/health-page/README.md +13 -0
  261. package/recipes/health-page/recipe.yaml +23 -0
  262. package/recipes/health-page/tests/requests.json +36 -0
  263. package/recipes/health-page/urlcode.yaml +19 -0
  264. package/recipes/json-api/README.md +6 -0
  265. package/recipes/json-api/functions/echo.mjs +3 -0
  266. package/recipes/json-api/recipe.yaml +25 -0
  267. package/recipes/json-api/tests/requests.json +34 -0
  268. package/recipes/json-api/urlcode.yaml +12 -0
  269. package/recipes/middleware/README.md +34 -0
  270. package/recipes/middleware/functions/catalog.mjs +3 -0
  271. package/recipes/middleware/functions/fail.mjs +4 -0
  272. package/recipes/middleware/functions/items.mjs +3 -0
  273. package/recipes/middleware/functions/profile.mjs +3 -0
  274. package/recipes/middleware/functions/resource.mjs +3 -0
  275. package/recipes/middleware/functions/status.mjs +3 -0
  276. package/recipes/middleware/middleware/auth.mjs +48 -0
  277. package/recipes/middleware/middleware/body.mjs +15 -0
  278. package/recipes/middleware/middleware/bucket.mjs +19 -0
  279. package/recipes/middleware/middleware/cors.mjs +21 -0
  280. package/recipes/middleware/middleware/debug.mjs +13 -0
  281. package/recipes/middleware/middleware/envelope.mjs +11 -0
  282. package/recipes/middleware/middleware/errors.mjs +11 -0
  283. package/recipes/middleware/middleware/etag.mjs +18 -0
  284. package/recipes/middleware/middleware/locale.mjs +16 -0
  285. package/recipes/middleware/middleware/maintenance.mjs +10 -0
  286. package/recipes/middleware/middleware/methods.mjs +15 -0
  287. package/recipes/middleware/middleware/negotiate.mjs +20 -0
  288. package/recipes/middleware/middleware/referer.mjs +12 -0
  289. package/recipes/middleware/middleware/request-id.mjs +16 -0
  290. package/recipes/middleware/public/guide.txt +1 -0
  291. package/recipes/middleware/recipe.yaml +50 -0
  292. package/recipes/middleware/tests/requests.json +528 -0
  293. package/recipes/middleware/urlcode.yaml +127 -0
  294. package/recipes/protected-download/README.md +22 -0
  295. package/recipes/protected-download/files/report.txt +1 -0
  296. package/recipes/protected-download/recipe.yaml +31 -0
  297. package/recipes/protected-download/tests/requests.json +32 -0
  298. package/recipes/protected-download/urlcode.yaml +15 -0
  299. package/recipes/redirect/README.md +7 -0
  300. package/recipes/redirect/recipe.yaml +25 -0
  301. package/recipes/redirect/tests/requests.json +19 -0
  302. package/recipes/redirect/urlcode.yaml +9 -0
  303. package/recipes/static-plus-api/README.md +15 -0
  304. package/recipes/static-plus-api/functions/info.mjs +3 -0
  305. package/recipes/static-plus-api/public/assets/index.html +3 -0
  306. package/recipes/static-plus-api/public/assets/site.css +1 -0
  307. package/recipes/static-plus-api/public/index.html +8 -0
  308. package/recipes/static-plus-api/recipe.yaml +29 -0
  309. package/recipes/static-plus-api/tests/requests.json +56 -0
  310. package/recipes/static-plus-api/urlcode.yaml +23 -0
  311. package/recipes/typescript/README.md +7 -0
  312. package/recipes/typescript/functions/hello.ts +5 -0
  313. package/recipes/typescript/recipe.yaml +23 -0
  314. package/recipes/typescript/tests/requests.json +18 -0
  315. package/recipes/typescript/urlcode.yaml +5 -0
  316. package/recipes/webhook-receiver/README.md +16 -0
  317. package/recipes/webhook-receiver/functions/receive.mjs +16 -0
  318. package/recipes/webhook-receiver/recipe.yaml +26 -0
  319. package/recipes/webhook-receiver/tests/requests.json +59 -0
  320. package/recipes/webhook-receiver/urlcode.yaml +16 -0
  321. package/schemas/recipe.schema.json +138 -0
  322. package/schemas/urlcode.schema.json +656 -80
  323. package/skills/urlcode/SKILL.md +98 -0
  324. package/starters/default/.github/workflows/urlcode.yml +23 -0
  325. package/starters/default/.mcp.json +12 -0
  326. package/starters/default/AGENTS.md +79 -0
@@ -0,0 +1,41 @@
1
+ import {fileURLToPath} from 'node:url';
2
+ import {readAuthoringFile,publishAuthoringProject} from './authoring-files.js';
3
+ import {validateDocument,parseYaml} from './config.js';
4
+ import {readMetadata,searchMetadata} from './catalog.js';
5
+
6
+ import {assert} from './errors.js';
7
+
8
+ /** recipe.yaml, validated against schemas/recipe.schema.json; `name` repeats `id` and `files` is the copy list. */
9
+
10
+
11
+
12
+
13
+ // This fixed local catalog is trusted package data, never an executable registry:
14
+ // names come from here, metadata from each recipe's schema-checked recipe.yaml.
15
+ export const recipeNames=['redirect','json-api','typescript','middleware','health-page','static-plus-api','cors-api','webhook-receiver','contact-form','authenticated-json-api','protected-download'] ;
16
+ const recipesRoot=fileURLToPath(new URL('../recipes/',import.meta.url));
17
+ const root=(name )=>recipesRoot+name+'/';
18
+ async function metadata(name ) {const value=await readMetadata(root(name),name,'recipe.yaml');return {name,...value};}
19
+ export async function listRecipes() {
20
+ const result =[];
21
+ for(const name of recipeNames)result.push(await metadata(name));
22
+ return result;
23
+ }
24
+ function flatten(hits ) {return hits.map(hit=>({...hit.entry,score:hit.score,matched:hit.matched}));}
25
+ /** Matches id, description, tags and capabilities locally; no service is consulted. */
26
+ export async function searchRecipes(text ) {
27
+ const hits=searchMetadata(await listRecipes(),text);
28
+ return {query:text,count:hits.length,results:flatten(hits)};
29
+ }
30
+ export async function showRecipe(name ) {
31
+ assert((recipeNames ).includes(name),'Unknown local recipe');
32
+ const summary=await metadata(name),content =Object.create(null) ;
33
+ for(const path of summary.files)content[path]=(await readAuthoringFile(root(name),path,1048576)).toString('utf8');
34
+ assert(content['urlcode.yaml'],'Recipe must copy urlcode.yaml');
35
+ validateDocument(parseYaml(content['urlcode.yaml']));
36
+ return {...summary,content};
37
+ }
38
+ export async function addRecipe(name ,output ,{dryRun=false} ={}) {
39
+ const recipe=await showRecipe(name);
40
+ return {name,output:await publishAuthoringProject(output,new Map(Object.entries(recipe.content)),dryRun),dryRun,files:[...recipe.files]};
41
+ }
@@ -0,0 +1,106 @@
1
+ // Route-inventory diff: compares two `urlcode routes` reports and reports what
2
+ // was added, removed or changed. Pure and generic: it knows nothing about
3
+ // pull requests; the GitHub action renders its Markdown into a PR comment.
4
+ import { ConfigError } from './errors.js';
5
+
6
+
7
+ /** One route as the diff sees it: the inventory entry plus its policy description from the `policies` map. */
8
+
9
+ /** The parts of a `urlcode routes` report the diff reads. */
10
+
11
+
12
+
13
+
14
+ const states = ['active','disabled','expired'];
15
+ const isRecord = (value ) => value !== null && typeof value === 'object' && !Array.isArray(value);
16
+ const isStrings = (value ) => Array.isArray(value) && value.every(item => typeof item === 'string');
17
+ function entry(value , index ) {
18
+ const fail = (what ) => { throw new ConfigError(`Route report inventory[${index}] ${what}`); };
19
+ if (!isRecord(value)) return fail('is not an object');
20
+ const { path, handler, methods, middleware, policies, generated, state } = value;
21
+ if (typeof path !== 'string' || !path) return fail('needs a path');
22
+ if (handler !== undefined && typeof handler !== 'string') return fail('has an invalid handler');
23
+ if (!isStrings(methods)) return fail('needs methods');
24
+ if (typeof middleware !== 'number' || !Number.isInteger(middleware) || middleware < 0) return fail('needs a middleware count');
25
+ if (!isStrings(policies)) return fail('needs policies');
26
+ if (generated !== undefined && typeof generated !== 'string') return fail('has an invalid generated marker');
27
+ if (typeof state !== 'string' || !states.includes(state )) return fail('needs a state');
28
+ return { path, handler, methods, middleware, policies, ...(generated !== undefined ? { generated } : {}), state:state };
29
+ }
30
+ /** Validates a parsed `urlcode routes` JSON report (a child-process or file boundary) into a snapshot. */
31
+ export function parseRouteSnapshot(value ) {
32
+ if (!isRecord(value) || !Array.isArray(value.inventory)) throw new ConfigError('Route report needs an inventory array');
33
+ const inventory = value.inventory.map(entry);
34
+ const policies = {};
35
+ if (value.policies !== undefined) {
36
+ if (!isRecord(value.policies)) throw new ConfigError('Route report policies must be an object');
37
+ for (const [path, description] of Object.entries(value.policies)) {
38
+ if (!isRecord(description)) throw new ConfigError(`Route report policies[${path}] must be an object`);
39
+ policies[path] = description;
40
+ }
41
+ }
42
+ return { inventory, policies };
43
+ }
44
+ // Key order in a report is not a change: compare canonical JSON.
45
+ function canonical(value ) {
46
+ if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`;
47
+ if (isRecord(value)) return `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${canonical(value[key])}`).join(',')}}`;
48
+ return JSON.stringify(value) ?? 'null';
49
+ }
50
+ function records(snapshot ) {
51
+ const map = new Map ();
52
+ for (const item of snapshot.inventory) {
53
+ if (map.has(item.path)) throw new ConfigError(`Route report lists ${item.path} twice`);
54
+ const policy = snapshot.policies?.[item.path];
55
+ map.set(item.path, policy ? { ...item, policy } : { ...item });
56
+ }
57
+ return map;
58
+ }
59
+ /** Compares two route reports. Paths are sorted; an entry changes when any inventory field or its policy description differs. */
60
+ export function diffRoutes(before , after ) {
61
+ const previous = records(before), current = records(after);
62
+ const paths = [...new Set([...previous.keys(), ...current.keys()])].sort();
63
+ const diff = { added:[], removed:[], changed:[] };
64
+ for (const path of paths) {
65
+ const old = previous.get(path), now = current.get(path);
66
+ if (old && !now) diff.removed.push(old);
67
+ else if (now && !old) diff.added.push(now);
68
+ else if (old && now && canonical(old) !== canonical(now)) diff.changed.push({ path, before:old, after:now });
69
+ }
70
+ return diff;
71
+ }
72
+ export const hasRouteChanges = (diff ) => diff.added.length + diff.removed.length + diff.changed.length > 0;
73
+
74
+ const fields = ['handler','methods','state','middleware','policies','generated','policy'] ;
75
+
76
+ const cell = (value ) => {
77
+ const text = value === undefined ? '' : Array.isArray(value) && value.every(item => typeof item === 'string') ? value.join(', ') : typeof value === 'string' ? value : canonical(value);
78
+ // Backslashes first, so an escape this adds is never itself re-escaped.
79
+ return text.replace(/\\/g, '\\\\').replace(/\|/g, '\\|').replace(/\r?\n/g, ' ') || '-';
80
+ };
81
+ const code = (value ) => `\`${value.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\|/g, '\\|')}\``;
82
+ function table(headers , rows ) {
83
+ return [`| ${headers.join(' | ')} |`, `|${headers.map(() => '---').join('|')}|`, ...rows.map(row => `| ${row.join(' | ')} |`)];
84
+ }
85
+ const routeRow = (record ) => [code(record.path), cell(record.handler), cell(record.methods), cell(record.state), String(record.middleware), cell(record.policies), cell(record.generated)];
86
+ /** Renders a diff as Markdown: one table per nonempty section, or "No route changes". */
87
+ export function renderRouteDiff(diff ) {
88
+ if (!hasRouteChanges(diff)) return 'No route changes\n';
89
+ const lines = [];
90
+ const routeHeaders = ['Route','Handler','Methods','State','Middleware','Policies','Generated'];
91
+ const section = (title , list ) => {
92
+ if (!list.length) return;
93
+ lines.push(`### ${title} (${list.length})`, '', ...table(routeHeaders, list.map(routeRow)), '');
94
+ };
95
+ section('Added routes', diff.added);
96
+ section('Removed routes', diff.removed);
97
+ if (diff.changed.length) {
98
+ const rows = [];
99
+ for (const change of diff.changed) {
100
+ const differing = fields.filter((field ) => canonical(change.before[field]) !== canonical(change.after[field]));
101
+ for (const field of differing) rows.push([code(change.path), field, cell(change.before[field]), cell(change.after[field])]);
102
+ }
103
+ lines.push(`### Changed routes (${diff.changed.length})`, '', ...table(['Route','Field','Before','After'], rows), '');
104
+ }
105
+ return lines.join('\n');
106
+ }
package/dist/router.js CHANGED
@@ -1,3 +1,9 @@
1
+ import { hasExtensionPolicy } from './extensions.js';
2
+ import { validateProxy } from './proxy.js';
3
+ import { validateSignal } from './signals.js';
4
+
5
+ import { normalizeMatch, assertDisjointMatches } from './conditions.js';
6
+ import { effectivePolicies } from './policies.js';
1
7
  import { setImmediate as yieldTurn } from 'node:timers/promises';
2
8
  import { compileHttp } from './http-policy.js';
3
9
  import Ajv from 'ajv/dist/2020.js';
@@ -63,7 +69,7 @@ export async function compileRoutes(loaded , bindings
63
69
  const parts = segments(pattern);
64
70
  assert(!pattern.startsWith('/_urlcode'), 'The /_urlcode prefix is reserved for runtime operations');
65
71
  const names = parts.map(parameterName).filter((name) => Boolean(name));
66
- assert(!pattern.includes('*') || (config.static && pattern.endsWith('/*') && parts.filter(p => p.includes('*')).length === 1 && parts.at(-1) === '*' && !names.length), 'Only static routes support a terminal /* wildcard');
72
+ assert(!pattern.includes('*') || ((config.static || config.extension) && pattern.endsWith('/*') && parts.filter(p => p.includes('*')).length === 1 && parts.at(-1) === '*' && !names.length), 'Only static or extension routes support a terminal /* wildcard');
67
73
  assert(!config.static || pattern.endsWith('/*'), 'Static routes require a terminal /* wildcard');
68
74
  if (config.page || config.download || config.static) assert((config.methods || methodsDefault).every(m => methodsDefault.includes(m)), 'Asset routes support only GET and HEAD');
69
75
  assert(new Set(names).size === names.length, 'Duplicate path parameter');
@@ -72,6 +78,28 @@ export async function compileRoutes(loaded , bindings
72
78
  const route = { ...declared, pattern, parts, names, specificity: parts.length - names.length,
73
79
  methods: config.methods || methodsDefault, parameters: [], env: dict(), secrets: dict(), responseHeaders: [], middleware: [] };
74
80
  compileHttp(route);
81
+ if (config.match) route.match = normalizeMatch(config.match);
82
+ if(config.extension){assert(!config.middleware?.length&&!config.parameters?.length&&!config.env&&!config.secrets,'Extension handlers cannot declare guest middleware, parameters or bindings');assert(pattern.endsWith('/*')&&!names.length&&pattern!=='/*','Extension handler requires a non-root literal /* mount');}
83
+ if (config.match || config.conditional || config.extension || hasExtensionPolicy(loaded.document,config)) {
84
+ const cache = effectivePolicies(loaded.document,config).cache;
85
+ assert(!cache || cache.strategy === 'no-store', `${pattern}: conditional routing requires cache disabled or no-store`);
86
+ assert(!route.responseHeaders.some(([name,value]) => ['cache-control','cdn-cache-control','vercel-cdn-cache-control','surrogate-control'].includes(name.toLowerCase()) && value !== 'no-store'), 'Conditional responses require no-store');
87
+ }
88
+ if (config.conditional) {
89
+ const matches = config.conditional.cases.map(item => normalizeMatch(item.match));
90
+ assertDisjointMatches(matches);
91
+ const compileBranch = async (branch ) => {
92
+ const entry = { ...branch, ...(config.parameters ? { parameters: config.parameters } : {}), ...(config.methods ? { methods: config.methods } : {}) };
93
+ const table = await compileRoutes({ ...loaded, routes: { [pattern]: entry } }, {}, {});
94
+ return table.exact.get(pattern) ?? [...table.byLength.values()].flat()[0] ;
95
+ };
96
+ route.conditionalRoutes = { cases: [] };
97
+ for (const [index,item] of config.conditional.cases.entries()) {
98
+ const { match: _match, ...branch } = item;
99
+ route.conditionalRoutes.cases.push({ match: matches[index] , route: await compileBranch(branch) });
100
+ }
101
+ if (config.conditional.fallback) route.conditionalRoutes.fallback = await compileBranch(config.conditional.fallback);
102
+ }
75
103
  const seen = new Set ();
76
104
  for (const param of config.parameters || []) {
77
105
  const schema = param.schema, name = param.in === 'header' ? param.name.toLowerCase() : param.name;
@@ -101,6 +129,17 @@ export async function compileRoutes(loaded , bindings
101
129
  assert(typeof value === 'string' && value.length, 'Missing required secret binding');
102
130
  route.secrets[alias] = value;
103
131
  }
132
+ const resolveHeaders=(headers ) => Object.fromEntries(Object.entries(headers||{}).map(([key,value])=>{if(typeof value==='string')return [key,value];assert(own(route.secrets,value.secret),'Unknown egress secret alias');return [key,route.secrets[value.secret] ];}));
133
+ if(config.proxy){
134
+ assert(!config.middleware?.length,'Proxy middleware is not supported; authorize requests with host policy before egress');
135
+ const cache=effectivePolicies(loaded.document,config).cache;
136
+ assert(!cache||cache.strategy==='no-store','Proxy routes require cache disabled or no-store');
137
+ assert(!route.responseHeaders.some(([name,value])=>['cache-control','cdn-cache-control','vercel-cdn-cache-control','surrogate-control'].includes(name.toLowerCase())&&value!=='no-store'),'Proxy response caching must be no-store');
138
+ route.compiledProxy={...config.proxy,headers:resolveHeaders(config.proxy.headers)};validateProxy(route.compiledProxy);
139
+ for(const match of config.proxy.url.matchAll(/(?:\{|%7B)([A-Za-z_][A-Za-z0-9_]*)(?:\}|%7D)/gi))assert(names.includes(match[1] ), 'Proxy placeholder requires a declared path parameter');
140
+ assert(!Object.keys(config.response?.headers||{}).some(name=>name.toLowerCase()==='content-encoding'),'Proxy content encoding cannot be overridden');
141
+ }
142
+ if(config.signals){route.compiledSignals=config.signals.map(signal=>({...signal,headers:resolveHeaders(signal.headers)}));for(const signal of route.compiledSignals)validateSignal(signal);}
104
143
  if (config.expires) {
105
144
  assert(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/.test(config.expires) && Number.isFinite(Date.parse(config.expires)), 'Expiry must be a UTC ISO timestamp');
106
145
  route.expiresAt = Date.parse(config.expires);
@@ -141,7 +180,7 @@ export async function compileRoutes(loaded , bindings
141
180
  route.function = { ...declaredFunction, source, export: declaredFunction.export || 'default' };
142
181
  for (const ref of Object.values(declaredFunction.args || {})) referenceCheck(ref, route, true);
143
182
  }
144
- if (config.static) { route.prefix = pattern.slice(0, -1); mounts.push(route); }
183
+ if (config.static || config.extension) { route.prefix = pattern.slice(0, -1); mounts.push(route); }
145
184
  else if (!names.length) exact.set(pattern, route);
146
185
  else {
147
186
  assert(dynamic.length < 1000, 'Maximum 1000 parameterized routes per snapshot');
@@ -160,6 +199,7 @@ export async function compileRoutes(loaded , bindings
160
199
  if (!byLength.has(route.parts.length)) byLength.set(route.parts.length, []);
161
200
  byLength.get(route.parts.length) .push(route);
162
201
  }
202
+ for(const mount of mounts.filter(route=>route.extension)){const base=mount.parts.slice(0,-1);for(const candidate of [...exact.values(),...dynamic,...mounts]){if(candidate===mount)continue;const parts=candidate.parts;const shared=Math.min(base.length,parts.length-(candidate.prefix?1:0));const compatible=base.slice(0,shared).every((part,index)=>part===parts[index]||parameterName(parts[index] ));assert(!compatible||(!candidate.prefix&&parts.length<base.length),'Extension mount overlaps another route');}}
163
203
  mounts.sort((a,b) => b.prefix .length - a.prefix .length);
164
204
  assert(performance.now()<deadline, 'Route compilation deadline exceeded');
165
205
  return { exact, byLength, mounts, modules: [...modules.keys()], count: exact.size + dynamic.length + mounts.length };
package/dist/runtime.js CHANGED
@@ -1,3 +1,11 @@
1
+ import { prepareExtensions, effectiveExtensionPolicies, hasExtensionPolicy, extensionResponse } from './extensions.js';
2
+
3
+ import { EgressClient, EgressError } from './egress.js';
4
+
5
+ import { executeProxy } from './proxy.js';
6
+ import { SignalBroker } from './signals.js';
7
+ import { matchesRoute } from './conditions.js';
8
+ import { analyzeProjectCapabilities, assertTargetCompatibility } from './capabilities.js';
1
9
  import { projectPlan, hasRedirect } from './readiness.js';
2
10
 
3
11
  import { checkRequest, decorateResponse } from './http-policy.js';
@@ -6,7 +14,7 @@ import { loadDocument, loadBindings } from './config.js';
6
14
  import { compileRoutes, parseTarget, matchRoute, contextFor, resolveValue, redirectLocation } from './router.js';
7
15
  import { FunctionPool } from './functions.js';
8
16
 
9
- import { prepareFunctionSnapshot, validatePolicy } from './policy.js';
17
+ import { prepareFunctionSnapshot, validatePolicy, authorizeEgress } from './policy.js';
10
18
 
11
19
  import {openLinkStore} from './link-store.js';
12
20
 
@@ -35,11 +43,20 @@ import { applySite } from './site.js';
35
43
 
36
44
 
37
45
 
46
+
47
+
48
+
38
49
 
39
50
 
40
51
 
41
52
 
42
53
 
54
+
55
+
56
+
57
+
58
+
59
+
43
60
 
44
61
  /** Per-request facts the host may read after handle() settles; never request text. */
45
62
 
@@ -71,11 +88,23 @@ export async function createRuntime(project , rawOptions
71
88
  // route at the same path wins. The public origin, when the server knows
72
89
  // it, is what absolute URLs in generated files are built from.
73
90
  await applySite(loaded, { origin: options.origin, log: options.log });
91
+ if (options.only !== undefined) {
92
+ const only = options.only;
93
+ assert(Array.isArray(only) && only.every(pattern => typeof pattern === 'string'), 'Route restriction must be a string array');
94
+ // An unknown pattern is a caller mistake, not an empty selection: a silent
95
+ // miss would prerender a partial site that looks whole.
96
+ for (const pattern of only) assert(Object.hasOwn(loaded.routes, pattern), `Route restriction names unknown route ${pattern}`);
97
+ const kept = new Set(only);
98
+ for (const pattern of Object.keys(loaded.routes)) if (!kept.has(pattern)) delete loaded.routes[pattern];
99
+ }
100
+ assertTargetCompatibility(analyzeProjectCapabilities(loaded, options.target || 'node'));
74
101
  const dynamicLinks=loaded.document.dynamicLinks===true;
75
102
  assert(dynamicLinks || (!options.linkStore && !Object.keys(options.linkStores||{}).length),'Link-store bindings require dynamicLinks: true in urlcode.yaml');
76
- const bindings = await loadBindings(loaded.root, options.local, options.environment);
77
103
  const snapshot = await prepareFunctionSnapshot(loaded);
78
104
  if (options.permissions) validatePolicy(options.permissions);
105
+ const egressGrants=authorizeEgress(loaded,snapshot.projectSha256,options.permissions);
106
+ const extensionPlan=prepareExtensions(loaded.document,loaded.routes,options.extensions,{origin:options.origin??'',target:options.target??'node',projectSha256:snapshot.projectSha256});
107
+ const bindings = await loadBindings(loaded.root, options.local, options.environment);
79
108
  const compiled = await compileRoutes(loaded, bindings, options.permissions, snapshot.projectSha256);
80
109
  const routes = [...compiled.mounts, ...compiled.exact.values(), ...[...compiled.byLength.values()].flat()];
81
110
  const assets = await compileAssets(loaded.root, routes);
@@ -85,6 +114,9 @@ export async function createRuntime(project , rawOptions
85
114
  // reloads: a new snapshot starts with empty counters and an empty cache.
86
115
  const target = options.target || 'node';
87
116
  const plugins = validatePlugins(options.plugins, target);
117
+ // Capture the operator declaration once, before activation hooks can mutate
118
+ // their plugin objects. This boundary applies to public guest routes too.
119
+ const credentialHeaders=new Set(plugins.flatMap(plugin=>plugin.credentialHeaders||[]).map(name=>name.toLowerCase()));
88
120
  const shared = { target, log: options.log, routes: routes.length };
89
121
  const anyPolicy = Boolean(loaded.document.policies) || routes.some(route => route.policies);
90
122
  for (const route of routes) route.policy = anyPolicy ? await compilePolicies(loaded.document, route, { route, shared, target, root: loaded.root }) : null;
@@ -106,6 +138,19 @@ export async function createRuntime(project , rawOptions
106
138
  let pool ;
107
139
  try{pool=await new FunctionPool(routes, { root:loaded.root, snapshot, log:options.log, workers:options.workers, timeoutMs:options.timeoutMs, maxBytes:options.maxBytes }).start();}
108
140
  catch(error){await ownedStore?.close();throw error;}
141
+ const proxyClient=new EgressClient({grantOrigins:egressGrants.proxy},options.egressDependencies);
142
+ const signalClient=new EgressClient({grantOrigins:egressGrants.signals,concurrency:8},options.egressDependencies);
143
+ let lastSignals={accepted:0,delivered:0,failed:0,dropped:0};
144
+ const signalBroker=new SignalBroker(signalClient,8,stats=>{for(const outcome of ['accepted','delivered','failed','dropped'] ){const count=stats[outcome]-lastSignals[outcome];if(count)sink({event:'signal',outcome,count});}lastSignals=stats;});
145
+ let extensionRegistry ;
146
+ try{extensionRegistry=await extensionPlan.activate();}
147
+ catch(error){await pool.close();await ownedStore?.close();await closePolicies(shared);await proxyClient.close();await signalClient.close();throw error;}
148
+ for(const name of extensionRegistry.credentialHeaders)credentialHeaders.add(name);
149
+ for(const route of routes)route.extensionPolicyNames=Object.keys(effectiveExtensionPolicies(loaded.document,route));
150
+ const privateRoutes=new Set(routes.filter(route=>route.extension||hasExtensionPolicy(loaded.document,route)).map(route=>route.pattern));
151
+ // Only an extension's own mount can serve its declared immutable assets.
152
+ const assetPrefixes=new Map(routes.filter(route=>route.extension).map(route=>[route.pattern,extensionRegistry.entries.get(route.extension ) .assetPrefixes]));
153
+ const assetContext=(method ,path ,pattern ) =>{const prefixes=assetPrefixes.get(pattern);return prefixes?.length?{method,path,prefixes}:undefined;};
109
154
  let active = 0, closing = false, finish ;
110
155
  // Response phase: cache store, throttle headers, security headers,
111
156
  // compression, then operator plugins in reverse. A result produced by a
@@ -115,19 +160,23 @@ export async function createRuntime(project , rawOptions
115
160
  // its status is not cacheable). A plugin short-circuit ran before any
116
161
  // policy, so it skips every request-phase policy's response hook.
117
162
  async function finishPolicies(policy , request , result , producer ) {
118
- let out = result;
163
+ const confidential=privateRoutes.has(request.route),asset=assetContext(request.method,request.path,request.route);
164
+ let out = confidential?extensionResponse(result,asset):result;
119
165
  for (const [module, state] of policy?.response || []) {
166
+ if(confidential&&module.name==='compression')continue;
120
167
  if (producer === 'plugin' ? module.onRequest : module === producer) continue;
121
168
  out = await module.onResponse?.(state, request, out) ?? out;
122
169
  }
123
- return pluginsResponse(plugins, request, out);
170
+ out=await pluginsResponse(plugins, request, out);
171
+ return confidential?extensionResponse(out,asset):out;
124
172
  }
125
173
  function policyInventory() {
126
174
  return Object.fromEntries(routes.flatMap(route => route.policy && Object.keys(route.policy.describe).length ? [[route.pattern, route.policy.describe] ] : []));
127
175
  }
128
176
  const workers = () => ({ healthy: pool.slots.filter(slot => slot?.ready).length, slots: pool.size });
129
177
  const testPlan = () => ({...projectPlan(compiled),dynamicLinks,policies:policyInventory()});
130
- await activatePlugins(plugins, { testPlan, version: loaded.version + assets.digest, root: loaded.root, target });
178
+ try{await activatePlugins(plugins, { testPlan, version: loaded.version + assets.digest, root: loaded.root, target });}
179
+ catch(error){await Promise.all([extensionRegistry.close(),signalBroker.close(),signalClient.close(),proxyClient.close(),pool.close(),ownedStore?.close(),closePolicies(shared),closePlugins(plugins),sink.close()]);throw error;}
131
180
  return {
132
181
  get healthy() { return !closing && pool.healthy && Object.values(stores).every(store=>(store.readHealthy??store.healthy)!==false); },
133
182
  assetWatch: assets.watch, version: loaded.version + assets.digest, count: compiled.count, root: loaded.root,
@@ -144,7 +193,7 @@ export async function createRuntime(project , rawOptions
144
193
  },
145
194
  requestLimit(target) {
146
195
  const match = matchRoute(compiled, parseTarget(target));
147
- return match?.route.request?.body?.maxBytes;
196
+ return match?.route.request?.body?.maxBytes ?? (match?.route.proxy||match?.route.extension?1048576:undefined);
148
197
  },
149
198
  async handle({ target, method = 'GET', headers = new Headers(), body, headerCounts, trace = {}, origin = 'http://localhost', client }) {
150
199
  if (closing) throw new HttpError(503, 'Runtime unavailable');
@@ -160,20 +209,28 @@ export async function createRuntime(project , rawOptions
160
209
  // Known from here on, so an error thrown by the route's own checks
161
210
  // (disabled, expired) is answered with that route's security headers.
162
211
  policy = route.policy;
212
+ const conditionRequest = { query: parsed.query, headers, method, origin, headerCounts };
213
+ if (route.match && !matchesRoute(route.match,conditionRequest)) throw new HttpError(404,'Not found');
163
214
  if (route.enabled === false) throw new HttpError(404, 'Not found');
164
215
  if (route.expiresAt && Date.now() >= route.expiresAt) throw new HttpError(410, 'Gone');
165
216
  // Host policies and plugins run once the route is known and before
166
217
  // its contract is checked: a denied agent or an exhausted budget is
167
218
  // answered without reading a body or touching the sandbox.
168
- if (policy || plugins.length) {
219
+ const protectedRoute=privateRoutes.has(route.pattern);
220
+ const extensionRequest ={method,target,path:parsed.path,query:new URLSearchParams(parsed.query),headers:new Headers(headers),headerCounts:{...headerCounts},body:body??new Uint8Array(),origin:options.origin??origin,route:route.pattern,mount:route.extension?route.pattern.slice(0,-2):null,client:client??null};
221
+ if(protectedRoute&&(body?.byteLength??0)>Math.min(1048576,route.request?.body?.maxBytes??1048576))throw new HttpError(413,'Request body too large');
222
+ const authorize=async() =>{for(const name of Object.keys(effectiveExtensionPolicies(loaded.document,route))){const entry=extensionRegistry.entries.get(name) ;const result=await entry.instance.authorize (entry.policies.get(route.pattern) ,extensionRequest);if(result)return result;}return undefined;};
223
+ if (policy || plugins.length || protectedRoute) {
169
224
  policyReq = policyRequest({ method, target, path: parsed.path, params: path, query: parsed.query, headers, headerCounts, client, origin, route });
170
225
  trace.client = policyReq.client;
171
- const early = await pluginsRequest(plugins, policyReq);
172
- if (early) return await finishPolicies(policy, policyReq, early, 'plugin');
226
+ if(!protectedRoute){const early=await pluginsRequest(plugins,policyReq);if(early)return await finishPolicies(policy,policyReq,early,'plugin');}
227
+ let authorized=false;
173
228
  for (const [module, state] of policy?.request || []) {
229
+ if(protectedRoute&&module.name==='cache'&&!authorized){const denied=await authorize();authorized=true;if(denied)return await finishPolicies(policy,policyReq,denied);}
174
230
  const result = await module.onRequest?.(state, policyReq);
175
231
  if (result) return await finishPolicies(policy, policyReq, result, module);
176
232
  }
233
+ if(protectedRoute){if(!authorized){const denied=await authorize();if(denied)return await finishPolicies(policy,policyReq,denied);}const early=await pluginsRequest(plugins,policyReq);if(early)return await finishPolicies(policy,policyReq,early,'plugin');}
177
234
  }
178
235
  if (!route.methods.includes(method)) {
179
236
  const refused = { status: 405, headers: [['allow', route.methods.join(', ')]], body: Buffer.from('Method not allowed\n') };
@@ -182,10 +239,32 @@ export async function createRuntime(project , rawOptions
182
239
  return policyReq ? await finishPolicies(policy, policyReq, refused) : refused;
183
240
  }
184
241
  checkRequest(route, body || Buffer.alloc(0), headers, headerCounts);
185
- const finishResponse = async (result ) => policyReq ? finishPolicies(policy, policyReq, decorateResponse(route,result)) : decorateResponse(route,result);
186
- const context = contextFor(route, path, parsed.query, headers, headerCounts);
242
+ const finishResponse = async (result ) => {
243
+ const out = policyReq ? await finishPolicies(policy, policyReq, decorateResponse(route,result)) : decorateResponse(route,result);
244
+ if(method!=='HEAD'&&!trace.probe)for(const signal of route.compiledSignals||[])signalBroker.emit(signal,{route:route.pattern,status:out.status,method});
245
+ if (route.proxy || route.match || route.conditional) return { ...out, headers: [...out.headers.filter(([name]) => !['cache-control','cdn-cache-control','vercel-cdn-cache-control','surrogate-control'].includes(name.toLowerCase())), ['cache-control','no-store']] };
246
+ return out;
247
+ };
248
+ // Policies, plugins and body checks retain the original request. Project
249
+ // inputs and the guest receive a separate, credential-free projection.
250
+ const guestHeaders=credentialHeaders.size?new Headers(headers):headers;
251
+ for(const name of credentialHeaders)guestHeaders.delete(name);
252
+ const context = contextFor(route, path, parsed.query, guestHeaders, headerCounts);
253
+ // A declared schema default must not recreate a withheld header entry.
254
+ for(const name of credentialHeaders)delete context.inputs.header[name];
187
255
  let native ;
188
- if (hasRedirect(route)) native = { status: route.redirect.status || 302,
256
+ if(route.extension){native=extensionResponse(await extensionRegistry.entries.get(route.extension) .instance.handle(extensionRequest),assetContext(method,parsed.path,route.pattern));}
257
+ else if(route.compiledProxy){
258
+ try {const result=await executeProxy(proxyClient,route.compiledProxy,{method,url:origin+target,params:path,headers:Object.fromEntries(headers),...(body?{body}:{})});native={status:result.status,headers:Object.entries(result.headers),body:result.body};}
259
+ catch(error){throw new HttpError(error instanceof EgressError&&error.code==='timeout'?504:error instanceof EgressError&&['busy','closed','aborted'].includes(error.code)?503:502,'Proxy upstream unavailable');}
260
+ }
261
+ else if (route.conditionalRoutes) {
262
+ const selected = route.conditionalRoutes.cases.find(item => matchesRoute(item.match,conditionRequest))?.route ?? route.conditionalRoutes.fallback;
263
+ if (!selected) throw new HttpError(404,'Not found');
264
+ if (hasRedirect(selected)) native = { status: selected.redirect.status || 302, headers: [['location',redirectLocation(selected,context,parsed.query)]], body: Buffer.alloc(0) };
265
+ else native = selected.reply;
266
+ }
267
+ else if (hasRedirect(route)) native = { status: route.redirect.status || 302,
189
268
  headers: [['location', redirectLocation(route, context, parsed.query)]], body: Buffer.alloc(0) };
190
269
  else if(route.link){
191
270
  // Resolution outcome for a trusted post-response observer. It records
@@ -212,9 +291,9 @@ export async function createRuntime(project , rawOptions
212
291
  native = {status:error.status,headers:[['content-type','text/plain; charset=utf-8'],['cache-control','no-store']],body:Buffer.from(error.message+'\n')};
213
292
  }
214
293
  }
215
- if (native && !route.middleware.length) return finishResponse(native);
294
+ if (native && !route.middleware.length) return await finishResponse(native);
216
295
  context.args = Object.fromEntries(Object.entries(route.function?.args || {}).map(([key, ref]) => [key, resolveValue(ref, context)]));
217
- return finishResponse(await pool.execute(route, { url: origin + target, method, headers: [...headers], body }, context, native));
296
+ return await finishResponse(await pool.execute(route, { url: origin + target, method, headers: [...guestHeaders], body }, context, native));
218
297
  } catch (error) {
219
298
  if (policy !== undefined && error && typeof error === 'object') errorRoutes.set(error, policy);
220
299
  if (policyReq) {
@@ -233,11 +312,13 @@ export async function createRuntime(project , rawOptions
233
312
  },
234
313
  async close() {
235
314
  closing = true;
315
+ await Promise.all([signalBroker.close(),signalClient.close(),proxyClient.close()]);
236
316
  if (active) await new Promise (resolve => { finish = resolve; });
237
317
  await pool.close();
238
318
  await ownedStore?.close();
239
319
  await closePolicies(shared);
240
320
  await closePlugins(plugins);
321
+ await extensionRegistry.close();
241
322
  await sink.close();
242
323
  },
243
324
  };
@@ -0,0 +1,62 @@
1
+ import {readFileSync} from 'node:fs';
2
+ import {fileURLToPath} from 'node:url';
3
+ import {ConfigError} from './errors.js';
4
+
5
+
6
+
7
+ const maxDepth=12;
8
+ let cached ;
9
+ function schema() {
10
+ cached??=JSON.parse(readFileSync(fileURLToPath(new URL('../schemas/urlcode.schema.json',import.meta.url)),'utf8')) ;
11
+ return cached;
12
+ }
13
+ const object=(value ) =>value!==null&&typeof value==='object'&&!Array.isArray(value);
14
+ const properties=(node ) =>object(node.properties)?node.properties:{};
15
+ function definition(root ,ref ) {
16
+ const defs=object(root.$defs)?root.$defs:{};
17
+ const name=ref.startsWith('#/$defs/')?ref.slice('#/$defs/'.length):undefined;
18
+ const found=name===undefined?undefined:defs[name];
19
+ if(!object(found))throw new ConfigError('Schema reference cannot be resolved');
20
+ return found;
21
+ }
22
+ /** Definitions that are their own dotted path stay summarized when nested, so `routes` does not inline the whole route object. */
23
+ const summarized ={'#/$defs/route':'route','#/$defs/policies':'policies'};
24
+ /** Inline every local `$ref` with a depth bound and a cycle guard. The bundled schema has no cycles; a cycle would surface as an annotation, not a crash. */
25
+ function inline(root ,node ,stack ) {
26
+ if(Array.isArray(node))return node.map(item=>inline(root,item,stack));
27
+ if(!object(node))return node;
28
+ if(typeof node.$ref==='string') {
29
+ const {$ref,...rest}=node;
30
+ if(stack.includes($ref)||stack.length>=maxDepth)return {...rest,$comment:`unresolved reference ${$ref}: ${stack.includes($ref)?'cycle':'depth limit'}`};
31
+ const path=summarized[$ref];
32
+ if(path!==undefined)return {type:'object',...rest,$comment:`${path} object; see urlcode schema ${path}`};
33
+ return {...inline(root,definition(root,$ref),[...stack,$ref]) ,...inline(root,rest,stack) };
34
+ }
35
+ return Object.fromEntries(Object.entries(node).map(([key,value])=>[key,inline(root,value,stack)]));
36
+ }
37
+ const resolved=(root ,node ) =>typeof node.$ref==='string'?{...definition(root,node.$ref),...Object.fromEntries(Object.entries(node).filter(([key])=>key!=='$ref'))}:node;
38
+ /** Top-level document keys first, then `route` itself and every route property (`redirect`, `function`, `middleware`, ...). */
39
+ export function schemaPathNames() {
40
+ const root=schema();
41
+ const route=definition(root,'#/$defs/route');
42
+ return [...new Set([...Object.keys(properties(root)),'route',...Object.keys(properties(route))])];
43
+ }
44
+ /** Only the fragment for a dotted path, with references resolved inline. No validation, defaults or authority. */
45
+ export function getSchemaFragment(path ) {
46
+ if(typeof path!=='string'||path.length>256||!/^[A-Za-z][A-Za-z0-9]*(?:\.[A-Za-z][A-Za-z0-9]*)*$/.test(path))throw new ConfigError('Schema path must be dotted property names; top-level names: '+schemaPathNames().join(', '));
47
+ const root=schema(),route=definition(root,'#/$defs/route');
48
+ const [first,...rest]=path.split('.');
49
+ let node ,pointer ;
50
+ if(first!==undefined&&object(properties(root)[first])){node=properties(root)[first] ;pointer='#/properties/'+first;}
51
+ else if(first==='route'){node=route;pointer='#/$defs/route';}
52
+ else if(first!==undefined&&object(properties(route)[first])){node=properties(route)[first] ;pointer='#/$defs/route/properties/'+first;}
53
+ else throw new ConfigError(`Unknown schema path; top-level names: ${schemaPathNames().join(', ')}`);
54
+ for(const segment of rest) {
55
+ const current=resolved(root,node);
56
+ const alternatives=[current,...(Array.isArray(current.oneOf)?current.oneOf.filter(object).map(item=>resolved(root,item)):[])];
57
+ const next=alternatives.map(item=>properties(item)[segment]).find(object);
58
+ if(!next)throw new ConfigError(`Unknown schema path segment; names under ${pointer}: ${[...new Set(alternatives.flatMap(item=>Object.keys(properties(item))))].join(', ')||'(none)'}`);
59
+ node=next;pointer+='/properties/'+segment;
60
+ }
61
+ return {format:1,path,pointer,schema:inline(root,resolved(root,node),[]) };
62
+ }
@@ -0,0 +1,24 @@
1
+ import { egressUrl, safeEgressHeaders } from './egress.js';
2
+
3
+
4
+
5
+
6
+ export function validateSignal(definition ) {if(!definition||typeof definition!=='object'||Object.keys(definition).some(key=>!['url','headers'].includes(key))) throw new Error('Invalid signal');egressUrl(definition.url);safeEgressHeaders(definition.headers||{});}
7
+ /** No queue, retries, ordering promise, or durable delivery. Counters contain no URL/header data. */
8
+ export class SignalBroker {
9
+ #onStats ; #client ; #concurrency ; #pending=new Set (); #controller=new AbortController();
10
+ #stats ={accepted:0,delivered:0,failed:0,dropped:0}; #closed=false;
11
+ constructor(client ,concurrency=8,onStats ) {if(!Number.isSafeInteger(concurrency)||concurrency<1||concurrency>128) throw new Error('Invalid signal concurrency');this.#onStats=onStats;this.#client=client;this.#concurrency=concurrency;}
12
+ get stats() {return {...this.#stats};}
13
+ emit(definition ,event ) {
14
+ if(this.#closed||this.#pending.size>=this.#concurrency) {this.#stats.dropped++;this.#notify();return false;}
15
+ try {validateSignal(definition);} catch {this.#stats.dropped++;this.#notify();return false;}
16
+ if(!event.route.startsWith('/')||event.route.length>2048||!/^[A-Z]{1,16}$/.test(event.method)||!Number.isInteger(event.status)||event.status<100||event.status>599) {this.#stats.dropped++;this.#notify();return false;}
17
+ this.#stats.accepted++;this.#notify();
18
+ const body=Buffer.from(JSON.stringify({version:1,route:event.route,status:event.status,method:event.method}));
19
+ const pending=Promise.resolve().then(()=>this.#client.request({url:definition.url,method:'POST',headers:{...definition.headers,'content-type':'application/json'},body,signal:this.#controller.signal})).then(result=>{if(result.status>=200&&result.status<300)this.#stats.delivered++;else this.#stats.failed++;},()=>{this.#stats.failed++;}).finally(()=>{this.#pending.delete(pending);this.#notify();});
20
+ this.#pending.add(pending);return true;
21
+ }
22
+ #notify() {try {this.#onStats?.(this.stats);} catch { /* Observers cannot alter delivery. */ }}
23
+ async close() {this.#closed=true;this.#controller.abort();await Promise.allSettled([...this.#pending]);}
24
+ }
package/dist/site.js CHANGED
Binary file