@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
@@ -83,6 +83,7 @@ port.on('message', async ({id,source,name,request,context,maxBytes,timeoutMs,cha
83
83
  if (output === undefined || Buffer.byteLength(output) > maxBytes * 6 + 65536) throw new Error('Output limit');
84
84
  const value = JSON.parse(output) ; // trust boundary: guest JSON, checked below
85
85
  if (!value || !Number.isInteger(value.status) || value.status < 200 || value.status > 599 || typeof value.body !== 'string' || !Array.isArray(value.headers) || value.headers.length > 256) throw new Error('Invalid response');
86
+ if (value.contentLength !== undefined && (!Number.isInteger(value.contentLength) || value.contentLength < 0)) throw new Error('Invalid response');
86
87
  if (value.nativeBody) {
87
88
  if (!native || value.status !== native.status || value.body !== '') throw new Error('Invalid native response');
88
89
  // Preserve native status and metadata (validators, ranges, redirect Location).
@@ -100,6 +101,7 @@ port.on('message', async ({id,source,name,request,context,maxBytes,timeoutMs,cha
100
101
  bytes += Buffer.byteLength(pair[0]) + Buffer.byteLength(pair[1]) + 4;
101
102
  }
102
103
  if (bytes > 16384) throw new Error('Header limit');
103
- post({id,status:value.status,headers:value.headers,body,nativeBody:value.nativeBody === true});
104
+ post({id,status:value.status,headers:value.headers,body,nativeBody:value.nativeBody === true,
105
+ ...(typeof value.contentLength === 'number' ? {contentLength:value.contentLength} : {})});
104
106
  } catch { post({id,error:true}); }
105
107
  });
package/dist/functions.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { Worker } from 'node:worker_threads';
2
2
  import { randomUUID } from 'node:crypto';
3
- import { collectFunctionSources, routeFunctions } from './function-sources.js';
4
-
3
+ import { collectSourcesFor, routeFunctions } from './function-sources.js';
4
+
5
5
  import { assert, ConfigError, HttpError } from './errors.js';
6
6
 
7
7
 
@@ -9,10 +9,24 @@ import { assert, ConfigError, HttpError } from './errors.js';
9
9
 
10
10
 
11
11
 
12
-
12
+ /** A module entry point the sandbox must load: an absolute source file path
13
+ * (already resolved and validated, e.g. via `functionFile()`) plus which
14
+ * export of it needs to be reachable. The same shape a `FunctionDefinition`
15
+ * already uses for `function`/`middleware` routes. */
16
+
17
+ /** What one `SandboxPool.execute()` call invokes: the same `{source, export}`
18
+ * shape as a `SandboxEntry`, naming one of the pool's declared entries. */
19
+
20
+ /** `entry` runs last (the route's `function`/handler); `chain` runs first, in
21
+ * order, each with `(request, context, next)` — the same wrap/middleware
22
+ * semantics `__invokePipeline` already gives sandboxed routes. Neither is
23
+ * required: an empty invocation with a `native` reply just returns it. */
24
+
25
+
13
26
 
14
27
 
15
28
 
29
+
16
30
 
17
31
  // The worker protocol. Only JSON-shaped data and byte buffers cross it.
18
32
 
@@ -26,27 +40,43 @@ import { assert, ConfigError, HttpError } from './errors.js';
26
40
 
27
41
 
28
42
 
29
-
43
+
30
44
 
31
45
 
32
46
 
33
47
 
34
48
 
35
- export class FunctionPool {
36
- root ; routes ; preparedSnapshot ; snapshot ;
49
+ // The generalized sandbox primitive: the pool of worker threads, each running
50
+ // the same function-worker.ts (QuickJS engine setup, dependency-closure module
51
+ // allowlisting, memory/stack limits, deadline enforcement via both the
52
+ // interrupt handler and outer worker termination, response-shape validation),
53
+ // driven by an explicit list of `{source, export}` entries rather than
54
+ // anything route/YAML-shaped. This is the ONE place that owns worker
55
+ // spawning/QuickJS setup/deadline enforcement; `FunctionPool` below is a thin
56
+ // route-shaped wrapper over it, not a second copy of the mechanics. Exported
57
+ // publicly (as `@jimhoyd/urlcode/sandbox`, see src/sandbox.ts) for an
58
+ // extension package that needs to run PROJECT code — a hook a project's own
59
+ // config names — through the same trusted/sandboxed dispatch selection route
60
+ // dispatch gets, when that hook declares `sandbox: true`
61
+ // (docs/EXTENSIONS.md#project-level-lifecycle-hooks). There is no "trusted"
62
+ // mode here: an extension wanting trusted execution just calls the project's
63
+ // function directly via `import()` (already possible via
64
+ // `ExtensionActivation.root`); this primitive is only ever the sandboxed path.
65
+ export class SandboxPool {
66
+ root ; entries ; preparedSnapshot ; snapshot ;
37
67
  restarts ; restartTimers ; log ; timeoutMs ; maxBytes ;
38
68
  modules ; size ; slots ; closed ;
39
- constructor(routes , { root, snapshot, workers = 2, timeoutMs = 5000, maxBytes = 1048576, log = () => {} } = {}) {
69
+ constructor(entries , { root, snapshot, workers = 2, timeoutMs = 5000, maxBytes = 1048576, log = () => {} } = {}) {
40
70
  assert(Number.isInteger(workers) && workers >= 1 && workers <= 32, 'Workers must be 1–32');
41
71
  assert(Number.isInteger(timeoutMs) && timeoutMs >= 10 && timeoutMs <= 60000, 'Function timeout must be 10–60000 ms');
42
72
  assert(Number.isInteger(maxBytes) && maxBytes >= 1 && maxBytes <= 16777216, 'Response limit must be 1–16777216 bytes');
43
- this.root = root; this.routes = routes; this.preparedSnapshot = snapshot;
73
+ this.root = root; this.entries = entries; this.preparedSnapshot = snapshot;
44
74
  // Consecutive replacement attempts per slot; cleared by a completed invocation.
45
75
  this.restarts = new Map(); this.restartTimers = new Set();
46
76
  this.log = log;
47
77
  this.timeoutMs = timeoutMs; this.maxBytes = maxBytes;
48
78
  const modules = new Map ();
49
- for (const definition of routes.flatMap(routeFunctions)) {
79
+ for (const definition of entries) {
50
80
  const { source, export: name } = definition;
51
81
  let names = modules.get(source);
52
82
  if (!names) { names = new Set(); modules.set(source, names); }
@@ -58,7 +88,7 @@ export class FunctionPool {
58
88
  }
59
89
  async start() {
60
90
  let snapshot = this.preparedSnapshot;
61
- if (!snapshot && this.size) { assert(this.root !== undefined, 'Function pool requires a project root'); snapshot = await collectFunctionSources(this.routes,this.root); }
91
+ if (!snapshot && this.size) { assert(this.root !== undefined, 'Function pool requires a project root'); snapshot = await collectSourcesFor(this.entries,this.root); }
62
92
  snapshot ??= {sources:{},dependencies:{},entries:[],names:new Map()};
63
93
  this.snapshot = snapshot;
64
94
  try { await Promise.all(Array.from({ length: this.size }, (_, i) => this.spawn(i))); }
@@ -138,11 +168,18 @@ export class FunctionPool {
138
168
  timer.unref(); this.restartTimers.add(timer);
139
169
  }
140
170
  get healthy() { return !this.closed && this.slots.length === this.size && this.slots.every(slot => slot?.ready); }
141
- execute(route , request , context , native ) {
171
+ /** `invocation.entry` runs last (chain-wrapped), `invocation.chain` first, in
172
+ * declared order — the same `__invokePipeline` wrap/middleware discipline
173
+ * (`next()` callable at most once) the sandboxed route path already
174
+ * enforces; there is no second dispatch mechanism for this. Every entry and
175
+ * chain source named here must already be one of this pool's declared
176
+ * `entries` (constructor), or the worker's own module allowlist denies it. */
177
+ execute(invocation , request , context , native ) {
142
178
  const slot = this.slots.find(s => s?.ready && !s.pending);
143
179
  const snapshot = this.snapshot;
144
180
  if (this.closed || !slot || !snapshot) return Promise.reject(new HttpError(503, 'Function capacity unavailable'));
145
181
  const id = randomUUID();
182
+ const { entry, chain = [] } = invocation;
146
183
  return new Promise((resolve, reject) => {
147
184
  const timer = setTimeout(() => {
148
185
  slot.pending = null; slot.ready = false;
@@ -154,8 +191,8 @@ export class FunctionPool {
154
191
  if (message.nativeBody && native) resolve({...message,body:native.body,...(native.contentLength === undefined ? {} : {contentLength:native.contentLength})});
155
192
  else resolve(message);
156
193
  }, reject };
157
- const message = { id, source: route.function ? snapshot.names.get(route.function.source) : undefined, name: route.function?.export,
158
- chain: (route.middleware || []).map(item => ({source:snapshot.names.get(item.source),name:item.export})),
194
+ const message = { id, source: entry ? snapshot.names.get(entry.source) : undefined, name: entry?.export,
195
+ chain: chain.map(item => ({source:snapshot.names.get(item.source),name:item.export})),
159
196
  native: native ? {status:native.status,headers:native.headers} : undefined,
160
197
  request, context, maxBytes: this.maxBytes, timeoutMs:this.timeoutMs + 100 };
161
198
  slot.worker.postMessage(message);
@@ -172,3 +209,37 @@ export class FunctionPool {
172
209
  await Promise.all(this.slots.map(s => s?.worker.terminate()));
173
210
  }
174
211
  }
212
+ // Thin, route-shaped wrapper over `SandboxPool`: every existing internal
213
+ // caller (route-level `function`/`middleware` dispatch, `src/runtime.ts`)
214
+ // keeps working exactly as before, translating `FunctionRoute[]`/`FunctionRoute`
215
+ // into the generalized `{source, export}` entries/target shape at the edge,
216
+ // never duplicating worker spawning, module allowlisting or deadline
217
+ // enforcement — that all still lives in the wrapped `SandboxPool`. Composition
218
+ // rather than inheritance because `execute()`'s route-shaped and
219
+ // entries-shaped parameters are genuinely different, incompatible types; the
220
+ // fields/methods below the constructor exist only so the (unchanged) test
221
+ // suite and `src/runtime.ts` keep reaching the wrapped pool's own state
222
+ // exactly as they did before this file had two classes.
223
+ export class FunctionPool {
224
+ routes ; pool ;
225
+ constructor(routes , options = {}) {
226
+ this.routes = routes;
227
+ this.pool = new SandboxPool(routes.flatMap(routeFunctions), options);
228
+ }
229
+ async start() { await this.pool.start(); return this; }
230
+ execute(route , request , context , native ) {
231
+ return this.pool.execute({ entry: route.function, chain: route.middleware }, request, context, native);
232
+ }
233
+ scheduleRespawn(index ) { this.pool.scheduleRespawn(index); }
234
+ close() { return this.pool.close(); }
235
+ get healthy() { return this.pool.healthy; }
236
+ get restarts() { return this.pool.restarts; }
237
+ get restartTimers() { return this.pool.restartTimers; }
238
+ get slots() { return this.pool.slots; }
239
+ get size() { return this.pool.size; }
240
+ get closed() { return this.pool.closed; }
241
+ get snapshot() { return this.pool.snapshot; }
242
+ get log() { return this.pool.log; }
243
+ get timeoutMs() { return this.pool.timeoutMs; }
244
+ get maxBytes() { return this.pool.maxBytes; }
245
+ }
package/dist/guest-api.js CHANGED
@@ -5,7 +5,7 @@
5
5
  /** The request the guest receives (stringified as JSON in the worker). */
6
6
 
7
7
  /** What the guest returns as JSON text; the worker enforces this shape before trusting it. */
8
-
8
+
9
9
  // Runs only inside QuickJS/WASM. No native host functions or objects are exposed.
10
10
  // This is the documented text/JSON subset, not a complete Fetch implementation.
11
11
  export const guestBootstrap = String.raw`
@@ -13,6 +13,24 @@ export const guestBootstrap = String.raw`
13
13
  const NativeJSON = JSON;
14
14
  const stringify = JSON.stringify.bind(JSON);
15
15
  const now = Date.now.bind(Date);
16
+ // The guest has no TextEncoder/Buffer; this counts the UTF-8 bytes a
17
+ // Response's text would occupy on the wire (matching Buffer.byteLength on
18
+ // the host side, including its handling of lone surrogates as U+FFFD),
19
+ // without pulling any Node capability into the sandbox (#144).
20
+ function byteLength(text) {
21
+ let bytes = 0;
22
+ for (let i = 0; i < text.length; i++) {
23
+ const code = text.charCodeAt(i);
24
+ if (code >= 0xd800 && code <= 0xdbff) {
25
+ const next = text.charCodeAt(i + 1);
26
+ if (next >= 0xdc00 && next <= 0xdfff) { bytes += 4; i++; continue; }
27
+ bytes += 3; continue;
28
+ }
29
+ if (code >= 0xdc00 && code <= 0xdfff) { bytes += 3; continue; }
30
+ bytes += code < 0x80 ? 1 : code < 0x800 ? 2 : 3;
31
+ }
32
+ return bytes;
33
+ }
16
34
  const timers = new Map(); let next = 1;
17
35
  globalThis.setTimeout = (fn, delay = 0) => {
18
36
  if (typeof fn !== 'function' || timers.size >= 128) throw new Error('Timer limit');
@@ -94,8 +112,14 @@ export const guestBootstrap = String.raw`
94
112
  const response = await dispatch(0);
95
113
  if (violated || !(response instanceof Response)) throw new TypeError('Invalid middleware response');
96
114
  const nativeBody = response === nativeResponse;
115
+ const isHead = input.request.method === 'HEAD';
116
+ // The real length is already known "for free": _text is a fully
117
+ // materialized string at construction time, no stream read needed
118
+ // (#144, mirroring #139's fix for the trusted path). Only the
119
+ // transmitted bytes are suppressed for HEAD, never the length.
97
120
  globalThis.__output = stringify({status:response.status,headers:response.headers._pairs,
98
- body:nativeBody || input.request.method === 'HEAD' ? '' : response._text,nativeBody});
121
+ body:nativeBody || isHead ? '' : response._text,nativeBody,
122
+ ...(isHead && !nativeBody ? {contentLength:byteLength(response._text)} : {})});
99
123
  globalThis.__state = 'done';
100
124
  } catch { globalThis.__state = 'failed'; }
101
125
  };
@@ -106,7 +130,9 @@ export const guestBootstrap = String.raw`
106
130
  const response = await handler(new Request(input.request.url, input.request), input.context);
107
131
  if (!(response instanceof Response)) throw new TypeError('Return a Response');
108
132
  const headers = response.headers._pairs;
109
- globalThis.__output = stringify({status:response.status,headers,body: input.request.method === 'HEAD' ? '' : response._text});
133
+ const isHead = input.request.method === 'HEAD';
134
+ globalThis.__output = stringify({status:response.status,headers,body: isHead ? '' : response._text,
135
+ ...(isHead ? {contentLength:byteLength(response._text)} : {})});
110
136
  globalThis.__state = 'done';
111
137
  } catch { globalThis.__state = 'failed'; }
112
138
  };
package/dist/index.js CHANGED
@@ -1,11 +1,45 @@
1
1
  export { createRuntime } from './runtime.js';
2
-
2
+
3
3
  export { startServer } from './server.js';
4
4
 
5
5
  export { loadDocument, validateDocument, parseYaml } from './config.js';
6
- export {openLinkStore} from './link-store.js';
7
-
8
- export {startLinkApi} from './link-api.js';
9
-
10
-
11
6
  export { events as observabilityEvents, validateObservers, createObserverSink, createMetrics, renderPrometheus } from './observability.js';
7
+
8
+ export { getCapabilities, routeCapabilities, analyzeProjectCapabilities, analyzeCompiledCapabilities, assertTargetCompatibility, normalizeCapabilityTarget } from './capabilities.js';
9
+ export { capabilityDetails } from './capabilities.js';
10
+
11
+
12
+ export { importRoutes, exportRoutes } from './interchange.js';
13
+
14
+
15
+ export {listRecipes, searchRecipes, showRecipe, addRecipe} from './recipes.js';
16
+
17
+ export {listExamples, searchExamples} from './examples.js';
18
+
19
+
20
+ export {buildTypeScriptProject} from './typescript-authoring.js';
21
+
22
+ export {importBulkProject} from './bulk.js';
23
+
24
+ export {inspectProject, validateProject, explainRoute, explainProject, previewImport, previewExport, getCapability, getSchemaFragment, schemaPathNames, inspectExtensions, describeExtensions, buildContext, renderContext, estimateTokens, documentationTokens} from './tooling.js';
25
+
26
+ export {buildManifest, renderManifest, MANIFEST_SCHEMA_VERSION} from './manifest.js';
27
+
28
+ export {serveMcp} from './mcp.js';
29
+
30
+ export {providerConformanceCases, runProviderConformance, verifyProviderDeployment} from './provider-verification.js';
31
+
32
+ export {normalizeMatch, assertDisjointMatches, matchesRoute} from './conditions.js';
33
+
34
+
35
+ export {buildCloudflare} from './build-cloudflare.js';
36
+
37
+ export {buildStatic} from './build-static.js';
38
+
39
+ export {runProjectTests} from './project-tests.js';
40
+
41
+ export {scaffoldProject} from './scaffold.js';
42
+
43
+ export {initProject, addRedirect} from './authoring.js';
44
+ export {initProjectWith} from './init-with.js';
45
+
@@ -0,0 +1,165 @@
1
+ import { mkdir, open, readFile, rm, unlink, lstat } from 'node:fs/promises';
2
+ import { createRequire } from 'node:module';
3
+ import { basename, dirname, join, relative, resolve, sep } from 'node:path';
4
+ import { pathToFileURL } from 'node:url';
5
+ import { parseDocument, stringify } from 'yaml';
6
+ import { initProject } from './authoring.js';
7
+ import { mcpConfigFile, renderMcpConfig } from './agents-guide.js';
8
+ import { loadDocument, parseYaml, validateDocument } from './config.js';
9
+ import { inspectExtensionRevision } from './extensions.js';
10
+
11
+ import { ConfigError, assert } from './errors.js';
12
+
13
+ /** Directory names inside the generated site. The route project lives under `app/`; everything else is operator-owned. */
14
+ export const PROJECT_DIRECTORY = 'app', HOST_FILE = 'host.mjs', ROUTES_FILE = 'routes/extensions.yaml';
15
+ const namePattern = /^[a-z][a-z0-9-]{0,63}$/;
16
+
17
+
18
+
19
+ export function parseWithNames(value ) {
20
+ const names = value.split(',').map(name => name.trim());
21
+ assert(names.length > 0 && names.every(name => namePattern.test(name)), 'Use --with name[,name] where each name is a lowercase extension package suffix such as auth');
22
+ assert(new Set(names).size === names.length, 'Duplicate --with names');
23
+ return names;
24
+ }
25
+ export const packageName = (name ) => `@jimhoyd/urlcode-${name}`;
26
+ const isCode = (error , code ) => error instanceof Error && 'code' in error && error.code === code;
27
+ const strings = (value ) => Array.isArray(value) && value.every(item => typeof item === 'string');
28
+ const record = (value ) => value !== null && typeof value === 'object' && !Array.isArray(value);
29
+
30
+ /**
31
+ * Resolves the extension package from the invoking directory (Node's package resolution with the default
32
+ * conditions), imports it, and calls its `scaffold` export. Nothing is bundled; core never imports these packages
33
+ * at build time. Refuses a missing package or a package without `scaffold` before anything is written.
34
+ */
35
+ export async function loadScaffold(name , request , cwd ) {
36
+ const pkg = packageName(name);
37
+ let entry ;
38
+ try { entry = createRequire(join(cwd, 'package.json')).resolve(pkg); }
39
+ catch (error) {
40
+ if (isCode(error, 'MODULE_NOT_FOUND')) throw new ConfigError(`Extension package ${pkg} is not installed in ${cwd}; run: npm install ${pkg}`);
41
+ throw error;
42
+ }
43
+ const module = await import(pathToFileURL(entry).href) ;
44
+ const scaffold = module.scaffold;
45
+ if (typeof scaffold !== 'function') throw new ConfigError(`${pkg} does not export scaffold; upgrade it to a release that supports urlcode init --with, or add ${name} by hand following its README`);
46
+ let result ;
47
+ try { result = await (scaffold )(request); }
48
+ catch (error) { throw new ConfigError(`${pkg} scaffold refused: ${error instanceof Error ? error.message : String(error)}`); }
49
+ assert(record(result) && result.name === name, `${pkg} scaffold must return a result named ${name}`);
50
+ assert(record(result.extensions) && record(result.routes), `${pkg} scaffold must return extensions and routes objects`);
51
+ assert(strings(result.hostImports) && strings(result.hostSetup) && strings(result.hostEntries) && (result.hostClose === undefined || strings(result.hostClose)), `${pkg} scaffold must return host fragments as string arrays`);
52
+ assert(strings(result.nextSteps) && typeof result.readme === 'string', `${pkg} scaffold must return readme text and nextSteps strings`);
53
+ assert(result.env === undefined || (record(result.env) && Object.values(result.env).every(item => typeof item === 'string')), `${pkg} scaffold env must map names to descriptions`);
54
+ assert(Array.isArray(result.files) && result.files.every((file ) => record(file) && typeof file.path === 'string' && (typeof file.content === 'string' || file.content instanceof Uint8Array) && (file.mode === undefined || (Number.isInteger(file.mode) && (file.mode ) >= 0 && (file.mode ) <= 0o777))), `${pkg} scaffold files must carry a path, content and an optional mode`);
55
+ return result ;
56
+ }
57
+
58
+ function filePath(root , path ) {
59
+ assert(typeof path === 'string' && path.length > 0 && path.length <= 1024 && !path.includes('\0'), 'Invalid scaffold file path');
60
+ const target = resolve(root, path), rel = relative(root, target);
61
+ assert(!path.startsWith('/') && rel === path.split('/').join(sep) && rel.length > 0 && !rel.startsWith('..'), `Scaffold file path must stay inside the site directory: ${path}`);
62
+ assert(rel !== PROJECT_DIRECTORY && !rel.startsWith(PROJECT_DIRECTORY + sep), `Scaffold files must stay outside the route project: ${path}`);
63
+ return target;
64
+ }
65
+ /** Creates the file exclusively: nothing generated is ever overwritten. */
66
+ async function write(target , content , mode = 0o644) {
67
+ await mkdir(dirname(target), { recursive: true, mode: 0o700 });
68
+ const file = await open(target, 'wx', mode);
69
+ try { await file.writeFile(content); await file.sync(); } finally { await file.close(); }
70
+ }
71
+ export function renderHost(names , results ) {
72
+ const lines = [`// Generated by urlcode init --with ${names.join(',')}. Trusted operator code: keep it outside ${PROJECT_DIRECTORY}/ and review before serving.`];
73
+ for (const result of results) lines.push(...result.hostImports);
74
+ lines.push('');
75
+ for (const result of results) if (result.hostSetup.length) lines.push(...result.hostSetup);
76
+ lines.push('export default {', ' extensions: [');
77
+ for (const result of results) for (const entry of result.hostEntries) lines.push(` ${entry},`);
78
+ lines.push(' ],', ' async close() {');
79
+ // Later extensions may depend on earlier setup, so release in reverse order.
80
+ for (const result of [...results].reverse()) for (const statement of result.hostClose ?? []) lines.push(` ${statement}`);
81
+ lines.push(' },', '};');
82
+ return lines.join('\n') + '\n';
83
+ }
84
+ function demote(markdown ) {
85
+ let fence = false;
86
+ return markdown.split('\n').map(line => { if (/^\s*(?:```|~~~)/.test(line)) fence = !fence; return !fence && /^#{1,5} /.test(line) ? `#${line}` : line; }).join('\n');
87
+ }
88
+ export function renderReadme(directory , names , results , starter , env , projectSha256 ) {
89
+ const steps = results.flatMap(result => result.nextSteps);
90
+ const parts = [`# ${basename(directory)}`, '',
91
+ `Created with \`urlcode init ${basename(directory)} --with ${names.join(',')}\`. \`${PROJECT_DIRECTORY}/\` is the route project (\`urlcode.yaml\`, functions, tests); \`${HOST_FILE}\` is the trusted operator host that wires the installed extension packages; operator modules and private data stay outside the project. Run every command with \`--project ${PROJECT_DIRECTORY} --host-file "$PWD/${HOST_FILE}"\`.`, '',
92
+ '## Starter', '', `The starter files live in \`${PROJECT_DIRECTORY}/\`; add \`--project ${PROJECT_DIRECTORY}\` and the host file to the commands below.`, '', demote(starter).trim(), ''];
93
+ for (const result of results) parts.push(`## Extension: ${result.name}`, '', result.readme.trim(), '');
94
+ parts.push('## Next steps', '', ...steps.map((step, index) => `${index + 1}. ${step}`), '');
95
+ if (Object.keys(env).length) parts.push('## Environment', '', ...Object.entries(env).map(([key, text]) => `- \`${key}\`: ${text}`), '');
96
+ parts.push('## Project revision', '', `\`${PROJECT_DIRECTORY}/urlcode.yaml\` currently has revision \`${projectSha256}\` (\`inspectExtensionRevision\`). Review the project, then pin exactly that value where the host expects it; any change to extension YAML, policies or mounts changes it and needs a new explicit review.`, '');
97
+ return parts.join('\n');
98
+ }
99
+
100
+ /**
101
+ * `urlcode init <directory> --with a,b`: the starter under `app/`, every extension's fragments merged into one
102
+ * `urlcode.yaml`, one `host.mjs`, one `README.md` and the extensions' own files. All packages are resolved and
103
+ * their scaffolds computed before anything is written, so a refusal leaves no directory behind.
104
+ */
105
+ export async function initProjectWith(destination , names , { cwd = process.cwd() } = {}) {
106
+ assert(names.length > 0, 'Provide at least one --with name');
107
+ const directory = resolve(destination), project = join(directory, PROJECT_DIRECTORY), hostFile = join(directory, HOST_FILE);
108
+ const request = { directory, project, hostFile, names };
109
+ const results = [];
110
+ const wipe = () => { for (const result of results) for (const file of result.files) if (file.content instanceof Uint8Array) file.content.fill(0); };
111
+ try {
112
+ for (const name of names) results.push(await loadScaffold(name, request, cwd));
113
+ // Cross-result conflicts are refused before the destination exists.
114
+ const extensions = Object.create(null) , routes = Object.create(null) , env = {};
115
+ const owners = new Map ();
116
+ for (const result of results) {
117
+ for (const [key, value] of Object.entries(result.extensions)) { assert(!Object.hasOwn(extensions, key), `Extension ${key} is declared by both ${owners.get('e:' + key)} and ${result.name}`); owners.set('e:' + key, result.name); extensions[key] = value; }
118
+ for (const [key, value] of Object.entries(result.routes)) { assert(!Object.hasOwn(routes, key), `Route ${key} is added by both ${owners.get('r:' + key)} and ${result.name}`); owners.set('r:' + key, result.name); routes[key] = value; }
119
+ for (const [key, value] of Object.entries(result.env ?? {})) { assert(!Object.hasOwn(env, key) || env[key] === value, `Environment variable ${key} is described differently by ${result.name}`); env[key] = value; }
120
+ const seen = new Set ();
121
+ for (const file of result.files) { const path = filePath(directory, file.path); assert(!seen.has(path), `${result.name} scaffolds ${file.path} twice`); seen.add(path); }
122
+ }
123
+ await mkdir(dirname(directory), { recursive: true });
124
+ await mkdir(directory, { mode: 0o700 }); // refuses an existing destination
125
+ try {
126
+ await initProject(project);
127
+ const starter = await readFile(join(project, 'README.md'), 'utf8');
128
+ await unlink(join(project, 'README.md')); // its content moves into the site README
129
+ await unlink(join(project, mcpConfigFile)); // re-registered at the site root, pointing at app/
130
+ // Refuse routes or extensions the starter already declares, including in its included files.
131
+ const loaded = await loadDocument(project);
132
+ for (const key of Object.keys(routes)) assert(!Object.hasOwn(loaded.routes, key), `Route ${key} from ${owners.get('r:' + key)} already exists in the starter`);
133
+ for (const key of Object.keys(extensions)) assert(!Object.hasOwn(loaded.document.extensions ?? {}, key), `Extension ${key} from ${owners.get('e:' + key)} already exists in the starter`);
134
+ const yamlFile = join(project, 'urlcode.yaml'), original = await readFile(yamlFile, 'utf8');
135
+ const doc = parseDocument(original);
136
+ // Extensions are declared in the entry file; their routes go into a last include so the starter's own routes
137
+ // stay first in the loaded order and the entry file stays small.
138
+ doc.set('extensions', { ...loaded.document.extensions, ...extensions });
139
+ doc.addIn(['includes'], ROUTES_FILE);
140
+ const fragment = stringify({ version: '1', routes });
141
+ validateDocument(doc.toJS()); validateDocument(parseYaml(fragment));
142
+ await write(join(project, ROUTES_FILE), `# Routes added by urlcode init --with ${names.join(',')}. Mounts are exclusive to the named extension.\n${fragment}`);
143
+ await rm(yamlFile); await write(yamlFile, String(doc));
144
+ await loadDocument(project);
145
+ const projectSha256 = await inspectExtensionRevision(project);
146
+ const written = new Set ();
147
+ for (const result of results) for (const file of result.files) {
148
+ const target = filePath(directory, file.path);
149
+ assert(!written.has(target), `Scaffold file ${file.path} is written by more than one extension`);
150
+ // Parent directories are created; existing files or symlinks anywhere on the path are refused.
151
+ let probe = dirname(target);
152
+ while (probe !== directory && probe.startsWith(directory)) { try { assert(!(await lstat(probe)).isSymbolicLink(), `Scaffold path passes through a symlink: ${file.path}`); } catch (error) { if (!isCode(error, 'ENOENT')) throw error; } probe = dirname(probe); }
153
+ await write(target, file.content, file.mode ?? 0o644); written.add(target);
154
+ }
155
+ await write(hostFile, renderHost(names, results), 0o600);
156
+ await write(join(directory, 'README.md'), renderReadme(directory, names, results, starter, env, projectSha256));
157
+ await write(join(directory, '.gitignore'), 'node_modules/\ndata/\n.env\n.env.*\n');
158
+ // The read-only MCP server for agents opened at the site root; --host-file and --allow-authoring stay operator choices.
159
+ await write(join(directory, mcpConfigFile), renderMcpConfig(PROJECT_DIRECTORY));
160
+ // AGENTS.md: initProject writes the application-level file into app/ once it produces one (NEXT-STEPS 1.1);
161
+ // nothing here overrides it. A site-level agent note would be assembled beside README.md at this point.
162
+ return { directory, project, hostFile, extensions: [...names], projectSha256, nextSteps: results.flatMap(result => result.nextSteps) };
163
+ } catch (error) { await rm(directory, { recursive: true, force: true }); throw error; }
164
+ } finally { wipe(); }
165
+ }
@@ -0,0 +1,42 @@
1
+ import { open, unlink } from 'node:fs/promises';
2
+ import { extname } from 'node:path';
3
+ import { loadDocument } from './config.js';
4
+ import { importRoutes, exportRoutes } from './interchange.js';
5
+
6
+ import { assert } from './errors.js';
7
+
8
+ export async function readConversionInput(file ) {
9
+ const handle=await open(file,'r');
10
+ try {
11
+ const stat=await handle.stat();assert(stat.isFile()&&stat.size<=32*1024*1024,'Conversion input must be a regular file of at most 32 MiB');
12
+ const buffer=Buffer.alloc(stat.size+1);let size=0;
13
+ while(size<buffer.length){const result=await handle.read(buffer,size,buffer.length-size,null);if(!result.bytesRead)break;size+=result.bytesRead;}
14
+ assert(size<=stat.size,'Conversion source grew during read');
15
+ return new TextDecoder('utf-8',{fatal:true}).decode(buffer.subarray(0,size));
16
+ } finally {await handle.close();}
17
+ }
18
+ export async function runInterchange(command ,args ,options ) {
19
+ assert(!options.report||options.report==='json','Use --report json');
20
+ const explicit=args.length===2?args[0]:undefined;
21
+ const file=args.length===2?args[1]:args[0];
22
+ assert(command==='import'?!!file&&args.length<=2:args.length===0,'Invalid conversion arguments');
23
+ const format=(options.format??(command==='export'?options.target:explicit??extname(file ).slice(1))) ;
24
+ let report ;
25
+ if(command==='import') report=await importRoutes({format,text:await readConversionInput(file ),source:file ,acceptProviderDifferences:options.acceptProviderDifferences===true});
26
+ else {
27
+ const loaded=await loadDocument(options.project);
28
+ const {includes:_includes,...document}=loaded.document;
29
+ report=await exportRoutes({format,document:{...document,routes:loaded.routes},source:options.project,acceptProviderDifferences:options.acceptProviderDifferences===true});
30
+ }
31
+ if(report.ok&&options.out&&!options.dryRun) {
32
+ const handle=await open(options.out,'wx',0o600);
33
+ try {await handle.writeFile(report.output );await handle.sync();}
34
+ catch(error){await handle.close();await unlink(options.out);throw error;}
35
+ await handle.close();
36
+ }
37
+ // Acknowledged non-lossless conversion always carries a report, even when
38
+ // output is written to a file; warnings cannot disappear into raw stdout.
39
+ const text=options.report==='json'||options.dryRun||options.out||!report.ok||!report.lossless
40
+ ? JSON.stringify(report)+'\n' : report.output ;
41
+ return {report,text};
42
+ }