@jimhoyd/urlcode 0.4.0-alpha.1 → 0.4.0-alpha.3

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 (232) hide show
  1. package/.claude/skills/urlcode-authoring/SKILL.md +36 -22
  2. package/.claude/skills/urlcode-operations/SKILL.md +16 -22
  3. package/.claude-plugin/marketplace.json +1 -1
  4. package/CONTRIBUTING.md +20 -16
  5. package/README.md +83 -80
  6. package/ROADMAP.md +61 -331
  7. package/SECURITY.md +23 -8
  8. package/dist/BUILD-MANIFEST.json +42 -47
  9. package/dist/agents-guide.js +18 -18
  10. package/dist/build-static.js +136 -0
  11. package/dist/capabilities.js +85 -31
  12. package/dist/capability-query.js +0 -1
  13. package/dist/cli.js +24 -32
  14. package/dist/compliance-rules/baseline.js +2 -10
  15. package/dist/compliance-rules/privacy.js +5 -16
  16. package/dist/compliance-rules/shared.js +0 -2
  17. package/dist/compliance.js +6 -8
  18. package/dist/config.js +15 -8
  19. package/dist/context.js +9 -10
  20. package/dist/examples.js +2 -2
  21. package/dist/explain-cli.js +7 -5
  22. package/dist/explain.js +10 -5
  23. package/dist/extensions.js +61 -2
  24. package/dist/function-sources.js +34 -2
  25. package/dist/function-worker.js +3 -1
  26. package/dist/functions.js +84 -13
  27. package/dist/guest-api.js +29 -3
  28. package/dist/index.js +3 -6
  29. package/dist/manifest.js +11 -7
  30. package/dist/mcp-authoring.js +2 -2
  31. package/dist/mcp.js +1 -1
  32. package/dist/observability.js +1 -21
  33. package/dist/policies/cache.js +8 -3
  34. package/dist/policies.js +3 -1
  35. package/dist/policy.js +28 -9
  36. package/dist/prerender.js +4 -0
  37. package/dist/project-tests.js +3 -3
  38. package/dist/readiness.js +35 -9
  39. package/dist/route-diff.js +12 -5
  40. package/dist/router.js +5 -7
  41. package/dist/runtime.js +77 -58
  42. package/dist/sandbox.js +48 -0
  43. package/dist/scaffold.js +0 -0
  44. package/dist/scripts/operational-drills.js +12 -54
  45. package/dist/server.js +3 -29
  46. package/dist/tooling.js +1 -1
  47. package/dist/trusted-functions.js +210 -0
  48. package/dist/types/build-static.d.ts +43 -0
  49. package/dist/types/capabilities.d.ts +14 -5
  50. package/dist/types/compliance-rules/shared.d.ts +0 -2
  51. package/dist/types/compliance.d.ts +0 -3
  52. package/dist/types/config.d.ts +2 -1
  53. package/dist/types/context.d.ts +2 -1
  54. package/dist/types/examples.d.ts +1 -1
  55. package/dist/types/explain.d.ts +6 -0
  56. package/dist/types/extensions.d.ts +55 -0
  57. package/dist/types/function-sources.d.ts +4 -0
  58. package/dist/types/functions.d.ts +48 -5
  59. package/dist/types/guest-api.d.ts +1 -0
  60. package/dist/types/index.d.ts +3 -6
  61. package/dist/types/manifest.d.ts +5 -3
  62. package/dist/types/observability.d.ts +1 -14
  63. package/dist/types/project-tests.d.ts +1 -2
  64. package/dist/types/readiness.d.ts +12 -3
  65. package/dist/types/router.d.ts +2 -1
  66. package/dist/types/runtime.d.ts +0 -27
  67. package/dist/types/sandbox.d.ts +12 -0
  68. package/dist/types/scaffold.d.ts +0 -2
  69. package/dist/types/server.d.ts +1 -4
  70. package/dist/types/tooling.d.ts +3 -3
  71. package/dist/types/trusted-functions.d.ts +29 -0
  72. package/dist/types/types.d.ts +22 -8
  73. package/dist/types/verify-deployment.d.ts +2 -2
  74. package/dist/types.js +25 -5
  75. package/dist/typescript-authoring.js +67 -18
  76. package/dist/verify-deployment.js +3 -3
  77. package/docs/AI-AUTHORING.md +109 -10
  78. package/docs/ASSETS.md +2 -1
  79. package/docs/AUTH-BACKUP.md +32 -0
  80. package/docs/AWS.md +4 -4
  81. package/docs/BEST-PRACTICES.md +17 -8
  82. package/docs/CAPABILITIES.md +30 -17
  83. package/docs/CAPACITY.md +128 -31
  84. package/docs/CI-FOLLOWUP-2026-09-19.md +97 -0
  85. package/docs/CI-RELEASE-AUDIT-2026-09-19.md +322 -0
  86. package/docs/CI.md +8 -3
  87. package/docs/CLOUDFLARE.md +1 -2
  88. package/docs/CODEBASE-AUDIT-2026-09-20.md +278 -0
  89. package/docs/COMPLIANCE.md +6 -9
  90. package/docs/DEPLOYMENT-CHECKS.md +1 -1
  91. package/docs/DEVELOPMENT-PIPELINE.md +181 -0
  92. package/docs/EXTENSIONS.md +193 -11
  93. package/docs/FRAMEWORK.md +53 -33
  94. package/docs/FUNCTION-SECURITY.md +173 -32
  95. package/docs/INSTALL.md +0 -5
  96. package/docs/LOAD-TESTING.md +4 -4
  97. package/docs/LOCAL-DEVELOPMENT.md +3 -0
  98. package/docs/MIDDLEWARE.md +27 -16
  99. package/docs/MONITORING.md +2 -19
  100. package/docs/OBSERVABILITY.md +7 -16
  101. package/docs/OPEN-DECISIONS.md +184 -0
  102. package/docs/OPERATIONAL-PROOF.md +26 -30
  103. package/docs/OPERATIONS.md +23 -32
  104. package/docs/POLICIES.md +27 -8
  105. package/docs/PRERENDER.md +25 -13
  106. package/docs/PROJECT-DIRECTION.md +35 -10
  107. package/docs/READINESS.md +6 -2
  108. package/docs/README.md +20 -41
  109. package/docs/RELEASE-0.4.0-alpha.3.md +50 -0
  110. package/docs/RELEASE-READINESS.md +50 -30
  111. package/docs/RELEASE-SECURITY.md +72 -86
  112. package/docs/RESILIENCE.md +16 -15
  113. package/docs/ROUTING.md +7 -10
  114. package/docs/SANDBOX-REVIEW.md +19 -6
  115. package/docs/SCAFFOLDING.md +0 -2
  116. package/docs/SECURITY-AUDIT.md +42 -2
  117. package/docs/SPECIFICATION.md +63 -27
  118. package/docs/SPIKE-AI-FRAMEWORK-BENCHMARK.md +288 -0
  119. package/docs/SPIKE-BUSINESS-SUITE.md +16 -0
  120. package/docs/SPIKE-CORE-LAYERING.md +368 -0
  121. package/docs/SPIKE-DEFAULT-TRUST-MODEL.md +211 -0
  122. package/docs/SPIKE-LAMBDA-COMPILE.md +12 -2
  123. package/docs/STARTERS.md +1 -1
  124. package/docs/STATIC.md +105 -0
  125. package/docs/TOOLING.md +17 -12
  126. package/docs/TUNNELS.md +0 -3
  127. package/docs/TYPESCRIPT-AUTHORING.md +32 -12
  128. package/docs/TYPESCRIPT.md +25 -4
  129. package/docs/VERCEL.md +4 -5
  130. package/docs/VERSION-ALIGNMENT.md +46 -0
  131. package/docs/YAML-GUIDE.md +0 -3
  132. package/docs/YAML-REFERENCE.md +16 -6
  133. package/docs/archive/2026-09-19/EXTENSION-IMPLEMENTATION.md +68 -0
  134. package/docs/{MANAGEMENT-SECURITY.md → archive/2026-09-19/MANAGEMENT-SECURITY.md} +20 -0
  135. package/docs/{NEXT-PHASE-PLAN.md → archive/2026-09-19/NEXT-PHASE-PLAN.md} +26 -8
  136. package/docs/{NEXT-STEPS.md → archive/2026-09-19/NEXT-STEPS.md} +274 -43
  137. package/docs/archive/2026-09-19/OPEN-DECISIONS.md +277 -0
  138. package/docs/archive/2026-09-19/RELEASE-SECURITY.md +186 -0
  139. package/docs/archive/2026-09-19/ROADMAP.md +386 -0
  140. package/docs/{SPIKE-EXTENSION-MODEL.md → archive/2026-09-19/SPIKE-EXTENSION-MODEL.md} +12 -2
  141. package/docs/{SPIKE-EXTENSIONS.md → archive/2026-09-19/SPIKE-EXTENSIONS.md} +28 -12
  142. package/docs/archive/2026-09-19/SPIKE-MONOREPO.md +776 -0
  143. package/docs/{USABILITY-REVIEW.md → archive/2026-09-19/USABILITY-REVIEW.md} +18 -2
  144. package/docs/archive/README.md +27 -0
  145. package/docs/policies/agents.md +1 -1
  146. package/docs/policies/cache.md +13 -0
  147. package/docs/policies/compression.md +3 -2
  148. package/docs/policies/security.md +3 -2
  149. package/examples/assets/example.yaml +2 -2
  150. package/examples/aws/example.yaml +1 -1
  151. package/examples/cloudflare/example.yaml +1 -1
  152. package/examples/conditions/example.yaml +1 -1
  153. package/examples/cookbook/example.yaml +1 -1
  154. package/examples/cookbook/middleware/bucket.mjs +12 -2
  155. package/examples/cookbook/middleware/locale.mjs +7 -3
  156. package/examples/cookbook/route-index.json +1 -1
  157. package/examples/cookbook/routes/middleware.yaml +1 -1
  158. package/examples/egress/example.yaml +1 -1
  159. package/examples/extensions/example.yaml +1 -1
  160. package/examples/prerender/README.md +14 -6
  161. package/examples/prerender/example.yaml +1 -1
  162. package/examples/prerender/functions/page.mjs +4 -2
  163. package/examples/prerender/middleware/template.mjs +1 -1
  164. package/examples/prerender/prerender.mjs +1 -1
  165. package/examples/prerender/urlcode.yaml +8 -4
  166. package/examples/provider-conformance/example.yaml +1 -1
  167. package/examples/vercel/example.yaml +1 -1
  168. package/llms-full.txt +686 -239
  169. package/llms.txt +27 -15
  170. package/package.json +32 -5
  171. package/packaging/claude-plugin/.claude-plugin/plugin.json +2 -2
  172. package/packaging/claude-plugin/skills/urlcode-authoring/SKILL.md +36 -22
  173. package/packaging/claude-plugin/skills/urlcode-operations/SKILL.md +16 -22
  174. package/recipes/authenticated-json-api/README.md +4 -3
  175. package/recipes/authenticated-json-api/functions/profile.mjs +2 -1
  176. package/recipes/authenticated-json-api/recipe.yaml +2 -2
  177. package/recipes/contact-form/functions/contact.mjs +2 -1
  178. package/recipes/contact-form/recipe.yaml +3 -3
  179. package/recipes/cors-api/README.md +2 -2
  180. package/recipes/cors-api/recipe.yaml +2 -2
  181. package/recipes/health-page/README.md +1 -1
  182. package/recipes/health-page/recipe.yaml +1 -1
  183. package/recipes/json-api/README.md +1 -1
  184. package/recipes/json-api/recipe.yaml +4 -4
  185. package/recipes/middleware/README.md +8 -4
  186. package/recipes/middleware/middleware/bucket.mjs +12 -2
  187. package/recipes/middleware/middleware/locale.mjs +7 -3
  188. package/recipes/middleware/recipe.yaml +1 -1
  189. package/recipes/protected-download/README.md +1 -1
  190. package/recipes/protected-download/recipe.yaml +2 -2
  191. package/recipes/redirect/recipe.yaml +1 -1
  192. package/recipes/static-plus-api/README.md +2 -2
  193. package/recipes/static-plus-api/public/index.html +1 -1
  194. package/recipes/static-plus-api/recipe.yaml +2 -2
  195. package/recipes/static-plus-api/urlcode.yaml +1 -1
  196. package/recipes/typescript/README.md +2 -1
  197. package/recipes/typescript/recipe.yaml +5 -5
  198. package/recipes/webhook-receiver/README.md +5 -1
  199. package/recipes/webhook-receiver/recipe.yaml +2 -1
  200. package/recipes/webhook-receiver/urlcode.yaml +7 -0
  201. package/schemas/recipe.schema.json +4 -3
  202. package/schemas/urlcode.schema.json +14 -41
  203. package/skills/urlcode/SKILL.md +32 -11
  204. package/starters/default/AGENTS.md +18 -18
  205. package/starters/default/urlcode.yaml +0 -1
  206. package/dist/link-api.js +0 -136
  207. package/dist/link-cli.js +0 -141
  208. package/dist/link-events.js +0 -76
  209. package/dist/link-records.js +0 -31
  210. package/dist/link-store-worker.js +0 -150
  211. package/dist/link-store.js +0 -250
  212. package/dist/management-policy.js +0 -40
  213. package/dist/sqlite-version.js +0 -6
  214. package/dist/types/link-api.d.ts +0 -30
  215. package/dist/types/link-cli.d.ts +0 -37
  216. package/dist/types/link-events.d.ts +0 -27
  217. package/dist/types/link-records.d.ts +0 -11
  218. package/dist/types/link-store-worker.d.ts +0 -1
  219. package/dist/types/link-store.d.ts +0 -130
  220. package/dist/types/management-policy.d.ts +0 -8
  221. package/dist/types/sqlite-version.d.ts +0 -1
  222. package/docs/DYNAMIC-LINKS.md +0 -61
  223. package/docs/links/cli.md +0 -110
  224. package/docs/links/limits.md +0 -175
  225. package/docs/links/management-api.md +0 -80
  226. package/docs/links/pools.md +0 -75
  227. package/docs/links/setup.md +0 -135
  228. package/docs/yaml/links.md +0 -30
  229. package/examples/live-links/README.md +0 -11
  230. package/examples/live-links/example.yaml +0 -21
  231. package/examples/live-links/tests/requests.json +0 -6
  232. package/examples/live-links/urlcode.yaml +0 -16
