@jimhoyd/urlcode 0.4.1 → 0.4.6

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 (212) hide show
  1. package/.claude/skills/urlcode-authoring/SKILL.md +60 -7
  2. package/.claude/skills/urlcode-operations/SKILL.md +4 -0
  3. package/README.md +19 -15
  4. package/SECURITY.md +5 -3
  5. package/dist/BUILD-MANIFEST.json +31 -28
  6. package/dist/agent-context.js +82 -0
  7. package/dist/agents-guide.js +42 -42
  8. package/dist/authoring.js +12 -2
  9. package/dist/body-schema.js +159 -0
  10. package/dist/build-cloudflare.js +2 -0
  11. package/dist/capabilities.js +1 -1
  12. package/dist/cli.js +29 -12
  13. package/dist/config.js +74 -6
  14. package/dist/context.js +4 -6
  15. package/dist/errors.js +3 -1
  16. package/dist/examples.js +1 -1
  17. package/dist/extensions.js +138 -3
  18. package/dist/http-policy.js +19 -4
  19. package/dist/http-response.js +2 -2
  20. package/dist/init-with.js +71 -9
  21. package/dist/mcp.js +15 -2
  22. package/dist/pattern-guard.js +32 -0
  23. package/dist/policies/security.js +0 -0
  24. package/dist/policy.js +16 -0
  25. package/dist/project-tests.js +35 -11
  26. package/dist/readiness.js +188 -32
  27. package/dist/recipes.js +1 -1
  28. package/dist/router.js +17 -0
  29. package/dist/runtime.js +26 -3
  30. package/dist/scaffold.js +0 -0
  31. package/dist/server.js +26 -3
  32. package/dist/site.js +0 -0
  33. package/dist/tooling.js +3 -1
  34. package/dist/types/agent-context.d.ts +44 -0
  35. package/dist/types/authoring.d.ts +3 -1
  36. package/dist/types/body-schema.d.ts +58 -0
  37. package/dist/types/config.d.ts +10 -2
  38. package/dist/types/context.d.ts +1 -1
  39. package/dist/types/errors.d.ts +9 -1
  40. package/dist/types/examples.d.ts +1 -1
  41. package/dist/types/extensions.d.ts +105 -2
  42. package/dist/types/http-policy.d.ts +3 -0
  43. package/dist/types/init-with.d.ts +10 -1
  44. package/dist/types/pattern-guard.d.ts +10 -0
  45. package/dist/types/project-tests.d.ts +9 -0
  46. package/dist/types/readiness.d.ts +71 -0
  47. package/dist/types/recipes.d.ts +1 -1
  48. package/dist/types/runtime.d.ts +4 -0
  49. package/dist/types/server.d.ts +9 -1
  50. package/dist/types/tooling.d.ts +2 -0
  51. package/dist/types/types.d.ts +13 -0
  52. package/dist/types.js +10 -2
  53. package/dist/typescript-authoring.js +5 -3
  54. package/dist/verify-deployment.js +16 -10
  55. package/examples/body-validation/README.md +16 -0
  56. package/examples/body-validation/example.yaml +17 -0
  57. package/examples/body-validation/tests/requests.json +15 -0
  58. package/examples/body-validation/urlcode.yaml +34 -0
  59. package/examples/coverage-waiver/README.md +8 -0
  60. package/examples/coverage-waiver/example.yaml +16 -0
  61. package/examples/coverage-waiver/functions/notes.mjs +2 -0
  62. package/examples/coverage-waiver/tests/requests.json +3 -0
  63. package/examples/coverage-waiver/urlcode.yaml +10 -0
  64. package/examples/data-dir/README.md +39 -0
  65. package/examples/data-dir/data/welcome.txt +1 -0
  66. package/examples/data-dir/example.yaml +22 -0
  67. package/examples/data-dir/functions/note.mjs +18 -0
  68. package/examples/data-dir/tests/requests.json +6 -0
  69. package/examples/data-dir/urlcode.yaml +6 -0
  70. package/examples/lifecycle/README.md +15 -0
  71. package/examples/lifecycle/example.yaml +19 -0
  72. package/examples/lifecycle/functions/notes.mjs +29 -0
  73. package/examples/lifecycle/tests/requests.json +12 -0
  74. package/examples/lifecycle/urlcode.yaml +29 -0
  75. package/examples/not-found/README.md +10 -0
  76. package/examples/not-found/example.yaml +17 -0
  77. package/examples/not-found/public/404.html +3 -0
  78. package/examples/not-found/public/index.html +3 -0
  79. package/examples/not-found/tests/requests.json +7 -0
  80. package/examples/not-found/urlcode.yaml +7 -0
  81. package/examples/shared-blocks/README.md +11 -0
  82. package/examples/shared-blocks/example.yaml +17 -0
  83. package/examples/shared-blocks/tests/requests.json +7 -0
  84. package/examples/shared-blocks/urlcode.yaml +37 -0
  85. package/llms-full.txt +436 -149
  86. package/llms.txt +44 -6
  87. package/package.json +15 -22
  88. package/recipes/static-page/README.md +9 -0
  89. package/recipes/static-page/public/index.html +11 -0
  90. package/recipes/static-page/recipe.yaml +21 -0
  91. package/recipes/static-page/tests/requests.json +22 -0
  92. package/recipes/static-page/urlcode.yaml +7 -0
  93. package/recipes/static-plus-api/README.md +6 -0
  94. package/recipes/static-plus-api/urlcode.yaml +4 -0
  95. package/recipes/store-crud/README.md +53 -0
  96. package/recipes/store-crud/recipe.yaml +31 -0
  97. package/recipes/store-crud/tests/requests.json +18 -0
  98. package/recipes/store-crud/urlcode.yaml +18 -0
  99. package/schemas/urlcode.schema.json +125 -60
  100. package/skills/urlcode/SKILL.md +53 -26
  101. package/starters/default/AGENTS.md +43 -43
  102. package/starters/page/README.md +14 -0
  103. package/starters/page/public/index.html +12 -0
  104. package/starters/page/tests/requests.json +17 -0
  105. package/starters/page/urlcode.yaml +6 -0
  106. package/.claude-plugin/marketplace.json +0 -18
  107. package/CONTRIBUTING.md +0 -112
  108. package/ROADMAP.md +0 -66
  109. package/docs/AI-AUTHORING.md +0 -338
  110. package/docs/ASSETS.md +0 -107
  111. package/docs/AUTH-BACKUP.md +0 -32
  112. package/docs/AWS.md +0 -86
  113. package/docs/BEST-PRACTICES.md +0 -276
  114. package/docs/BULK.md +0 -79
  115. package/docs/CAPABILITIES.md +0 -192
  116. package/docs/CAPACITY.md +0 -305
  117. package/docs/CI-FOLLOWUP-2026-09-19.md +0 -97
  118. package/docs/CI-RELEASE-AUDIT-2026-09-19.md +0 -322
  119. package/docs/CI.md +0 -147
  120. package/docs/CLOUDFLARE.md +0 -109
  121. package/docs/CODEBASE-AUDIT-2026-09-20.md +0 -284
  122. package/docs/COMPLIANCE.md +0 -239
  123. package/docs/COMPOSING-A-SITE.md +0 -278
  124. package/docs/CONDITIONS.md +0 -74
  125. package/docs/DEPLOYMENT-CHECKS.md +0 -108
  126. package/docs/DEVELOPMENT-PIPELINE.md +0 -270
  127. package/docs/EGRESS.md +0 -125
  128. package/docs/EXTENSIONS.md +0 -438
  129. package/docs/FRAMEWORK.md +0 -217
  130. package/docs/FUNCTION-SECURITY.md +0 -254
  131. package/docs/HTTP.md +0 -129
  132. package/docs/INSTALL.md +0 -128
  133. package/docs/INTERCHANGE.md +0 -134
  134. package/docs/LOAD-TESTING.md +0 -91
  135. package/docs/LOCAL-DEVELOPMENT.md +0 -102
  136. package/docs/MIDDLEWARE-EXAMPLES.md +0 -75
  137. package/docs/MIDDLEWARE.md +0 -102
  138. package/docs/MONITORING.md +0 -115
  139. package/docs/OBSERVABILITY.md +0 -222
  140. package/docs/OPEN-DECISIONS.md +0 -224
  141. package/docs/OPERATIONAL-PROOF.md +0 -41
  142. package/docs/OPERATIONS.md +0 -201
  143. package/docs/ORGANIZATION.md +0 -135
  144. package/docs/PERFORMANCE.md +0 -72
  145. package/docs/PLUGINS.md +0 -271
  146. package/docs/POLICIES.md +0 -211
  147. package/docs/PRERENDER.md +0 -245
  148. package/docs/PROJECT-DIRECTION.md +0 -118
  149. package/docs/PROVIDER-VERIFICATION.md +0 -84
  150. package/docs/READINESS.md +0 -150
  151. package/docs/README.md +0 -87
  152. package/docs/RECIPES.md +0 -99
  153. package/docs/RELEASE-0.4.0-alpha.3.md +0 -50
  154. package/docs/RELEASE-0.4.1.md +0 -73
  155. package/docs/RELEASE-READINESS.md +0 -117
  156. package/docs/RELEASE-SECURITY.md +0 -96
  157. package/docs/RESILIENCE.md +0 -161
  158. package/docs/ROUTING.md +0 -92
  159. package/docs/SANDBOX-REVIEW.md +0 -72
  160. package/docs/SCAFFOLDING.md +0 -70
  161. package/docs/SECURITY-AUDIT.md +0 -164
  162. package/docs/SITE.md +0 -150
  163. package/docs/SPECIFICATION.md +0 -359
  164. package/docs/SPIKE-AI-FRAMEWORK-BENCHMARK.md +0 -288
  165. package/docs/SPIKE-BUSINESS-SUITE.md +0 -1029
  166. package/docs/SPIKE-CORE-LAYERING.md +0 -368
  167. package/docs/SPIKE-DEFAULT-TRUST-MODEL.md +0 -211
  168. package/docs/STANDARDS.md +0 -311
  169. package/docs/STARTERS.md +0 -83
  170. package/docs/STATIC.md +0 -105
  171. package/docs/TOOLING.md +0 -298
  172. package/docs/TUNNELS.md +0 -72
  173. package/docs/TYPESCRIPT-AUTHORING.md +0 -87
  174. package/docs/TYPESCRIPT.md +0 -123
  175. package/docs/VERCEL.md +0 -114
  176. package/docs/VERSION-ALIGNMENT.md +0 -80
  177. package/docs/YAML-GUIDE.md +0 -57
  178. package/docs/YAML-REFERENCE.md +0 -449
  179. package/docs/archive/2026-09-19/EXTENSION-IMPLEMENTATION.md +0 -68
  180. package/docs/archive/2026-09-19/MANAGEMENT-SECURITY.md +0 -102
  181. package/docs/archive/2026-09-19/NEXT-PHASE-PLAN.md +0 -108
  182. package/docs/archive/2026-09-19/NEXT-STEPS.md +0 -646
  183. package/docs/archive/2026-09-19/OPEN-DECISIONS.md +0 -277
  184. package/docs/archive/2026-09-19/RELEASE-SECURITY.md +0 -186
  185. package/docs/archive/2026-09-19/ROADMAP.md +0 -387
  186. package/docs/archive/2026-09-19/SPIKE-EXTENSION-MODEL.md +0 -430
  187. package/docs/archive/2026-09-19/SPIKE-EXTENSIONS.md +0 -492
  188. package/docs/archive/2026-09-19/SPIKE-LAMBDA-COMPILE.md +0 -365
  189. package/docs/archive/2026-09-19/SPIKE-MONOREPO.md +0 -778
  190. package/docs/archive/2026-09-19/USABILITY-REVIEW.md +0 -139
  191. package/docs/archive/README.md +0 -28
  192. package/docs/policies/agents.md +0 -182
  193. package/docs/policies/cache.md +0 -152
  194. package/docs/policies/compression.md +0 -169
  195. package/docs/policies/contract.md +0 -52
  196. package/docs/policies/hardened.md +0 -56
  197. package/docs/policies/interoperability.md +0 -169
  198. package/docs/policies/operations.md +0 -45
  199. package/docs/policies/security.md +0 -161
  200. package/docs/policies/throttle.md +0 -103
  201. package/docs/yaml/assets.md +0 -36
  202. package/docs/yaml/conditions.md +0 -20
  203. package/docs/yaml/functions.md +0 -168
  204. package/docs/yaml/middleware.md +0 -31
  205. package/docs/yaml/organization.md +0 -74
  206. package/docs/yaml/policies.md +0 -37
  207. package/docs/yaml/redirects.md +0 -64
  208. package/docs/yaml/responses.md +0 -57
  209. package/docs/yaml/site.md +0 -24
  210. package/packaging/claude-plugin/.claude-plugin/plugin.json +0 -19
  211. package/packaging/claude-plugin/skills/urlcode-authoring/SKILL.md +0 -120
  212. package/packaging/claude-plugin/skills/urlcode-operations/SKILL.md +0 -108
