@jimhoyd/urlcode 0.3.0 → 0.4.0-alpha.2

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 (380) hide show
  1. package/.claude/skills/urlcode-authoring/SKILL.md +122 -0
  2. package/.claude/skills/urlcode-operations/SKILL.md +108 -0
  3. package/.claude-plugin/marketplace.json +18 -0
  4. package/CONTRIBUTING.md +30 -2
  5. package/README.md +195 -255
  6. package/ROADMAP.md +143 -15
  7. package/SECURITY.md +31 -9
  8. package/dist/BUILD-MANIFEST.json +72 -47
  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/build-static.js +134 -0
  17. package/dist/bulk.js +37 -0
  18. package/dist/capabilities.js +262 -0
  19. package/dist/capability-query.js +71 -0
  20. package/dist/catalog.js +105 -0
  21. package/dist/cli.js +165 -34
  22. package/dist/client-address.js +1 -1
  23. package/dist/compliance-rules/baseline.js +9 -17
  24. package/dist/compliance-rules/privacy.js +7 -18
  25. package/dist/compliance-rules/shared.js +0 -2
  26. package/dist/compliance-rules/strict.js +5 -5
  27. package/dist/compliance.js +6 -8
  28. package/dist/conditions.js +88 -0
  29. package/dist/config.js +69 -6
  30. package/dist/context.js +155 -0
  31. package/dist/ecosystem-cli.js +88 -0
  32. package/dist/egress.js +98 -0
  33. package/dist/examples.js +92 -0
  34. package/dist/explain-cli.js +64 -0
  35. package/dist/explain.js +131 -0
  36. package/dist/extensions.js +231 -0
  37. package/dist/function-sources.js +49 -5
  38. package/dist/function-worker.js +3 -1
  39. package/dist/functions.js +84 -13
  40. package/dist/guest-api.js +29 -3
  41. package/dist/index.js +40 -6
  42. package/dist/init-with.js +165 -0
  43. package/dist/interchange-cli.js +42 -0
  44. package/dist/interchange.js +189 -0
  45. package/dist/manifest.js +109 -0
  46. package/dist/match.js +2 -2
  47. package/dist/mcp-authoring.js +147 -0
  48. package/dist/mcp.js +97 -0
  49. package/dist/observability.js +7 -21
  50. package/dist/operator-host.js +29 -0
  51. package/dist/plugins.js +12 -0
  52. package/dist/policies/agents.js +2 -2
  53. package/dist/policies/cache.js +8 -3
  54. package/dist/policies/compression.js +2 -1
  55. package/dist/policies/security.js +0 -0
  56. package/dist/policies.js +1 -1
  57. package/dist/policy.js +56 -15
  58. package/dist/prerender.js +100 -41
  59. package/dist/project-tests.js +3 -3
  60. package/dist/provider-verification.js +92 -0
  61. package/dist/proxy.js +44 -0
  62. package/dist/readiness.js +34 -11
  63. package/dist/recipes.js +41 -0
  64. package/dist/route-diff.js +106 -0
  65. package/dist/router.js +45 -7
  66. package/dist/runtime.js +164 -64
  67. package/dist/sandbox.js +48 -0
  68. package/dist/scaffold.js +0 -0
  69. package/dist/schema-query.js +62 -0
  70. package/dist/scripts/operational-drills.js +12 -54
  71. package/dist/server.js +3 -29
  72. package/dist/signals.js +24 -0
  73. package/dist/site.js +0 -0
  74. package/dist/tooling.js +96 -0
  75. package/dist/trusted-functions.js +210 -0
  76. package/dist/types/adapters.d.ts +7 -4
  77. package/dist/types/agent-lists.d.ts +0 -1
  78. package/dist/types/agents-guide.d.ts +17 -0
  79. package/dist/types/authoring-files.d.ts +10 -0
  80. package/dist/types/aws.d.ts +3 -1
  81. package/dist/types/build-cloudflare.d.ts +1 -0
  82. package/dist/types/build-static.d.ts +43 -0
  83. package/dist/types/bulk.d.ts +27 -0
  84. package/dist/types/capabilities.d.ts +64 -0
  85. package/dist/types/capability-query.d.ts +24 -0
  86. package/dist/types/catalog.d.ts +65 -0
  87. package/dist/types/client-address.d.ts +0 -1
  88. package/dist/types/compliance-rules/baseline.d.ts +1 -9
  89. package/dist/types/compliance-rules/privacy.d.ts +1 -4
  90. package/dist/types/compliance-rules/shared.d.ts +0 -2
  91. package/dist/types/compliance-rules/strict.d.ts +0 -5
  92. package/dist/types/compliance.d.ts +0 -3
  93. package/dist/types/conditions.d.ts +19 -0
  94. package/dist/types/config.d.ts +21 -2
  95. package/dist/types/context.d.ts +66 -0
  96. package/dist/types/ecosystem-cli.d.ts +17 -0
  97. package/dist/types/egress.d.ts +46 -0
  98. package/dist/types/examples.d.ts +50 -0
  99. package/dist/types/explain-cli.d.ts +11 -0
  100. package/dist/types/explain.d.ts +95 -0
  101. package/dist/types/extensions.d.ts +177 -0
  102. package/dist/types/function-sources.d.ts +9 -0
  103. package/dist/types/functions.d.ts +48 -5
  104. package/dist/types/guest-api.d.ts +1 -0
  105. package/dist/types/index.d.ts +36 -6
  106. package/dist/types/init-with.d.ts +30 -0
  107. package/dist/types/interchange-cli.d.ts +16 -0
  108. package/dist/types/interchange.d.ts +42 -0
  109. package/dist/types/manifest.d.ts +79 -0
  110. package/dist/types/match.d.ts +1 -0
  111. package/dist/types/mcp-authoring.d.ts +92 -0
  112. package/dist/types/mcp.d.ts +12 -0
  113. package/dist/types/observability.d.ts +3 -14
  114. package/dist/types/operator-host.d.ts +8 -0
  115. package/dist/types/plugins.d.ts +2 -0
  116. package/dist/types/policies/agents.d.ts +0 -2
  117. package/dist/types/policies/compression.d.ts +2 -0
  118. package/dist/types/policies/security.d.ts +0 -1
  119. package/dist/types/policy.d.ts +15 -4
  120. package/dist/types/project-tests.d.ts +3 -2
  121. package/dist/types/provider-verification.d.ts +53 -0
  122. package/dist/types/proxy.d.ts +21 -0
  123. package/dist/types/readiness.d.ts +10 -3
  124. package/dist/types/recipes.d.ts +30 -0
  125. package/dist/types/route-diff.d.ts +27 -0
  126. package/dist/types/router.d.ts +2 -1
  127. package/dist/types/runtime.d.ts +11 -27
  128. package/dist/types/sandbox.d.ts +12 -0
  129. package/dist/types/scaffold.d.ts +0 -2
  130. package/dist/types/schema-query.d.ts +12 -0
  131. package/dist/types/server.d.ts +1 -4
  132. package/dist/types/signals.d.ts +25 -0
  133. package/dist/types/site.d.ts +0 -1
  134. package/dist/types/tooling.d.ts +115 -0
  135. package/dist/types/trusted-functions.d.ts +29 -0
  136. package/dist/types/types.d.ts +71 -7
  137. package/dist/types/typescript-authoring.d.ts +12 -0
  138. package/dist/types/vercel.d.ts +3 -1
  139. package/dist/types/verify-deployment.d.ts +47 -0
  140. package/dist/types.js +37 -5
  141. package/dist/typescript-authoring.js +142 -0
  142. package/dist/vercel.js +4 -3
  143. package/dist/verify-deployment.js +270 -0
  144. package/docs/AI-AUTHORING.md +232 -15
  145. package/docs/AWS.md +4 -4
  146. package/docs/BEST-PRACTICES.md +3 -2
  147. package/docs/BULK.md +79 -0
  148. package/docs/CAPABILITIES.md +192 -0
  149. package/docs/CAPACITY.md +129 -32
  150. package/docs/CI.md +142 -0
  151. package/docs/CLOUDFLARE.md +1 -2
  152. package/docs/COMPLIANCE.md +6 -9
  153. package/docs/CONDITIONS.md +74 -0
  154. package/docs/DEPLOYMENT-CHECKS.md +108 -0
  155. package/docs/EGRESS.md +125 -0
  156. package/docs/EXTENSIONS.md +398 -0
  157. package/docs/FRAMEWORK.md +198 -0
  158. package/docs/FUNCTION-SECURITY.md +129 -32
  159. package/docs/INSTALL.md +45 -12
  160. package/docs/INTERCHANGE.md +134 -0
  161. package/docs/LOAD-TESTING.md +4 -4
  162. package/docs/MIDDLEWARE-EXAMPLES.md +75 -0
  163. package/docs/MIDDLEWARE.md +29 -16
  164. package/docs/MONITORING.md +2 -19
  165. package/docs/NEXT-PHASE-PLAN.md +98 -0
  166. package/docs/NEXT-STEPS.md +634 -0
  167. package/docs/OBSERVABILITY.md +11 -18
  168. package/docs/OPEN-DECISIONS.md +212 -0
  169. package/docs/OPERATIONAL-PROOF.md +30 -31
  170. package/docs/OPERATIONS.md +29 -35
  171. package/docs/PLUGINS.md +37 -0
  172. package/docs/POLICIES.md +23 -309
  173. package/docs/PRERENDER.md +41 -1
  174. package/docs/PROJECT-DIRECTION.md +75 -8
  175. package/docs/PROVIDER-VERIFICATION.md +84 -0
  176. package/docs/READINESS.md +21 -1
  177. package/docs/README.md +87 -34
  178. package/docs/RECIPES.md +99 -0
  179. package/docs/RELEASE-READINESS.md +57 -35
  180. package/docs/RELEASE-SECURITY.md +116 -7
  181. package/docs/RESILIENCE.md +16 -15
  182. package/docs/ROUTING.md +8 -10
  183. package/docs/SANDBOX-REVIEW.md +19 -6
  184. package/docs/SCAFFOLDING.md +0 -2
  185. package/docs/SECURITY-AUDIT.md +41 -1
  186. package/docs/SPECIFICATION.md +150 -29
  187. package/docs/SPIKE-AI-FRAMEWORK-BENCHMARK.md +287 -0
  188. package/docs/SPIKE-BUSINESS-SUITE.md +1021 -0
  189. package/docs/SPIKE-CORE-LAYERING.md +337 -0
  190. package/docs/SPIKE-DEFAULT-TRUST-MODEL.md +209 -0
  191. package/docs/SPIKE-EXTENSION-MODEL.md +419 -0
  192. package/docs/SPIKE-EXTENSIONS.md +6 -0
  193. package/docs/SPIKE-LAMBDA-COMPILE.md +201 -0
  194. package/docs/SPIKE-MONOREPO.md +322 -0
  195. package/docs/STANDARDS.md +150 -142
  196. package/docs/STARTERS.md +21 -1
  197. package/docs/STATIC.md +94 -0
  198. package/docs/TOOLING.md +295 -0
  199. package/docs/TUNNELS.md +0 -3
  200. package/docs/TYPESCRIPT-AUTHORING.md +82 -0
  201. package/docs/TYPESCRIPT.md +25 -4
  202. package/docs/USABILITY-REVIEW.md +129 -0
  203. package/docs/VERCEL.md +4 -5
  204. package/docs/VERSION-ALIGNMENT.md +205 -0
  205. package/docs/YAML-GUIDE.md +15 -479
  206. package/docs/YAML-REFERENCE.md +143 -22
  207. package/docs/policies/agents.md +1 -1
  208. package/docs/policies/cache.md +13 -0
  209. package/docs/policies/contract.md +52 -0
  210. package/docs/policies/hardened.md +56 -0
  211. package/docs/policies/interoperability.md +169 -0
  212. package/docs/policies/operations.md +45 -0
  213. package/docs/yaml/assets.md +36 -0
  214. package/docs/yaml/conditions.md +20 -0
  215. package/docs/yaml/functions.md +160 -0
  216. package/docs/yaml/middleware.md +29 -0
  217. package/docs/yaml/organization.md +74 -0
  218. package/docs/yaml/policies.md +37 -0
  219. package/docs/yaml/redirects.md +64 -0
  220. package/docs/yaml/responses.md +57 -0
  221. package/docs/yaml/site.md +24 -0
  222. package/examples/assets/example.yaml +17 -0
  223. package/examples/aws/example.yaml +20 -0
  224. package/examples/cloudflare/example.yaml +19 -0
  225. package/examples/compliance/example.yaml +11 -0
  226. package/examples/conditions/README.md +12 -0
  227. package/examples/conditions/example.yaml +19 -0
  228. package/examples/conditions/tests/requests.json +13 -0
  229. package/examples/conditions/urlcode.yaml +24 -0
  230. package/examples/cookbook/README.md +8 -4
  231. package/examples/cookbook/example.yaml +17 -0
  232. package/examples/cookbook/functions/catalog.mjs +3 -0
  233. package/examples/cookbook/functions/fail.mjs +4 -0
  234. package/examples/cookbook/functions/items.mjs +3 -0
  235. package/examples/cookbook/functions/profile.mjs +3 -0
  236. package/examples/cookbook/functions/resource.mjs +3 -0
  237. package/examples/cookbook/functions/status.mjs +3 -0
  238. package/examples/cookbook/middleware/auth.mjs +48 -0
  239. package/examples/cookbook/middleware/body.mjs +15 -0
  240. package/examples/cookbook/middleware/bucket.mjs +29 -0
  241. package/examples/cookbook/middleware/cors.mjs +21 -0
  242. package/examples/cookbook/middleware/debug.mjs +13 -0
  243. package/examples/cookbook/middleware/envelope.mjs +11 -0
  244. package/examples/cookbook/middleware/errors.mjs +11 -0
  245. package/examples/cookbook/middleware/etag.mjs +18 -0
  246. package/examples/cookbook/middleware/locale.mjs +20 -0
  247. package/examples/cookbook/middleware/maintenance.mjs +10 -0
  248. package/examples/cookbook/middleware/methods.mjs +15 -0
  249. package/examples/cookbook/middleware/negotiate.mjs +20 -0
  250. package/examples/cookbook/middleware/referer.mjs +12 -0
  251. package/examples/cookbook/middleware/request-id.mjs +16 -0
  252. package/examples/cookbook/route-index.json +676 -0
  253. package/examples/cookbook/routes/middleware.yaml +126 -0
  254. package/examples/cookbook/tests/requests.json +526 -0
  255. package/examples/cookbook/urlcode.yaml +1 -0
  256. package/examples/egress/README.md +22 -0
  257. package/examples/egress/example.yaml +19 -0
  258. package/examples/egress/urlcode.yaml +19 -0
  259. package/examples/extensions/README.md +7 -0
  260. package/examples/extensions/example.yaml +21 -0
  261. package/examples/extensions/urlcode.yaml +25 -0
  262. package/examples/monitoring/example.yaml +8 -0
  263. package/examples/prerender/README.md +2 -2
  264. package/examples/prerender/example.yaml +16 -0
  265. package/examples/provider-conformance/README.md +12 -0
  266. package/examples/provider-conformance/example.yaml +14 -0
  267. package/examples/provider-conformance/urlcode.yaml +34 -0
  268. package/examples/tunnel/example.yaml +8 -0
  269. package/examples/vercel/example.yaml +19 -0
  270. package/llms-full.txt +3084 -0
  271. package/llms.txt +61 -21
  272. package/package.json +36 -7
  273. package/packaging/claude-plugin/.claude-plugin/plugin.json +19 -0
  274. package/packaging/claude-plugin/skills/urlcode-authoring/SKILL.md +122 -0
  275. package/packaging/claude-plugin/skills/urlcode-operations/SKILL.md +108 -0
  276. package/recipes/authenticated-json-api/README.md +51 -0
  277. package/recipes/authenticated-json-api/functions/profile.mjs +5 -0
  278. package/recipes/authenticated-json-api/recipe.yaml +34 -0
  279. package/recipes/authenticated-json-api/tests/requests.json +39 -0
  280. package/recipes/authenticated-json-api/urlcode.yaml +12 -0
  281. package/recipes/contact-form/README.md +25 -0
  282. package/recipes/contact-form/functions/contact.mjs +17 -0
  283. package/recipes/contact-form/recipe.yaml +33 -0
  284. package/recipes/contact-form/tests/requests.json +47 -0
  285. package/recipes/contact-form/urlcode.yaml +18 -0
  286. package/recipes/cors-api/README.md +16 -0
  287. package/recipes/cors-api/functions/items.mjs +3 -0
  288. package/recipes/cors-api/middleware/cors.mjs +21 -0
  289. package/recipes/cors-api/recipe.yaml +26 -0
  290. package/recipes/cors-api/tests/requests.json +65 -0
  291. package/recipes/cors-api/urlcode.yaml +12 -0
  292. package/recipes/health-page/README.md +13 -0
  293. package/recipes/health-page/recipe.yaml +23 -0
  294. package/recipes/health-page/tests/requests.json +36 -0
  295. package/recipes/health-page/urlcode.yaml +19 -0
  296. package/recipes/json-api/README.md +6 -0
  297. package/recipes/json-api/functions/echo.mjs +3 -0
  298. package/recipes/json-api/recipe.yaml +25 -0
  299. package/recipes/json-api/tests/requests.json +34 -0
  300. package/recipes/json-api/urlcode.yaml +12 -0
  301. package/recipes/middleware/README.md +34 -0
  302. package/recipes/middleware/functions/catalog.mjs +3 -0
  303. package/recipes/middleware/functions/fail.mjs +4 -0
  304. package/recipes/middleware/functions/items.mjs +3 -0
  305. package/recipes/middleware/functions/profile.mjs +3 -0
  306. package/recipes/middleware/functions/resource.mjs +3 -0
  307. package/recipes/middleware/functions/status.mjs +3 -0
  308. package/recipes/middleware/middleware/auth.mjs +48 -0
  309. package/recipes/middleware/middleware/body.mjs +15 -0
  310. package/recipes/middleware/middleware/bucket.mjs +29 -0
  311. package/recipes/middleware/middleware/cors.mjs +21 -0
  312. package/recipes/middleware/middleware/debug.mjs +13 -0
  313. package/recipes/middleware/middleware/envelope.mjs +11 -0
  314. package/recipes/middleware/middleware/errors.mjs +11 -0
  315. package/recipes/middleware/middleware/etag.mjs +18 -0
  316. package/recipes/middleware/middleware/locale.mjs +20 -0
  317. package/recipes/middleware/middleware/maintenance.mjs +10 -0
  318. package/recipes/middleware/middleware/methods.mjs +15 -0
  319. package/recipes/middleware/middleware/negotiate.mjs +20 -0
  320. package/recipes/middleware/middleware/referer.mjs +12 -0
  321. package/recipes/middleware/middleware/request-id.mjs +16 -0
  322. package/recipes/middleware/public/guide.txt +1 -0
  323. package/recipes/middleware/recipe.yaml +50 -0
  324. package/recipes/middleware/tests/requests.json +528 -0
  325. package/recipes/middleware/urlcode.yaml +127 -0
  326. package/recipes/protected-download/README.md +22 -0
  327. package/recipes/protected-download/files/report.txt +1 -0
  328. package/recipes/protected-download/recipe.yaml +31 -0
  329. package/recipes/protected-download/tests/requests.json +32 -0
  330. package/recipes/protected-download/urlcode.yaml +15 -0
  331. package/recipes/redirect/README.md +7 -0
  332. package/recipes/redirect/recipe.yaml +25 -0
  333. package/recipes/redirect/tests/requests.json +19 -0
  334. package/recipes/redirect/urlcode.yaml +9 -0
  335. package/recipes/static-plus-api/README.md +15 -0
  336. package/recipes/static-plus-api/functions/info.mjs +3 -0
  337. package/recipes/static-plus-api/public/assets/index.html +3 -0
  338. package/recipes/static-plus-api/public/assets/site.css +1 -0
  339. package/recipes/static-plus-api/public/index.html +8 -0
  340. package/recipes/static-plus-api/recipe.yaml +29 -0
  341. package/recipes/static-plus-api/tests/requests.json +56 -0
  342. package/recipes/static-plus-api/urlcode.yaml +23 -0
  343. package/recipes/typescript/README.md +8 -0
  344. package/recipes/typescript/functions/hello.ts +5 -0
  345. package/recipes/typescript/recipe.yaml +23 -0
  346. package/recipes/typescript/tests/requests.json +18 -0
  347. package/recipes/typescript/urlcode.yaml +5 -0
  348. package/recipes/webhook-receiver/README.md +20 -0
  349. package/recipes/webhook-receiver/functions/receive.mjs +16 -0
  350. package/recipes/webhook-receiver/recipe.yaml +27 -0
  351. package/recipes/webhook-receiver/tests/requests.json +59 -0
  352. package/recipes/webhook-receiver/urlcode.yaml +23 -0
  353. package/schemas/recipe.schema.json +139 -0
  354. package/schemas/urlcode.schema.json +659 -110
  355. package/skills/urlcode/SKILL.md +119 -0
  356. package/starters/default/.github/workflows/urlcode.yml +23 -0
  357. package/starters/default/.mcp.json +12 -0
  358. package/starters/default/AGENTS.md +79 -0
  359. package/starters/default/urlcode.yaml +0 -1
  360. package/dist/link-api.js +0 -136
  361. package/dist/link-cli.js +0 -141
  362. package/dist/link-events.js +0 -76
  363. package/dist/link-records.js +0 -31
  364. package/dist/link-store-worker.js +0 -150
  365. package/dist/link-store.js +0 -250
  366. package/dist/management-policy.js +0 -41
  367. package/dist/sqlite-version.js +0 -6
  368. package/dist/types/link-api.d.ts +0 -30
  369. package/dist/types/link-cli.d.ts +0 -36
  370. package/dist/types/link-events.d.ts +0 -27
  371. package/dist/types/link-records.d.ts +0 -11
  372. package/dist/types/link-store-worker.d.ts +0 -1
  373. package/dist/types/link-store.d.ts +0 -130
  374. package/dist/types/management-policy.d.ts +0 -9
  375. package/dist/types/sqlite-version.d.ts +0 -1
  376. package/docs/DYNAMIC-LINKS.md +0 -561
  377. package/docs/MANAGEMENT-SECURITY.md +0 -82
  378. package/examples/live-links/README.md +0 -11
  379. package/examples/live-links/tests/requests.json +0 -6
  380. package/examples/live-links/urlcode.yaml +0 -16