@@ -1,75 +1,33 @@
1
1
  // Disposable local proof, never a claim about the production deployment.
2
2
  import assert from 'node:assert/strict';
3
- import {mkdtemp,mkdir,writeFile,copyFile,rm,statfs} from 'node:fs/promises';
3
+ import {mkdtemp,mkdir,writeFile,rm} from 'node:fs/promises';
4
4
  import {tmpdir} from 'node:os';
5
- import {join,resolve} from 'node:path';
6
- import {DatabaseSync} from 'node:sqlite';
7
- import {openLinkStore} from '../link-store.js';
5
+ import {join} from 'node:path';
8
6
  import {startServer} from '../server.js';
9
-
10
7
 
11
- import {HttpError} from '../errors.js';
12
- // The SQLite rows the drills inspect; node:sqlite returns untyped records.
13
-
14
-
15
-
16
-
17
- // node:sqlite boundary: a single-row query, typed by the caller's expectation.
18
- const row= (db ,sql ) =>{const r=db.prepare(sql).get();assert.ok(r,`${sql} returned no row`);return r ;};
8
+
19
9
  const seconds=Number(process.env.URLCODE_SOAK_SECONDS||5);
20
10
  assert(Number.isInteger(seconds)&&seconds>=1&&seconds<=3600,'Soak must be 1–3600 seconds');