@@ -1,6 +1,8 @@
1
1
  import Ajv from 'ajv/dist/2020.js';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { pathToFileURL } from 'node:url';
2
4
  import { assert, HttpError } from './errors.js';
3
- import { loadDocument } from './config.js';
5
+ import { functionFile, loadDocument } from './config.js';
4
6
  import { prepareFunctionSnapshot } from './policy.js';
5
7
  import { validateHeaderName, validateHeaderValue } from './header-validation.js';
6
8
 
@@ -52,9 +54,99 @@ import { validateHeaderName, validateHeaderValue } from './header-validation.js'
52
54
  * vary on credentials. Everything else under the mount stays no-store.
53
55
  */
54
56
 
57
+
58
+
59
+
60
+ /** Machine-readable contract for one project hook an extension exposes. */
61
+
62
+
63
+
64
+
65
+
66
+
67
+
68
+
69
+ /** One project-owned customization surface, shown to people and authoring agents by CLI/MCP inspection. */
70
+
71
+
72
+
73
+
74
+
75
+
76
+
77
+
78
+
79
+ /**
80
+ * Machine-readable guidance for changing an installed extension without
81
+ * copying its behavior into the application. This is descriptive only: it
82
+ * grants nothing and is never executed by the runtime.
83
+ */
84
+
85
+
86
+
87
+
88
+
89
+
90
+ /** Shared schema for project hook references. Omission means trusted execution. */
91
+ export const extensionHookReferenceSchema={
92
+ oneOf:[
93
+ {type:'string',minLength:1,maxLength:1024},
94
+ {type:'object',additionalProperties:false,required:['source'],properties:{
95
+ source:{type:'string',minLength:1,maxLength:1024},
96
+ export:{type:'string',pattern:'^[A-Za-z_][A-Za-z0-9_]*$'},
97
+ sandbox:{type:'boolean'},
98
+ sandboxReason:{type:'string',minLength:1,maxLength:512},
99
+ }},
100
+ ],
101
+ } ;
102
+ /** Builds the strict `config.hooks` schema from an extension's declared hook names. */
103
+ export function extensionHooksSchema(contracts ) {
104
+ return {type:'object',additionalProperties:false,properties:Object.fromEntries(contracts.map(contract=>[contract.name,extensionHookReferenceSchema]))};
105
+ }
106
+ /**
107
+ * Loads project hooks once per activation. Project hooks are trusted first-party
108
+ * code by default, matching function/middleware routes. Sandboxed arbitrary-value
109
+ * hooks are not part of contract v1 and are refused rather than run trusted.
110
+ */
111
+ export async function loadExtensionHooks (config ,contracts ,context ) {
112
+ const known=new Map(contracts.map(contract=>[contract.name,contract]));
113
+ const hooks =Object.create(null) ;
114
+ if(config===undefined)return hooks ;
115
+ assert(config&&typeof config==='object'&&!Array.isArray(config),'Extension hooks must be an object');
116
+ const epoch=randomUUID();
117
+ for(const [name,raw] of Object.entries(config)){
118
+ const contract=known.get(name);assert(contract,`Unknown extension hook: ${name}`);
119
+ assert(typeof raw==='string'||raw&&typeof raw==='object'&&!Array.isArray(raw),`Invalid extension hook: ${name}`);
120
+ const reference =typeof raw==='string'?{source:raw}:raw ;
121
+ if(reference.sandbox===true)throw new Error(`hook ${name}: sandbox: true is not supported for extension hooks; hooks run trusted by default`);
122
+ const exportName=reference.export??'default';
123
+ let modulePath ;
124
+ try{modulePath=await functionFile(context.root,reference.source);}
125
+ catch(error){throw new Error(`hook ${name}: failed to load module "${reference.source}"`,{cause:error});}
126
+ let module ;
127
+ try{module=await import(pathToFileURL(modulePath).href+'?urlcode-extension-hook-epoch='+epoch) ;}
128
+ catch(error){throw new Error(`hook ${name}: failed to load module "${reference.source}"`,{cause:error});}
129
+ const fn=module[exportName];
130
+ if(typeof fn!=='function')throw new Error(`hook ${name}: export "${exportName}" in "${reference.source}" is not a function`);
131
+ const ajv=new Ajv.default({strict:false,allErrors:false});
132
+ const validateInput=ajv.compile(contract.inputSchema);
133
+ const validateOutput=contract.outputSchema?ajv.compile(contract.outputSchema):undefined;
134
+ hooks[name]=(input ) =>{
135
+ assert(validateInput(input),`Invalid extension hook input: ${name}`);
136
+ const validate=(output ) =>{if(validateOutput)assert(validateOutput(output),`Invalid extension hook output: ${name}`);return output;};
137
+ const output=(fn )(input);
138
+ return output instanceof Promise?output.then(validate):validate(output);
139
+ };
140
+ }
141
+ return hooks ;
142
+ }
55
143
 
