@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
package/dist/config.js CHANGED
@@ -5,7 +5,7 @@ import { createHash } from 'node:crypto';
5
5
  import { parseDocument, visit, isAlias, isScalar, isMap, isNode } from 'yaml';
6
6
  import Ajv from 'ajv/dist/2020.js';
7
7
  import { assert, ConfigError } from './errors.js';
8
-
8
+
9
9
 
10
10
  /** What config-worker.ts posts back: the loaded document, or the ConfigError message. */
11
11
 
@@ -53,7 +53,41 @@ export function validateDocument(data ) {
53
53
  const e = validate.errors [0] ;
54
54
  throw new ConfigError(`Invalid configuration at ${e.instancePath || '/'} (${e.keyword})`);
55
55
  }
56
- return data ; // trust boundary: the schema just admitted it
56
+ const document = data ; // trust boundary: the schema just admitted it
57
+ for (const [pattern, route] of Object.entries(document.routes)) document.routes[pattern] = normalizeRoute(pattern, route);
58
+ return document;
59
+ }
60
+ /** The input declaration a short-form function gets for each `{param}` it does not declare itself. */
61
+ export const SHORT_FORM_PATH_SCHEMA = { type: 'string', minLength: 1, maxLength: 128 } ;
62
+ // A short-form path is checked here, before the file system, so the error can name the route.
63
+ function modulePath(pattern , kind , file ) {
64
+ const relativePosix = !isAbsolute(file) && !/^(?:[A-Za-z]:|[\\/])/.test(file);
65
+ const segments = file.split(/[\\/]/);
66
+ assert(relativePosix && segments.every(s => s !== '..') && ['.mjs', '.js'].includes(extname(file)) && !file.endsWith('/') && !file.endsWith('\\'),
67
+ `${pattern}: ${kind} short form must be a project-relative .mjs or .js path without .. segments`);
68
+ return file;
69
+ }
70
+ /**
71
+ * Expands the YAML short forms into the canonical long form. `function: functions/x.mjs`
72
+ * becomes `{source, args}` with an argument per `{param}` in the path, declaring any
73
+ * parameter the route does not declare itself; a string middleware entry becomes `{source}`.
74
+ * Everything downstream (routes, audit, the compiled table) sees only the long form.
75
+ */
76
+ export function normalizeRoute(pattern , route ) {
77
+ const authored = route ;
78
+ if (typeof authored.function !== 'string' && !authored.middleware?.some(entry => typeof entry === 'string')) return route ;
79
+ const result = { ...(route ) };
80
+ if (authored.middleware) result.middleware = authored.middleware.map((entry) => typeof entry === 'string' ? { source: modulePath(pattern, 'middleware', entry) } : entry);
81
+ if (typeof authored.function === 'string') {
82
+ const source = modulePath(pattern, 'function', authored.function);
83
+ const names = pattern.split('/').flatMap(part => { const match = /^\{([A-Za-z_][A-Za-z0-9_]*)\}$/.exec(part); return match ? [match[1] ] : []; });
84
+ const declared = authored.parameters ?? [];
85
+ const parameters = [...declared, ...names.filter(name => !declared.some(p => p.in === 'path' && p.name === name)).map(name => ({ name, in: 'path' , required: true, schema: { ...SHORT_FORM_PATH_SCHEMA } }))];
86
+ const expanded = { source, args: Object.fromEntries(names.map(name => [name, { from: 'path' , name }])) };
87
+ if (parameters.length) result.parameters = parameters;
88
+ result.function = expanded;
89
+ }
90
+ return result;
57
91
  }
58
92
  export async function safeFile(root , file ) {
59
93
  root = await realpath(root);
@@ -64,7 +98,7 @@ export async function safeFile(root , file ) {
64
98
  assert((await stat(actual)).isFile(), 'Reference must point to a file');
65
99
  return actual;
66
100
  }
67
- export const MAX_PROJECT_CONFIG_BYTES = 64 * 1024 * 1024;
101
+ const MAX_PROJECT_CONFIG_BYTES = 64 * 1024 * 1024;
68
102
  async function readConfig(file , budget ) {
69
103
  const handle = await open(file, 'r');
70
104
  try {
@@ -111,12 +145,31 @@ export async function loadDocument(project , {timeoutMs=10000}
111
145
  });
112
146
  } finally { try { await worker?.terminate(); } finally { activeLoads--; } }
113
147
  }