21
11
  const directory=await mkdtemp(join(tmpdir(),'urlcode-drills-')),project=join(directory,'app');
22
- let store ,app ;
12
+ let app ;
23
13
  try {
24
14
  await mkdir(project);
25
- const config='version: "1"\ndynamicLinks: true\nroutes:\n /go:\n redirect: {url: "https://example.com/v1"}\n /function:\n function: {source: f.mjs}\n /live/{code}:\n parameters:\n - {name: code, in: path, required: true, schema: {type: string}}\n link: {collection: links, code: {from: path, name: code}}\n';
15
+ const config='version: "1"\nroutes:\n /go:\n redirect: {url: "https://example.com/v1"}\n /function:\n function: {source: f.mjs}\n';
26
16
  await writeFile(join(project,'urlcode.yaml'),config);
27
17
  await writeFile(join(project,'f.mjs'),'export default () => new Response("isolated");');
28
- const file=join(directory,'links.sqlite');
29
- store=await openLinkStore({file,project});let link=await store.create('links',{url:'https://example.com/live'},'demo');
30
- app=await startServer({project,port:0,linkStore:{collection:'links',file},log:()=>{}});
18
+ app=await startServer({project,port:0,log:()=>{}});
31
19
  const server=app;
32
20
  const get=async(path )=>{const r=await fetch(`http://127.0.0.1:${server.address.port}${path}`,{redirect:'manual'});const body=await r.text();return {status:r.status,location:r.headers.get('location'),body};};
33
21
  const start=performance.now(),samples =[];let requests=0;
34
22
  while(performance.now()-start<seconds*1000){
35
- const before=performance.now();const responses=await Promise.all(['/go','/function','/live/demo'].map(get));
36
- assert.deepEqual(responses.map(r=>r.status),[302,200,302]);assert.equal(responses[1]?.body,'isolated');
37
- assert.equal(responses[2]?.location,link.url);samples.push(performance.now()-before);requests+=3;
38
- if(requests%30===0)link=await store.update('links','demo',{url:`https://example.com/live-${requests}`},link.version);
23
+ const before=performance.now();const responses=await Promise.all(['/go','/function'].map(get));
24
+ assert.deepEqual(responses.map(r=>r.status),[302,200]);assert.equal(responses[1]?.body,'isolated');
25
+ samples.push(performance.now()-before);requests+=2;
39
26
  }
40
27
  await writeFile(join(project,'urlcode.yaml'),'invalid: configuration');assert.equal(await app.reload(),false);assert.equal((await get('/go')).location,'https://example.com/v1');
41
28
  await writeFile(join(project,'urlcode.yaml'),config.replace('/v1','/v2'));assert.equal(await app.reload(),true);assert.equal((await get('/go')).location,'https://example.com/v2');
42
29
  await writeFile(join(project,'urlcode.yaml'),config);assert.equal(await app.reload(),true);assert.equal((await get('/go')).location,'https://example.com/v1');
43
- await app.close();app=undefined;await store.close();store=undefined;
44
- // A read-only connection may have been the last to close, leaving WAL frames.
45
- // Quiesce all users, explicitly checkpoint, then close before copying.
46
- const checkpointDb=new DatabaseSync(file);
47
- try{const result=row (checkpointDb,'PRAGMA wal_checkpoint(TRUNCATE)');assert.equal(result.busy,0);assert.equal(result.log,0);assert.equal(result.checkpointed,0);}finally{checkpointDb.close();}
48
- const restoreStart=performance.now(),backup=join(directory,'restored.sqlite');await copyFile(file,backup);
49
- store=await openLinkStore({file:backup,project,readOnly:true});assert.deepEqual(await store.get('links','demo'),link);
50
- const db=new DatabaseSync(backup,{readOnly:true});assert.equal(row (db,'PRAGMA integrity_check').integrity_check,'ok');assert.equal(row (db,'SELECT max(revision) AS revision FROM urlcode_link_audit').revision,link.version);db.close();
30
+ await app.close();app=undefined;
51
31
  samples.sort((a,b)=>a-b);
52
- console.log(JSON.stringify({event:'local-operational-proof',seconds,requests,batchP99Ms:samples[Math.floor(samples.length*.99)],rssBytes:process.memoryUsage().rss,restoreMs:performance.now()-restoreStart,checks:['mixed HTTP load','invalid reload preserves last-good','configuration rollback','quiesced backup/restore including audit'],deploymentProof:false}));
53
- await store.close();store=undefined;
54
- // Only enable inside a disposable, size-limited mount (CI uses 16 MiB tmpfs).
55
- if(process.argv[2]==='--disk-full-dir'){
56
- const volume=resolve(process.argv[3] ?? '');const isolated=await mkdtemp(join(volume,'urlcode-full-'));
57
- try {
58
- const reserve=join(isolated,'reserve');await writeFile(reserve,Buffer.alloc(4*1024*1024));
59
- const fullFile=join(isolated,'full.sqlite');store=await openLinkStore({file:fullFile,project});let full=false,committed=0;
60
- for(let i=0;i<12000;i++){
61
- try{await store.create('links',{url:'https://example.com/'+ 'x'.repeat(7000)},'code-'+i);committed++;}
62
- catch(error){assert.ok(error instanceof HttpError,'store failure must be an HttpError');assert.equal(error.status,503);full=true;break;}
63
- }
64
- assert(full,'Disposable volume did not fill within the 84 MiB write budget');
65
- const space=await statfs(isolated);assert(space.bavail*space.bsize<1024*1024,'Expected actual volume exhaustion');
66
- await rm(reserve);await store.create('links',{url:'https://example.com/recovered'},'recovered');committed++;
67
- await store.close();store=undefined;
68
- const check=new DatabaseSync(fullFile,{readOnly:true});
69
- assert.equal(row (check,'PRAGMA integrity_check').integrity_check,'ok');
70
- assert.equal(row (check,'SELECT count(*) AS n FROM urlcode_links').n,committed);
71
- assert.equal(row (check,'SELECT count(*) AS n FROM urlcode_link_audit').n,committed);check.close();
72
- console.log(JSON.stringify({event:'disposable-volume-exhaustion',committed,integrity:'ok',auditAtomic:true}));
73
- }finally{await store?.close();store=undefined;await rm(isolated,{recursive:true,force:true});}
74
- }
75
- } finally {await app?.close();await store?.close();await rm(directory,{recursive:true,force:true});}
32
+ console.log(JSON.stringify({event:'local-operational-proof',seconds,requests,batchP99Ms:samples[Math.floor(samples.length*.99)],rssBytes:process.memoryUsage().rss,checks:['mixed HTTP load','invalid reload preserves last-good','configuration rollback'],deploymentProof:false}));
33
+ } finally {await app?.close();await rm(directory,{recursive:true,force:true});}
package/dist/server.js CHANGED
@@ -7,8 +7,6 @@ import { join } from 'node:path';
7
7
  import { createRuntime } from './runtime.js';