56
144
 
57
145
 
146
+
147
+
148
+
149
+
58
150
 
59
151
 
60
152
 
@@ -82,20 +174,39 @@ import { validateHeaderName, validateHeaderValue } from './header-validation.js'
82
174
 
83
175
 
84
176
 
85
-
177
+
86
178
 
179
+
180
+
181
+
182
+
183
+
184
+
185
+
87
186
 
88
187
 
89
188
 
90
189
 
91
190
 
191
+
192
+
193
+
194
+
195
+
196
+
197
+
198
+
199
+
200
+
201
+
202
+
92
203
 
93
204
 
94
205
 
95
206
 
96
207
 
97
208
 
98
-
209
+
99
210
 
100
211
 
101
212
 
@@ -105,6 +216,8 @@ import { validateHeaderName, validateHeaderValue } from './header-validation.js'
105
216
 
106
217
 
107
218
  const namePattern=/^[a-z][a-z0-9-]{0,63}$/;
219
+ const hookNamePattern=/^[a-z][A-Za-z0-9]{0,63}$/;
220
+ const authoringNamePattern=/^[A-Za-z0-9][A-Za-z0-9 ._/-]{0,127}$/;
108
221
  const cacheHeaders=new Set(['cache-control','cdn-cache-control','vercel-cdn-cache-control','surrogate-control']);
109
222
  const segmentPattern=/^[A-Za-z0-9_-][A-Za-z0-9._-]{0,63}$/;
110
223
  export const immutableCacheControl='public, max-age=31536000, immutable';