@@ -12,16 +12,13 @@ import { assert } from './errors.js';
12
12
  // URL, query string, header, body, client address, User-Agent string, binding
13
13
  // or secret; `route` is always the configured pattern.
14
14
  export const events = Object.freeze({
15
+ signal: Object.freeze(['event','outcome','count']),
15
16
  request: Object.freeze(['event', 'requestId', 'status', 'durationMs', 'method', 'route']),
16
17
  reload: Object.freeze(['event', 'status', 'version', 'routes']),
17
18
  watch: Object.freeze(['event', 'status']),
18
19
  function_worker: Object.freeze(['event', 'status', 'slot', 'attempt', 'delayMs']),
19
- link_store_worker: Object.freeze(['event', 'status', 'readOnly', 'attempt', 'delayMs']),
20
- link_request: Object.freeze(['event', 'requestId', 'collection', 'route', 'code', 'method', 'status', 'outcome', 'durationMs']),
21
- link_observer: Object.freeze(['event', 'status', 'reason', 'dropped', 'queued', 'delivered', 'failed', 'timedOut', 'closed']),
22
20
  logs_dropped: Object.freeze(['event', 'count']),
23
21
  observer: Object.freeze(['event', 'status', 'name']),
24
- management_request: Object.freeze(['event', 'timestamp', 'requestId', 'collection', 'action', 'authenticated', 'principal', 'status', 'outcome', 'durationMs']),
25
22
  throttle: Object.freeze(['event', 'route', 'outcome', 'remaining']),
26
23
  agents: Object.freeze(['event', 'route', 'list', 'outcome']),
27
24
  cache: Object.freeze(['event', 'route', 'outcome']),
@@ -46,9 +43,8 @@ export const events = Object.freeze({
46
43
 
47
44
 
48
45
 
49
-
50
46
 
51
-
47
+
52
48
 
53
49
 
54
50
 
@@ -83,14 +79,13 @@ export function validateObservers(observers = []) {
83
79
  return observers ; // every entry was just checked
84
80
  }
85
81
 
86
- export const SNAPSHOT_VERSION = 1;
82
+ export const SNAPSHOT_VERSION = 2;
87
83
  const statusClasses = ['2xx', '3xx', '4xx', '5xx'];
88
84
  const MAX_ROUTES = 10000;
89
85
  const outcomes = {
90
86
  throttle: ['allowed', 'exceeded'],
91
87
  agents: ['denied', 'reported'],
92
88
  cache: ['hit', 'stale', 'miss', 'store'],
93
- link_request: ['completed', 'aborted', 'missing', 'disabled', 'expired', 'invalid_code', 'invalid_record', 'unavailable'],
94
89
  };
95
90
  const zeroed = (keys ) => Object.fromEntries(keys.map(key => [key, 0]));
96
91
 
@@ -106,10 +101,8 @@ export function createMetrics() {
106
101
  const reloads = { ok: 0, rejected: 0 };
107
102
  const watch = { failed: 0 };
108
103
  const functionWorkers = { started: 0, restarts: 0 };
109
- const linkStoreWorkers = { started: 0, restarts: 0 };
110
104
  const policies = { throttle: zeroed(outcomes.throttle), agents: zeroed(outcomes.agents), cache: zeroed(outcomes.cache) };
111
- const linkRequests = zeroed(outcomes.link_request);
112
- const linkObserver = { failed: 0, dropped: 0 };
105
+ const signals = zeroed(['accepted','delivered','failed','dropped']);
113
106
  let logsDropped = 0, observerErrors = 0;
114
107
  const count = (table , key ) => { if (typeof key === 'string' && Object.hasOwn(table, key)) table[key] ++; };
115
108
  function countRequest(target , status , route ) {
@@ -129,14 +122,12 @@ export function createMetrics() {
129
122
  if (!event || typeof event !== 'object') return;
130
123
  const record = event ; // any object is read as a record; unknown keys are ignored
131
124
  switch (record.event) {
125
+ case 'signal': if(typeof record.outcome==='string'&&Object.hasOwn(signals,record.outcome)&&Number.isSafeInteger(record.count)&&Number(record.count)>0)signals[record.outcome] +=Number(record.count);break;
132
126
  case 'request': countRequest(context.probe ? health : requests, record.status, context.probe ? null : (record.route ?? context.route)); break;
133
127
  case 'reload': count(reloads, record.status); break;
134
128
  case 'watch': if (record.status === 'failed') watch.failed++; break;
135
129
  case 'function_worker': if (record.status === 'restarting') functionWorkers.restarts++; else if (record.status === 'started') functionWorkers.started++; break;
136
- case 'link_store_worker': if (record.status === 'restarting') linkStoreWorkers.restarts++; else if (record.status === 'started') linkStoreWorkers.started++; break;
137
130
  case 'throttle': case 'agents': case 'cache': count(policies[record.event], record.outcome); break;
138
- case 'link_request': count(linkRequests, record.outcome); break;
139
- case 'link_observer': if (record.status === 'failed') linkObserver.failed++; else if (record.status === 'dropped' || record.status === 'closed') linkObserver.dropped = Math.max(linkObserver.dropped, Number(record.dropped) || 0); break;
140
131
  case 'logs_dropped': logsDropped += Number(record.count) || 0; break;
141
132
  case 'observer': if (record.status === 'failed') observerErrors++; break;
142
133
  default: break;
@@ -156,10 +147,8 @@ export function createMetrics() {
156
147
  reloads: { ...reloads },
157
148
  watch: { ...watch },
158
149
  functionWorkers: { ...functionWorkers, healthySlots: 0, slots: 0 },
159
- linkStoreWorkers: { ...linkStoreWorkers },
160
150
  policies: { throttle: { ...policies.throttle }, agents: { ...policies.agents }, cache: { ...policies.cache } },
161
- linkRequests: { ...linkRequests },
162
- linkObserver: { ...linkObserver },
151
+ signals: {...signals},
163
152
  logsDropped,
164
153
  observers: { errors: observerErrors },
165
154
  ...extra,
@@ -226,13 +215,10 @@ export function renderPrometheus(snapshot ) {
226
215
  metric('function_worker_restarts_total', 'counter', 'Function worker replacements scheduled.', [[{}, snapshot.functionWorkers?.restarts]]);
227
216
  metric('function_worker_healthy_slots', 'gauge', 'Function worker slots ready to serve.', [[{}, snapshot.functionWorkers?.healthySlots]]);
228
217
  metric('function_worker_slots', 'gauge', 'Function worker slots configured.', [[{}, snapshot.functionWorkers?.slots]]);
229
- metric('link_store_worker_restarts_total', 'counter', 'Link-store worker replacements scheduled.', [[{}, snapshot.linkStoreWorkers?.restarts]]);
230
218
  metric('throttle_total', 'counter', 'Throttle policy decisions.', byKey(snapshot.policies?.throttle, 'outcome'));
231
219
  metric('agents_total', 'counter', 'Agents policy decisions.', byKey(snapshot.policies?.agents, 'outcome'));
232
220
  metric('cache_total', 'counter', 'Cache policy outcomes.', byKey(snapshot.policies?.cache, 'outcome'));
233
- metric('link_requests_total', 'counter', 'Dynamic link requests by outcome.', byKey(snapshot.linkRequests, 'outcome'));
234
- metric('link_observer_failures_total', 'counter', 'Link event collector failures.', [[{}, snapshot.linkObserver?.failed]]);
235
- metric('link_observer_dropped_total', 'counter', 'Link events dropped under overload.', [[{}, snapshot.linkObserver?.dropped]]);
221
+ metric('signals_total','counter','Best-effort webhook outcomes.',Object.entries(snapshot.signals||{}).map(([outcome,value])=>[{outcome},value]));
236
222
  metric('logs_dropped_total', 'counter', 'Log records the JSON logger shed.', [[{}, snapshot.logsDropped]]);
237
223
  metric('observer_errors_total', 'counter', 'Observer hooks that threw or rejected.', [[{}, snapshot.observers?.errors]]);
238
224
  metric('uptime_seconds', 'gauge', 'Seconds since the process started serving.', [[{}, snapshot.uptimeSeconds]]);
@@ -0,0 +1,29 @@
1
+ import { realpath, stat } from 'node:fs/promises';
2
+ import { isAbsolute, relative, sep, extname } from 'node:path';
3
+ import { pathToFileURL } from 'node:url';
4
+ import { assert } from './errors.js';
5
+
6
+
7
+ /** Explicitly loaded operator code. Never discovered in application directories. */
8
+
9
+
10
+
11
+
12
+
13
+ export async function loadOperatorHost(file , project ) {
14
+ if (file === undefined) return {};
15
+ assert(isAbsolute(file) && ['.mjs', '.js'].includes(extname(file)), 'Host file must be an absolute ES module path (.mjs or .js)');
16
+ const root = await realpath(project), path = await realpath(file), rel = relative(root, path);
17
+ assert(isAbsolute(rel) || rel === '..' || rel.startsWith('..' + sep), 'Host file must be outside the application project');
18
+ const info = await stat(path);
19
+ assert(info.isFile() && info.size <= 1048576, 'Host file must be a regular file of at most 1 MiB');
20
+ const module = await import(pathToFileURL(path).href) ;
21
+ const host = module.default;
22
+ assert(host !== null && typeof host === 'object' && !Array.isArray(host), 'Host file must default-export an operator configuration object');
23
+ assert(Object.keys(host).every(key => ['extensions', 'plugins', 'close'].includes(key)), 'Unknown operator host setting');
24
+ const result = host ;
25
+ assert(result.extensions === undefined || Array.isArray(result.extensions), 'Host extensions must be an array');
26
+ assert(result.plugins === undefined || Array.isArray(result.plugins), 'Host plugins must be an array');
27
+ assert(result.close === undefined || typeof result.close === 'function', 'Host close must be a function');
28
+ return result;
29
+ }
package/dist/plugins.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { validateHeaderName } from './header-validation.js';
1
2
  import { assert, ConfigError } from './errors.js';
2
3
  import { targets as knownTargets } from './policies.js';
3
4
 
@@ -21,6 +22,8 @@ import { targets as knownTargets } from './policies.js';
21
22
 
22
23
 
23
24
 
25
+
26
+
24
27
 
25
28
 
26
29
 
@@ -41,6 +44,15 @@ export function validatePlugins(plugins = [], target = 'node')
41
44
  assert(typeof plugin.version === 'string' && plugin.version.length <= 64, `Plugin "${plugin.name}" needs a version string`);
42
45
  assert(Array.isArray(plugin.targets) && plugin.targets.every(t => (knownTargets ).includes(t)), `Plugin "${plugin.name}" must list its supported targets`);
43
46
  if (!(plugin.targets ).includes(target)) throw new ConfigError(`Plugin "${plugin.name}" does not support the ${target} target`);
47
+ if(plugin.credentialHeaders!==undefined){
48
+ assert(Array.isArray(plugin.credentialHeaders)&&plugin.credentialHeaders.length<=64,'Plugin credentialHeaders must contain at most 64 header names');
49
+ const headers=new Set ();
50
+ for(const name of plugin.credentialHeaders){
51
+ assert(typeof name==='string'&&name.length<=128,'Invalid plugin credential header name');
52
+ validateHeaderName(name);
53
+ assert(!headers.has(name.toLowerCase()),'Duplicate plugin credential header name');headers.add(name.toLowerCase());
54
+ }
55
+ }
44
56
  for (const hook of hookNames) assert(plugin[hook] === undefined || typeof plugin[hook] === 'function', `Plugin "${plugin.name}" hook ${hook} must be a function`);
45
57
  assert(hookNames.some(hook => plugin[hook]), `Plugin "${plugin.name}" declares no hooks`);
46
58
  }
@@ -35,8 +35,8 @@ export function targets() { return { node
35
35
 
36
36
 
37
37
  export const bundledLists = Object.freeze(Object.keys(bundled));
38
- export const MAX_PATTERN_BYTES = 256;
39
- export const MAX_REPEAT = 64;
38
+ const MAX_PATTERN_BYTES = 256;
39
+ const MAX_REPEAT = 64;
40
40
 
41
41
  // The pattern subset: anchors, literals, `.`, escapes, character classes,
42
42
  // groups, alternation, and quantifiers on a single atom only. Nothing here
@@ -202,9 +202,14 @@ function revalidate(state , req , result
202
202
  if (result.status !== 200 || (req.method !== 'GET' && req.method !== 'HEAD')) return result;
203
203
  let headers = result.headers, etag = header(headers, 'etag');
204
204
  if (!etag) {
205
- // A strong validator over the representation. A HEAD answer without a
206
- // body (a function's) has nothing to hash and gets no validator, rather
207
- // than one that would disagree with GET.
205
+ // A strong validator over the representation. A trusted function's HEAD
206
+ // answer is now measured the same way its GET answer is (the body is
207
+ // read to determine its real length even though HEAD never puts it on
208
+ // the wire — see #139), so it has real bytes to hash here and gets a
209
+ // validator that agrees with GET's. Only a HEAD answer that genuinely
210
+ // has no body to measure (e.g. one carrying a native reply with no
211
+ // body) skips computing one, rather than hashing zero bytes and
212
+ // asserting a validator for a representation it never measured.
208
213
  const body = bodyOf(result);
209
214
  if (req.method === 'HEAD' && !body.length) return result;
210
215
  etag = '"' + createHash('sha256').update(body).digest('hex') + '"'; headers = [...headers, ['etag', etag]];
@@ -26,7 +26,8 @@ export const phases = ['response'];
26
26
 
27
27
  const DYNAMIC_LIMIT = 1048576; // bytes; above this a body is never compressed on the request path
28
28
  const PRECOMPRESSED_BUDGET = 64 * 1024 * 1024; // aggregate bytes of asset variants per runtime
29
- const DEFAULT_TYPES = ['text/*','application/json','application/javascript','application/xml','image/svg+xml','application/manifest+json','application/ld+json'];
29
+ /** The media types compressed when `types` is not declared; exported so the deployment verifier reads the same table. */
30
+ export const DEFAULT_TYPES = ['text/*','application/json','application/javascript','application/xml','image/svg+xml','application/manifest+json','application/ld+json'];
30
31
  const SUFFIX = { br:'-br', gzip:'-gz', deflate:'-df', zstd:'-zs' };
31
32
  const BODYLESS = new Set([204,205,304]);
32
33
 
Binary file
package/dist/policies.js CHANGED
@@ -72,7 +72,7 @@ export function effectivePolicies(document , routeConfig
72
72
  layers.push(route);
73
73
  const effective = {};
74
74
  for (const layer of layers) for (const [key, value] of Object.entries(layer)) {
75
- if (key === 'profile') continue;
75
+ if (key === 'profile' || key === 'extensions') continue;
76
76
  if (value === false) { delete effective[key]; continue; }
77
77
  effective[key] = { ...(effective[key] || {}), ...value };
78
78
  }
package/dist/policy.js CHANGED
@@ -1,28 +1,52 @@
1
+ import { egressUrl } from './egress.js';
1
2
  import { readFile, realpath, stat } from 'node:fs/promises';
2
3
  import { relative, isAbsolute, sep } from 'node:path';
3
4
  import { createHash } from 'node:crypto';
4
5
  import { functionFile } from './config.js';
5
- import { collectFunctionSources, routeFunctions } from './function-sources.js';
6
-
6
+ import { collectFunctionSources, collectTrustedSources, routeFunctions } from './function-sources.js';
7
+
7
8
  import { assert } from './errors.js';
8
9
 
9
10
 
11
+
12
+
10
13
  /** The operator's binding grants: which env and secret names each route may read, pinned to a project hash. */
11
-
14
+
12
15
  /** The function snapshot plus the hash operator grants are pinned to. */
13
16
 
14
17
 
15
18
  export async function prepareFunctionSnapshot(loaded ) {
16
- const definitions = [];
17
- for (const [pattern,route] of Object.entries(loaded.routes)) for (const definition of routeFunctions(route)) definitions.push({pattern,function:{
18
- source:await functionFile(loaded.root,definition.source),export:definition.export || 'default',
19
- }});
20
- const collected = await collectFunctionSources(definitions,loaded.root);
21
- const sources = Object.fromEntries(Object.entries(collected.sources).sort(([a],[b])=>a < b ? -1 : a > b ? 1 : 0));
19
+ // Only `sandbox: true` routes are bundled into the QuickJS module snapshot
20
+ // FunctionPool loads (docs/SPIKE-DEFAULT-TRUST-MODEL.md): a trusted route's
21
+ // module never needs to satisfy the sandbox's relative-static-import-only
22
+ // rule or its per-module/total byte budgets, since it runs through Node's
23
+ // own module resolution, not the WASM guest.
24
+ const sandboxed = [];
25
+ const trusted = [];
26
+ const resolveOne = async (definition ) =>
27
+ ({source:await functionFile(loaded.root,definition.source),export:definition.export || 'default'});
28
+ for (const [pattern,route] of Object.entries(loaded.routes)) {
29
+ if (!routeFunctions(route).length) continue;
30
+ const middleware = await Promise.all((route.middleware || []).map(resolveOne));
31
+ const fn = route.function ? await resolveOne(route.function) : undefined;
32
+ if (route.sandbox) { for (const definition of [...middleware, ...(fn ? [fn] : [])]) sandboxed.push({pattern,function:definition}); }
33
+ else trusted.push({middleware, function: fn});
34
+ }
35
+ const collected = await collectFunctionSources(sandboxed,loaded.root);
36
+ const trustedSources = await collectTrustedSources(trusted,loaded.root);
37
+ // The hash operator grants pin to still covers trusted routes' own source, so
38
+ // an env/secret grant is invalidated when the trusted code that could use it
39
+ // changes, even though that code never enters collectFunctionSources's
40
+ // sandbox-shaped snapshot (see collectTrustedSources for what this does not
41
+ // catch: changes to a helper module a trusted entry imports but does not
42
+ // itself change).
43
+ const sources = Object.fromEntries(Object.entries({...collected.sources,...trustedSources}).sort(([a],[b])=>a < b ? -1 : a > b ? 1 : 0));
22
44
  // Generated site routes carry no bindings and depend on the origin, so they
23
- // stay out of the hash that operator grants are pinned to.
45
+ // stay out of the hash that operator grants are pinned to. Declared site and
46
+ // inherited policy/profile behavior are included: changing a pre-egress
47
+ // restriction must require a fresh operator review even if routes are unchanged.
24
48
  const declared = Object.fromEntries(Object.entries(loaded.routes).filter(([,route])=>!route.generated));
25
- const snapshot = { ...collected, projectSha256: createHash('sha256').update(JSON.stringify({routes:declared,...(loaded.document.dynamicLinks===true?{dynamicLinks:true}:{}),sources})).digest('hex') };
49
+ const snapshot = { ...collected, projectSha256: createHash('sha256').update(JSON.stringify({...(loaded.document.extensions?{extensions:loaded.document.extensions}:{}),routes:declared,...(loaded.document.policies?{policies:loaded.document.policies}:{}),...(loaded.document.profiles?{profiles:loaded.document.profiles}:{}),...(loaded.document.site?{site:loaded.document.site}:{}),sources})).digest('hex') };
26
50
  return snapshot;
27
51
  }
28
52
  export function validatePolicy(value ) {
@@ -33,8 +57,13 @@ export function validatePolicy(value ) {
33
57
  assert(routes && typeof routes === 'object' && !Array.isArray(routes), 'Policy requires route grants');
34
58
  for (const [path,grant] of Object.entries(routes)) {
35
59
  assert(path.startsWith('/') && grant && typeof grant === 'object' && !Array.isArray(grant), 'Invalid route grant');
36
- assert(Object.keys(grant).every(k=>['env','secrets'].includes(k)), 'Unsupported policy capability');
37
- for (const list of Object.values(grant)) assert(Array.isArray(list) && list.length <= 64 && list.every(n=>typeof n === 'string' && /^[A-Za-z_][A-Za-z0-9_]*$/.test(n)), 'Invalid binding grant');
60
+ assert(Object.keys(grant).every(k=>['env','secrets','egress'].includes(k)), 'Unsupported policy capability');
61
+ const record=grant ;
62
+ if(record.egress!==undefined) {
63
+ assert(record.egress&&typeof record.egress==='object'&&!Array.isArray(record.egress)&&Object.keys(record.egress).every(key=>['proxy','signals'].includes(key)),'Invalid egress grant');
64
+ for(const origins of Object.values(record.egress)){assert(Array.isArray(origins)&&origins.length<=64,'Invalid egress origins');for(const origin of origins){const url=egressUrl(origin);assert(url.origin===origin,'Egress grants must be exact HTTPS origins');}}
65
+ }
66
+ for (const list of [record.env,record.secrets].filter(value=>value!==undefined)) assert(Array.isArray(list) && list.length <= 64 && list.every(n=>typeof n === 'string' && /^[A-Za-z_][A-Za-z0-9_]*$/.test(n)), 'Invalid binding grant');
38
67
  }
39
68
  return value ; // every field was just checked
40
69
  }
@@ -47,9 +76,21 @@ export async function loadOperatorPolicy(file , project
47
76
  return validatePolicy(JSON.parse(await readFile(path,'utf8')));
48
77
  }
49
78
  export function requestedPermissions(loaded , snapshot ) {
50
- return {version:1,projectSha256:snapshot.projectSha256,routes:Object.fromEntries(Object.entries(loaded.routes).flatMap(([path,route]) => {
79
+ return {version:1,projectSha256:snapshot.projectSha256,routes:Object.fromEntries(Object.entries(loaded.routes).flatMap(([path,route]) => {
51
80
  const env = Object.values(route.env || {}).flatMap(ref=>ref.env ? [ref.env] : []);
52
81
  const secrets = Object.values(route.secrets || {}).map(ref=>ref.secret);
53
- return env.length || secrets.length ? [[path,{env:[...new Set(env)],secrets:[...new Set(secrets)]}]] : [];
82
+ const egress ={...(route.proxy?{proxy:[egressUrl(route.proxy.url).origin]}:{}),...(route.signals?.length?{signals:[...new Set(route.signals.map(signal=>egressUrl(signal.url).origin))]}:{})};
83
+ return env.length || secrets.length || Object.keys(egress).length ? [[path,{env:[...new Set(env)],secrets:[...new Set(secrets)],...(Object.keys(egress).length?{egress}:{})}]] : [];
54
84
  }))};
55
85
  }
86
+
87
+ /** Called before resources or credentials are activated. Grants never come from YAML. */
88
+ export function authorizeEgress(loaded ,projectSha256 ,policy ) {
89
+ const proxy=new Set (),signals=new Set ();
90
+ for(const [path,route] of Object.entries(loaded.routes))for(const [purpose,urls]of [['proxy',route.proxy?[route.proxy.url]:[]],['signals',route.signals?.map(signal=>signal.url)||[]]] )for(const destination of urls){
91
+ const origin=egressUrl(destination).origin;
92
+ assert(policy?.projectSha256===projectSha256&&policy.routes[path]?.egress?.[purpose]?.includes(origin),'Egress denied by revision-pinned operator policy');
93
+ (purpose==='proxy'?proxy:signals).add(origin);
94
+ }
95
+ assert(proxy.size<=64&&signals.size<=64,'Runtime egress origins exceed limit');return {proxy:[...proxy],signals:[...signals]};
96
+ }
package/dist/prerender.js CHANGED
@@ -1,6 +1,9 @@
1
1
  import {mkdir, writeFile} from 'node:fs/promises';
2
2
  import {resolve, relative, isAbsolute, join, dirname, sep} from 'node:path';
3
3
  import {createRuntime} from './runtime.js';
4
+ import {loadDocument, functionFile} from './config.js';
5
+ import {applySite} from './site.js';
6
+ import {collectFunctionSources, routeFunctions, MODULE_LIMIT, TOTAL_BYTE_LIMIT} from './function-sources.js';
4
7
  import {ConfigError, assert} from './errors.js';
5
8
 
6
9
 
@@ -80,6 +83,55 @@ export async function assertNativeProject(project , {allow = ['page', 'st
80
83
  } finally { await runtime.close(); }
81
84
  }
82
85
 
86
+ // Which routes can share one function snapshot. A guest snapshot is bounded in
87
+ // module count and source bytes (docs/FUNCTION-SECURITY.md), and those bounds
88
+ // are not relaxed for build-time content: a site whose pages each compile to
89
+ // their own module therefore cannot be rendered in a single pass past 127
90
+ // modules, whatever their size. Rather than making that the caller's problem,
91
+ // the helper measures each route's module closure up front — reading sources
92
+ // only, never building a snapshot — and packs routes greedily into passes that
93
+ // stay inside the budgets. Shared modules (a template middleware) are counted
94
+ // once per pass, so they are paid for in every pass that needs them.
95
+ async function planPasses(source ) {
96
+ const loaded = await loadDocument(source);
97
+ // Site conventions become routes before compilation, so they must be named
98
+ // here too: `only` is matched against the expanded route set.
99
+ await applySite(loaded, {log: () => {}});
100
+ const patterns = Object.keys(loaded.routes);
101
+ assert(patterns.length, 'Source project declares no routes');
102
+ const cost = new Map ();
103
+ for (const pattern of patterns) {
104
+ const route = loaded.routes[pattern] ;
105
+ const definitions = [];
106
+ for (const definition of routeFunctions(route))
107
+ definitions.push({function: {source: await functionFile(loaded.root, definition.source), export: definition.export || 'default'}});
108
+ // One route at a time: the same collector the snapshot uses, so a route
109
+ // whose own closure cannot fit fails here with the collector's message.
110
+ const collected = await collectFunctionSources(definitions, loaded.root);
111
+ cost.set(pattern, Object.entries(collected.sources).map(([name, code]) => [name, Buffer.byteLength(code)]));
112
+ }
113
+ const passes = [];
114
+ let current = [], modules = new Map (), bytes = 0;
115
+ for (const pattern of patterns) {
116
+ const own = cost.get(pattern) ;
117
+ const fits = () => {
118
+ const added = own.filter(([name]) => !modules.has(name));
119
+ // `collect` admits a module only while the count is below the limit, so a
120
+ // pass holds at most MODULE_LIMIT - 1 modules.
121
+ return modules.size + added.length <= MODULE_LIMIT - 1 && bytes + added.reduce((total, [, size]) => total + size, 0) <= TOTAL_BYTE_LIMIT;
122
+ };
123
+ if (current.length && !fits()) {
124
+ passes.push(current);
125
+ current = []; modules = new Map(); bytes = 0;
126
+ }
127
+ assert(fits(), `Route ${pattern} needs ${own.length} modules and ${own.reduce((total, [, size]) => total + size, 0)} bytes of source, more than one snapshot can hold`);
128
+ for (const [name, size] of own) if (!modules.has(name)) { modules.set(name, size); bytes += size; }
129
+ current.push(pattern);
130
+ }
131
+ if (current.length) passes.push(current);
132
+ return passes;
133
+ }
134
+
83
135
  export async function prerenderPages(project , output , {
84
136
  origin = 'http://localhost', fileName = pageFileName, ignoreUnrenderable = false,
85
137
  maxPages = 500, maxPageBytes = 512 * 1024, maxTotalBytes = 32 * 1024 * 1024, log = () => {},
@@ -97,49 +149,56 @@ export async function prerenderPages(project , output , {
97
149
  assert(!overlaps(source, directory) && !overlaps(directory, source), 'Source and output directories must not overlap');
98
150
 
99
151
  const pages = [];
100
- let bytes = 0;
101
- const runtime = await createRuntime(source, {log: () => {}});
102
- try {
103
- const inventory = runtime.testPlan().inventory;
104
- const renderable = (route ) => route.handler === 'function' && route.state === 'active' && route.methods.includes('GET');
105
- const targets = inventory.filter(renderable);
106
- // Silently skipping a route publishes an incomplete site that looks whole.
107
- if (!ignoreUnrenderable) {
108
- const skipped = inventory.filter(route => !renderable(route));
109
- assert(!skipped.length,
110
- `Source project has routes this build would not render: ${skipped.map(route => `${route.path} (${route.handler}, ${route.state})`).join(', ')}. Pass ignoreUnrenderable to allow it`);
111
- }
112
- assert(targets.length, 'Source project has no active GET function routes to prerender');
113
- assert(targets.length <= maxPages, `Source project page count ${targets.length} exceeds maxPages ${maxPages}`);
114
- // Case-insensitive: on macOS and Windows two names differing only in case
115
- // are one file, so the second render would silently replace the first.
116
- const taken = new Map ();
117
- for (const route of targets) {
118
- assertLiteralRoutePath(route.path);
119
- const file = fileName(route.path);
120
- assert(typeof file === 'string' && FILE.test(file) && !PROTECTED.has(file), `Unsafe output filename ${file} for route ${route.path}`);
121
- const key = file.toLowerCase();
122
- assert(!taken.has(key), `Routes ${taken.get(key)} and ${route.path} both render ${file}`);
123
- taken.set(key, route.path);
124
- const result = await runtime.handle({target: route.path, method: 'GET', origin});
125
- assert(result.status === 200, `${route.path} rendered ${result.status}; expected 200`);
126
- const type = result.headers.find(([name]) => name.toLowerCase() === 'content-type')?.[1] ?? '';
127
- assert(/^text\/html\s*(?:;|$)/i.test(type), `${route.path} rendered ${type || 'no content type'}; expected text/html`);
128
- // The response body is bytes. Keeping it as a Buffer is what preserves a
129
- // multi-byte character exactly, through the file and its fixture alike.
130
- const body = typeof result.body === 'string' ? Buffer.from(result.body) : Buffer.from(result.body ?? []);
131
- assert(body.length > 0 && body.length <= maxPageBytes,
132
- `${route.path} rendered ${body.length} bytes; expected 1..${maxPageBytes} (maxPageBytes)`);
133
- bytes += body.length;
134
- assert(bytes <= maxTotalBytes, `Rendered pages exceed the total budget of ${maxTotalBytes} bytes (maxTotalBytes)`);
135
- pages.push({path: route.path, file, bytes: body.length, body});
136
- log({event: 'prerendered', path: route.path, file, bytes: body.length});
152
+ let bytes = 0, targetCount = 0;
153
+ const renderable = (route ) => route.handler === 'function' && route.state === 'active' && route.methods.includes('GET');
154
+ // Case-insensitive, and shared across passes: on macOS and Windows two names
155
+ // differing only in case are one file, so the second render would silently
156
+ // replace the first.
157
+ const taken = new Map ();
158
+ const passes = await planPasses(source);
159
+ if (passes.length > 1) log({event: 'prerender-passes', passes: passes.length});
160
+ for (const only of passes) {
161
+ // One runtime per pass, each holding only its own pass's function snapshot.
162
+ const runtime = await createRuntime(source, {log: () => {}, only});
163
+ try {
164
+ const inventory = runtime.testPlan().inventory;
165
+ const targets = inventory.filter(renderable);
166
+ // Silently skipping a route publishes an incomplete site that looks whole.
167
+ if (!ignoreUnrenderable) {
168
+ const skipped = inventory.filter(route => !renderable(route));
169
+ assert(!skipped.length,
170
+ `Source project has routes this build would not render: ${skipped.map(route => `${route.path} (${route.handler}, ${route.state})`).join(', ')}. Pass ignoreUnrenderable to allow it`);
171
+ }
172
+ targetCount += targets.length;
173
+ assert(targetCount <= maxPages, `Source project page count ${targetCount} exceeds maxPages ${maxPages}`);
174
+ for (const route of targets) {
175
+ assertLiteralRoutePath(route.path);
176
+ const file = fileName(route.path);
177
+ assert(typeof file === 'string' && FILE.test(file) && !PROTECTED.has(file), `Unsafe output filename ${file} for route ${route.path}`);
178
+ const key = file.toLowerCase();
179
+ assert(!taken.has(key), `Routes ${taken.get(key)} and ${route.path} both render ${file}`);
180
+ taken.set(key, route.path);
181
+ const result = await runtime.handle({target: route.path, method: 'GET', origin});
182
+ assert(result.status === 200, `${route.path} rendered ${result.status}; expected 200`);
183
+ const type = result.headers.find(([name]) => name.toLowerCase() === 'content-type')?.[1] ?? '';
184
+ assert(/^text\/html\s*(?:;|$)/i.test(type), `${route.path} rendered ${type || 'no content type'}; expected text/html`);
185
+ // The response body is bytes. Keeping it as a Buffer is what preserves a
186
+ // multi-byte character exactly, through the file and its fixture alike.
187
+ const body = typeof result.body === 'string' ? Buffer.from(result.body) : Buffer.from(result.body ?? []);
188
+ assert(body.length > 0 && body.length <= maxPageBytes,
189
+ `${route.path} rendered ${body.length} bytes; expected 1..${maxPageBytes} (maxPageBytes)`);
190
+ bytes += body.length;
191
+ assert(bytes <= maxTotalBytes, `Rendered pages exceed the total budget of ${maxTotalBytes} bytes (maxTotalBytes)`);
192
+ pages.push({path: route.path, file, bytes: body.length, body});
193
+ log({event: 'prerendered', path: route.path, file, bytes: body.length});
194
+ }
195
+ } finally {
196
+ // The runtime owns worker threads. Close it whether or not the render
197
+ // succeeded, so a failing build exits instead of hanging.
198
+ await runtime.close();
137
199
  }
138
- } finally {
139
- // The runtime owns worker threads. Close it whether or not the render
140
- // succeeded, so a failing build exits instead of hanging.
141
- await runtime.close();
142
200
  }
201
+ assert(targetCount, 'Source project has no active GET function routes to prerender');
143
202
 
144
203
  // Nothing is written until every page has rendered, and the directory itself
145
204
  // must be new: a failed or partial build never damages an existing artifact.
@@ -5,12 +5,12 @@ import { startServer } from './server.js';
5
5
  import { readCases, hit } from './readiness.js';
6
6
 
7
7
 
8
-
8
+
9
9
 
10
10
 
11
- export async function runProjectTests(project , { log = () => {}, permissions, linkStore, origin } = {}) {
11
+ export async function runProjectTests(project , { log = () => {}, permissions, origin, extensions, plugins } = {}) {
12
12
  const root = await realpath(project), cases = await readCases(root);
13
- const app = await startServer({ project, port: 0, local: true, log, permissions, linkStore, origin });
13
+ const app = await startServer({ project, port: 0, local: true, log, permissions, origin, extensions, plugins });
14
14
  const agent = new Agent({keepAlive:true,maxSockets:1}); let failed = 0;
15
15
  try {
16
16
  for (const [i,test] of cases.entries()) {
@@ -0,0 +1,92 @@
1
+ import { request } from 'node:https';
2
+ import { createHash } from 'node:crypto';
3
+
4
+
5
+
6
+
7
+
8
+
9
+
10
+
11
+
12
+
13
+
14
+
15
+
16
+ const MAX_BYTES=64*1024;
17
+ const prefix='/urlcode-conformance';
18
+ /** Synthetic read-only routes, except a POST to a declared constant response. */
19
+ export function providerConformanceCases() {
20
+ return [
21
+ {id:'redirect-query-drop',path:`${prefix}/redirect?ignored=secret`,method:'GET',headers:{},expected:{status:302,headers:{location:'https://example.test/destination'},body:''}},
22
+ {id:'redirect-head',path:`${prefix}/redirect`,method:'HEAD',headers:{},expected:{status:302,headers:{location:'https://example.test/destination'},body:''}},
23
+ {id:'path-component',path:`${prefix}/people/A%20B`,method:'GET',headers:{},expected:{status:307,headers:{location:'https://example.test/people/A%20B'},body:''}},
24
+ {id:'query-validation',path:`${prefix}/query?n=7&ignored=yes`,method:'GET',headers:{},expected:{status:302,headers:{location:'https://example.test/query?n=7'},body:''}},
25
+ {id:'query-invalid',path:`${prefix}/query?n=not-a-number`,method:'GET',headers:{},expected:{status:400}},
26
+ {id:'query-duplicate',path:`${prefix}/query?n=1&n=2`,method:'GET',headers:{},expected:{status:400}},
27
+ {id:'constant-response',path:`${prefix}/hello`,method:'GET',headers:{},expected:{status:200,headers:{'content-type':'text/plain; charset=utf-8','x-conformance':'v1'},body:'provider conformance v1'}},
28
+ {id:'response-head',path:`${prefix}/hello`,method:'HEAD',headers:{},expected:{status:200,body:''}},
29
+ {id:'method-refusal',path:`${prefix}/hello`,method:'POST',headers:{},body:'',expected:{status:405,headers:{allow:'GET, HEAD'}}},
30
+ {id:'body-accepted',path:`${prefix}/body`,method:'POST',headers:{'content-type':'text/plain'},body:'small',expected:{status:200,body:'accepted'}},
31
+ {id:'body-limit',path:`${prefix}/body`,method:'POST',headers:{'content-type':'text/plain'},body:'x'.repeat(33),expected:{status:413}},
32
+ {id:'missing-route',path:`${prefix}/missing`,method:'GET',headers:{},expected:{status:404}},
33
+ ];
34
+ }
35
+ function optionsChecked(target ,options ) {
36
+ if(!['self-hosted','aws','vercel','cloudflare'].includes(target))throw new Error('Unknown verification target');
37
+ const timeout=options.timeoutMs??3000;
38
+ if(!Number.isInteger(timeout)||timeout<50||timeout>10000)throw new Error('Timeout must be 50–10000 ms');
39
+ for(const value of [options.release,options.gitCommit])if(value!==undefined&&(typeof value!=='string'||value.length>256||/[\u0000-\u001f\u007f]/u.test(value)))throw new Error('Release identity must be at most 256 printable characters');
40
+ return timeout;
41
+ }
42
+ async function run(target ,transport ,options ,origin ) {
43
+ const timeout=optionsChecked(target,options),cases=providerConformanceCases(),findings =[];
44
+ const deadline=performance.now()+60000;let requests=0;
45
+ for(const probe of cases){
46
+ const failures =[];let status =null;
47
+ const controller=new AbortController();let timer ;
48
+ try{
49
+ const remaining=deadline-performance.now();if(remaining<=0)throw new Error('suite deadline');
50
+ requests++;
51
+ const answer=await Promise.race([transport(probe,controller.signal),new Promise ((_,reject)=>{timer=setTimeout(()=>{reject(new Error('request deadline'));controller.abort();},Math.min(timeout,remaining));})]);
52
+ status=answer.status;
53
+ if(Buffer.byteLength(answer.body)>MAX_BYTES)throw new Error('response limit');
54
+ if(answer.status!==probe.expected.status)failures.push('status differs');
55
+ const headers=Object.fromEntries(Object.entries(answer.headers).map(([name,value])=>[name.toLowerCase(),value]));
56
+ for(const [name,value]of Object.entries(probe.expected.headers??{}))if(headers[name]!==value)failures.push(`header ${name} differs`);
57
+ if(probe.expected.body!==undefined&&answer.body!==probe.expected.body)failures.push('body differs');
58
+ }catch{failures.push('transport, deadline or response-limit failure');}
59
+ finally{clearTimeout(timer);controller.abort();}
60
+ findings.push({case:probe.id,pass:failures.length===0,expectedStatus:probe.expected.status,observedStatus:status,failures});
61
+ }
62
+ return {schemaVersion:1,fixtureVersion:1,target,origin,evidence:origin===null?'local-adapter':'deployment-http',providerVerification:origin===null?'unverified':'observed',timestamp:new Date().toISOString(),release:options.release??null,gitCommit:options.gitCommit??null,fixtureSha256:createHash('sha256').update(JSON.stringify(cases)).digest('hex'),pass:findings.every(f=>f.pass),requests,findings,notes:[
63
+ origin===null?'Local adapter replay is not evidence of a deployed provider.':'HTTP observations apply only to the supplied origin and timestamp; provider identity and ownership are asserted by the caller, not attested.',
64
+ 'Release and Git identities are caller-supplied labels, not independently verified deployment identity.',
65
+ 'No provisioning, credentials, policy guarantees, soak, recovery or independent security assessment is included.',
66
+ 'Duplicate raw header counts and malformed-path normalization can differ at provider ingress; this common suite does not prove those transports equivalent.'
67
+ ]};
68
+ }
69
+ /** Replay against a caller-supplied local adapter; never label this a provider deployment. */
70
+ export async function runProviderConformance(target ,transport ,options ={}) {
71
+ return run(target,transport,options,null);
72
+ }
73
+ function httpsTransport(origin ) {
74
+ return (probe,signal)=>new Promise((resolve,reject)=>{
75
+ let settled=false;
76
+ const fail=()=>{if(!settled){settled=true;reject(new Error('HTTPS probe failed'));}};
77
+ const req=request(new URL(probe.path,origin),{method:probe.method,headers:{'user-agent':'URLCode-provider-conformance/1','accept-encoding':'identity',...probe.headers},agent:false,maxHeaderSize:16384,signal},res=>{
78
+ const chunks =[];let size=0;
79
+ res.on('error',fail);res.on('aborted',fail);
80
+ res.on('data',(chunk )=>{size+=chunk.length;if(size>MAX_BYTES){fail();res.destroy();req.destroy();}else chunks.push(chunk);});
81
+ res.on('end',()=>{if(settled)return;settled=true;const headers ={};for(const [name,value]of Object.entries(res.headers))if(value!==undefined)headers[name]=Array.isArray(value)?value.join(', '):value;resolve({status:res.statusCode??0,headers,body:Buffer.concat(chunks).toString('utf8')});});
82
+ });
83
+ req.on('error',fail);req.end(probe.body);
84
+ });
85
+ }
86
+ /** Probe only an explicitly supplied, operator-owned deployment of the fixture. Redirects are never followed. */
87
+ export async function verifyProviderDeployment(target ,origin ,options ={}) {
88
+ optionsChecked(target,options);
89
+ const url=new URL(origin);
90
+ if(url.protocol!=='https:'||url.username||url.password||url.pathname!=='/'||url.search||url.hash||url.origin!==origin.replace(/\/$/,''))throw new Error('Provide an explicit HTTPS origin without credentials, path, query or fragment');
91
+ return run(target,httpsTransport(url),options,url.origin);
92
+ }