148
+ /**
149
+ * Expands the route-level `auth` short form into the canonical `policies.extensions.auth` requirement so every
150
+ * downstream consumer (compiler, routes, audit, explain, revision hash) sees one form. `auth: {required: false}`
151
+ * documents intent and emits nothing. Refuses routes that use both forms or lack an `extensions.auth` declaration.
152
+ */
153
+ export function normalizeRouteAuth(document , routes ) {
154
+ for (const [pattern, route] of Object.entries(routes)) {
155
+ if (route.auth === undefined) continue;
156
+ assert(document.extensions?.auth !== undefined, `Route ${pattern} declares auth but the project declares no extensions.auth`);
157
+ const extensions = route.policies?.extensions;
158
+ assert(extensions !== false, `Route ${pattern} declares auth alongside policies.extensions: false`);
159
+ assert(!(extensions && Object.hasOwn(extensions, 'auth')), `Route ${pattern} declares both auth and policies.extensions.auth; use one form`);
160
+ const { required = true, ...requirement } = route.auth === true ? {} : route.auth;
161
+ delete route.auth;
162
+ if (!required) continue;
163
+ route.policies = { ...route.policies, extensions: { ...extensions, auth: requirement } };
164
+ }
165
+ }
114
166
  export async function loadDocumentInWorker(project ) {
115
167
  const budget={remaining:MAX_PROJECT_CONFIG_BYTES};
116
168
  const root = await realpath(project);
117
169
  const file = await safeFile(root, 'urlcode.yaml');
118
170
  const document = validateDocument(await readConfig(file, budget));
119
171
  const routes = Object.assign(Object.create(null) , document.routes);
172
+ const extensions = Object.assign(Object.create(null),document.extensions??{}) ;
120
173
  const files = [file];
121
174
  let routeCount=Object.keys(routes).length;
122
175
  for (const include of document.includes || []) {
@@ -127,15 +180,18 @@ export async function loadDocumentInWorker(project )
127
180
  assert(!part.includes?.length, 'Nested includes are unsupported');
128
181
  assert(part.dynamicLinks===undefined, 'dynamicLinks may only be set in the entry urlcode.yaml');
129
182
  assert(part.site===undefined, 'site may only be set in the entry urlcode.yaml');
183
+ for(const [name,extension]of Object.entries(part.extensions??{})){assert(!Object.hasOwn(extensions,name),'Duplicate extension declaration across files');extensions[name]=extension;assert(Object.keys(extensions).length<=16,'Maximum 16 extensions per project');}
130
184
  for (const [pattern, route] of Object.entries(part.routes)) {
131
185
  assert(!Object.hasOwn(routes, pattern), 'Duplicate route across files');
132
186
  routes[pattern] = route;
133
187
  assert(++routeCount <= 100000, 'Maximum 100000 routes per project');
134
188
  }
135
189
  }
190
+ if(Object.keys(extensions).length)document.extensions=extensions;
191
+ normalizeRouteAuth(document, routes);
136
192
  assert(Object.keys(routes).length <= 100000, 'Maximum 100000 routes per project');
137
193
  assert(document.dynamicLinks===true || !Object.values(routes).some(route=>route.link), 'Link routes require dynamicLinks: true in urlcode.yaml');
138
- return { root, document, routes, files, version: createHash('sha256').update(JSON.stringify(document.site ? {...(document.dynamicLinks===true?{routes,dynamicLinks:true}:{routes}), site:document.site} : document.dynamicLinks===true?{routes,dynamicLinks:true}:routes)).digest('hex').slice(0, 16) };
194
+ return { root, document, routes, files, version: createHash('sha256').update(JSON.stringify(document.extensions?{routes,extensions:document.extensions,policies:document.policies,profiles:document.profiles,site:document.site,dynamicLinks:document.dynamicLinks}: document.site ? {...(document.dynamicLinks===true?{routes,dynamicLinks:true}:{routes}), site:document.site} : document.dynamicLinks===true?{routes,dynamicLinks:true}:routes)).digest('hex').slice(0, 16) };
139
195
  }
