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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (191) hide show
  1. package/.claude/skills/urlcode-authoring/SKILL.md +24 -8
  2. package/.claude/skills/urlcode-operations/SKILL.md +10 -16
  3. package/.claude-plugin/marketplace.json +1 -1
  4. package/README.md +75 -62
  5. package/ROADMAP.md +61 -16
  6. package/SECURITY.md +23 -8
  7. package/dist/BUILD-MANIFEST.json +38 -43
  8. package/dist/agents-guide.js +18 -18
  9. package/dist/build-static.js +134 -0
  10. package/dist/capabilities.js +85 -31
  11. package/dist/capability-query.js +0 -1
  12. package/dist/cli.js +23 -31
  13. package/dist/compliance-rules/baseline.js +2 -10
  14. package/dist/compliance-rules/privacy.js +5 -16
  15. package/dist/compliance-rules/shared.js +0 -2
  16. package/dist/compliance.js +6 -8
  17. package/dist/config.js +15 -8
  18. package/dist/context.js +9 -10
  19. package/dist/examples.js +2 -2
  20. package/dist/explain-cli.js +3 -3
  21. package/dist/explain.js +5 -6
  22. package/dist/extensions.js +61 -2
  23. package/dist/function-sources.js +34 -2
  24. package/dist/function-worker.js +3 -1
  25. package/dist/functions.js +84 -13
  26. package/dist/guest-api.js +29 -3
  27. package/dist/index.js +3 -6
  28. package/dist/manifest.js +5 -7
  29. package/dist/mcp.js +1 -1
  30. package/dist/observability.js +1 -21
  31. package/dist/policies/cache.js +8 -3
  32. package/dist/policy.js +28 -9
  33. package/dist/project-tests.js +3 -3
  34. package/dist/readiness.js +32 -9
  35. package/dist/router.js +5 -7
  36. package/dist/runtime.js +77 -58
  37. package/dist/sandbox.js +48 -0
  38. package/dist/scaffold.js +0 -0
  39. package/dist/scripts/operational-drills.js +12 -54
  40. package/dist/server.js +3 -29
  41. package/dist/tooling.js +1 -1
  42. package/dist/trusted-functions.js +210 -0
  43. package/dist/types/build-static.d.ts +43 -0
  44. package/dist/types/capabilities.d.ts +14 -5
  45. package/dist/types/compliance-rules/shared.d.ts +0 -2
  46. package/dist/types/compliance.d.ts +0 -3
  47. package/dist/types/config.d.ts +2 -1
  48. package/dist/types/context.d.ts +2 -1
  49. package/dist/types/examples.d.ts +1 -1
  50. package/dist/types/extensions.d.ts +55 -0
  51. package/dist/types/function-sources.d.ts +4 -0
  52. package/dist/types/functions.d.ts +48 -5
  53. package/dist/types/guest-api.d.ts +1 -0
  54. package/dist/types/index.d.ts +3 -6
  55. package/dist/types/manifest.d.ts +1 -3
  56. package/dist/types/observability.d.ts +1 -14
  57. package/dist/types/project-tests.d.ts +1 -2
  58. package/dist/types/readiness.d.ts +10 -3
  59. package/dist/types/router.d.ts +2 -1
  60. package/dist/types/runtime.d.ts +0 -27
  61. package/dist/types/sandbox.d.ts +12 -0
  62. package/dist/types/scaffold.d.ts +0 -2
  63. package/dist/types/server.d.ts +1 -4
  64. package/dist/types/tooling.d.ts +3 -3
  65. package/dist/types/trusted-functions.d.ts +29 -0
  66. package/dist/types/types.d.ts +14 -7
  67. package/dist/types/verify-deployment.d.ts +2 -2
  68. package/dist/types.js +17 -4
  69. package/dist/typescript-authoring.js +55 -17
  70. package/dist/verify-deployment.js +3 -3
  71. package/docs/AI-AUTHORING.md +104 -9
  72. package/docs/AWS.md +4 -4
  73. package/docs/BEST-PRACTICES.md +3 -2
  74. package/docs/CAPABILITIES.md +29 -16
  75. package/docs/CAPACITY.md +128 -31
  76. package/docs/CLOUDFLARE.md +1 -2
  77. package/docs/COMPLIANCE.md +6 -9
  78. package/docs/DEPLOYMENT-CHECKS.md +1 -1
  79. package/docs/EXTENSIONS.md +183 -11
  80. package/docs/FRAMEWORK.md +35 -19
  81. package/docs/FUNCTION-SECURITY.md +129 -32
  82. package/docs/INSTALL.md +0 -5
  83. package/docs/LOAD-TESTING.md +4 -4
  84. package/docs/MIDDLEWARE.md +27 -16
  85. package/docs/MONITORING.md +2 -19
  86. package/docs/NEXT-PHASE-PLAN.md +12 -4
  87. package/docs/NEXT-STEPS.md +259 -40
  88. package/docs/OBSERVABILITY.md +7 -16
  89. package/docs/OPEN-DECISIONS.md +212 -0
  90. package/docs/OPERATIONAL-PROOF.md +26 -30
  91. package/docs/OPERATIONS.md +23 -32
  92. package/docs/POLICIES.md +15 -4
  93. package/docs/PRERENDER.md +2 -2
  94. package/docs/PROJECT-DIRECTION.md +34 -9
  95. package/docs/README.md +13 -11
  96. package/docs/RELEASE-READINESS.md +49 -29
  97. package/docs/RELEASE-SECURITY.md +89 -3
  98. package/docs/RESILIENCE.md +16 -15
  99. package/docs/ROUTING.md +8 -10
  100. package/docs/SANDBOX-REVIEW.md +19 -6
  101. package/docs/SCAFFOLDING.md +0 -2
  102. package/docs/SECURITY-AUDIT.md +40 -0
  103. package/docs/SPECIFICATION.md +60 -26
  104. package/docs/SPIKE-AI-FRAMEWORK-BENCHMARK.md +287 -0
  105. package/docs/SPIKE-BUSINESS-SUITE.md +8 -0
  106. package/docs/SPIKE-CORE-LAYERING.md +337 -0
  107. package/docs/SPIKE-DEFAULT-TRUST-MODEL.md +209 -0
  108. package/docs/SPIKE-EXTENSION-MODEL.md +2 -2
  109. package/docs/SPIKE-EXTENSIONS.md +5 -0
  110. package/docs/SPIKE-LAMBDA-COMPILE.md +4 -2
  111. package/docs/SPIKE-MONOREPO.md +322 -0
  112. package/docs/STARTERS.md +1 -1
  113. package/docs/STATIC.md +94 -0
  114. package/docs/TOOLING.md +13 -9
  115. package/docs/TUNNELS.md +0 -3
  116. package/docs/TYPESCRIPT-AUTHORING.md +27 -12
  117. package/docs/TYPESCRIPT.md +25 -4
  118. package/docs/USABILITY-REVIEW.md +7 -1
  119. package/docs/VERCEL.md +4 -5
  120. package/docs/VERSION-ALIGNMENT.md +205 -0
  121. package/docs/YAML-GUIDE.md +0 -3
  122. package/docs/YAML-REFERENCE.md +16 -6
  123. package/docs/policies/cache.md +13 -0
  124. package/examples/assets/example.yaml +1 -1
  125. package/examples/aws/example.yaml +1 -1
  126. package/examples/cloudflare/example.yaml +1 -1
  127. package/examples/conditions/example.yaml +1 -1
  128. package/examples/cookbook/example.yaml +1 -1
  129. package/examples/cookbook/middleware/bucket.mjs +12 -2
  130. package/examples/cookbook/middleware/locale.mjs +7 -3
  131. package/examples/egress/example.yaml +1 -1
  132. package/examples/extensions/example.yaml +1 -1
  133. package/examples/prerender/README.md +2 -2
  134. package/examples/prerender/example.yaml +1 -1
  135. package/examples/provider-conformance/example.yaml +1 -1
  136. package/examples/vercel/example.yaml +1 -1
  137. package/llms-full.txt +594 -219
  138. package/llms.txt +23 -12
  139. package/package.json +10 -3
  140. package/packaging/claude-plugin/.claude-plugin/plugin.json +1 -1
  141. package/packaging/claude-plugin/skills/urlcode-authoring/SKILL.md +24 -8
  142. package/packaging/claude-plugin/skills/urlcode-operations/SKILL.md +10 -16
  143. package/recipes/authenticated-json-api/recipe.yaml +1 -1
  144. package/recipes/contact-form/recipe.yaml +1 -1
  145. package/recipes/cors-api/recipe.yaml +1 -1
  146. package/recipes/health-page/recipe.yaml +1 -1
  147. package/recipes/json-api/recipe.yaml +1 -1
  148. package/recipes/middleware/middleware/bucket.mjs +12 -2
  149. package/recipes/middleware/middleware/locale.mjs +7 -3
  150. package/recipes/middleware/recipe.yaml +1 -1
  151. package/recipes/protected-download/recipe.yaml +1 -1
  152. package/recipes/redirect/recipe.yaml +1 -1
  153. package/recipes/static-plus-api/recipe.yaml +1 -1
  154. package/recipes/typescript/README.md +2 -1
  155. package/recipes/typescript/recipe.yaml +1 -1
  156. package/recipes/webhook-receiver/README.md +5 -1
  157. package/recipes/webhook-receiver/recipe.yaml +2 -1
  158. package/recipes/webhook-receiver/urlcode.yaml +7 -0
  159. package/schemas/recipe.schema.json +4 -3
  160. package/schemas/urlcode.schema.json +14 -41
  161. package/skills/urlcode/SKILL.md +29 -8
  162. package/starters/default/AGENTS.md +18 -18
  163. package/starters/default/urlcode.yaml +0 -1
  164. package/dist/link-api.js +0 -136
  165. package/dist/link-cli.js +0 -141
  166. package/dist/link-events.js +0 -76
  167. package/dist/link-records.js +0 -31
  168. package/dist/link-store-worker.js +0 -150
  169. package/dist/link-store.js +0 -250
  170. package/dist/management-policy.js +0 -40
  171. package/dist/sqlite-version.js +0 -6
  172. package/dist/types/link-api.d.ts +0 -30
  173. package/dist/types/link-cli.d.ts +0 -37
  174. package/dist/types/link-events.d.ts +0 -27
  175. package/dist/types/link-records.d.ts +0 -11
  176. package/dist/types/link-store-worker.d.ts +0 -1
  177. package/dist/types/link-store.d.ts +0 -130
  178. package/dist/types/management-policy.d.ts +0 -8
  179. package/dist/types/sqlite-version.d.ts +0 -1
  180. package/docs/DYNAMIC-LINKS.md +0 -61
  181. package/docs/MANAGEMENT-SECURITY.md +0 -82
  182. package/docs/links/cli.md +0 -110
  183. package/docs/links/limits.md +0 -175
  184. package/docs/links/management-api.md +0 -80
  185. package/docs/links/pools.md +0 -75
  186. package/docs/links/setup.md +0 -135
  187. package/docs/yaml/links.md +0 -30
  188. package/examples/live-links/README.md +0 -11
  189. package/examples/live-links/example.yaml +0 -21
  190. package/examples/live-links/tests/requests.json +0 -6
  191. package/examples/live-links/urlcode.yaml +0 -16