@@ -153,6 +266,28 @@ export function prepareExtensions(document ,routes
153
266
  assert(registration.version==='1'&&typeof registration.activate==='function','Invalid extension version or activation hook');
154
267
  assert(Array.isArray(registration.targets)&&registration.targets.every(target=>['node','aws','vercel'].includes(target)),'Extension targets must be node, aws or vercel');
155
268
  assert(typeof registration.projectSha256==='string'&&/^[a-f0-9]{64}$/.test(registration.projectSha256),'Extension requires an explicit operator revision pin');
269
+ const hookNames=new Set ();
270
+ for(const hook of registration.hooks??[]){
271
+ assert(hook&&typeof hook==='object'&&hookNamePattern.test(hook.name)&&!hookNames.has(hook.name),'Invalid extension hook contract');
272
+ assert(['filter','action'].includes(hook.kind)&&typeof hook.description==='string'&&hook.description.length>=1&&hook.description.length<=512,'Invalid extension hook contract');
273
+ assert(hook.inputSchema&&typeof hook.inputSchema==='object'&&(!hook.outputSchema||typeof hook.outputSchema==='object'),'Invalid extension hook contract');
274
+ hookNames.add(hook.name);
275
+ }
276
+ if(registration.authoring!==undefined){
277
+ const authoring=registration.authoring;
278
+ assert(authoring&&typeof authoring==='object'&&typeof authoring.description==='string'&&authoring.description.length>=1&&authoring.description.length<=1024,'Invalid extension authoring contract');
279
+ assert(Array.isArray(authoring.surfaces)&&authoring.surfaces.length<=64,'Invalid extension authoring surfaces');
280
+ const surfaceNames=new Set ();
281
+ for(const surface of authoring.surfaces){
282
+ assert(surface&&typeof surface==='object'&&['configuration','theme','copy','component','template','stylesheet','hook','extension'].includes(surface.kind),'Invalid extension authoring surface kind');
283
+ assert(typeof surface.name==='string'&&authoringNamePattern.test(surface.name)&&!surfaceNames.has(surface.name),'Invalid extension authoring surface name');
284
+ assert(typeof surface.description==='string'&&surface.description.length>=1&&surface.description.length<=1024,'Invalid extension authoring surface description');
285
+ assert(surface.path===undefined||typeof surface.path==='string'&&surface.path.length>=1&&surface.path.length<=1024,'Invalid extension authoring surface path');
286
+ assert(surface.command===undefined||typeof surface.command==='string'&&surface.command.length>=1&&surface.command.length<=2048,'Invalid extension authoring surface command');
287
+ surfaceNames.add(surface.name);
288
+ }
289
+ assert(authoring.fastChecks===undefined||Array.isArray(authoring.fastChecks)&&authoring.fastChecks.length<=32&&authoring.fastChecks.every(check=>typeof check==='string'&&check.length>=1&&check.length<=2048),'Invalid extension authoring fast checks');
290
+ }
156
291
  provided.set(registration.name,registration);
157
292
  }
158
293
  const declarations=document.extensions??{};
@@ -2,9 +2,11 @@ import { validateHeaderName, validateHeaderValue } from './header-validation.js'
2
2
  import { assert, HttpError } from './errors.js';
3
3
 
4
4
 
5
+ import { assertBodySchema, bodySchemaIssues, bodySchemaLine, bodySchemaJson, prefersJson } from './body-schema.js';
6
+
5
7
 
6
8
 
7
-
9
+
8
10
  /** A static reply compiled from `respond`; the body is bytes so every host, including the Worker, shares the type. */
9
11
 
10
12
  /** The declared HTTP surface of a route: response headers, request body policy and a static reply. */
@@ -18,14 +20,14 @@ import { assert, HttpError } from './errors.js';
18
20
  const encoder = new TextEncoder();
19
21
  const byteLength = (value ) => encoder.encode(value).length;
20
22
 