8
8
 
9
9
  import { createJsonLogger } from './logging.js';
10
- import { createLinkObserver } from './link-events.js';
11
-
12
10
  import { createObserverSink, renderPrometheus } from './observability.js';
13
11
 
14
12
  import { assert, HttpError } from './errors.js';
@@ -19,12 +17,12 @@ import { compileTrustedProxies, resolveClient } from './client-address.js';
19
17
 
20
18
 
21
19
 
22
-
20
+
23
21
 
24
22
 
25
23
 
26
24
 
27
-
25
+
28
26
 
29
27
 
30
28
 
@@ -83,7 +81,7 @@ function originForm(target ) {
83
81
  export async function startServer({ project = '.', host = '127.0.0.1', port = 3000, watch = false,
84
82
  local = false, log = createJsonLogger(),
85
83
  maxBodyBytes = 1048576, maxInFlightRequests = 64, maxInFlightHealthRequests = 16,
86
- requestLog = 'minimal', trustRequestId = false, origin, linkEvents, trustedProxies = [],
84
+ requestLog = 'minimal', trustRequestId = false, origin, trustedProxies = [],
87
85
  observers = [], metrics = false, metricsIntervalMs = 0, ...runtimeOptions } = {}) {
88
86
  // Which peers may set X-Forwarded-For. Empty means the socket peer is the
89
87
  // client for every policy; a forwarded header from anyone else is ignored.
@@ -105,9 +103,6 @@ export async function startServer({ project = '.', host = '127.0.0.1', port = 30
105
103
  const sink = createObserverSink(observers, log);
106
104
  const counters = sink.metrics;
107
105
  const emit = (event , context ) => { try { sink(event, context); } catch { /* Logging cannot fail requests. */ } };
108
- // Operator-supplied and explicitly enabled; route YAML cannot reach it and no
109
- // callback is ever loaded from the project. Undefined leaves it off.
110
- const observer = createLinkObserver(linkEvents, emit);
111
106
  let current = await createRuntime(project, { local, log: emit, origin, ...runtimeOptions });
112
107
  let shuttingDown = false, reloading = false, watching = false, interval , lastFingerprint , inFlight = 0, healthInFlight = 0;
113
108
  const retired = new Set ();
@@ -128,24 +123,6 @@ export async function startServer({ project = '.', host = '127.0.0.1', port = 30
128
123
  let status = 500;
129
124
  res.on('error', () => {});
130
125
  req.on('error', () => {});
131
- {
132
- // Enqueued after the response is over, so an observer can neither delay a
133
- // redirect nor turn its own failure into one. A response that never
134
- // finished is reported as aborted rather than counted as a click. The
135
- // outcome is counted whether or not an operator collects link events.
136
- let observed = false;
137
- const settle = () => {
138
- if (observed || !trace.link) return;
139
- observed = true;
140
- const event = { event: 'link_request', requestId, collection: trace.link.collection, route: trace.route ?? null,
141
- code: trace.link.code, method, status,
142
- outcome: trace.link.result === 'redirect' ? (res.writableFinished ? 'completed' : 'aborted') : trace.link.result,
143
- durationMs: Math.round((performance.now() - started) * 100) / 100 };
144
- counters.record(event);
145
- if (observer) observer.emit(event);
146
- };
147
- res.once('finish', settle); res.once('close', settle);
148
- }
149
126
  try {
150
127
  if (shuttingDown) throw new HttpError(503, 'Runtime shutting down');
151
128
  let result ;
@@ -246,7 +223,6 @@ export async function startServer({ project = '.', host = '127.0.0.1', port = 30
246
223
  }
247
224
  return {
248
225
  server, reload, address, root: current.root, testPlan: () => current.testPlan(),
249
- linkEventStats: () => observer?.stats(),
250
226
  metrics: snapshot,
251
227
  get observers() { return observers.map(observer => ({ name: observer.name, version: observer.version })); },
252
228
  // What a request sees as its own origin: behind a tunnel or proxy this is
@@ -260,8 +236,6 @@ export async function startServer({ project = '.', host = '127.0.0.1', port = 30
260
236
  clearTimeout(deadline);
261
237
  while (reloading) await new Promise(resolve => setTimeout(resolve,10));
262
238
  await current.close(); await Promise.all(retired);
263
- // Observers drain after the connections they describe are gone.
264
- if (observer) emit({ event: 'link_observer', status: 'closed', ...await observer.close() });
265
239
  // A final snapshot, then observers release in reverse order.
266
240
  sink.publish(snapshot());
267
241
  await sink.close();
package/dist/tooling.js CHANGED
@@ -44,7 +44,7 @@ export async function inspectProject(project ,options ={}) {
44
44
  const offset=options.offset??0,limit=options.limit??100;
45
45
  if(!Number.isSafeInteger(offset)||offset<0||!Number.isSafeInteger(limit)||limit<1||limit>1000)throw new Error('Invalid inspection page');
46
46
  const {loaded,compiled,routes,projectSha256}=await prepare(project,options);
47
- const report=analyzeCompiledCapabilities(loaded.document,compiled,options.target??'self-hosted');
47
+ const report=analyzeCompiledCapabilities(loaded.document,compiled,options.target??'self-hosted',options.extensions);
48
48
  return {format:1,projectSha256,routeCount:compiled.count,offset,limit,routes:routes.slice(offset,offset+limit).map(route=>({path:route.pattern,methods:route.methods,enabled:route.enabled!==false,capabilities:routeCapabilities(route,loaded.document)})),compatibility:{...compatibilitySummary(report),offset,limit,hasMore:offset+limit<report.issues.length,issues:report.issues.slice(offset,offset+limit)}};
49
49
  }
50
50
  export async function validateProject(project ,options ={}) {
@@ -0,0 +1,210 @@
1
+ // Trusted (unsandboxed) execution for `function`/`middleware` routes that do
2
+ // not declare `sandbox: true` (docs/SPIKE-DEFAULT-TRUST-MODEL.md). This is the
3
+ // new default: an ordinary dynamic `import()` of the project's own module,
4
+ // called in the host process with full Node access — no worker thread, no
5
+ // QuickJS/WASM, no fresh-heap-per-call, no module-graph allowlist. The
6
+ // sandboxed path (FunctionPool, function-worker.ts) is untouched by this file
7
+ // and keeps its own guarantees exactly as before for any route that opts in
8
+ // with `sandbox: true`.
9
+ //
10
+ // The request/response contract mirrors the sandboxed path (guest-api.ts) as
11
+ // closely as an in-process call can: a `Request` and a `context` in, a
12
+ // `Response` out, the same middleware `(request, context, next)` chain with
13
+ // `next()` callable at most once and no arguments, and the same native-reply
14
+ // passthrough (a middleware chain wrapping a native reply — redirect, asset,
15
+ // static reply — can return that exact Response unchanged to avoid
16
+ // re-encoding its body). Node's own `Request`/`Response`/`Headers` are used
17
+ // instead of guest-api.ts's restricted classes, so a trusted function can
18
+ // return richer bodies (e.g. binary) than a sandboxed one can; that is a
19
+ // documented, intentional capability difference, not a contract violation.
20
+ //
21
+ // Deadline: unlike the sandboxed path, there is no interrupt mechanism that
22
+ // can stop trusted code running in the host's own event loop. `timeoutMs`
23
+ // here is an advisory race against the handler's promise settling — it
24
+ // rejects the *call* once the deadline passes, but cannot preempt trusted
25
+ // code that is blocking the event loop synchronously (a WASM interrupt has no
26
+ // equivalent in-process). This is a documented difference from the
27
+ // sandboxed path's forced worker termination; see docs/CAPACITY.md.
28
+ import { randomUUID } from 'node:crypto';
29
+ import { pathToFileURL } from 'node:url';
30
+ import { ConfigError, HttpError } from './errors.js';
31
+ import { routeFunctions } from './function-sources.js';
32
+
33
+
34
+
35
+
36
+
37
+
38
+
39
+
40
+
41
+
42
+
43
+
44
+ export class TrustedFunctions {
45
+ timeoutMs ; maxBytes ; log ;
46
+ // Node's ESM loader caches a resolved module forever by URL, unlike a
47
+ // sandboxed worker, which gets a genuinely fresh module registry on every
48
+ // reload/restart. A snapshot reload constructs a brand-new TrustedFunctions
49
+ // (createRuntime runs again), so each instance gets its own cache-busting
50
+ // query string: reloaded code is re-imported and re-executed, matching the
51
+ // sandboxed pool's "new workers, new snapshot" reload contract, while a
52
+ // single instance still only imports each module once per process.
53
+ epoch = randomUUID();
54
+ constructor({ timeoutMs = 5000, maxBytes = 1048576, log = () => {} } = {}) {
55
+ this.timeoutMs = timeoutMs; this.maxBytes = maxBytes; this.log = log;
56
+ }
57
+ // Eagerly imports and validates every declared export exists as a function,
58
+ // the same guarantee FunctionPool.start() gives the sandboxed path: a
59
+ // broken function/middleware module fails runtime activation up front
60
+ // rather than the first request that happens to hit it.
61
+ async start(routes ) {
62
+ try { await Promise.all(routes.flatMap(routeFunctions).map(definition => this.loadExport(definition))); }
63
+ catch { throw new ConfigError('Function initialization failed (check module syntax, imports and exports)'); }
64
+ return this;
65
+ }
66
+ // No dependency allowlist, no relative-static-import-only rule and no
67
+ // per-module byte budget apply here — those are sandbox-snapshot
68
+ // constraints (function-sources.ts), not trusted-path ones. A trusted
69
+ // module may use bare specifiers, Node builtins, npm packages and dynamic
70
+ // import exactly like any other project code.
71
+ async loadExport(definition ) {
72
+ let mod ;
73
+ try { mod = await import(pathToFileURL(definition.source).href + '?urlcode-trusted-epoch=' + this.epoch) ; }
74
+ catch { throw new HttpError(502, 'Function execution failed'); }
75
+ const value = mod[definition.export];
76
+ if (typeof value !== 'function') throw new HttpError(502, 'Function execution failed');
77
+ return value;
78
+ }
79
+ async execute(route , request , context , native ) {
80
+ // The timer backing the deadline race must be cleared on every path —
81
+ // success or failure — or a completed call leaves a live Timeout behind
82
+ // for the full timeoutMs (see #138: 100 sequential calls, 100 leaked
83
+ // timers). `finally` covers both outcomes of the race in one place.
84
+ // The AbortController lets a fired deadline reach into `invoke`'s body
85
+ // read and cancel it immediately (#137), instead of letting a losing
86
+ // racer keep consuming an oversized or slow stream after this call has
87
+ // already rejected with 504.
88
+ const controller = new AbortController();
89
+ let timer ;
90
+ const timeout = new Promise ((_, reject) => {
91
+ timer = setTimeout(() => { controller.abort(); reject(new HttpError(504, 'Function deadline exceeded')); }, this.timeoutMs);
92
+ timer.unref?.();
93
+ });
94
+ const invocation = this.invoke(route, request, context, native, controller.signal);
95
+ try { return await Promise.race([invocation, timeout]); }
96
+ catch (error) { throw error instanceof HttpError ? error : new HttpError(502, 'Function execution failed'); }
97
+ finally {
98
+ clearTimeout(timer);
99
+ // `invocation` may still be running (e.g. blocked on a cancelled
100
+ // reader settling) after the race above has already returned; a later
101
+ // rejection from it must not surface as an unhandled rejection.
102
+ invocation.catch(() => {});
103
+ }
104
+ }
105
+ // Reads a Response body incrementally, rejecting/cancelling as soon as
106
+ // `maxBytes` is exceeded instead of buffering the whole stream first
107
+ // (#137: a single `.arrayBuffer()` call reads everything unconditionally,
108
+ // so a configured limit only ever rejects *after* the oversized body has
109
+ // already been fully read into memory). Also cancels the reader as soon as
110
+ // `signal` aborts (the call's deadline firing), rather than letting a slow
111
+ // stream keep being pulled after the request has already timed out.
112
+ // Applies on every path that has a real body to measure, GET or HEAD alike
113
+ // (#139): the caller decides whether to transmit the bytes, this only
114
+ // decides how many there are, matching the convention the native/asset
115
+ // path already uses (the full body and its length are always determined
116
+ // up front; only `prepareResponse` in http-response.ts drops the body for
117
+ // HEAD at write time).
118
+ async readBody(response , signal ) {
119
+ if (response.body === null) return Buffer.alloc(0);
120
+ const reader = response.body.getReader();
121
+ const cancel = () => { reader.cancel().catch(() => {}); };
122
+ if (signal.aborted) { cancel(); throw new HttpError(504, 'Function deadline exceeded'); }
123
+ signal.addEventListener('abort', cancel, { once: true });
124
+ try {
125
+ const chunks = [];
126
+ let total = 0;
127
+ for (;;) {
128
+ const { done, value } = await reader.read();
129
+ if (done) break;
130
+ total += value.byteLength;
131
+ if (total > this.maxBytes) { cancel(); throw new HttpError(502, 'Function execution failed'); }
132
+ chunks.push(Buffer.from(value));
133
+ }
134
+ return Buffer.concat(chunks, total);
135
+ } catch (error) {
136
+ if (signal.aborted) throw new HttpError(504, 'Function deadline exceeded');
137
+ throw error instanceof HttpError ? error : new HttpError(502, 'Function execution failed');
138
+ } finally { signal.removeEventListener('abort', cancel); }
139
+ }
140
+ async invoke(route , request , requestContext , native , signal ) {
141
+ // Matches guest-api.ts's __invokePipeline/__invoke: a fresh `state` object
142
+ // per invocation, shared across the whole middleware+handler chain for
143
+ // this one call only, never carried between requests.
144
+ const context = requestContext ;
145
+ context.state = {};
146
+ const hasBody = request.body !== undefined && request.body.length > 0 && !['GET','HEAD'].includes(request.method);
147
+ const req = new Request(request.url, {
148
+ method: request.method, headers: request.headers,
149
+ ...(hasBody ? { body: request.body , duplex: 'half' } : {}),
150
+ });
151
+ let nativeResponse ;
152
+ if (native) {
153
+ const nativeBytes = native.body ;
154
+ const bodyless = [204,205,304].includes(native.status) || !nativeBytes || nativeBytes.length === 0;
155
+ nativeResponse = new Response(bodyless ? null : nativeBytes, { status: native.status, headers: native.headers });
156
+ }
157
+ const middleware = route.middleware || [];
158
+ const handlers = await Promise.all(middleware.map(definition => this.loadExport(definition))) ;
159
+ const entry = route.function ? (await this.loadExport(route.function) ) : undefined;
160
+ const dispatch = async (index ) => {
161
+ if (index === handlers.length) {
162
+ if (entry) return await entry(req, context);
163
+ if (nativeResponse) return nativeResponse;
164
+ throw new HttpError(502, 'Function execution failed');
165
+ }
166
+ let called = false, open = true, pending ;
167
+ const next = async (...args ) => {
168
+ if (args.length || called || !open) throw new TypeError('next may be called once during middleware');
169
+ called = true; pending = dispatch(index + 1); return pending;
170
+ };
171
+ let response ;
172
+ try { response = await handlers[index] (req, context, next); if (pending) await pending.catch(() => {}); }
173
+ finally { open = false; }
174
+ if (!(response instanceof Response)) throw new HttpError(502, 'Function execution failed');
175
+ return response;
176
+ };
177
+ const response = await dispatch(0);
178
+ if (!(response instanceof Response) || !Number.isInteger(response.status) || response.status < 200 || response.status > 599) throw new HttpError(502, 'Function execution failed');
179
+ const nativeBody = response === nativeResponse;
180
+ const headers = [...response.headers.entries()];
181
+ if (nativeBody && native) {
182
+ // The route returned the untouched native reply (possibly with extra
183
+ // headers a middleware added, e.g. `x-seen` above `location`): reject
184
+ // only if native's *own* metadata was changed, the same contract the
185
+ // sandboxed path enforces (function-worker.ts's "Native metadata
186
+ // changed" check) — an added header is fine, a changed one is not.
187
+ for (const key of new Set(native.headers.map(([k]) => k.toLowerCase()))) {
188
+ const originals = native.headers.filter(([k]) => k.toLowerCase() === key).map(([,v]) => v);
189
+ const current = headers.filter(([k]) => k.toLowerCase() === key).map(([,v]) => v);
190
+ if (JSON.stringify(originals) !== JSON.stringify(current)) throw new HttpError(502, 'Function execution failed');
191
+ }
192
+ }
193
+ // Header count/byte limits apply either way, matching function-worker.ts.
194
+ { let bytes = 0; for (const [k,v] of headers) bytes += Buffer.byteLength(k) + Buffer.byteLength(v) + 4;
195
+ if (headers.length > 256 || bytes > 16384) throw new HttpError(502, 'Function execution failed'); }
196
+ // Measured on HEAD too, not skipped: prepareResponse (http-response.ts)
197
+ // is what decides not to put the bytes on the wire for HEAD, but it
198
+ // still needs the real length rather than the 0 a skipped read leaves it
199
+ // to assume (#139).
200
+ const body = nativeBody && native
201
+ ? ((native.body ) ?? Buffer.alloc(0))
202
+ : await this.readBody(response, signal);
203
+ return { status: response.status, headers, body, nativeBody,
204
+ ...(nativeBody && native?.contentLength !== undefined ? { contentLength: native.contentLength } : {}) };
205
+ }
206
+ // Symmetry with FunctionPool.close(): nothing to release for the trusted
207
+ // path (no workers, no pending requests it owns), but the runtime can call
208
+ // either executor's close() uniformly during shutdown.
209
+ async close() { /* no owned resources */ }
210
+ }
@@ -0,0 +1,43 @@
1
+ import type { LogFn } from './types.ts';
2
+ export interface BuildOptions {
3
+ out?: string | undefined;
4
+ origin?: string | undefined;
5
+ log?: LogFn | undefined;
6
+ }
7
+ export interface BuildReport {
8
+ out: string;
9
+ format: number;
10
+ version: string;
11
+ routes: number;
12
+ redirects: number;
13
+ files: number;
14
+ manifest: string;
15
+ }
16
+ /** One S3 per-object website redirect: a zero-byte object at `key` carrying
17
+ * `x-amz-website-redirect-location: location`, which S3 always answers with a
18
+ * 301 regardless of the object's own metadata. */
19
+ export interface StaticRedirect {
20
+ key: string;
21
+ location: string;
22
+ status: 301;
23
+ }
24
+ /** One served file: the bytes are written under `out/objects/<key>`; the
25
+ * metadata here is what a deploy step sets as the S3 object's own headers. */
26
+ export interface StaticObject {
27
+ key: string;
28
+ contentType: string;
29
+ cacheControl?: string;
30
+ contentDisposition?: string;
31
+ }
32
+ export interface RedirectManifest {
33
+ format: number;
34
+ redirects: StaticRedirect[];
35
+ }
36
+ export interface ObjectManifest {
37
+ format: number;
38
+ objects: StaticObject[];
39
+ }
40
+ declare function objectKey(pattern: string): string;
41
+ export declare function buildStatic(project: string, { out, origin, log }?: BuildOptions): Promise<BuildReport>;
42
+ export declare const staticObjectKey: typeof objectKey;
43
+ export {};
@@ -1,9 +1,12 @@
1
+ import type { RuntimeExtension } from './extensions.ts';
1
2
  import type { CompiledRoute, CompiledRouteTable, LoadedDocument, PolicySupport, ProjectDocument, RouteConfig } from './types.ts';
2
- export declare const capabilityTargets: readonly ["self-hosted", "cloudflare", "aws", "vercel"];
3
+ export declare const capabilityTargets: readonly ["self-hosted", "cloudflare", "aws", "vercel", "static"];
3
4
  export type CapabilityTarget = typeof capabilityTargets[number];
4
5
  export type CapabilitySupport = PolicySupport | 'conditional' | 'unknown';
5
- export declare const capabilityNames: readonly ["extension", "policies.extensions", "proxy", "signals", "conditional", "conditions", "redirect", "respond", "page", "static", "download", "function", "middleware", "link", "dynamicLinks", "parameters", "methods", "enabled", "expires", "request.body", "response.headers", "bindings", "policies.agents", "policies.security", "policies.cache", "policies.compression", "policies.throttle"];
6
+ export declare const capabilityNames: readonly ["extension", "policies.extensions", "proxy", "signals", "conditional", "conditions", "redirect", "respond", "page", "static", "download", "function", "middleware", "parameters", "methods", "enabled", "expires", "request.body", "response.headers", "bindings", "policies.agents", "policies.security", "policies.cache", "policies.compression", "policies.throttle"];
6
7
  export type CapabilityName = typeof capabilityNames[number];
8
+ /** The resolved operator registration set, when known (loaded via --host-file, same as `inspectExtensions`). Keyed by extension name. */
9
+ export type ExtensionRegistry = ReadonlyMap<string, RuntimeExtension>;
7
10
  export interface CapabilityDecision {
8
11
  support: CapabilitySupport;
9
12
  reason: string;
@@ -35,10 +38,16 @@ export declare function normalizeCapabilityTarget(target: string): CapabilityTar
35
38
  export declare function getCapabilities(target?: string): CapabilityCatalog;
36
39
  /** A safe projection shared by declaration preflight and the existing compiled IR. No values escape. */
37
40
  export declare function routeCapabilities(route: RouteConfig | CompiledRoute, document: ProjectDocument): CapabilityName[];
38
- /** Preflight only: call after schema validation/site expansion, before resolving bindings. */
39
- export declare function analyzeProjectCapabilities(loaded: LoadedDocument, target: string): CompatibilityReport;
41
+ /**
42
+ * Preflight only: call after schema validation/site expansion, before resolving bindings.
43
+ * `registrations` is the resolved operator extension set, when known (the same
44
+ * shape --host-file loads for `inspectExtensions`); without it, `extension`/
45
+ * `policies.extensions` requirements report `conditional`/`unknown` rather
46
+ * than a blanket `native` that ignores the specific extension's own targets.
47
+ */
48
+ export declare function analyzeProjectCapabilities(loaded: LoadedDocument, target: string, registrations?: readonly RuntimeExtension[]): CompatibilityReport;
40
49
  /** Analyze normalized route semantics without exporting validators, resources or resolved secrets. */
41
- export declare function analyzeCompiledCapabilities(document: ProjectDocument, compiled: CompiledRouteTable, target: string): CompatibilityReport;
50
+ export declare function analyzeCompiledCapabilities(document: ProjectDocument, compiled: CompiledRouteTable, target: string, registrations?: readonly RuntimeExtension[]): CompatibilityReport;
42
51
  export declare function assertTargetCompatibility(report: CompatibilityReport): void;
43
52
  export declare function formatCapabilities(catalog: CapabilityCatalog): string;
44
53
  export type CapabilityKind = 'handler' | 'policy' | 'routing' | 'request' | 'binding' | 'egress' | 'middleware' | 'project';
@@ -7,8 +7,6 @@ export declare const rfc6585: Standard;
7
7
  export declare const rfc9309: Standard;
8
8
  export declare const breach: Standard;
9
9
  export declare const monitoring: Standard;
10
- export declare const linkChannel: Standard;
11
- export declare const management: Standard;
12
10
  export declare const agentLists: Standard;
13
11
  export declare const active: (route: PlanInventoryEntry) => boolean;
14
12
  export declare const functionLike: (route: PlanInventoryEntry) => boolean;
@@ -27,8 +27,6 @@ export type RuleResult = RawFinding[] | RawFinding | null | undefined | false;
27
27
  /** The host settings under review, null where the operator declared nothing. */
28
28
  export interface HostSettings {
29
29
  requestLog: 'minimal' | 'detailed' | null;
30
- linkEvents: boolean | null;
31
- includeCode: boolean | null;
32
30
  }
33
31
  export interface ProjectContext {
34
32
  document: ProjectDocument;
@@ -92,7 +90,6 @@ export interface ComplianceReport {
92
90
  evidence: {
93
91
  routes: number;
94
92
  active: number;
95
- dynamicLinks: boolean;
96
93
  policies: string[];
97
94
  files: string[];
98
95
  origin: string | null;
@@ -20,7 +20,8 @@ export declare const SHORT_FORM_PATH_SCHEMA: {
20
20
  /**
21
21
  * Expands the YAML short forms into the canonical long form. `function: functions/x.mjs`
22
22
  * becomes `{source, args}` with an argument per `{param}` in the path, declaring any
23
- * parameter the route does not declare itself; a string middleware entry becomes `{source}`.
23
+ * parameter the route does not declare itself; a string middleware entry becomes `{source}`;
24
+ * a route-level `cache` becomes `policies.cache` (refused alongside a direct `policies.cache`).
24
25
  * Everything downstream (routes, audit, the compiled table) sees only the long form.
25
26
  */
26
27
  export declare function normalizeRoute(pattern: string, route: AuthoredRouteConfig | RouteConfig): RouteConfig;
@@ -25,7 +25,6 @@ export interface ProjectContext {
25
25
  env: string[];
26
26
  secrets: string[];
27
27
  };
28
- dynamicLinks: boolean;
29
28
  site: string[];
30
29
  files?: {
31
30
  includes: string[];
@@ -41,6 +40,8 @@ export interface ProjectContext {
41
40
  path: string;
42
41
  methods: string[];
43
42
  handler: string;
43
+ sandbox: boolean;
44
+ sandboxReason?: string;
44
45
  }[];
45
46
  constraints: Record<string, boolean | string | {
46
47
  value: boolean | string;
@@ -31,7 +31,7 @@ export interface ExampleSearchResult {
31
31
  matchedRoutes: RouteIndexEntry[];
32
32
  })[];
33
33
  }
34
- export declare const exampleNames: readonly ["assets", "aws", "cloudflare", "compliance", "conditions", "cookbook", "egress", "extensions", "live-links", "monitoring", "prerender", "provider-conformance", "tunnel", "vercel"];
34
+ export declare const exampleNames: readonly ["assets", "aws", "cloudflare", "compliance", "conditions", "cookbook", "egress", "extensions", "monitoring", "prerender", "provider-conformance", "tunnel", "vercel"];
35
35
  export declare const routeIndexFile = "route-index.json";
36
36
  export declare function listExamples(): Promise<ExampleSummary[]>;
37
37
  /**
@@ -55,6 +55,12 @@ export interface RouteExplanation {
55
55
  source: string;
56
56
  export: string;
57
57
  }[];
58
+ /** Execution mode for this route's whole `function`/`middleware` chain: `true` for the
59
+ * QuickJS sandbox, `false` for trusted in-process execution. Route-level, not handler-level:
60
+ * a native handler with `middleware` runs project code too, and its mode has to be reviewable.
61
+ * Reported for every route, including those that run no project code at all. */
62
+ sandbox: boolean;
63
+ sandboxReason?: string;
58
64
  inputs: {
59
65
  parameters: ExplainedParameter[];
60
66
  body?: RequestBodyPolicy;