140
196
  export async function loadBindings(root , local = false, environment = process.env) {
141
197
  const vars = {};
@@ -0,0 +1,156 @@
1
+ import {readdir,readFile} from 'node:fs/promises';
2
+ import {relative} from 'node:path';
3
+ import {stringify} from 'yaml';
4
+ import {loadDocument} from './config.js';
5
+ import {applySite} from './site.js';
6
+ import {prepareFunctionSnapshot,requestedPermissions} from './policy.js';
7
+ import {compileRoutes} from './router.js';
8
+ import {compilePolicies,closePolicies,effectivePolicies,registry} from './policies.js';
9
+ import {capabilityTargets,getCapabilities,normalizeCapabilityTarget,routeCapabilities} from './capabilities.js';
10
+
11
+ import {loadOperatorHost} from './operator-host.js';
12
+
13
+
14
+
15
+
16
+
17
+
18
+
19
+
20
+
21
+
22
+
23
+
24
+
25
+
26
+
27
+
28
+
29
+
30
+
31
+
32
+
33
+
34
+
35
+
36
+
37
+
38
+
39
+
40
+ /** Characters divided by four, rounded up: an estimate, not a tokenizer. */
41
+ export function estimateTokens(text ) {return Math.ceil(text.length/4);}
42
+ export function renderContext(context ) {return stringify(context,{lineWidth:0,aliasDuplicateObjects:false});}
43
+ const handlerNames=['redirect','respond','page','static','download','function','link','proxy','conditional','extension'] ;
44
+ const policyNames=Object.keys(registry).sort() ;
45
+ // Fixed for every project: what generation must not attempt, whatever the documentation says.
46
+ const constraints ={
47
+ guestNetwork:{value:false,note:'Functions and middleware run in a WASM sandbox without fetch or sockets; outbound calls are proxy or signals routes under operator grants'},
48
+ nodeApis:{value:false,note:'No Node built-ins, process, filesystem or npm packages in guest code; relative ES-module imports only'},
49
+ regexRoutes:{value:false,note:'Paths are whole segments: exact literals or {param} placeholders declared as required string parameters'},
50
+ oneHandlerPerRoute:{value:true,note:'Exactly one of redirect, respond, page, static, download, function, link, proxy, conditional or extension; middleware wraps it'},
51
+ pathShape:{value:'exact or {param}',note:'No greedy captures or general-purpose wildcards; a segment is a literal or a named placeholder'},
52
+ wildcardMounts:{value:false,note:'Only static and extension routes mount a subtree; nothing else matches below its path'},
53
+ yamlInterpolation:{value:false,note:'No ${...} templating; bind typed inputs through parameters, args and context'},
54
+ secretsByOperatorGrant:{value:true,note:'Projects request named env and secret bindings; only an operator policy pinned to the project revision grants them'},
55
+ };
56
+ const routesOf=(table ) =>[...table.exact.values(),...[...table.byLength.values()].flat(),...table.mounts];
57
+ const sorted=(values )=>[...new Set(values)].sort();
58
+ async function packageVersion() {return (JSON.parse(await readFile(new URL('../package.json',import.meta.url),'utf8')) ).version;}
59
+ /** Same loader and semantic compiler as inspectProject: no binding reads, no guest execution, no network. */
60
+ async function compile(project ) {
61
+ const loaded=await loadDocument(project);await applySite(loaded,{});
62
+ const snapshot=await prepareFunctionSnapshot(loaded),bindings =Object.create(null);
63
+ for(const route of Object.values(loaded.routes)) {for(const ref of Object.values(route.env||{}))if(ref.env)bindings[ref.env]='validation-only';for(const ref of Object.values(route.secrets||{}))bindings[ref.secret]='validation-only';}
64
+ const compiled=await compileRoutes(loaded,bindings,requestedPermissions(loaded,snapshot),snapshot.projectSha256),routes=routesOf(compiled);
65
+ const shared ={target:'node',routes:routes.length,log:()=>{}};
66
+ try {for(const route of routes)await compilePolicies(loaded.document,route,{route,shared,target:'node',root:loaded.root});}finally{await closePolicies(shared);}
67
+ return {loaded,compiled,routes};
68
+ }
69
+ function handlerOf(route ) {return handlerNames.find(name=>route[name])??'none';}
70
+ /** Derived only from the compiled project and the capability catalog, never from prose. Key order is fixed. */
71
+ export async function buildContext(project ,options ={}) {
72
+ const budget=options.budget;
73
+ if(budget!==undefined&&(!Number.isSafeInteger(budget)||budget<1))throw new Error('Invalid context budget');
74
+ const selected =options.target===undefined?[...capabilityTargets]:[normalizeCapabilityTarget(options.target)];
75
+ const host=await loadOperatorHost(options.hostFile,project);
76
+ try {
77
+ const {loaded,compiled,routes}=await compile(project),document=loaded.document;
78
+ const handlers ={},policyCounts ={},used=new Set ();
79
+ for(const name of handlerNames)handlers[name]=0;
80
+ for(const name of policyNames)policyCounts[name]=0;
81
+ if(Object.keys(document.extensions??{}).length)used.add('extension');
82
+ if(document.dynamicLinks)used.add('dynamicLinks');
83
+ for(const route of routes) {
84
+ handlers[handlerOf(route)]=(handlers[handlerOf(route)]??0)+1;
85
+ for(const capability of routeCapabilities(route,document))used.add(capability);
86
+ const effective=effectivePolicies(document,route);
87
+ for(const name of policyNames)if(effective[name])policyCounts[name] ++;
88
+ }
89
+ for(const name of handlerNames)if(!handlers[name])delete handlers[name];
90
+ for(const name of policyNames)if(!policyCounts[name])delete policyCounts[name];
91
+ const declared=Object.values(loaded.routes),env=new Set (),secrets=new Set (),functions=new Set (),middleware=new Set ();
92
+ for(const route of declared) {
93
+ for(const ref of Object.values(route.env??{}))if(ref.env)env.add(ref.env);
94
+ for(const ref of Object.values(route.secrets??{}))secrets.add(ref.secret);
95
+ if(route.function)functions.add(route.function.source);
96
+ for(const item of route.middleware??[])middleware.add(item.source);
97
+ }
98
+ const topLevel=effectivePolicies(document,undefined);
99
+ const catalog=getCapabilities();
100
+ const targets ={};
101
+ for(const target of selected) {
102
+ const entry={deployment:catalog.targets.find(item=>item.target===target) .deployment,supported:[] ,conditional:[] ,refused:[] ,unknown:[] };
103
+ for(const row of catalog.capabilities) {
104
+ if(!used.has(row.capability))continue;
105
+ const support=row.targets[target]?.support??'unknown';
106
+ (support==='refused'?entry.refused:support==='conditional'?entry.conditional:support==='unknown'?entry.unknown:entry.supported).push(row.capability);
107
+ }
108
+ targets[target]=entry;
109
+ }
110
+ const flag=options.projectFlag??project;
111
+ const context ={
112
+ urlcode:await packageVersion(),schema:'1',
113
+ project:{
114
+ entry:'urlcode.yaml',routes:compiled.count,handlers,extensions:sorted(Object.keys(document.extensions??{})),
115
+ policies:{project:policyNames.filter(name=>topLevel[name]),routes:policyCounts},
116
+ bindings:{env:sorted(env),secrets:sorted(secrets)},dynamicLinks:document.dynamicLinks===true,site:sorted(Object.keys(document.site??{})),
117
+ files:{includes:loaded.files.slice(1).map(file=>relative(loaded.root,file).split('\\').join('/')),functions:sorted(functions),middleware:sorted(middleware)},
118
+ ...(options.hostFile===undefined?{}:{host:{extensions:sorted((host.extensions??[]).map(item=>item.name)),plugins:(host.plugins??[]).length}}),
119
+ },
120
+ routes:routes.map(route=>({path:route.pattern,methods:route.methods,handler:handlerOf(route)})).sort((a,b)=>a.path<b.path?-1:a.path>b.path?1:0),
121
+ constraints:{...constraints},
122
+ targets,
123
+ commands:{
124
+ validate:`urlcode validate --local --project ${flag}`,
125
+ test:`urlcode test --project ${flag}`,
126
+ audit:`urlcode audit --project ${flag} --expect-routes ${compiled.count}`,
127
+ routes:`urlcode routes --project ${flag}`,
128
+ capabilities:`urlcode capabilities${options.target===undefined?'':` --target ${selected[0]}`}`,
129
+ },
130
+ };
131
+ return budget===undefined?context:fitBudget(context,budget);
132
+ } finally {await host.close?.();}
133
+ }
134
+ // Sections leave in this order; each step is a fixed transformation so two runs agree.
135
+ const drops =[
136
+ ['routes',context=>{delete context.routes;}],
137
+ ['targets',context=>{delete context.targets;}],
138
+ ['constraintNotes',context=>{for(const [key,item] of Object.entries(context.constraints))context.constraints[key]=typeof item==='object'?item.value:item;}],
139
+ ['files',context=>{delete context.project.files;}],
140
+ ['commands',context=>{delete context.commands;}],
141
+ ['summary',context=>{context.project={entry:context.project.entry,routes:context.project.routes,handlers:context.project.handlers,extensions:[],policies:{project:[],routes:{}},bindings:{env:[],secrets:[]},dynamicLinks:context.project.dynamicLinks,site:[]};}],
142
+ ];
143
+ function fitBudget(context ,budget ) {
144
+ const omitted =[];
145
+ const fits=()=>estimateTokens(renderContext(omitted.length?{...context,omitted}:context))<=budget;
146
+ for(const [section,drop] of drops) {if(fits())break;drop(context);omitted.push(section);}
147
+ if(!fits())throw new Error(`Context budget ${budget} is below the smallest rendering`);
148
+ return omitted.length?{...context,omitted}:context;
149
+ }
150
+ /** Estimated size of the shipped documentation (docs/*.md and llms.txt), for comparison with an emitted context. */
151
+ export async function documentationTokens() {
152
+ const docs=new URL('../docs/',import.meta.url);let chars=0;
153
+ for(const name of (await readdir(docs)).filter(name=>name.endsWith('.md')).sort())chars+=(await readFile(new URL(name,docs),'utf8')).length;
154
+ chars+=(await readFile(new URL('../llms.txt',import.meta.url),'utf8')).length;
155
+ return Math.ceil(chars/4);
156
+ }
@@ -0,0 +1,88 @@
1
+ import {basename} from 'node:path';
2
+ import {assert} from './errors.js';
3
+ import {readConversionInput} from './interchange-cli.js';
4
+
5
+
6
+
7
+
8
+
9
+
10
+ export async function runEcosystemCommand(command ,args ,options ,print ) {
11
+ if(command==='recipes'||command==='recipe'){
12
+ const {listRecipes,showRecipe,searchRecipes,addRecipe}=await import('./recipes.js');
13
+ const [operation='list',name]=args;
14
+ assert(args.length<=2,'Unexpected recipe arguments');
15
+ if(operation==='list'){assert(name===undefined,'Unexpected recipe name');const recipes=await listRecipes();print(options.json?recipes:formatCatalog(recipes));}
16
+ else if(operation==='search'){assert(name,'Provide search text');const found=await searchRecipes(name);print(options.json?found:formatSearch(found.query,found.results));}
17
+ else if(operation==='show'){assert(name,'Provide a recipe name');const recipe=await showRecipe(name);print(options.json?recipe:formatMetadata(recipe)+Object.entries(recipe.content).map(([file,text])=>`\n--- ${file}\n${text}`).join(''));}
18
+ else if(operation==='add'){assert(name && options.out,'Provide a recipe name and --out new-directory');print(await addRecipe(name,options.out,{dryRun:options['dry-run']}));}
19
+ else assert(false,'Use recipes list, search, show or add');
20
+ }else if(command==='examples'||command==='example'){
21
+ const {listExamples,searchExamples}=await import('./examples.js');
22
+ const [operation='list',text]=args;
23
+ assert(args.length<=2,'Unexpected example arguments');
24
+ if(operation==='list'){assert(text===undefined,'Unexpected example name');const examples=await listExamples();print(options.json?examples:formatCatalog(examples));}
25
+ else if(operation==='search'){
26
+ assert(text,'Provide search text');const found=await searchExamples(text);
27
+ if(options.json)print(found);
28
+ else{
29
+ const lines=[formatSearch(found.query,found.results)];
30
+ if(found.best)lines.push(`\nSmallest runnable match: examples/${found.best.id} (${found.best.routes??0} routes)`+(found.best.route?`, route ${found.best.route.path} in ${found.best.route.file}`:''));
31
+ for(const result of found.results)for(const route of result.matchedRoutes.slice(0,5))lines.push(` ${result.id} ${route.path.padEnd(24)} ${route.file} ${route.tags.join(' ')}`);
32
+ print(lines.join('\n')+'\n');
33
+ }
34
+ }
35
+ else assert(false,'Use examples list or search');
36
+ }else if(command==='build-typescript'){
37
+ assert(args.length===0 && options.out,'Provide --out new-directory');
38
+ const {buildTypeScriptProject}=await import('./typescript-authoring.js');
39
+ print(await buildTypeScriptProject(options.project,options.out,{dryRun:options['dry-run']}));
40
+ }else if(command==='bulk-import'){
41
+ const format=options.format??args[0],input=options.input??args[options.format?0:1];
42
+ assert(args.length<=(options.format?1:2),'Unexpected bulk import arguments');
43
+ assert(format==='csv'||format==='json'||format==='yaml','Use bulk-import csv|json|yaml <file> --out new-directory');
44
+ assert(input && options.out,'Provide an input file and --out new-directory');
45
+ const {importBulkProject}=await import('./bulk.js');
46
+ const report=await importBulkProject(await readConversionInput(input),format,options.out,{dryRun:options['dry-run'],source:basename(input)});
47
+ print(report);if(!report.ok)process.exitCode=1;
48
+ }else if(command==='verify-provider'){
49
+ assert(args.length===0,'Unexpected provider verification arguments');
50
+ const target=options.target;
51
+ assert(target==='self-hosted'||target==='aws'||target==='vercel'||target==='cloudflare','Provide --target self-hosted|aws|vercel|cloudflare');
52
+ assert(options.origin,'Provide --origin https://owned-fixture.example');
53
+ assert(options['timeout-ms']===undefined || /^\d{1,5}$/.test(options['timeout-ms']),'Invalid --timeout-ms');
54
+ const {verifyProviderDeployment}=await import('./provider-verification.js');
55
+ const report=await verifyProviderDeployment(target,options.origin,{
56
+ ...(options['timeout-ms']===undefined?{}:{timeoutMs:Number(options['timeout-ms'])}),
57
+ ...(options.release===undefined?{}:{release:options.release}),
58
+ ...(options['git-commit']===undefined?{}:{gitCommit:options['git-commit']}),
59
+ });
60
+ print(report);if(!report.pass)process.exitCode=1;
61
+ }else if(command==='mcp'){
62
+ assert(args.length===0,'Unexpected MCP arguments');
63
+ const {serveMcp}=await import('./mcp.js');
64
+ // The flags reach the server from parsed argv only; no tool argument or environment variable can set them.
65
+ await serveMcp({project:options.project,...(options.origin===undefined?{}:{origin:options.origin}),...(options['allow-authoring']?{allowAuthoring:true}:{}),...(options['host-file']===undefined?{}:{hostFile:options['host-file']})});
66
+ }else assert(false,'Unknown ecosystem command');
67
+ }
68
+
69
+
70
+ function formatCatalog(entries ) {
71
+ return entries.map(entry=>`${entry.id.padEnd(24)} ${entry.complexity.padEnd(13)} ${String(entry.routes??'-').padStart(2)} routes ${entry.description}`).join('\n')+'\n';
72
+ }
73
+ function formatSearch(query ,results ) {
74
+ if(!results.length)return `No match for "${query}"\n`;
75
+ return results.map(entry=>`${entry.id.padEnd(24)} score ${String(entry.score).padStart(2)} ${entry.description}\n${''.padEnd(24)} tags: ${entry.tags.join(' ')}`).join('\n')+'\n';
76
+ }
77
+ /** Metadata first, so a reader sees what a recipe needs before its files scroll past. */
78
+ function formatMetadata(entry ) {
79
+ const lines=[`${entry.id}: ${entry.description}`,`complexity: ${entry.complexity} routes: ${entry.routes??'-'} runnable: ${entry.runnable!==false}`,`tags: ${entry.tags.join(' ')}`];
80
+ if(entry.capabilities?.length)lines.push(`capabilities: ${entry.capabilities.join(' ')}`);
81
+ if(entry.targets)lines.push(`targets: ${Object.entries(entry.targets).map(([target,verdict])=>`${target}=${verdict}`).join(' ')}`);
82
+ for(const [title,items] of [['services',entry.services],['grants',entry.grants]] )if(items?.length)lines.push(`${title}:`,...items.map(item=>` - ${'kind' in item?item.kind:item.name}: ${item.description}`));
83
+ if(entry.inputs?.length)lines.push('inputs:',...entry.inputs.map(input=>` - ${input.name} (${input.file}): ${input.description}`));
84
+ if(entry.tests)lines.push('tests:',...(entry.tests.fixtures?[` fixtures: ${entry.tests.fixtures}`]:[]),...entry.tests.commands.map(command=>` $ ${command}`));
85
+ if(entry.behavior?.length)lines.push('behavior:',...entry.behavior.map(line=>` - ${line}`));
86
+ lines.push(`files: ${entry.files.join(' ')}`);
87
+ return lines.join('\n')+'\n';
88
+ }
package/dist/egress.js ADDED
@@ -0,0 +1,98 @@
1
+ import { lookup } from 'node:dns/promises';
2
+ import { request } from 'node:https';
3
+ import { isIP } from 'node:net';
4
+
5
+ export const EGRESS_DNS_LIMIT=64;
6
+ let unresolvedDns=0;
7
+
8
+
9
+
10
+
11
+ export class EgressError extends Error { code ; constructor(code ) { super(`Egress ${code}`); this.code=code; } }
12
+ /** Conservative public-unicast filter. IPv4-mapped IPv6 and transition mechanisms are denied. */
13
+ export function isPublicAddress(address ) {
14
+ if (isIP(address)===4) {
15
+ const [a=0,b=0,c=0] = address.split('.').map(Number);
16
+ return !(a===0 || a===10 || a===127 || a>=224 || (a===100&&b>=64&&b<=127) || (a===169&&b===254) || (a===172&&b>=16&&b<=31) || (a===192&&(b===168 || b===0 || (b===88&&c===99) || (b===0&&c===2))) || (a===198&&(b===18||b===19|| (b===51&&c===100))) || (a===203&&b===0&&c===113));
17
+ }
18
+ if (isIP(address)!==6) return false;
19
+ // Only ordinary global unicast; reject documentation and special 2001 allocations.
20
+ if(address.includes('.')) return false;
21
+ const first = parseInt(address.split(':')[0] ,16);
22
+ return first>=0x2000 && first<=0x3fff && !/^2001:/i.test(address) && !/^2002:/i.test(address) && !/^3fff:/i.test(address);
23
+ }
24
+ export function egressUrl(value ) {
25
+ if(typeof value!=='string'||value.length>8192||/[\\\x00-\x20\x7f]/.test(value)) throw new EgressError('denied');
26
+ let url ; try { url=new URL(value); } catch { throw new EgressError('denied'); }
27
+ if(url.protocol!=='https:' || url.username || url.password || url.hash || !url.hostname || url.hostname.endsWith('.') || url.hostname.includes('%')) throw new EgressError('denied');
28
+ return url;
29
+ }
30
+ const forbidden = new Set(['host','connection','keep-alive','proxy-authenticate','proxy-authorization','te','trailer','transfer-encoding','upgrade','content-length']);
31
+ export function safeEgressHeaders(headers , maxBytes=16384) {
32
+ if(!headers||typeof headers!=='object'||Array.isArray(headers)) throw new EgressError('denied');
33
+ const result = Object.create(null); let bytes=0;
34
+ for(const [name,value] of Object.entries(headers)) {
35
+ const key=name.toLowerCase();
36
+ if(typeof value!=='string'||!/^[!#$%&'*+.^_`|~0-9a-z-]+$/.test(key) || forbidden.has(key) || /[\x00-\x1f\x7f]/.test(value)) throw new EgressError('denied');
37
+ bytes+=Buffer.byteLength(key)+Buffer.byteLength(value)+4;
38
+ if(bytes>maxBytes) throw new EgressError('limit'); result[key]=value;
39
+ }
40
+ return result;
41
+ }
42
+ /** Operator-owned transport. Never exposed to guests. Each request has its own nonpooled socket. */
43
+
44
+ export class EgressClient {
45
+ #dependencies ; #origins ; #options ;
46
+ #active = new Set (); #pending = new Set (); #closed=false; #resolving=0;
47
+ constructor(options , dependencies ={}) {
48
+ this.#dependencies=dependencies;
49
+ if(!Array.isArray(options.grantOrigins)||options.grantOrigins.length>64) throw new EgressError('denied');
50
+ this.#origins=new Set(options.grantOrigins.map(origin=> {const u=egressUrl(origin); if(u.pathname!=='/'||u.search) throw new EgressError('denied'); return u.origin;}));
51
+ this.#options={timeoutMs:5000,maxRequestBytes:1048576,maxResponseBytes:1048576,maxHeaderBytes:16384,concurrency:16,...options};
52
+ for(const value of Object.values(this.#options)) if(typeof value==='number'&&(!Number.isSafeInteger(value)||value<1)) throw new EgressError('denied');
53
+ }
54
+ request(input ) {
55
+ if(this.#closed) return Promise.reject(new EgressError('closed'));
56
+ if(this.#active.size>=this.#options.concurrency||this.#resolving>=this.#options.concurrency) return Promise.reject(new EgressError('busy'));
57
+ const controller=new AbortController(); this.#active.add(controller);
58
+ const pending=this.#request(input,controller).finally(()=>{this.#active.delete(controller);this.#pending.delete(pending);});
59
+ this.#pending.add(pending); return pending;
60
+ }
61
+ async close() {this.#closed=true; for(const controller of this.#active) controller.abort(); await Promise.allSettled([...this.#pending]);}
62
+ async #resolve(hostname ) {
63
+ if(this.#resolving>=this.#options.concurrency||unresolvedDns>=EGRESS_DNS_LIMIT) throw new EgressError('busy');
64
+ this.#resolving++;unresolvedDns++;
65
+ try {return await (this.#dependencies.resolve?this.#dependencies.resolve(hostname):lookup(hostname,{all:true,verbatim:true}));}
66
+ finally {this.#resolving--;unresolvedDns--;}
67
+ }
68
+ async #request(input ,controller ) {
69
+ const opts=this.#options, url=egressUrl(input.url);
70
+ if(!this.#origins.has(url.origin)||! /^[A-Z]+$/.test(input.method)) throw new EgressError('denied');
71
+ if((input.body?.byteLength||0)>opts.maxRequestBytes) throw new EgressError('limit');
72
+ const headers=safeEgressHeaders(input.headers||{},opts.maxHeaderBytes);
73
+ let timedOut=false; const timer=setTimeout(()=>{timedOut=true;controller.abort();},opts.timeoutMs);
74
+ const abort=()=>controller.abort(); input.signal?.addEventListener('abort',abort,{once:true}); if(input.signal?.aborted) controller.abort();
75
+ try {
76
+ return await new Promise ((resolve,reject)=>{
77
+ const fail=()=>reject(new EgressError(timedOut?'timeout':'aborted'));
78
+ controller.signal.addEventListener('abort',fail,{once:true}); if(controller.signal.aborted) {fail();return;}
79
+ void (async()=> {
80
+ const hostname=url.hostname.replace(/^\[|\]$/g,'');
81
+ const answers=isIP(hostname)?[{address:hostname,family:isIP(hostname)}]:await this.#resolve(hostname);
82
+ if(controller.signal.aborted) return;
83
+ if(!answers.length||answers.some(answer=>!isPublicAddress(answer.address))) throw new EgressError('denied');
84
+ const pinned=answers[0] ;
85
+ const req=(this.#dependencies.request||request)(url,{method:input.method,headers,agent:false,maxHeaderSize:opts.maxHeaderBytes,signal:controller.signal,
86
+ lookup:(_host,options,callback)=>options.all?callback(null,[pinned]):callback(null,pinned.address,pinned.family)},res=>{
87
+ const chunks =[]; let size=0;
88
+ res.on('data',(chunk )=>{size+=chunk.length; if(size>opts.maxResponseBytes) {reject(new EgressError('limit'));req.destroy();} else chunks.push(chunk);});
89
+ res.on('error',()=>reject(new EgressError('upstream')));
90
+ res.on('end',()=>{const result =Object.create(null); for(const [name,value] of Object.entries(res.headers)) if(typeof value==='string') result[name]=value; resolve({status:res.statusCode||502,headers:result,body:Buffer.concat(chunks)});});
91
+ });
92
+ req.on('error',()=>reject(new EgressError(controller.signal.aborted?(timedOut?'timeout':'aborted'):'upstream')));
93
+ req.end(input.body);
94
+ })().catch(error=>reject(error instanceof EgressError?error:new EgressError('upstream')));
95
+ });
96
+ } finally {clearTimeout(timer); input.signal?.removeEventListener('abort',abort);}
97
+ }
98
+ }
@@ -0,0 +1,92 @@
1
+ import {readFile} from 'node:fs/promises';
2
+ import {basename} from 'node:path';
3
+ import {fileURLToPath} from 'node:url';
4
+ import {loadDocument,parseYaml} from './config.js';
5
+ import {applySite} from './site.js';
6
+ import {routeCapabilities} from './capabilities.js';
7
+ import {readMetadata,searchMetadata,searchTerms} from './catalog.js';
8
+
9
+ import {assert} from './errors.js';
10
+
11
+
12
+ /** example.yaml, the same schema as recipe.yaml; `name` repeats `id`. */
13
+
14
+ /** One cookbook route in the generated tag index (examples/cookbook/route-index.json). */
15
+
16
+
17
+
18
+
19
+
20
+
21
+
22
+
23
+ // Fixed package data, like the recipe catalog: names come from here, metadata from each example.yaml.
24
+ export const exampleNames=['assets','aws','cloudflare','compliance','conditions','cookbook','egress','extensions','live-links','monitoring','prerender','provider-conformance','tunnel','vercel'] ;
25
+ export const routeIndexFile='route-index.json';
26
+ const examplesRoot=fileURLToPath(new URL('../examples/',import.meta.url));
27
+ const root=(name )=>examplesRoot+name+'/';
28
+ async function metadata(name ) {const value=await readMetadata(root(name),name,'example.yaml');return {name,...value};}
29
+ export async function listExamples() {
30
+ const result =[];
31
+ for(const name of exampleNames)result.push(await metadata(name));
32
+ return result;
33
+ }
34
+ const handlerNames=['extension','proxy','conditional','redirect','function','page','static','download','respond','link'] ;
35
+ function handlerOf(route ) {return handlerNames.find(name=>route[name]!==undefined)??'unknown';}
36
+ /**
37
+ * Derives the per-route tag index of one project from its loaded routes: handler,
38
+ * methods, capabilities, policy names and middleware module names. The file each
39
+ * route comes from is read from the entry and its includes; routes that neither
40
+ * declares were generated by `site`.
41
+ */
42
+ export async function buildRouteIndex(project ,label ) {
43
+ const loaded=await loadDocument(project);await applySite(loaded,{});
44
+ const origin=new Map ();
45
+ for(const file of ['urlcode.yaml',...(loaded.document.includes??[])]){
46
+ const parsed=parseYaml(await readFile(project+'/'+file,'utf8')) ;
47
+ for(const path of Object.keys(parsed.routes??{}))origin.set(path,file);
48
+ }
49
+ const entries =Object.entries(loaded.routes).map(([path,route])=>{
50
+ const handler=handlerOf(route),file=origin.get(path)??'site';
51
+ const methods=route.methods??['GET','HEAD'];
52
+ const tags=new Set ([handler,...(file==='site'?['site']:[]),...methods.map(method=>method.toLowerCase())]);
53
+ for(const capability of routeCapabilities(route,loaded.document))if(capability!=='methods'&&capability!=='enabled')tags.add(capability);
54
+ for(const module of route.middleware??[])tags.add(basename(module.source).replace(/\.[cm]?js$/,''));
55
+ if(route.enabled===false)tags.add('disabled');
56
+ if(route.expires)tags.add('expiring');
57
+ return {path,file,handler,methods,tags:[...tags],description:route.description??''};
58
+ });
59
+ return {format:1,project:label,routes:entries.length,entries};
60
+ }
61
+ export async function readRouteIndex(name ) {
62
+ const value =JSON.parse(await readFile(root(name)+routeIndexFile,'utf8'));
63
+ assert(typeof value==='object'&&value!==null&&(value ).format===1&&Array.isArray((value ).entries),'Malformed route index');
64
+ return value ;
65
+ }
66
+ function matchRoutes(index ,terms ) {
67
+ const scored=index.entries.map(entry=>{
68
+ const text=[entry.path,entry.description].join(' ').toLowerCase();
69
+ let score=0;
70
+ for(const term of terms){if(entry.tags.includes(term))score+=4;else if(entry.tags.some(tag=>tag.includes(term)))score+=2;else if(text.includes(term))score+=1;}
71
+ return {entry,score};
72
+ }).filter(item=>item.score>0).sort((a,b)=>b.score-a.score);
73
+ return scored.map(item=>item.entry);
74
+ }
75
+ /**
76
+ * Searches example.yaml files and the cookbook's per-route index locally. Results
77
+ * are ordered smallest runnable example first, so `best` is the least code that
78
+ * demonstrates the match; cookbook hits carry their matching routes.
79
+ */
80
+ export async function searchExamples(text ) {
81
+ const terms=searchTerms(text),examples=await listExamples();
82
+ const indexes=new Map ();
83
+ for(const example of examples)if(example.id==='cookbook')indexes.set(example.id,await readRouteIndex(example.id));
84
+ const routeText=(example )=>(indexes.get(example.id)?.entries??[]).flatMap(entry=>[entry.path,entry.description,...entry.tags]);
85
+ const hits =searchMetadata(examples,text,routeText);
86
+ const results=hits.map(hit=>({...hit.entry,score:hit.score,matched:hit.matched,matchedRoutes:indexes.has(hit.entry.id)?matchRoutes(indexes.get(hit.entry.id) ,terms):[]}))
87
+ .sort((a,b)=>Number(b.runnable!==false)-Number(a.runnable!==false)||(a.routes??0)-(b.routes??0)||b.score-a.score);
88
+ const first=results.find(result=>result.runnable!==false);
89
+ let best =null;
90
+ if(first){const {score:_score,matched:_matched,matchedRoutes,...summary}=first;best={...summary,...(matchedRoutes[0]?{route:matchedRoutes[0]}:{})};}
91
+ return {query:text,count:results.length,best,results};
92
+ }
@@ -0,0 +1,64 @@
1
+ import {ConfigError} from './errors.js';
2
+ import {normalizeCapabilityTarget} from './capabilities.js';
3
+
4
+ import {explainRoute,explainProject} from './tooling.js';
5
+
6
+ import {buildManifest,renderManifest} from './manifest.js';
7
+
8
+
9
+
10
+ const table=(rows ) =>{const widths=rows[0] .map((_,i)=>Math.max(...rows.map(row=>row[i] .length)));return rows.map(row=>row.map((cell,i)=>cell.padEnd(widths[i] )).join(' ').trimEnd()).join('\n')+'\n';};
11
+ const targetsOf=(target ) =>target===undefined?['self-hosted','cloudflare','aws','vercel']:[normalizeCapabilityTarget(target)];
12
+ function summary(explanation ,targets ) {
13
+ const handler=explanation.handler,detail=handler.kind==='function'?`${handler.source }#${handler.export }`:handler.kind==='redirect'?`${handler.status } ${handler.url }`:handler.kind==='extension'?handler.name :handler.kind==='page'||handler.kind==='download'?handler.file :handler.kind==='static'?handler.directory :handler.kind==='proxy'?handler.url :handler.kind==='respond'?String(handler.status):'';
14
+ const support=targets.map(target=>explanation.targets[target].compatible?target:`${target}:refused`).join(',');
15
+ return [explanation.path,explanation.methods.join(','),`${handler.kind}${detail?` ${detail}`:''}`,explanation.state,String(explanation.middleware.length),explanation.policies.names.join(',')||'-',explanation.cache.outcome,support].join('\t');
16
+ }
17
+ function detail(explanation ,targets ) {
18
+ const lines =[`route: ${explanation.path}`];
19
+ if(explanation.description)lines.push(`description: ${explanation.description}`);
20
+ if(explanation.generated)lines.push(`generated: site.${explanation.generated}`);
21
+ lines.push(`state: ${explanation.state}${explanation.expires?` (expires ${explanation.expires})`:''}`,`methods: ${explanation.methods.join(', ')}`,`handler: ${JSON.stringify(explanation.handler)}`);
22
+ lines.push(`middleware: ${explanation.middleware.length?explanation.middleware.map(item=>`${item.source}#${item.export}`).join(' -> '):'none'}`);
23
+ lines.push(`inputs: ${explanation.inputs.parameters.length?explanation.inputs.parameters.map(p=>`${p.in}:${p.name}${p.required?'':'?'}`).join(', '):'none'}${explanation.inputs.body?`; body ${JSON.stringify(explanation.inputs.body)}`:''}`);
24
+ lines.push(`policies: ${explanation.policies.names.length?explanation.policies.names.join(', '):'none'}`);
25
+ for(const [name,entry] of Object.entries(explanation.policies.inventory))lines.push(` ${name}: ${JSON.stringify(entry)}`);
26
+ for(const [name,entry] of Object.entries(explanation.policies.extensions))lines.push(` extensions.${name}: requires ${JSON.stringify(entry.requirement)}${entry.provider?entry.provider.registered?` (provider registered, revision ${entry.provider.revisionMatch?'matches':'differs'}, requirement ${entry.provider.requirementValid===false?'invalid':'valid'})`:' (no provider in host file)':''}`);
27
+ lines.push(`cache: ${explanation.cache.outcome}${explanation.cache.cacheControl?` (${explanation.cache.cacheControl})`:''}${explanation.cache.forcedNoStore?' forced':''}; ${explanation.cache.reason}`);
28
+ const env=Object.entries(explanation.bindings.env).map(([alias,ref])=>`${alias}=${'env' in ref?`$${ref.env}`:'literal'}`),secrets=Object.entries(explanation.bindings.secrets).map(([alias,ref])=>`${alias}=secret:${ref.secret}`);
29
+ lines.push(`bindings: ${[...env,...secrets].join(', ')||'none'}`);
30
+ if(explanation.egress.proxy||explanation.egress.signals)lines.push(`egress: ${[explanation.egress.proxy?`proxy ${explanation.egress.proxy}`:'',...(explanation.egress.signals??[]).map(item=>`signal ${item}`)].filter(Boolean).join(', ')}`);
31
+ if(explanation.responseHeaders.length)lines.push(`response headers: ${explanation.responseHeaders.map(([name,value])=>`${name}: ${value}`).join('; ')}`);
32
+ lines.push(`capabilities: ${explanation.capabilities.join(', ')}`);
33
+ for(const target of targets){const support=explanation.targets[target];lines.push(`target ${target}: ${support.compatible?'supported':support.issues.map(issue=>`${issue.capability} ${issue.support} (${issue.reason})`).join('; ')}`);}
34
+ lines.push(`note: ${explanation.note}`);
35
+ return lines.join('\n')+'\n';
36
+ }
37
+ /** Returns the process exit code: 1 when a requested route does not exist. */
38
+ export async function runExplainCommand(command ,route ,options ,print ) {
39
+ const base={...(options.origin===undefined?{}:{origin:options.origin}),...(options.extensions===undefined?{}:{extensions:options.extensions})};
40
+ if(command==='manifest'){
41
+ if(route!==undefined)throw new ConfigError('manifest takes no route argument');
42
+ const manifest=await buildManifest(options.project,base);
43
+ if(options.json){print(renderManifest(manifest));return 0;}
44
+ const lines=[`revision: ${manifest.revision}`,`urlcode: ${manifest.urlcode}`,`files: ${manifest.files.join(', ')}`,`routes: ${manifest.routeCount}`,`capabilities: ${manifest.capabilities.join(', ')}`,
45
+ `extensions: ${Object.keys(manifest.extensions).join(', ')||'none'}`,`recipes: ${manifest.recipes.map(recipe=>recipe.id).join(', ')||'none'}`,
46
+ `external: env ${manifest.external.env.join(', ')||'-'}; secrets ${manifest.external.secrets.join(', ')||'-'}; proxy ${manifest.external.egress.proxy.join(', ')||'-'}; signals ${manifest.external.egress.signals.join(', ')||'-'}; link stores ${manifest.external.linkStores.join(', ')||'-'}`,
47
+ `functions: ${manifest.functions.map(item=>`${item.source}#${item.export}`).join(', ')||'none'}`,`middleware: ${manifest.middleware.map(item=>`${item.source}#${item.export}`).join(', ')||'none'}`,
48
+ `targets: ${Object.entries(manifest.targets).map(([target,support])=>`${target} ${support.compatible?'supported':`${support.issues} issue${support.issues===1?'':'s'}`}`).join('; ')}`,'Use --json for the full manifest.'];
49
+ print(lines.join('\n')+'\n');return 0;
50
+ }
51
+ const targets=targetsOf(options.target);
52
+ if(route===undefined){
53
+ const report=await explainProject(options.project,base);
54
+ if(options.json){print(report);return 0;}
55
+ print(table([['route','methods','handler','state','mw','policies','cache','targets'],...report.routes.map(item=>summary(item,targets).split('\t'))]));return 0;
56
+ }
57
+ if(!route.startsWith('/'))throw new ConfigError('Provide an absolute route path such as /docs');
58
+ const explanation=await explainRoute(options.project,route,base);
59
+ if(!explanation.matched){
60
+ if(options.json)print(explanation);else print(`no route selects ${route}${explanation.nearest.length?`; nearest: ${explanation.nearest.join(', ')}`:''}\n`);
61
+ return 1;
62
+ }
63
+ print(options.json?explanation:detail(explanation,targets));return 0;
64
+ }