@@ -8,7 +8,18 @@ import { validateHeaderName, validateHeaderValue } from './header-validation.js'
8
8
 
9
9
 
10
10
 
11
-
11
+ /**
12
+ * `root` is the project's resolved absolute directory (the same value
13
+ * `loadDocument()` computes and `functionFile()`/router.ts resolves
14
+ * `function`/`middleware` `source` entries against). It is not the process's
15
+ * `cwd()`: `--project`/`--host-file` are independent paths, a server can be
16
+ * started from any working directory, and the JS API can load a project
17
+ * programmatically with no relationship to `cwd()` at all. An extension that
18
+ * resolves project-relative paths of its own (for example, an alternative
19
+ * middleware source it loads by convention rather than through core's native
20
+ * `middleware:` array) must resolve them against this field, never `cwd()`.
21
+ */
22
+
12
23
 
13
24
 
14
25
 
@@ -17,6 +28,20 @@ import { validateHeaderName, validateHeaderValue } from './header-validation.js'
17
28
 
18
29
 
19
30
 
31
+
32
+
33
+
34
+
35
+
36
+
37
+
38
+
39
+
40
+
41
+
42
+
43
+
44
+
20
45
 
21
46
 
22
47
  /** Trusted operator code only. YAML declares names/configuration, never modules. */