21
- const reserved = new Set(['connection','keep-alive','transfer-encoding','content-length','upgrade','trailer','proxy-authenticate','proxy-authorization','te','location','allow','content-range','accept-ranges','etag','last-modified','content-encoding','x-request-id','x-content-type-options']);
23
+ export const reservedResponseHeaders = new Set(['connection','keep-alive','transfer-encoding','content-length','upgrade','trailer','proxy-authenticate','proxy-authorization','te','location','allow','content-range','accept-ranges','etag','last-modified','content-encoding','x-request-id','x-content-type-options']);
22
24
  export function compileHttp(route ) {
23
25
  const seen = new Set (); let size = 0;
24
26
  const responseHeaders = route.responseHeaders = [];
25
27
  for (const [name,value] of Object.entries(route.response?.headers || {})) {
26
28
  const key = name.toLowerCase();
27
29
  assert(!seen.has(key), 'Duplicate response header (case insensitive)'); seen.add(key);
28
- assert(!reserved.has(key), 'Response header is owned by the runtime or handler');
30
+ assert(!reservedResponseHeaders.has(key), 'Response header is owned by the runtime or handler');
29
31
  assert(!Array.isArray(value) || key === 'set-cookie', 'Only Set-Cookie supports a header array');
30
32
  assert(!(route.page || route.static || route.download) || !['content-type','content-disposition','cache-control'].includes(key), 'Configure asset metadata on its handler');
31
33
  for (const item of Array.isArray(value) ? value : [value]) {
@@ -36,6 +38,11 @@ export function compileHttp(route ) {
36
38
  }
37
39
  }
38
40
  assert(size <= 16384, 'Response headers exceed 16 KiB');
41
+ const bodySchema = route.request?.body?.schema;
42
+ if (bodySchema !== undefined) {
43
+ assert(route.request?.body?.format === 'json', 'request.body.schema requires format json');
44
+ assertBodySchema(bodySchema);
45
+ }
39
46
  if (route.respond) {
40
47
  const status = route.respond.status ?? 200;
41
48
  assert(![206,304].includes(status), 'Use native asset handlers for partial/conditional responses');
@@ -61,7 +68,15 @@ export function checkRequest(route , body , headers
61
68
  try { text = new TextDecoder('utf-8',{fatal:true}).decode(body); } catch { throw new HttpError(400,'Body must be UTF-8'); }
62
69
  if (policy.format === 'json') {
63
70
  if (!/^application\/(?:[\w.+-]+\+)?json$/.test(type)) throw new HttpError(415,'Expected JSON media type');
64
- try { JSON.parse(text); } catch { throw new HttpError(400,'Invalid JSON body'); }
71
+ let parsed ;
72
+ try { parsed = JSON.parse(text); } catch { throw new HttpError(400,'Invalid JSON body'); }
73
+ if (policy.schema) {
74
+ const issues = bodySchemaIssues(policy.schema, parsed);
75
+ if (issues.length) {
76
+ const text = `Request body failed validation\n${issues.map(bodySchemaLine).join('\n')}`;
77
+ throw new HttpError(422, text, prefersJson(headers.get('accept')) ? { contentType: 'application/json', text: bodySchemaJson(issues) } : undefined);
78
+ }
79
+ }
65
80
  }
66
81
  }
67
82
  }
@@ -61,13 +61,13 @@ export function writeResponse(res , result , option
61
61
  // or sniffed whatever a project declares.
62
62
  export function errorResponse(error , { requestId, method, headers = [] } ) {
63
63
  const status = error instanceof HttpError ? error.status : 500;
64
- const fixed = [['content-type','text/plain; charset=utf-8'],['cache-control','no-store'],['x-request-id',requestId],['x-content-type-options','nosniff']];
64
+ const fixed = [['content-type',error instanceof HttpError && error.answer ? error.answer.contentType : 'text/plain; charset=utf-8'],['cache-control','no-store'],['x-request-id',requestId],['x-content-type-options','nosniff']];
65
65
  const taken = new Set(fixed.map(([key]) => key));
66
66
  const extra = headers.filter(([key]) => !taken.has(key.toLowerCase()) && !forbiddenHeaders.has(key.toLowerCase()));
67
67
  // Runtime error messages are fixed words, and the answer is text/plain
68
68
  // with nosniff; markup characters are still stripped so the body can never
69
69
  // be read as HTML by a client that ignores both.
70
- const text = `${error instanceof HttpError ? String(error.message).replace(/[<>&"']/g, '') : 'Internal server error'}\n`;
70
+ const text = error instanceof HttpError && error.answer ? error.answer.text + '\n' : `${error instanceof HttpError ? String(error.message).replace(/[<>&"']/g, '') : 'Internal server error'}\n`;
71
71
  const body = method === 'HEAD' ? undefined : text;
72
72
  // Stated explicitly so every host agrees, as prepareResponse does for results.
73
73
  return { status, headers: [...fixed, ['content-length', String(new TextEncoder().encode(text).length)], ...extra], body };
package/dist/init-with.js CHANGED
@@ -14,13 +14,16 @@ import { ConfigError, assert } from './errors.js';
14
14
 
15
15
  /** Directory names inside the generated site. The route project lives under `app/`; everything else is operator-owned. */
16
16
  const PROJECT_DIRECTORY = 'app', HOST_FILE = 'host.mjs', ROUTES_FILE = 'routes/extensions.yaml';
17
- const namePattern = /^[a-z][a-z0-9-]{0,63}$/;
17
+ const acknowledgementPattern = /^[a-z][a-z0-9-]{0,63}:[a-z][a-z0-9-]{0,63}$/;
18
+ const namePattern = /^[a-z][a-z0-9-]{0,63}$/, capabilityPattern = /^[a-z][a-z0-9.:-]{0,63}$/;
18
19
 
19
20
 
20
21
 
21
22
 
22
23
 
23
24
 
25
+
26
+
24
27
 
25
28
 
26
29
 
@@ -35,12 +38,48 @@ const isCode = (error , code ) => error instanceof Error
35
38
  const strings = (value ) => Array.isArray(value) && value.every(item => typeof item === 'string');
36
39
  const record = (value ) => value !== null && typeof value === 'object' && !Array.isArray(value);
37
40
 
41
+ /**
42
+ * Orders the requested set from the scaffolds' declared `requires`, `after`, `provides` and `conflicts`, never from
43
+ * the `--with` spelling. Kahn's algorithm with the lexically smallest ready extension first, so the result is
44
+ * deterministic and identical for every permutation. Refuses a missing requirement, a conflict or a cycle by name.
45
+ */
46
+ export function orderScaffolds(results ) {
47
+ const byName = new Map (), providers = new Map ();
48
+ for (const result of results) byName.set(result.name, result);
49
+ for (const result of results) for (const capability of result.provides ?? []) {
50
+ assert(!byName.has(capability) || capability === result.name, `${result.name} provides ${capability}, which is also an extension name`);
51
+ assert(!providers.has(capability) || providers.get(capability) === result.name, `Capability ${capability} is provided by both ${providers.get(capability)} and ${result.name}`);
52
+ providers.set(capability, result.name);
53
+ }
54
+ const locate = (dependency ) => byName.has(dependency) ? dependency : providers.get(dependency);
55
+ const edges = new Map (results.map(result => [result.name, new Set ()]));
56
+ for (const result of results) {
57
+ for (const other of result.conflicts ?? []) { const target = locate(other); assert(target === undefined || target === result.name, `${result.name} conflicts with ${target}; remove one from --with`); }
58
+ for (const dependency of result.requires ?? []) {
59
+ const target = locate(dependency);
60
+ assert(target !== undefined, `${result.name} requires ${dependency}${byName.has(dependency) ? '' : ', which is not part of this composition; add the extension that provides it to --with'}`);
61
+ if (target !== result.name) edges.get(result.name) .add(target);
62
+ }
63
+ for (const dependency of result.after ?? []) { const target = locate(dependency); if (target !== undefined && target !== result.name) edges.get(result.name) .add(target); }
64
+ }
65
+ const ordered = [], placed = new Set ();
66
+ while (ordered.length < results.length) {
67
+ const ready = [...byName.keys()].filter(name => !placed.has(name) && [...edges.get(name) ].every(dependency => placed.has(dependency))).sort();
68
+ if (ready.length === 0) {
69
+ const stuck = [...byName.keys()].filter(name => !placed.has(name)).sort();
70
+ throw new ConfigError(`Extension ordering has a cycle among ${stuck.map(name => `${name} (needs ${[...edges.get(name) ].filter(dependency => !placed.has(dependency)).sort().join(', ')})`).join('; ')}`);
71
+ }
72
+ placed.add(ready[0] ); ordered.push(byName.get(ready[0] ) );
73
+ }
74
+ return ordered;
75
+ }
76
+
38
77
  /**
39
78
  * Resolves the extension package from the invoking directory (Node's package resolution with the default
40
79
  * conditions), imports it, and calls its `scaffold` export. Nothing is bundled; core never imports these packages
41
80
  * at build time. Refuses a missing package or a package without `scaffold` before anything is written.
42
81
  */
43
- async function loadScaffold(name , request , cwd ) {
82
+ async function loadScaffold(name , request , cwd , retry ) {
44
83
  const pkg = packageName(name);
45
84
  let entry ;
46
85
  try { entry = createRequire(join(cwd, 'package.json')).resolve(pkg); }
@@ -53,11 +92,20 @@ async function loadScaffold(name , request , cwd )
53
92
  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`);
54
93
  let result ;
55
94
  try { result = await (scaffold )(request); }
56
- catch (error) { throw new ConfigError(`${pkg} scaffold refused: ${error instanceof Error ? error.message : String(error)}`); }
95
+ catch (error) {
96
+ // A refusal that names an acknowledgement id gets the exact command that would proceed; the extension owns the id and the risk wording, core only formats the retry.
97
+ const id = record(error) ? error.acknowledgement : undefined;
98
+ const message = error instanceof Error ? error.message : String(error);
99
+ if (typeof id === 'string' && acknowledgementPattern.test(id) && id.startsWith(`${name}:`) && !request.acknowledgements.includes(id)) throw new ConfigError(`${pkg} scaffold refused: ${message}. If you accept that risk, re-run with the acknowledgement: ${retry(id)}`);
100
+ throw new ConfigError(`${pkg} scaffold refused: ${message}`);
101
+ }
57
102
  assert(record(result) && result.name === name, `${pkg} scaffold must return a result named ${name}`);
58
103
  assert(record(result.extensions) && record(result.routes), `${pkg} scaffold must return extensions and routes objects`);
59
104
  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`);
105
+ assert(result.acknowledged === undefined || (strings(result.acknowledged) && result.acknowledged.every(id => request.acknowledgements.includes(id) && id.startsWith(`${name}:`))), `${pkg} scaffold acknowledged may only list ${name}:<id> acknowledgements the operator passed`);
106
+ assert(result.routeNotes === undefined || (strings(result.routeNotes) && result.routeNotes.every(note => note.length <= 300 && !/[\r\n]/.test(note))), `${pkg} scaffold routeNotes must be single-line strings`);
60
107
  assert(strings(result.nextSteps) && typeof result.readme === 'string', `${pkg} scaffold must return readme text and nextSteps strings`);
108
+ for (const key of ['provides', 'requires', 'after', 'conflicts'] ) assert(result[key] === undefined || (strings(result[key]) && (result[key] ).every(item => capabilityPattern.test(item))), `${pkg} scaffold ${key} must list extension names or capability names`);
61
109
  assert(result.env === undefined || (record(result.env) && Object.values(result.env).every(item => typeof item === 'string')), `${pkg} scaffold env must map names to descriptions`);
62
110
  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`);
63
111
  return result ;
@@ -78,7 +126,8 @@ async function write(target , content , mode = 0o644)
78
126
  }
79
127
  function renderHost(names , results ) {
80
128
  const lines = [`// Generated by urlcode init --with ${names.join(',')}. Trusted operator code: keep it outside ${PROJECT_DIRECTORY}/ and review before serving.`];
81
- for (const result of results) lines.push(...result.hostImports);
129
+ // Extensions that need the same module (node:url, for example) each list it; an identical line is written once so the host stays valid ESM.
130
+ for (const result of results) for (const line of result.hostImports) if (!lines.includes(line)) lines.push(line);
82
131
  lines.push('');
83
132
  for (const result of results) if (result.hostSetup.length) lines.push(...result.hostSetup);
84
133
  lines.push('export default {', ' extensions: [');
@@ -121,14 +170,26 @@ function renderReadme(directory , names , results
121
170
  * `urlcode.yaml`, one `host.mjs`, one `README.md` and the extensions' own files. All packages are resolved and
122
171
  * their scaffolds computed before anything is written, so a refusal leaves no directory behind.
123
172
  */
124
- export async function initProjectWith(destination , names , { cwd = process.cwd(), manifest = true, pins } = {}) {
125
- assert(names.length > 0, 'Provide at least one --with name');
173
+ export async function initProjectWith(destination , requested , { cwd = process.cwd(), manifest = true, pins, acknowledgements = [] } = {}) {
174
+ assert(requested.length > 0, 'Provide at least one --with name');
175
+ assert(new Set(requested).size === requested.length, 'Duplicate --with names');
176
+ // --with is an unordered set: scaffolds see one canonical name order, and the emitted order comes from their declared requirements.
177
+ const sorted = [...requested].sort();
126
178
  const directory = resolve(destination), project = join(directory, PROJECT_DIRECTORY), hostFile = join(directory, HOST_FILE);
127
- const request = { directory, project, hostFile, names };
179
+ assert(acknowledgements.every(id => acknowledgementPattern.test(id)), 'Use --ack <extension>:<id>, for example --ack store:public-write');
180
+ const acked = [...new Set(acknowledgements)].sort();
181
+ const request = { directory, project, hostFile, names: sorted, acknowledgements: acked };
182
+ const quote = (value ) => /^[\w@%+=:,./-]+$/.test(value) ? value : `'${value.replaceAll("'", `'\\''`)}'`;
183
+ const retry = (id ) => ['urlcode init', quote(destination), '--with', requested.join(','), ...(manifest ? [] : ['--no-manifest']), ...[...(pins ?? [])].flatMap(([pkg, specifier]) => ['--pin', quote(`${pkg}=${specifier}`)]), ...[...acked, id].sort().flatMap(item => ['--ack', item])].join(' ');
128
184
  const results = [];
129
185
  const wipe = () => { for (const result of results) for (const file of result.files) if (file.content instanceof Uint8Array) file.content.fill(0); };
130
186
  try {
131
- for (const name of names) results.push(await loadScaffold(name, request, cwd));
187
+ for (const name of sorted) results.push(await loadScaffold(name, request, cwd, retry));
188
+ const consumed = new Set(results.flatMap(result => result.acknowledged ?? []));
189
+ const unused = acked.filter(id => !consumed.has(id));
190
+ assert(unused.length === 0, `--ack ${unused.join(', ')} has no effect here: no scaffold in --with (${sorted.join(', ')}) consumed it. Remove it, or check the extension name and id in that extension's documentation`);
191
+ results.splice(0, results.length, ...orderScaffolds(results));
192
+ const names = results.map(result => result.name);
132
193
  // Cross-result conflicts are refused before the destination exists.
133
194
  const extensions = Object.create(null) , routes = Object.create(null) , env = {};
134
195
  const owners = new Map ();
@@ -161,7 +222,8 @@ export async function initProjectWith(destination , names
161
222
  doc.addIn(['includes'], ROUTES_FILE);
162
223
  const fragment = stringify({ version: '1', routes });
163
224
  validateDocument(doc.toJS()); validateDocument(parseYaml(fragment));
164
- await write(join(project, ROUTES_FILE), `# Routes added by urlcode init --with ${names.join(',')}. Mounts are exclusive to the named extension.\n${fragment}`);
225
+ const notes = results.flatMap(result => (result.routeNotes ?? []).map(note => `# ${result.name}: ${note}\n`)).join('');
226
+ await write(join(project, ROUTES_FILE), `# Routes added by urlcode init --with ${names.join(',')}. Mounts are exclusive to the named extension.\n${notes}${fragment}`);
165
227
  await rm(yamlFile); await write(yamlFile, String(doc));
166
228
  await loadDocument(project);
167
229
  const projectSha256 = await inspectExtensionRevision(project);
package/dist/mcp.js CHANGED
@@ -7,6 +7,7 @@ import {loadOperatorHost} from './operator-host.js';
7
7
  import {buildManifest} from './manifest.js';
8
8
 
9
9
  import {authoringDefinitions,callAuthoringTool} from './mcp-authoring.js';
10
+ import {listSkills,getSkill,searchDocs,getExample,validateYaml,explainError} from './agent-context.js';
10
11
  const protocolVersion='2025-11-25';
11
12
  const maxBytes=1048576;
12
13
  const text={type:'string',maxLength:8192};
@@ -25,10 +26,16 @@ const definitions=[
25
26
  {name:'recipes_show',description:'Show a bundled local recipe without writing it; metadata (capabilities, targets, grants, inputs, expected behavior) comes before file contents.',properties:{name:{type:'string',maxLength:64}},required:['name']},
26
27
  {name:'search_recipes',description:'Search bundled recipes by id, description, tags and capabilities; local text matching, no service. Check here before generating a common route by hand.',properties:{text:{type:'string',maxLength:256}},required:['text']},
27
28
  {name:'search_examples',description:'Search bundled runnable examples and the cookbook route index; returns the smallest matching example and its route.',properties:{text:{type:'string',maxLength:256}},required:['text']},
29
+ {name:'list_skills',description:'List compact metadata for the bundled agent skills. Load a skill only when it applies.',properties:{}},
30
+ {name:'get_skill',description:'Load one bundled agent SKILL.md by name.',properties:{name:{type:'string',maxLength:64}},required:['name']},
31
+ {name:'search_docs',description:'Deterministically search the small packaged agent documentation corpus and return at most three short excerpts.',properties:{text:{type:'string',maxLength:256}},required:['text']},
32
+ {name:'get_example',description:'Return the README and urlcode.yaml from one bundled runnable example.',properties:{name:{type:'string',maxLength:64}},required:['name']},
33
+ {name:'validate_yaml',description:'Validate supplied URLCode YAML syntax and schema only. It never reads includes, source files, bindings or a project directory.',properties:{yaml:{type:'string',maxLength:524288}},required:['yaml']},
34
+ {name:'explain_error',description:'Give deterministic next-step guidance for supplied URLCode validation output.',properties:{error:{type:'string',maxLength:8192}},required:['error']},
28
35
  {name:'get_context',description:'Emit the compact project context an authoring agent needs: versions, project summary, constraints, target support and exact commands, derived from the compiled project. Optional token budget drops sections in a fixed order.',properties:{target:text,budget:{type:'integer',minimum:1}}},
29
36
  ];
30
37
  // Only the operator's own --host-file exposes registered extension contracts; no tool argument can name one.
31
- const hostDefinition={name:'get_extensions',description:'List operator-registered extension contracts with configuration and policy JSON Schemas and where the project mounts them; activates nothing.',properties:{}};
38
+ const hostDefinition={name:'get_extensions',description:'List operator-registered extension contracts, schemas, hooks, and supported project-owned customization surfaces with fast checks; use these before generating replacement framework code. Activates nothing.',properties:{}};
32
39
  const ajv=new Ajv({strict:false});
33
40
  const readTools=definitions.map(def=>({name:def.name,description:def.description,inputSchema:{type:'object',properties:def.properties,required:def.required??[],additionalProperties:false},annotations:{readOnlyHint:true,destructiveHint:false,openWorldHint:false}}));
34
41
  const hostTool={name:hostDefinition.name,description:hostDefinition.description,inputSchema:{type:'object',properties:hostDefinition.properties,required:[],additionalProperties:false},annotations:{readOnlyHint:true,destructiveHint:false,openWorldHint:false}};
@@ -63,6 +70,12 @@ export async function serveMcp(options ) {
63
70
  case 'recipes_show':return showRecipe(args.name );
64
71
  case 'search_recipes':return searchRecipes(args.text );
65
72
  case 'search_examples':return searchExamples(args.text );
73
+ case 'list_skills':return listSkills();
74
+ case 'get_skill':return getSkill(args.name );
75
+ case 'search_docs':return searchDocs(args.text );
76
+ case 'get_example':return getExample(args.name );
77
+ case 'validate_yaml':return validateYaml(args.yaml );
78
+ case 'explain_error':return explainError(args.error );
66
79
  case 'get_extensions':return describeExtensions(project,host.extensions??[]);
67
80
  case 'get_context':return buildContext(project,{projectFlag:'.',...(typeof args.target==='string'?{target:args.target}:{}),...(typeof args.budget==='number'?{budget:args.budget}:{})});
68
81
  default:if(authoring)return callAuthoringTool(project,name,args,options.origin);throw new Error('Unknown tool');
@@ -78,7 +91,7 @@ export async function serveMcp(options ) {
78
91
  if(message.method==='initialize') {
79
92
  if(initialized){await error(id,-32600,'Already initialized');return;}
80
93
  if(typeof params.protocolVersion!=='string'||!object(params.capabilities)||!object(params.clientInfo)||typeof params.clientInfo.name!=='string'||typeof params.clientInfo.version!=='string'){await error(id,-32602,'Invalid initialize params');return;}
81
- initialized=true;await send({jsonrpc:'2.0',id,result:{protocolVersion,capabilities:{tools:{}},serverInfo:{name:'urlcode',version:'0.4.1'}}});return;
94
+ initialized=true;await send({jsonrpc:'2.0',id,result:{protocolVersion,capabilities:{tools:{}},serverInfo:{name:'urlcode',version:'0.4.6'}}});return;
82
95
  }
83
96
  if(message.method==='ping'){await send({jsonrpc:'2.0',id,result:{}});return;}
84
97
  if(!ready){await error(id,-32002,'Initialize first');return;}
@@ -0,0 +1,32 @@
1
+ import { assert } from './errors.js';
2
+
3
+ export const maxPatternLength = 128;
4
+ export const maxPatternInputLength = 128;
5
+ const maxUnboundedQuantifiers = 3;
6
+
7
+ /**
8
+ * Accepts an author regex only when it is conservatively safe to run on every
9
+ * request in the host process. Node has no linear-time engine, so this refuses
10
+ * the constructs that make backtracking blow up — repeated groups, lookaround
11
+ * and backreferences — and caps unbounded quantifiers. It is a restriction, not
12
+ * a proof: callers must also bound the input length to `maxPatternInputLength`.
13
+ */
14
+ export function assertSafePattern(pattern ) {
15
+ assert(pattern.length > 0 && pattern.length <= maxPatternLength, `Pattern must be 1 to ${maxPatternLength} characters`);
16
+ try { new RegExp(pattern, 'u'); } catch { assert(false, 'Invalid pattern'); }
17
+ let unbounded = 0, inClass = false;
18
+ for (let i = 0; i < pattern.length; i++) {
19
+ const char = pattern[i] ;
20
+ if (char === '\\') {
21
+ const next = pattern[i + 1] ?? '';
22
+ assert(inClass || !/[1-9k]/.test(next), 'Pattern backreferences are not supported');
23
+ i++; continue;
24
+ }
25
+ if (inClass) { if (char === ']') inClass = false; continue; }
26
+ if (char === '[') { inClass = true; continue; }
27
+ if (char === '(') assert(!/^\(\?<?[=!]/.test(pattern.slice(i, i + 4)), 'Pattern lookaround is not supported');
28
+ if (char === ')') assert(!/^(?:[*+]|\{\d+,\})/.test(pattern.slice(i + 1)), 'Pattern cannot repeat a group without a bound');
29
+ if (char === '*' || char === '+' || (char === '{' && /^\{\d+,\}/.test(pattern.slice(i)))) unbounded++;
30
+ }
31
+ assert(unbounded <= maxUnboundedQuantifiers, `Pattern allows at most ${maxUnboundedQuantifiers} unbounded quantifiers`);
32
+ }
Binary file
package/dist/policy.js CHANGED
@@ -32,6 +32,22 @@ export async function prepareFunctionSnapshot(loaded )
32
32
  if (route.sandbox) { for (const definition of [...middleware, ...(fn ? [fn] : [])]) sandboxed.push({pattern,function:definition}); }
33
33
  else trusted.push({middleware, function: fn});
34
34
  }
35
+ // Extension hooks are a core primitive even though their names and payloads
36
+ // belong to each extension. Include every declared entry module in the
37
+ // reviewed project revision, so editing trusted hook code invalidates the
38
+ // operator's extension pin just like editing a trusted route function.
39
+ for(const [extension,declaration] of Object.entries(loaded.document.extensions??{})){
40
+ const hooks=declaration.config.hooks;
41
+ if(hooks===undefined)continue;
42
+ assert(hooks&&typeof hooks==='object'&&!Array.isArray(hooks),`Extension ${extension} hooks must be an object`);
43
+ for(const [name,raw] of Object.entries(hooks )){
44
+ assert(typeof raw==='string'||raw&&typeof raw==='object'&&!Array.isArray(raw),`Invalid extension hook: ${extension}.${name}`);
45
+ const reference=typeof raw==='string'?{source:raw}:raw ;
46
+ assert(typeof reference.source==='string',`Invalid extension hook: ${extension}.${name}`);
47
+ assert(reference.export===undefined||typeof reference.export==='string',`Invalid extension hook: ${extension}.${name}`);
48
+ trusted.push({function:await resolveOne({source:reference.source,...(reference.export===undefined?{}:{export:reference.export })})});
49
+ }
50
+ }
35
51
  const collected = await collectFunctionSources(sandboxed,loaded.root);
36
52
  const trustedSources = await collectTrustedSources(trusted,loaded.root);
37
53
  // The hash operator grants pin to still covers trusted routes' own source, so
@@ -1,23 +1,47 @@
1
- import { realpath } from 'node:fs/promises';
1
+ import { mkdtemp, realpath, rm } from 'node:fs/promises';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
2
4
  import { Agent } from 'node:http';
3
5
  import { startServer } from './server.js';
4
-
5
- import { readCases, hit } from './readiness.js';
6
+
7
+ import { readFixtures, runFixtures } from './readiness.js';
8
+
6
9
 
7
10
 
8
11
 
9
12
 
10
13
 
14
+ /**
15
+ * A server that fixture `restart` steps can close and start again on the same project and the
16
+ * same data directory. The data directory is fresh and empty, offered to the project as
17
+ * `URLCODE_DATA_DIR`, and removed by `close()`; on a filesystem where it cannot be created, none is offered. `restart()` gets a new port; read `address` after it.
18
+ */
19
+ export async function startRestartable(options ) {
20
+ // A read-only filesystem (a locked-down container) has nowhere to put it: run without one instead of
21
+ // failing every test run. A project that reads `URLCODE_DATA_DIR` then refuses to activate, as it would unset.
22
+ const dataDir = await mkdtemp(join(tmpdir(), 'urlcode-data-')).catch(() => undefined);
23
+ const cleanup = async () => { if (dataDir !== undefined) await rm(dataDir, { recursive: true, force: true }); };
24
+ let current ;
25
+ try { current = await startServer({ ...options, dataDir }); } catch (error) { await cleanup(); throw error; }
26
+ const running = () => { if (!current) throw new Error('Server is not running'); return current; };
27
+ return {
28
+ get address() { return running().address; }, get root() { return running().root; }, testPlan: () => running().testPlan(),
29
+ async restart() { const old = running(); current = undefined; await old.close(); current = await startServer({ ...options, dataDir }); },
30
+ async close() { try { await current?.close(); } finally { current = undefined; await cleanup(); } },
31
+ };
32
+ }
33
+
11
34
  export async function runProjectTests(project , { log = () => {}, permissions, origin, extensions, plugins } = {}) {
12
- const root = await realpath(project), cases = await readCases(root);
13
- const app = await startServer({ project, port: 0, local: true, log, permissions, origin, extensions, plugins });
14
- const agent = new Agent({keepAlive:true,maxSockets:1}); let failed = 0;
35
+ const root = await realpath(project), fixtures = await readFixtures(root);
36
+ const app = await startRestartable({ project, port: 0, local: true, log, permissions, origin, extensions, plugins });
37
+ const agent = new Agent({keepAlive:true,maxSockets:1}); let failed = 0, total = 0;
15
38
  try {
16
- for (const [i,test] of cases.entries()) {
17
- const result=await hit(app,test,agent);
39
+ // Only case number, pass and status are logged: never a path, header or body, which may hold captured values.
40
+ await runFixtures(fixtures, { app, agent, restart: () => app.restart() }, ({ case: n, result }) => {
41
+ total++;
18
42
  if(!result.pass)failed++;
19
- log({event:'test',case:i+1,pass:result.pass,status:result.status});
20
- }
43
+ log({event:'test',case:n,pass:result.pass,status:result.status});
44
+ });
21
45
  } finally {agent.destroy();await app.close();}
22
- return {total:cases.length,failed};
46
+ return {total,failed};
23
47
  }