@@ -30,6 +55,23 @@ import { validateHeaderName, validateHeaderValue } from './header-validation.js'
30
55
 
31
56
 
32
57
 
58
+
59
+
60
+
61
+
62
+
63
+
64
+
65
+
66
+
67
+
68
+
69
+
70
+
71
+
72
+
73
+
74
+
33
75
 
34
76
 
35
77
  /** `urlcode init --with <name>` contract: what core hands `@jimhoyd/urlcode-<name>`'s `scaffold` export. Nothing is written by `scaffold`. */
@@ -85,6 +127,23 @@ export function effectiveExtensionPolicies(document ,route
85
127
  return result;
86
128
  }
87
129
  export function hasExtensionPolicy(document ,route ) {return Object.keys(effectiveExtensionPolicies(document,route)).length>0;}
130
+ /**
131
+ * Whether a route naming these `policies.extensions` names must be treated as
132
+ * confidential (forced no-store, no compression, extension response caps).
133
+ * `names` with no entries is never confidential. Without a `registrations`
134
+ * list to consult (a build path with no operator host loaded), every name is
135
+ * treated as sensitive: the safe default this can only relax, never weaken.
136
+ * A name whose registration is missing, or declares no `cacheSensitive` (or
137
+ * `true`), counts as sensitive; only an explicit `cacheSensitive: false`
138
+ * excuses it, and one sensitive name among several makes the whole route
139
+ * confidential.
140
+ */
141
+ export function isSensitiveExtensionPolicy(names ,registrations ) {
142
+ if(!names.length)return false;
143
+ if(!registrations)return true;
144
+ const byName=new Map(registrations.map(registration=>[registration.name,registration.cacheSensitive]));
145
+ return names.some(name=>byName.get(name)!==false);
146
+ }
88
147
  export function prepareExtensions(document ,routes ,registrations ,context ) {
89
148
  assert(registrations===undefined||Array.isArray(registrations)&&registrations.length<=16,'Extensions must be an array of at most 16 operator registrations');
90
149
  const provided=new Map (),entries=new Map (),credentialHeaders=new Set ();
@@ -127,7 +186,7 @@ export function prepareExtensions(document ,routes
127
186
  try{for(const {name,registration,config,policies,mounts,assetPrefixes}of preparations){
128
187
  const instance=await registration.activate(config,frozen({...context,mounts}));
129
188
  if(instance&&typeof instance==='object')entries.set(name,{instance,policies,assetPrefixes});
130
- assert(instance&&typeof instance.handle==='function'&&(!policies.size||typeof instance.authorize==='function'),`Extension ${name} lacks a required handler or authorization hook`);
189
+ assert(instance&&typeof instance.handle==='function'&&(!policies.size||typeof instance.authorize==='function'||typeof instance.middleware==='function'),`Extension ${name} lacks a required handler, authorization hook or middleware hook`);
131
190
  entries.set(name,{instance,policies,assetPrefixes});
132
191
  }}catch(error){for(const entry of [...entries.values()].reverse())try{await entry.instance.close?.();}catch{/* Keep the activation failure. */}throw error;}
133
192
  return {entries,credentialHeaders:[...credentialHeaders],async close(){for(const entry of [...entries.values()].reverse())try{await entry.instance.close?.();}catch{/* Operators own extension lifecycle diagnostics. */}}};
@@ -25,8 +25,35 @@ export const TOTAL_BYTE_LIMIT = 4194304;
25
25
 
26
26
  export function routeFunctions (route ) { return [...(route.middleware || []), ...(route.function ? [route.function] : [])]; }
27
27
 
28
+ // Trusted (unsandboxed) routes are never bundled into a QuickJS module snapshot:
29
+ // they run through Node's own module resolution, with no restriction on bare
30
+ // specifiers, dynamic import or dependency count/size. For revision pinning
31
+ // (docs/SPIKE-DEFAULT-TRUST-MODEL.md, "trust-declaration integrity"), the
32
+ // project hash must still change when a trusted function/middleware's own
33
+ // source changes, so an operator's env/secret grant is invalidated the moment
34
+ // the code that could use it is edited. This only hashes each entry file's own
35
+ // bytes, not its transitive import graph the way the sandboxed collector does:
36
+ // a change to a helper module a trusted entry imports, without touching the
37
+ // entry file itself, does not by itself change the project hash. Documented as
38
+ // a known limitation in docs/FUNCTION-SECURITY.md.
39
+ export async function collectTrustedSources(routes , root ) {
40
+ const sources = Object.create(null);
41
+ for (const definition of routes.flatMap(routeFunctions)) {
42
+ const name = '/' + relative(root, definition.source).split(sep).join('/');
43
+ if (Object.hasOwn(sources, name)) continue;
44
+ sources[name] = await readFile(definition.source, 'utf8');
45
+ }
46
+ return sources;
47
+ }
48
+
28
49
  // Parse and snapshot source without ever importing project code into Node.
29
- export async function collectFunctionSources(routes , root ) {
50
+ // Route-shaped callers go through `collectFunctionSources` below; a
51
+ // route-independent caller (the public sandbox primitive, src/sandbox.ts)
52
+ // calls this directly with its own explicit `{source, export}` list, so the
53
+ // module-allowlist walk and per-module/total byte budgets apply identically
54
+ // either way — there is exactly one collector, not a route-shaped one and a
55
+ // second generic one.
56
+ export async function collectSourcesFor(definitions , root ) {
30
57
  await init;
31
58
  const sources = Object.create(null), dependencies = Object.create(null);
32
59
  const entries = [], names = new Map (), seenEntries = new Set ();
@@ -56,7 +83,7 @@ export async function collectFunctionSources(routes , root
56
83
  }
57
84
  return name;
58
85
  }
59
- for (const definition of routes.flatMap(routeFunctions)) {
86
+ for (const definition of definitions) {
60
87
  const name = await collect(definition.source);
61
88
  names.set(definition.source,name);
62
89
  const key = name + ":" + definition.export;
@@ -64,3 +91,8 @@ export async function collectFunctionSources(routes , root
64
91
  }
65
92
  return { sources,dependencies,entries,names };
66
93
  }
94
+ /** Route-shaped convenience over `collectSourcesFor`: every existing caller (policy.ts,
95
+ * prerender.ts, typescript-authoring.ts) keeps working unchanged from `FunctionRoute[]`. */
96
+ export async function collectFunctionSources(routes , root ) {
97
+ return collectSourcesFor(routes.flatMap(routeFunctions), root);
98
+ }
@@ -83,6 +83,7 @@ port.on('message', async ({id,source,name,request,context,maxBytes,timeoutMs,cha
83
83
  if (output === undefined || Buffer.byteLength(output) > maxBytes * 6 + 65536) throw new Error('Output limit');
84
84
  const value = JSON.parse(output) ; // trust boundary: guest JSON, checked below
85
85
  if (!value || !Number.isInteger(value.status) || value.status < 200 || value.status > 599 || typeof value.body !== 'string' || !Array.isArray(value.headers) || value.headers.length > 256) throw new Error('Invalid response');
86
+ if (value.contentLength !== undefined && (!Number.isInteger(value.contentLength) || value.contentLength < 0)) throw new Error('Invalid response');
86
87
  if (value.nativeBody) {
87
88
  if (!native || value.status !== native.status || value.body !== '') throw new Error('Invalid native response');
88
89
  // Preserve native status and metadata (validators, ranges, redirect Location).
@@ -100,6 +101,7 @@ port.on('message', async ({id,source,name,request,context,maxBytes,timeoutMs,cha
100
101
  bytes += Buffer.byteLength(pair[0]) + Buffer.byteLength(pair[1]) + 4;
101
102
  }
102
103
  if (bytes > 16384) throw new Error('Header limit');
103
- post({id,status:value.status,headers:value.headers,body,nativeBody:value.nativeBody === true});
104
+ post({id,status:value.status,headers:value.headers,body,nativeBody:value.nativeBody === true,
105
+ ...(typeof value.contentLength === 'number' ? {contentLength:value.contentLength} : {})});
104
106
  } catch { post({id,error:true}); }
105
107
  });
package/dist/functions.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { Worker } from 'node:worker_threads';
2
2
  import { randomUUID } from 'node:crypto';
3
- import { collectFunctionSources, routeFunctions } from './function-sources.js';
4
-
3
+ import { collectSourcesFor, routeFunctions } from './function-sources.js';
4
+
5
5
  import { assert, ConfigError, HttpError } from './errors.js';
6
6
 
7
7
 
@@ -9,10 +9,24 @@ import { assert, ConfigError, HttpError } from './errors.js';
9
9
 
10
10
 
11
11
 
12
-
12
+ /** A module entry point the sandbox must load: an absolute source file path
13
+ * (already resolved and validated, e.g. via `functionFile()`) plus which
14
+ * export of it needs to be reachable. The same shape a `FunctionDefinition`
15
+ * already uses for `function`/`middleware` routes. */
16
+
17
+ /** What one `SandboxPool.execute()` call invokes: the same `{source, export}`
18
+ * shape as a `SandboxEntry`, naming one of the pool's declared entries. */
19
+
20
+ /** `entry` runs last (the route's `function`/handler); `chain` runs first, in
21
+ * order, each with `(request, context, next)` — the same wrap/middleware
22
+ * semantics `__invokePipeline` already gives sandboxed routes. Neither is
23
+ * required: an empty invocation with a `native` reply just returns it. */
24
+
25
+
13
26
 
14
27
 
15
28
 
29
+
16
30
 
17
31
  // The worker protocol. Only JSON-shaped data and byte buffers cross it.
18
32
 
@@ -26,27 +40,43 @@ import { assert, ConfigError, HttpError } from './errors.js';
26
40
 
27
41
 
28
42
 
29
-
43
+
30
44
 
31
45
 
32
46
 
33
47
 
34
48
 
35
- export class FunctionPool {
36
- root ; routes ; preparedSnapshot ; snapshot ;
49
+ // The generalized sandbox primitive: the pool of worker threads, each running
50
+ // the same function-worker.ts (QuickJS engine setup, dependency-closure module
51
+ // allowlisting, memory/stack limits, deadline enforcement via both the
52
+ // interrupt handler and outer worker termination, response-shape validation),
53
+ // driven by an explicit list of `{source, export}` entries rather than
54
+ // anything route/YAML-shaped. This is the ONE place that owns worker
55
+ // spawning/QuickJS setup/deadline enforcement; `FunctionPool` below is a thin
56
+ // route-shaped wrapper over it, not a second copy of the mechanics. Exported
57
+ // publicly (as `@jimhoyd/urlcode/sandbox`, see src/sandbox.ts) for an
58
+ // extension package that needs to run PROJECT code — a hook a project's own
59
+ // config names — through the same trusted/sandboxed dispatch selection route
60
+ // dispatch gets, when that hook declares `sandbox: true`
61
+ // (docs/EXTENSIONS.md#project-level-lifecycle-hooks). There is no "trusted"
62
+ // mode here: an extension wanting trusted execution just calls the project's
63
+ // function directly via `import()` (already possible via
64
+ // `ExtensionActivation.root`); this primitive is only ever the sandboxed path.
65
+ export class SandboxPool {
66
+ root ; entries ; preparedSnapshot ; snapshot ;
37
67
  restarts ; restartTimers ; log ; timeoutMs ; maxBytes ;
38
68
  modules ; size ; slots ; closed ;
39
- constructor(routes , { root, snapshot, workers = 2, timeoutMs = 5000, maxBytes = 1048576, log = () => {} } = {}) {
69
+ constructor(entries , { root, snapshot, workers = 2, timeoutMs = 5000, maxBytes = 1048576, log = () => {} } = {}) {
40
70
  assert(Number.isInteger(workers) && workers >= 1 && workers <= 32, 'Workers must be 1–32');
41
71
  assert(Number.isInteger(timeoutMs) && timeoutMs >= 10 && timeoutMs <= 60000, 'Function timeout must be 10–60000 ms');
42
72
  assert(Number.isInteger(maxBytes) && maxBytes >= 1 && maxBytes <= 16777216, 'Response limit must be 1–16777216 bytes');
43
- this.root = root; this.routes = routes; this.preparedSnapshot = snapshot;
73
+ this.root = root; this.entries = entries; this.preparedSnapshot = snapshot;
44
74
  // Consecutive replacement attempts per slot; cleared by a completed invocation.
45
75
  this.restarts = new Map(); this.restartTimers = new Set();
46
76
  this.log = log;
47
77
  this.timeoutMs = timeoutMs; this.maxBytes = maxBytes;
48
78
  const modules = new Map ();
49
- for (const definition of routes.flatMap(routeFunctions)) {
79
+ for (const definition of entries) {
50
80
  const { source, export: name } = definition;
51
81
  let names = modules.get(source);
52
82
  if (!names) { names = new Set(); modules.set(source, names); }
@@ -58,7 +88,7 @@ export class FunctionPool {
58
88
  }
59
89
  async start() {
60
90
  let snapshot = this.preparedSnapshot;
61
- if (!snapshot && this.size) { assert(this.root !== undefined, 'Function pool requires a project root'); snapshot = await collectFunctionSources(this.routes,this.root); }
91
+ if (!snapshot && this.size) { assert(this.root !== undefined, 'Function pool requires a project root'); snapshot = await collectSourcesFor(this.entries,this.root); }
62
92
  snapshot ??= {sources:{},dependencies:{},entries:[],names:new Map()};
63
93
  this.snapshot = snapshot;
64
94
  try { await Promise.all(Array.from({ length: this.size }, (_, i) => this.spawn(i))); }
@@ -138,11 +168,18 @@ export class FunctionPool {
138
168
  timer.unref(); this.restartTimers.add(timer);
139
169
  }
140
170
  get healthy() { return !this.closed && this.slots.length === this.size && this.slots.every(slot => slot?.ready); }
141
- execute(route , request , context , native ) {
171
+ /** `invocation.entry` runs last (chain-wrapped), `invocation.chain` first, in
172
+ * declared order — the same `__invokePipeline` wrap/middleware discipline
173
+ * (`next()` callable at most once) the sandboxed route path already
174
+ * enforces; there is no second dispatch mechanism for this. Every entry and
175
+ * chain source named here must already be one of this pool's declared
176
+ * `entries` (constructor), or the worker's own module allowlist denies it. */
177
+ execute(invocation , request , context , native ) {
142
178
  const slot = this.slots.find(s => s?.ready && !s.pending);
143
179
  const snapshot = this.snapshot;
144
180
  if (this.closed || !slot || !snapshot) return Promise.reject(new HttpError(503, 'Function capacity unavailable'));
145
181
  const id = randomUUID();
182
+ const { entry, chain = [] } = invocation;
146
183
  return new Promise((resolve, reject) => {
147
184
  const timer = setTimeout(() => {
148
185
  slot.pending = null; slot.ready = false;
@@ -154,8 +191,8 @@ export class FunctionPool {
154
191
  if (message.nativeBody && native) resolve({...message,body:native.body,...(native.contentLength === undefined ? {} : {contentLength:native.contentLength})});
155
192
  else resolve(message);
156
193
  }, reject };
157
- const message = { id, source: route.function ? snapshot.names.get(route.function.source) : undefined, name: route.function?.export,
158
- chain: (route.middleware || []).map(item => ({source:snapshot.names.get(item.source),name:item.export})),
194
+ const message = { id, source: entry ? snapshot.names.get(entry.source) : undefined, name: entry?.export,
195
+ chain: chain.map(item => ({source:snapshot.names.get(item.source),name:item.export})),
159
196
  native: native ? {status:native.status,headers:native.headers} : undefined,
160
197
  request, context, maxBytes: this.maxBytes, timeoutMs:this.timeoutMs + 100 };
161
198
  slot.worker.postMessage(message);
@@ -172,3 +209,37 @@ export class FunctionPool {
172
209
  await Promise.all(this.slots.map(s => s?.worker.terminate()));
173
210
  }
174
211
  }
212
+ // Thin, route-shaped wrapper over `SandboxPool`: every existing internal
213
+ // caller (route-level `function`/`middleware` dispatch, `src/runtime.ts`)
214
+ // keeps working exactly as before, translating `FunctionRoute[]`/`FunctionRoute`
215
+ // into the generalized `{source, export}` entries/target shape at the edge,
216
+ // never duplicating worker spawning, module allowlisting or deadline
217
+ // enforcement — that all still lives in the wrapped `SandboxPool`. Composition
218
+ // rather than inheritance because `execute()`'s route-shaped and
219
+ // entries-shaped parameters are genuinely different, incompatible types; the
220
+ // fields/methods below the constructor exist only so the (unchanged) test
221
+ // suite and `src/runtime.ts` keep reaching the wrapped pool's own state
222
+ // exactly as they did before this file had two classes.
223
+ export class FunctionPool {
224
+ routes ; pool ;
225
+ constructor(routes , options = {}) {
226
+ this.routes = routes;
227
+ this.pool = new SandboxPool(routes.flatMap(routeFunctions), options);
228
+ }
229
+ async start() { await this.pool.start(); return this; }
230
+ execute(route , request , context , native ) {
231
+ return this.pool.execute({ entry: route.function, chain: route.middleware }, request, context, native);
232
+ }
233
+ scheduleRespawn(index ) { this.pool.scheduleRespawn(index); }
234
+ close() { return this.pool.close(); }
235
+ get healthy() { return this.pool.healthy; }
236
+ get restarts() { return this.pool.restarts; }
237
+ get restartTimers() { return this.pool.restartTimers; }
238
+ get slots() { return this.pool.slots; }
239
+ get size() { return this.pool.size; }
240
+ get closed() { return this.pool.closed; }
241
+ get snapshot() { return this.pool.snapshot; }
242
+ get log() { return this.pool.log; }
243
+ get timeoutMs() { return this.pool.timeoutMs; }
244
+ get maxBytes() { return this.pool.maxBytes; }
245
+ }
package/dist/guest-api.js CHANGED
@@ -5,7 +5,7 @@
5
5
  /** The request the guest receives (stringified as JSON in the worker). */
6
6
 
7
7
  /** What the guest returns as JSON text; the worker enforces this shape before trusting it. */
8
-
8
+
9
9
  // Runs only inside QuickJS/WASM. No native host functions or objects are exposed.
10
10
  // This is the documented text/JSON subset, not a complete Fetch implementation.
11
11
  export const guestBootstrap = String.raw`
@@ -13,6 +13,24 @@ export const guestBootstrap = String.raw`
13
13
  const NativeJSON = JSON;
14
14
  const stringify = JSON.stringify.bind(JSON);
15
15
  const now = Date.now.bind(Date);
16
+ // The guest has no TextEncoder/Buffer; this counts the UTF-8 bytes a
17
+ // Response's text would occupy on the wire (matching Buffer.byteLength on
18
+ // the host side, including its handling of lone surrogates as U+FFFD),
19
+ // without pulling any Node capability into the sandbox (#144).
20
+ function byteLength(text) {
21
+ let bytes = 0;
22
+ for (let i = 0; i < text.length; i++) {
23
+ const code = text.charCodeAt(i);
24
+ if (code >= 0xd800 && code <= 0xdbff) {
25
+ const next = text.charCodeAt(i + 1);
26
+ if (next >= 0xdc00 && next <= 0xdfff) { bytes += 4; i++; continue; }
27
+ bytes += 3; continue;
28
+ }
29
+ if (code >= 0xdc00 && code <= 0xdfff) { bytes += 3; continue; }
30
+ bytes += code < 0x80 ? 1 : code < 0x800 ? 2 : 3;
31
+ }
32
+ return bytes;
33
+ }
16
34
  const timers = new Map(); let next = 1;
17
35
  globalThis.setTimeout = (fn, delay = 0) => {
18
36
  if (typeof fn !== 'function' || timers.size >= 128) throw new Error('Timer limit');
@@ -94,8 +112,14 @@ export const guestBootstrap = String.raw`
94
112
  const response = await dispatch(0);
95
113
  if (violated || !(response instanceof Response)) throw new TypeError('Invalid middleware response');
96
114
  const nativeBody = response === nativeResponse;
115
+ const isHead = input.request.method === 'HEAD';
116
+ // The real length is already known "for free": _text is a fully
117
+ // materialized string at construction time, no stream read needed
118
+ // (#144, mirroring #139's fix for the trusted path). Only the
119
+ // transmitted bytes are suppressed for HEAD, never the length.
97
120
  globalThis.__output = stringify({status:response.status,headers:response.headers._pairs,
98
- body:nativeBody || input.request.method === 'HEAD' ? '' : response._text,nativeBody});
121
+ body:nativeBody || isHead ? '' : response._text,nativeBody,
122
+ ...(isHead && !nativeBody ? {contentLength:byteLength(response._text)} : {})});
99
123
  globalThis.__state = 'done';
100
124
  } catch { globalThis.__state = 'failed'; }
101
125
  };
@@ -106,7 +130,9 @@ export const guestBootstrap = String.raw`
106
130
  const response = await handler(new Request(input.request.url, input.request), input.context);
107
131
  if (!(response instanceof Response)) throw new TypeError('Return a Response');
108
132
  const headers = response.headers._pairs;
109
- globalThis.__output = stringify({status:response.status,headers,body: input.request.method === 'HEAD' ? '' : response._text});
133
+ const isHead = input.request.method === 'HEAD';
134
+ globalThis.__output = stringify({status:response.status,headers,body: isHead ? '' : response._text,
135
+ ...(isHead ? {contentLength:byteLength(response._text)} : {})});
110
136
  globalThis.__state = 'done';
111
137
  } catch { globalThis.__state = 'failed'; }
112
138
  };
package/dist/index.js CHANGED
@@ -1,13 +1,8 @@
1
1
  export { createRuntime } from './runtime.js';
2
-
2
+
3
3
  export { startServer } from './server.js';
4
4
 
5
5
  export { loadDocument, validateDocument, parseYaml } from './config.js';
6
- export {openLinkStore} from './link-store.js';
7
-
8
- export {startLinkApi} from './link-api.js';
9
-
10
-
11
6
  export { events as observabilityEvents, validateObservers, createObserverSink, createMetrics, renderPrometheus } from './observability.js';
12
7
 
13
8
  export { getCapabilities, routeCapabilities, analyzeProjectCapabilities, analyzeCompiledCapabilities, assertTargetCompatibility, normalizeCapabilityTarget } from './capabilities.js';
@@ -39,6 +34,8 @@ export {normalizeMatch, assertDisjointMatches, matchesRoute} from './conditions.
39
34
 
40
35
  export {buildCloudflare} from './build-cloudflare.js';
41
36
 
37
+ export {buildStatic} from './build-static.js';
38
+
42
39
  export {runProjectTests} from './project-tests.js';
43
40
 
44
41
  export {scaffoldProject} from './scaffold.js';
package/dist/manifest.js CHANGED
@@ -15,7 +15,7 @@ import {explainCompiledRoute} from './explain.js';
15
15
  // checked in as a source of truth, and it is deterministic: the same project
16
16
  // yields the same bytes. Nothing in it is a binding value or source text.
17
17
 
18
- export const MANIFEST_SCHEMA_VERSION=1;
18
+ export const MANIFEST_SCHEMA_VERSION=2;
19
19
 
20
20
 
21
21
 
@@ -30,7 +30,7 @@ export const MANIFEST_SCHEMA_VERSION=1;
30
30
 
31
31
 
32
32
 
33
-
33
+
34
34
 
35
35
 
36
36
 
@@ -61,14 +61,13 @@ export async function buildManifest(project ,options ={})
61
61
  const explanations=routes.map(route=>explainCompiledRoute(loaded,route,chains.get(route.pattern),{projectSha256,now:0})).sort((a,b)=>compare(a.path,b.path));
62
62
  const functions=new Map (),middleware=new Map ();
63
63
  const register=(table ,source ,name ,path )=>{const key=`${source}#${name}`;const entry=table.get(key)??{source,export:name,routes:[]};entry.routes.push(path);table.set(key,entry);};
64
- const env=new Set (),secrets=new Set (),proxy=new Set (),signals=new Set (),linkStores=new Set ();
64
+ const env=new Set (),secrets=new Set (),proxy=new Set (),signals=new Set ();
65
65
  const manifestRoutes =[];
66
66
  for(const explanation of explanations){
67
67
  const declared=loaded.routes[explanation.path];
68
68
  const routeEnv=sorted(Object.values(declared?.env??{}).flatMap(ref=>ref.env?[ref.env]:[])),routeSecrets=sorted(Object.values(declared?.secrets??{}).map(ref=>ref.secret));
69
69
  for(const name of routeEnv)env.add(name);for(const name of routeSecrets)secrets.add(name);
70
70
  if(declared?.proxy)proxy.add(origin(declared.proxy.url));for(const signal of declared?.signals??[])signals.add(origin(signal.url));
71
- if(declared?.link)linkStores.add(declared.link.collection);
72
71
  if(explanation.handler.kind==='function')register(functions,explanation.handler.source ,explanation.handler.export ,explanation.path);
73
72
  for(const item of explanation.middleware)register(middleware,item.source,item.export,explanation.path);
74
73
  const extensions ={};
@@ -91,17 +90,16 @@ export async function buildManifest(project ,options ={})
91
90
  protectedRoutes:sorted(Object.entries(loaded.routes).filter(([,route])=>Object.hasOwn(effectiveExtensionPolicies(loaded.document,route),name)).map(([path])=>path))};
92
91
  }
93
92
  const targets={} ;
94
- for(const target of capabilityTargets){const report=analyzeCompiledCapabilities(loaded.document,compiled,target);targets[target]={compatible:report.compatible,issues:report.issues.length};}
93
+ for(const target of capabilityTargets){const report=analyzeCompiledCapabilities(loaded.document,compiled,target,options.extensions);targets[target]={compatible:report.compatible,issues:report.issues.length};}
95
94
  const capabilities=new Set ();
96
95
  for(const route of routes)for(const capability of routeCapabilities(route,loaded.document))capabilities.add(capability);
97
- if(loaded.document.dynamicLinks)capabilities.add('dynamicLinks');
98
96
  const modules=(table )=>[...table.values()].map(entry=>({...entry,routes:sorted(entry.routes)})).sort((a,b)=>compare(a.source,b.source)||compare(a.export,b.export));
99
97
  return {
100
98
  schemaVersion:MANIFEST_SCHEMA_VERSION,urlcode:packageVersion,entry:'urlcode.yaml',
101
99
  files:loaded.files.map(file=>relative(loaded.root,file).split('\\').join('/')),
102
100
  revision:projectSha256,configVersion:loaded.version,routeCount:manifestRoutes.length,routes:manifestRoutes,
103
101
  capabilities:[...capabilities].sort(compare),extensions:extensionDeclarations,recipes:await recipeProvenance(loaded.root),
104
- external:{env:sorted(env),secrets:sorted(secrets),egress:{proxy:sorted(proxy),signals:sorted(signals)},extensions:Object.keys(extensionDeclarations),linkStores:sorted(linkStores),dynamicLinks:loaded.document.dynamicLinks===true},
102
+ external:{env:sorted(env),secrets:sorted(secrets),egress:{proxy:sorted(proxy),signals:sorted(signals)},extensions:Object.keys(extensionDeclarations)},
105
103
  functions:modules(functions),middleware:modules(middleware),targets,
106
104
  };
107
105
  }
package/dist/mcp.js CHANGED
@@ -78,7 +78,7 @@ export async function serveMcp(options ) {
78
78
  if(message.method==='initialize') {
79
79
  if(initialized){await error(id,-32600,'Already initialized');return;}
80
80
  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.0-alpha.1'}}});return;
81
+ initialized=true;await send({jsonrpc:'2.0',id,result:{protocolVersion,capabilities:{tools:{}},serverInfo:{name:'urlcode',version:'0.4.0-alpha.2'}}});return;
82
82
  }
83
83
  if(message.method==='ping'){await send({jsonrpc:'2.0',id,result:{}});return;}
84
84
  if(!ready){await error(id,-32002,'Initialize first');return;}
@@ -17,12 +17,8 @@ export const events = Object.freeze({
17
17
  reload: Object.freeze(['event', 'status', 'version', 'routes']),
18
18
  watch: Object.freeze(['event', 'status']),
19
19
  function_worker: Object.freeze(['event', 'status', 'slot', 'attempt', 'delayMs']),
20
- link_store_worker: Object.freeze(['event', 'status', 'readOnly', 'attempt', 'delayMs']),
21
- link_request: Object.freeze(['event', 'requestId', 'collection', 'route', 'code', 'method', 'status', 'outcome', 'durationMs']),
22
- link_observer: Object.freeze(['event', 'status', 'reason', 'dropped', 'queued', 'delivered', 'failed', 'timedOut', 'closed']),
23
20
  logs_dropped: Object.freeze(['event', 'count']),
24
21
  observer: Object.freeze(['event', 'status', 'name']),
25
- management_request: Object.freeze(['event', 'timestamp', 'requestId', 'collection', 'action', 'authenticated', 'principal', 'status', 'outcome', 'durationMs']),
26
22
  throttle: Object.freeze(['event', 'route', 'outcome', 'remaining']),
27
23
  agents: Object.freeze(['event', 'route', 'list', 'outcome']),
28
24
  cache: Object.freeze(['event', 'route', 'outcome']),
@@ -47,10 +43,8 @@ export const events = Object.freeze({
47
43
 
48
44
 
49
45
 
50
-
51
46
 
52
47
 
53
-
54
48
 
55
49
 
56
50
 
@@ -85,14 +79,13 @@ export function validateObservers(observers = []) {
85
79
  return observers ; // every entry was just checked
86
80
  }
87
81
 
88
- export const SNAPSHOT_VERSION = 1;
82
+ export const SNAPSHOT_VERSION = 2;
89
83
  const statusClasses = ['2xx', '3xx', '4xx', '5xx'];
90
84
  const MAX_ROUTES = 10000;
91
85
  const outcomes = {
92
86
  throttle: ['allowed', 'exceeded'],
93
87
  agents: ['denied', 'reported'],
94
88
  cache: ['hit', 'stale', 'miss', 'store'],
95
- link_request: ['completed', 'aborted', 'missing', 'disabled', 'expired', 'invalid_code', 'invalid_record', 'unavailable'],
96
89
  };
97
90
  const zeroed = (keys ) => Object.fromEntries(keys.map(key => [key, 0]));
98
91
 
@@ -108,11 +101,8 @@ export function createMetrics() {
108
101
  const reloads = { ok: 0, rejected: 0 };
109
102
  const watch = { failed: 0 };
110
103
  const functionWorkers = { started: 0, restarts: 0 };
111
- const linkStoreWorkers = { started: 0, restarts: 0 };
112
104
  const policies = { throttle: zeroed(outcomes.throttle), agents: zeroed(outcomes.agents), cache: zeroed(outcomes.cache) };
113
- const linkRequests = zeroed(outcomes.link_request);
114
105
  const signals = zeroed(['accepted','delivered','failed','dropped']);
115
- const linkObserver = { failed: 0, dropped: 0 };
116
106
  let logsDropped = 0, observerErrors = 0;
117
107
  const count = (table , key ) => { if (typeof key === 'string' && Object.hasOwn(table, key)) table[key] ++; };
118
108
  function countRequest(target , status , route ) {
@@ -137,10 +127,7 @@ export function createMetrics() {
137
127
  case 'reload': count(reloads, record.status); break;
138
128
  case 'watch': if (record.status === 'failed') watch.failed++; break;
139
129
  case 'function_worker': if (record.status === 'restarting') functionWorkers.restarts++; else if (record.status === 'started') functionWorkers.started++; break;
140
- case 'link_store_worker': if (record.status === 'restarting') linkStoreWorkers.restarts++; else if (record.status === 'started') linkStoreWorkers.started++; break;
141
130
  case 'throttle': case 'agents': case 'cache': count(policies[record.event], record.outcome); break;
142
- case 'link_request': count(linkRequests, record.outcome); break;
143
- case 'link_observer': if (record.status === 'failed') linkObserver.failed++; else if (record.status === 'dropped' || record.status === 'closed') linkObserver.dropped = Math.max(linkObserver.dropped, Number(record.dropped) || 0); break;
144
131
  case 'logs_dropped': logsDropped += Number(record.count) || 0; break;
145
132
  case 'observer': if (record.status === 'failed') observerErrors++; break;
146
133
  default: break;
@@ -160,11 +147,8 @@ export function createMetrics() {
160
147
  reloads: { ...reloads },
161
148
  watch: { ...watch },
162
149
  functionWorkers: { ...functionWorkers, healthySlots: 0, slots: 0 },
163
- linkStoreWorkers: { ...linkStoreWorkers },
164
150
  policies: { throttle: { ...policies.throttle }, agents: { ...policies.agents }, cache: { ...policies.cache } },
165
151
  signals: {...signals},
166
- linkRequests: { ...linkRequests },
167
- linkObserver: { ...linkObserver },
168
152
  logsDropped,
169
153
  observers: { errors: observerErrors },
170
154
  ...extra,
@@ -231,14 +215,10 @@ export function renderPrometheus(snapshot ) {
231
215
  metric('function_worker_restarts_total', 'counter', 'Function worker replacements scheduled.', [[{}, snapshot.functionWorkers?.restarts]]);
232
216
  metric('function_worker_healthy_slots', 'gauge', 'Function worker slots ready to serve.', [[{}, snapshot.functionWorkers?.healthySlots]]);
233
217
  metric('function_worker_slots', 'gauge', 'Function worker slots configured.', [[{}, snapshot.functionWorkers?.slots]]);
234
- metric('link_store_worker_restarts_total', 'counter', 'Link-store worker replacements scheduled.', [[{}, snapshot.linkStoreWorkers?.restarts]]);
235
218
  metric('throttle_total', 'counter', 'Throttle policy decisions.', byKey(snapshot.policies?.throttle, 'outcome'));
236
219
  metric('agents_total', 'counter', 'Agents policy decisions.', byKey(snapshot.policies?.agents, 'outcome'));
237
220
  metric('cache_total', 'counter', 'Cache policy outcomes.', byKey(snapshot.policies?.cache, 'outcome'));
238
- metric('link_requests_total', 'counter', 'Dynamic link requests by outcome.', byKey(snapshot.linkRequests, 'outcome'));
239
221
  metric('signals_total','counter','Best-effort webhook outcomes.',Object.entries(snapshot.signals||{}).map(([outcome,value])=>[{outcome},value]));
240
- metric('link_observer_failures_total', 'counter', 'Link event collector failures.', [[{}, snapshot.linkObserver?.failed]]);
241
- metric('link_observer_dropped_total', 'counter', 'Link events dropped under overload.', [[{}, snapshot.linkObserver?.dropped]]);
242
222
  metric('logs_dropped_total', 'counter', 'Log records the JSON logger shed.', [[{}, snapshot.logsDropped]]);
243
223
  metric('observer_errors_total', 'counter', 'Observer hooks that threw or rejected.', [[{}, snapshot.observers?.errors]]);
244
224
  metric('uptime_seconds', 'gauge', 'Seconds since the process started serving.', [[{}, snapshot.uptimeSeconds]]);
@@ -202,9 +202,14 @@ function revalidate(state , req , result
202
202
  if (result.status !== 200 || (req.method !== 'GET' && req.method !== 'HEAD')) return result;
203
203
  let headers = result.headers, etag = header(headers, 'etag');
204
204
  if (!etag) {
205
- // A strong validator over the representation. A HEAD answer without a
206
- // body (a function's) has nothing to hash and gets no validator, rather
207
- // than one that would disagree with GET.
205
+ // A strong validator over the representation. A trusted function's HEAD
206
+ // answer is now measured the same way its GET answer is (the body is
207
+ // read to determine its real length even though HEAD never puts it on
208
+ // the wire — see #139), so it has real bytes to hash here and gets a
209
+ // validator that agrees with GET's. Only a HEAD answer that genuinely
210
+ // has no body to measure (e.g. one carrying a native reply with no
211
+ // body) skips computing one, rather than hashing zero bytes and
212
+ // asserting a validator for a representation it never measured.
208
213
  const body = bodyOf(result);
209
214
  if (req.method === 'HEAD' && !body.length) return result;
210
215
  etag = '"' + createHash('sha256').update(body).digest('hex') + '"'; headers = [...headers, ['etag', etag]];