@jimhoyd/urlcode 0.5.5 → 0.5.8

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 (136) hide show
  1. package/.claude/skills/urlcode-authoring/SKILL.md +16 -1
  2. package/.claude/skills/urlcode-operations/SKILL.md +5 -1
  3. package/README.md +24 -14
  4. package/dist/BUILD-MANIFEST.json +55 -54
  5. package/dist/agents-guide.js +5 -2
  6. package/dist/assets.js +2 -2
  7. package/dist/authoring.js +3 -58
  8. package/dist/aws.js +21 -4
  9. package/dist/body-schema.js +2 -3
  10. package/dist/capabilities.js +1 -1
  11. package/dist/capability-query.js +3 -3
  12. package/dist/catalog.js +1 -1
  13. package/dist/cli.js +42 -15
  14. package/dist/cloudflare.js +26 -1
  15. package/dist/config.js +1 -1
  16. package/dist/context.js +6 -6
  17. package/dist/egress.js +1 -1
  18. package/dist/examples.js +2 -2
  19. package/dist/explain.js +4 -6
  20. package/dist/extension-artifacts.js +8 -8
  21. package/dist/extension-transport.js +6 -4
  22. package/dist/function-worker.js +5 -2
  23. package/dist/functions.js +1 -1
  24. package/dist/guest-api.js +22 -15
  25. package/dist/http-policy.js +1 -3
  26. package/dist/http-response.js +61 -10
  27. package/dist/init-with.js +8 -8
  28. package/dist/interchange.js +3 -2
  29. package/dist/logging.js +4 -4
  30. package/dist/match.js +1 -1
  31. package/dist/mcp-authoring.js +1 -2
  32. package/dist/mcp.js +2 -2
  33. package/dist/object-guards.js +17 -0
  34. package/dist/pattern-guard.js +33 -3
  35. package/dist/policies/agents.js +1 -1
  36. package/dist/policies/cache.js +16 -4
  37. package/dist/policies/compression.js +1 -1
  38. package/dist/policies/security.js +1 -1
  39. package/dist/policies/throttle.js +1 -1
  40. package/dist/policies.js +1 -1
  41. package/dist/policy.js +3 -3
  42. package/dist/prerender.js +1 -2
  43. package/dist/project-dependencies.js +8 -8
  44. package/dist/proxy.js +1 -1
  45. package/dist/readiness.js +16 -17
  46. package/dist/review.js +2 -2
  47. package/dist/route-diff.js +4 -4
  48. package/dist/router.js +1 -1
  49. package/dist/runtime.js +4 -1
  50. package/dist/scaffold.js +1 -1
  51. package/dist/schema-query.js +1 -1
  52. package/dist/server.js +49 -7
  53. package/dist/signals.js +2 -2
  54. package/dist/site.js +2 -2
  55. package/dist/trusted-functions.js +1 -1
  56. package/dist/types/assets.d.ts +3 -2
  57. package/dist/types/authoring.d.ts +3 -6
  58. package/dist/types/body-schema.d.ts +2 -1
  59. package/dist/types/catalog.d.ts +2 -1
  60. package/dist/types/config.d.ts +0 -8
  61. package/dist/types/context.d.ts +3 -1
  62. package/dist/types/egress.d.ts +1 -1
  63. package/dist/types/explain.d.ts +4 -4
  64. package/dist/types/extension-artifacts.d.ts +4 -13
  65. package/dist/types/extension-transport.d.ts +6 -4
  66. package/dist/types/functions.d.ts +1 -1
  67. package/dist/types/http-response.d.ts +7 -3
  68. package/dist/types/init-with.d.ts +3 -9
  69. package/dist/types/logging.d.ts +4 -3
  70. package/dist/types/match.d.ts +2 -1
  71. package/dist/types/object-guards.d.ts +7 -0
  72. package/dist/types/pattern-guard.d.ts +0 -1
  73. package/dist/types/policies/cache.d.ts +4 -3
  74. package/dist/types/policies/compression.d.ts +1 -1
  75. package/dist/types/policies/throttle.d.ts +1 -1
  76. package/dist/types/policy.d.ts +4 -3
  77. package/dist/types/project-dependencies.d.ts +1 -40
  78. package/dist/types/proxy.d.ts +2 -1
  79. package/dist/types/readiness.d.ts +12 -16
  80. package/dist/types/review.d.ts +0 -2
  81. package/dist/types/route-diff.d.ts +4 -3
  82. package/dist/types/router.d.ts +1 -1
  83. package/dist/types/server.d.ts +22 -0
  84. package/dist/types/signals.d.ts +3 -2
  85. package/dist/types/site.d.ts +1 -2
  86. package/dist/types/trusted-functions.d.ts +1 -1
  87. package/dist/types/types.d.ts +17 -11
  88. package/dist/types/vercel.d.ts +1 -0
  89. package/dist/types/verify-deployment.d.ts +1 -1
  90. package/dist/types.js +19 -12
  91. package/dist/vercel.js +12 -3
  92. package/dist/verify-deployment.js +29 -11
  93. package/docs/AI-AUTHORING.md +10 -1
  94. package/docs/FUNCTION-SECURITY.md +4 -0
  95. package/docs/README.md +2 -2
  96. package/docs/TOOLING.md +27 -1
  97. package/examples/assets/Makefile +1 -1
  98. package/examples/assets/example.yaml +3 -3
  99. package/examples/aws/example.yaml +3 -3
  100. package/examples/cloudflare/example.yaml +3 -3
  101. package/examples/compliance/README.md +1 -1
  102. package/examples/compliance/example.yaml +1 -1
  103. package/examples/compliance/rules.mjs +2 -2
  104. package/examples/conditions/example.yaml +3 -3
  105. package/examples/cookbook/README.md +4 -4
  106. package/examples/cookbook/example.yaml +3 -3
  107. package/examples/coverage-waiver/example.yaml +3 -3
  108. package/examples/egress/example.yaml +2 -2
  109. package/examples/extensions/example.yaml +1 -1
  110. package/examples/lifecycle/example.yaml +2 -2
  111. package/examples/not-found/README.md +2 -2
  112. package/examples/not-found/example.yaml +3 -3
  113. package/examples/prerender/README.md +4 -4
  114. package/examples/prerender/example.yaml +2 -2
  115. package/examples/provider-conformance/example.yaml +2 -2
  116. package/examples/shared-blocks/example.yaml +3 -3
  117. package/examples/vercel/example.yaml +3 -3
  118. package/llms-full.txt +49 -18
  119. package/llms.txt +9 -2
  120. package/package.json +14 -13
  121. package/skills/urlcode/SKILL.md +7 -7
  122. package/starters/default/.github/workflows/urlcode.yml +2 -1
  123. package/starters/default/AGENTS.md +3 -3
  124. package/starters/default/Makefile +1 -1
  125. package/starters/default/README.md +15 -13
  126. package/starters/default/tests/requests.json +0 -50
  127. package/starters/default/urlcode.yaml +1 -4
  128. package/dist/scripts/operational-drills.js +0 -33
  129. package/starters/default/functions/hello.mjs +0 -3
  130. package/starters/default/middleware/headers.mjs +0 -6
  131. package/starters/default/routes/functions.yaml +0 -20
  132. package/starters/default/routes/marketing/links.yaml +0 -7
  133. package/starters/page/README.md +0 -14
  134. package/starters/page/public/index.html +0 -12
  135. package/starters/page/tests/requests.json +0 -17
  136. package/starters/page/urlcode.yaml +0 -6
@@ -5,18 +5,27 @@ import { HttpError } from './errors.js';
5
5
 
6
6
  /** What a handler, policy or asset produced; every host turns it into a wire response here. */
7
7
 
8
-
9
-
10
-
8
+
9
+
10
+
11
11
  /** The node:http ServerResponse surface this module writes to, kept structural so the module stays Node-free. */
12
12
 
13
13
 
14
14
 
15
15
 
16
+
16
17
 
17
18
 
18
19
  // Hop-by-hop and runtime-owned headers a handler must never set on the wire.
19
20
  const forbiddenHeaders = new Set(['connection','keep-alive','transfer-encoding','content-length','upgrade','trailer','proxy-authenticate','proxy-authorization','te']);
21
+ // Unlike Cache-Control (a real default a handler may explicitly override),
22
+ // these two are always the runtime's own value, appended once below. A
23
+ // handler-set copy of either (defense-in-depth code setting nosniff itself,
24
+ // say) is dropped here rather than kept alongside the runtime's: now that
25
+ // repeated headers are sent as separate wire lines instead of silently
26
+ // collapsing to the last `setHeader` call, keeping both would put two
27
+ // X-Content-Type-Options or X-Request-Id lines on the wire instead of one.
28
+ const runtimeOwnedHeaders = new Set(['x-content-type-options','x-request-id']);
20
29
 
21
30
  // One place decides what a URLCode response *is*, independent of how a host
22
31
  // delivers it. A Node server writes it to a socket; a Lambda returns it as
@@ -27,7 +36,7 @@ export function prepareResponse(result , { requestId, method }
27
36
  if (!Number.isInteger(status) || status < 200 || status > 599) throw new HttpError(502, 'Invalid function response');
28
37
  const headers = [], cookies = [];
29
38
  for (const [key,value] of result.headers) {
30
- if (forbiddenHeaders.has(key.toLowerCase())) continue;
39
+ if (forbiddenHeaders.has(key.toLowerCase()) || runtimeOwnedHeaders.has(key.toLowerCase())) continue;
31
40
  validateHeaderName(key); validateHeaderValue(key,value);
32
41
  if (key.toLowerCase() === 'set-cookie') cookies.push(value);
33
42
  else headers.push([key,value]);
@@ -39,16 +48,57 @@ export function prepareResponse(result , { requestId, method }
39
48
  // State the length rather than leaving a host to infer it. A Node server
40
49
  // computes this itself, but a host that returns JSON does not, so the policy
41
50
  // has to say it for every host to agree.
42
- // HEAD states the length GET would send (RFC 9110 §8.6), so it is measured
43
- // on the result's body before the body is dropped.
44
- if (!bodyless) headers.push(['content-length', String(result.contentLength ?? (result.body?.length ?? 0))]);
51
+ // A response that carries its body is framed by the bytes it carries, never
52
+ // by a stated length. Only HEAD may state one, because it sends no body and
53
+ // reports the length GET would send (RFC 9110 §8.6); without a stated
54
+ // length it is measured on the result's body before the body is dropped.
55
+ if (!bodyless) headers.push(['content-length', String(method === 'HEAD' ? headLength(result) : byteLength(result.body))]);
45
56
  headers.push(['x-request-id',requestId],['x-content-type-options','nosniff']);
46
57
  if (!headers.some(([key]) => key.toLowerCase() === 'cache-control')) headers.push(['cache-control','no-store']);
47
58
  return { status, headers, cookies, body };
48
59
  }
60
+ function headLength(result ) {
61
+ if (result.contentLength === undefined) return byteLength(result.body);
62
+ if (!Number.isSafeInteger(result.contentLength) || result.contentLength < 0) throw new HttpError(502, 'Invalid function response');
63
+ return result.contentLength;
64
+ }
65
+ /** The UTF-8 bytes a body occupies on the wire; a string's `.length` counts UTF-16 units, not bytes. */
66
+ export function byteLength(body ) {
67
+ if (body === null || body === undefined) return 0;
68
+ if (typeof body !== 'string') return body.byteLength;
69
+ let bytes = 0;
70
+ for (let i = 0; i < body.length; i++) {
71
+ const code = body.charCodeAt(i);
72
+ if (code < 0x80) bytes += 1;
73
+ else if (code < 0x800) bytes += 2;
74
+ else if (code >= 0xd800 && code <= 0xdbff && i + 1 < body.length && (body.charCodeAt(i + 1) & 0xfc00) === 0xdc00) { bytes += 4; i++; }
75
+ else bytes += 3; // other BMP characters, and a lone surrogate encoded as U+FFFD
76
+ }
77
+ return bytes;
78
+ }
79
+ // `res.setHeader(name, value)` called twice for the same name (case
80
+ // insensitively) replaces the first call rather than adding to it, so a
81
+ // handler, policy or profile that legitimately repeats a header (`Link`,
82
+ // `WWW-Authenticate`, a second `Vary` fragment produced upstream of this
83
+ // module) would otherwise lose every value but the last on the wire. Group
84
+ // by name first, in the case first seen, and hand Node the whole array; that
85
+ // is the one call shape ResponseWriter#setHeader accepts for a repeated
86
+ // header on every host this module writes to.
87
+ function setGroupedHeaders(res , headers ) {
88
+ const order = [], grouped = new Map ();
89
+ for (const [key, value] of headers) {
90
+ const lower = key.toLowerCase();
91
+ const values = grouped.get(lower);
92
+ if (values) values.push(value);
93
+ else { grouped.set(lower, [value]); order.push(key); }
94
+ }
95
+ for (const key of order) { const values = grouped.get(key.toLowerCase()) ; res.setHeader(key, values.length === 1 ? values[0] : values); }
96
+ }
49
97
  export function writeResponse(res , result , options ) {
50
98
  const prepared = prepareResponse(result, options);
51
- for (const [key,value] of prepared.headers) res.setHeader(key,value);
99
+ // Node then refuses to send a body whose size differs from the stated length.
100
+ res.strictContentLength = true;
101
+ setGroupedHeaders(res, prepared.headers);
52
102
  if (prepared.cookies.length) res.setHeader('set-cookie',prepared.cookies);
53
103
  res.statusCode = prepared.status;
54
104
  res.end(prepared.body);
@@ -70,14 +120,15 @@ export function errorResponse(error , { requestId, method, headers = []
70
120
  const text = error instanceof HttpError && error.answer ? error.answer.text + '\n' : `${error instanceof HttpError ? String(error.message).replace(/[<>&"']/g, '') : 'Internal server error'}\n`;
71
121
  const body = method === 'HEAD' ? undefined : text;
72
122
  // Stated explicitly so every host agrees, as prepareResponse does for results.
73
- return { status, headers: [...fixed, ['content-length', String(new TextEncoder().encode(text).length)], ...extra], body };
123
+ return { status, headers: [...fixed, ['content-length', String(byteLength(text))], ...extra], body };
74
124
  }
75
125
  export function writeError(res , error , options ) {
76
126
  const prepared = errorResponse(error, options);
77
127
  if (res.headersSent) { res.destroy(); return prepared.status; }
78
128
  for (const key of res.getHeaderNames()) res.removeHeader(key);
79
- for (const [key,value] of prepared.headers) res.setHeader(key,value);
129
+ setGroupedHeaders(res, prepared.headers);
80
130
  res.setHeader('connection','close');
131
+ res.strictContentLength = true;
81
132
  res.statusCode = prepared.status;
82
133
  res.end(prepared.body);
83
134
  return prepared.status;
package/dist/init-with.js CHANGED
@@ -12,12 +12,13 @@ import { collectDependencySet, installSteps, renderPackageManifest } from './pro
12
12
 
13
13
  import { ConfigError, assert } from './errors.js';
14
14
  import { installBundle, loadExtensionBundle, } from './extension-bundles.js';
15
+ import { isRecord as record, isCode } from './object-guards.js';
15
16
 
16
17
  /** Directory names inside the generated site. The route project lives under `app/`; everything else is operator-owned. */
17
18
  const PROJECT_DIRECTORY = 'app', HOST_FILE = 'host.mjs', ROUTES_FILE = 'routes/extensions.yaml';
18
19
  const acknowledgementPattern = /^[a-z][a-z0-9-]{0,63}:[a-z][a-z0-9-]{0,63}$/;
19
20
  const namePattern = /^[a-z][a-z0-9-]{0,63}$/, capabilityPattern = /^[a-z][a-z0-9.:-]{0,63}$/;
20
-
21
+
21
22
 
22
23
 
23
24
 
@@ -30,7 +31,7 @@ const namePattern = /^[a-z][a-z0-9-]{0,63}$/, capabilityPattern = /^[a-z][a-z0-9
30
31
 
31
32
 
32
33
 
33
-
34
+
34
35
 
35
36
  export function parseWithNames(value ) {
36
37
  const names = value.split(',').map(name => name.trim());
@@ -39,16 +40,14 @@ export function parseWithNames(value ) {
39
40
  return names;
40
41
  }
41
42
  const packageName = (name ) => `@jimhoyd/urlcode-${name}`;
42
- const isCode = (error , code ) => error instanceof Error && 'code' in error && error.code === code;
43
43
  const strings = (value ) => Array.isArray(value) && value.every(item => typeof item === 'string');
44
- const record = (value ) => value !== null && typeof value === 'object' && !Array.isArray(value);
45
44
 
46
45
  /**
47
46
  * Orders the requested set from the scaffolds' declared `requires`, `after`, `provides` and `conflicts`, never from
48
47
  * the `--with` spelling. Kahn's algorithm with the lexically smallest ready extension first, so the result is
49
48
  * deterministic and identical for every permutation. Refuses a missing requirement, a conflict or a cycle by name.
50
49
  */
51
- export function orderScaffolds(results ) {
50
+ function orderScaffolds(results ) {
52
51
  const byName = new Map (), providers = new Map ();
53
52
  for (const result of results) byName.set(result.name, result);
54
53
  for (const result of results) for (const capability of result.provides ?? []) {
@@ -248,10 +247,11 @@ export async function initProjectWith(destination , requested
248
247
  for (const key of Object.keys(extensions)) assert(!Object.hasOwn(loaded.document.extensions ?? {}, key), `Extension ${key} from ${owners.get('e:' + key)} already exists in the starter`);
249
248
  const yamlFile = join(project, 'urlcode.yaml'), original = await readFile(yamlFile, 'utf8');
250
249
  const doc = parseDocument(original);
251
- // Extensions are declared in the entry file; their routes go into a last include so the starter's own routes
252
- // stay first in the loaded order and the entry file stays small.
250
+ // Extensions are declared in the entry file; their routes go into a last include.
251
+ // The bare starter has no includes until an extension needs one.
253
252
  doc.set('extensions', { ...loaded.document.extensions, ...extensions });
254
- doc.addIn(['includes'], ROUTES_FILE);
253
+ if (doc.has('includes')) doc.addIn(['includes'], ROUTES_FILE);
254
+ else doc.set('includes', [ROUTES_FILE]);
255
255
  const fragment = stringify({ version: '1', routes });
256
256
  validateDocument(doc.toJS()); validateDocument(parseYaml(fragment));
257
257
  const notes = results.flatMap(result => (result.routeNotes ?? []).map(note => `# ${result.name}: ${note}\n`)).join('');
@@ -1,6 +1,7 @@
1
1
  import { stringify } from 'yaml';
2
2
  import { parseYaml, validateDocument, MAX_CONFIG_BYTES } from './config.js';
3
3
  import { compileRoutes } from './router.js';
4
+ import { isRecord } from './object-guards.js';
4
5
 
5
6
 
6
7
 
@@ -17,8 +18,8 @@ const formats = new Set(['csv','json','yaml','netlify','cloudflare','vercel','ne
17
18
  const providers = new Set(['netlify','cloudflare','vercel','netlify-toml']);
18
19
  const statuses = new Set([301,302,303,307,308]);
19
20
  function record(value ) {
20
- if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Expected an object');
21
- return value ;
21
+ if (!isRecord(value)) throw new Error('Expected an object');
22
+ return value;
22
23
  }
23
24
  function keys(value , allowed ) {
24
25
  if (Object.keys(value).some(key => !allowed.includes(key))) throw new Error('Unsupported field; conversion would discard behavior');
package/dist/logging.js CHANGED
@@ -1,9 +1,5 @@
1
-
2
- /** What the logger needs from its sink: the members of a Writable it touches. process.stdout satisfies it. */
3
-
4
1
  // Bound buffered operational output when the log collector cannot keep up.
5
2
  // Dropped records are counted and reported when output becomes writable again.
6
- const sinkStates = new WeakMap ();
7
3
  export function createJsonLogger(stream = process.stdout, maxBufferBytes = 1048576) {
8
4
  let state=sinkStates.get(stream);
9
5
  if(!state){const created={failed:false};state=created;sinkStates.set(stream,created);stream.on?.('error',()=>{created.failed=true;});}
@@ -20,3 +16,7 @@ export function createJsonLogger(stream = process.stdout, maxBufferByte
20
16
  } catch {sink.failed=true;dropped++;}
21
17
  };
22
18
  }
19
+
20
+ /** What the logger needs from its sink: the members of a Writable it touches. process.stdout satisfies it. */
21
+
22
+ const sinkStates = new WeakMap ();
package/dist/match.js CHANGED
@@ -5,7 +5,7 @@
5
5
  import { HttpError } from './errors.js';
6
6
 
7
7
 
8
-
8
+
9
9
 
10
10
  /** A parsed scalar, a parsed array, or a schema default (which the schema may shape freely). */
11
11
 
@@ -8,6 +8,7 @@ import {compileRoutes} from './router.js';
8
8
  import {prepareFunctionSnapshot,requestedPermissions} from './policy.js';
9
9
  import {validateProject} from './tooling.js';
10
10
  import {scaffoldProject} from './scaffold.js';
11
+ import {isRecord as object, isCode} from './object-guards.js';
11
12
  import {addRecipe} from './recipes.js';
12
13
  import {authoringPath} from './authoring-files.js';
13
14
  import {assert} from './errors.js';
@@ -31,7 +32,6 @@ export const authoringDefinitions=[
31
32
  {name:'run_test',description:'Run `urlcode test` against the project (activates the local runtime and executes fixtures); returns exit code and bounded output.',properties:{},required:[]},
32
33
  {name:'run_audit',description:'Run `urlcode audit` against the project; returns exit code and bounded output.',properties:{},required:[]},
33
34
  ];
34
- const isCode=(error ,code ) =>error instanceof Error&&'code' in error&&error.code===code;
35
35
  // Operator-owned material never lives under an authoring write, even when an
36
36
  // operator mistakenly placed it in the checkout.
37
37
  const operatorFile=/^(?:.*policy.*\.json|.*compliance.*\.(?:json|mjs|js|cjs|ts)|host(?:-file)?\.(?:mjs|js|cjs|ts)|.*\.(?:sqlite3?|db)(?:-wal|-shm|-journal)?|urlcode\.yaml\.lock)$/i;
@@ -53,7 +53,6 @@ async function verdict(root ,origin ) {
53
53
  try{return await validateProject(root,origin?{origin}:{});}
54
54
  catch{return {valid:false ,note:'Project does not validate; call run_validate for the CLI report.'};}
55
55
  }
56
- function object(value ) {return value!==null&&typeof value==='object'&&!Array.isArray(value);}
57
56
  function expandHandler(handler ) {
58
57
  if(object(handler))return handler;
59
58
  assert(typeof handler==='string','Handler must be a route object or a short form');
package/dist/mcp.js CHANGED
@@ -8,6 +8,7 @@ import {buildManifest} from './manifest.js';
8
8
 
9
9
  import {authoringDefinitions,callAuthoringTool} from './mcp-authoring.js';
10
10
  import {listSkills,getSkill,searchDocs,getExample,validateYaml,explainError} from './agent-context.js';
11
+ import {isRecord as object} from './object-guards.js';
11
12
  import {describeArtifactCache,readArtifactMember} from './extension-artifacts.js';
12
13
  const protocolVersion='2025-11-25';
13
14
  const maxBytes=1048576;
@@ -46,7 +47,6 @@ const readTools=definitions.map(def=>({name:def.name,description:def.description
46
47
  const hostTool={name:hostDefinition.name,description:hostDefinition.description,inputSchema:{type:'object',properties:hostDefinition.properties,required:[],additionalProperties:false},annotations:{readOnlyHint:true,destructiveHint:false,openWorldHint:false}};
47
48
  const authoringTools=authoringDefinitions.map(def=>({name:def.name,description:def.description,inputSchema:{type:'object',properties:def.properties,required:def.required,additionalProperties:false},annotations:{readOnlyHint:false,destructiveHint:false,openWorldHint:false}}));
48
49
  const validators=new Map([...readTools,hostTool,...authoringTools].map(tool=>[tool.name,ajv.compile(tool.inputSchema)]));
49
- function object(value ) {return value!==null&&typeof value==='object'&&!Array.isArray(value);}
50
50
  /** `allowAuthoring` and `hostFile` are set only by the `--allow-authoring` and `--host-file` command-line flags; tool arguments and the environment never enable them. */
51
51
 
52
52
  /** Operator selects the only project root. Read tools have no path, credential, write or execution authority; authoring tools write inside that root only. */
@@ -102,7 +102,7 @@ export async function serveMcp(options ) {
102
102
  if(message.method==='initialize') {
103
103
  if(initialized){await error(id,-32600,'Already initialized');return;}
104
104
  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;}
105
- initialized=true;await send({jsonrpc:'2.0',id,result:{protocolVersion,capabilities:{tools:{}},serverInfo:{name:'urlcode',version:'0.5.5'}}});return;
105
+ initialized=true;await send({jsonrpc:'2.0',id,result:{protocolVersion,capabilities:{tools:{}},serverInfo:{name:'urlcode',version:'0.5.8'}}});return;
106
106
  }
107
107
  if(message.method==='ping'){await send({jsonrpc:'2.0',id,result:{}});return;}
108
108
  if(!ready){await error(id,-32002,'Initialize first');return;}
@@ -0,0 +1,17 @@
1
+ // Small, dependency-free predicates shared across the runtime and its
2
+ // scripts. Kept separate from extension-transport.ts, which owns GitHub
3
+ // release/cache/lockfile plumbing (and imports Node-only modules) rather
4
+ // than generic object checks; router.ts and body-schema.ts reach this file
5
+ // instead so a Worker/edge bundle never pulls in Node builtins through it.
6
+
7
+
8
+ /** True for a non-null, non-array object -- the shape most parsed JSON/YAML fields expect. */
9
+ export const isRecord = (value ) =>
10
+ value !== null && typeof value === 'object' && !Array.isArray(value);
11
+
12
+ /** True for a `key` that `object` itself declares (not inherited). */
13
+ export const own = (object , key ) => Object.hasOwn(object, key);
14
+
15
+ /** True when `error` is a Node `Error` carrying the given `code` (e.g. `'ENOENT'`). */
16
+ export const isCode = (error , code ) =>
17
+ error instanceof Error && 'code' in error && error.code === code;
@@ -1,6 +1,6 @@
1
1
  import { assert } from './errors.js';
2
2
 
3
- export const maxPatternLength = 128;
3
+ const maxPatternLength = 128;
4
4
  export const maxPatternInputLength = 128;
5
5
  const maxUnboundedQuantifiers = 3;
6
6
 
@@ -15,6 +15,14 @@ export function assertSafePattern(pattern ) {
15
15
  assert(pattern.length > 0 && pattern.length <= maxPatternLength, `Pattern must be 1 to ${maxPatternLength} characters`);
16
16
  try { new RegExp(pattern, 'u'); } catch { assert(false, 'Invalid pattern'); }
17
17
  let unbounded = 0, inClass = false;
18
+ // One entry per currently open group, true once its body has shown a
19
+ // quantifier (`*+?{n,m}`) or a top-level alternation `|`. A *bounded*
20
+ // repeat of such a group (`(a+){2,3}`, `(a|b){2,3}`) still lets the
21
+ // backtracker retry each of the `n` copies against every inner
22
+ // possibility, which is super-linear even though the outer bound is
23
+ // finite; only a group with a genuinely flat body may be bound-repeated.
24
+ const groups = [];
25
+ const markQuantifier = () => { if (groups.length) groups[groups.length - 1] = true; };
18
26
  for (let i = 0; i < pattern.length; i++) {
19
27
  const char = pattern[i] ;
20
28
  if (char === '\\') {
@@ -24,8 +32,30 @@ export function assertSafePattern(pattern ) {
24
32
  }
25
33
  if (inClass) { if (char === ']') inClass = false; continue; }
26
34
  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');
35
+ if (char === '|') { markQuantifier(); continue; }
36
+ if (char === '(') {
37
+ assert(!/^\(\?<?[=!]/.test(pattern.slice(i, i + 4)), 'Pattern lookaround is not supported');
38
+ groups.push(false);
39
+ // Skip past `(?:` and `(?<name>` group syntax so its `?` (and the
40
+ // name's own characters) are never mistaken for a body quantifier.
41
+ if (pattern[i + 1] === '?') {
42
+ if (pattern[i + 2] === ':') i += 2;
43
+ else if (pattern[i + 2] === '<') { const end = pattern.indexOf('>', i + 3); i = end === -1 ? i + 2 : end; }
44
+ else i += 1;
45
+ }
46
+ continue;
47
+ }
48
+ if (char === ')') {
49
+ const hadInnerQuantifier = groups.pop() ?? false;
50
+ const rest = pattern.slice(i + 1);
51
+ const unboundedRepeat = /^(?:[*+]|\{\d+,\})/.test(rest);
52
+ const boundedRepeat = !unboundedRepeat && /^\{\d+(,\d+)?\}/.test(rest);
53
+ assert(!unboundedRepeat, 'Pattern cannot repeat a group without a bound');
54
+ assert(!(boundedRepeat && hadInnerQuantifier), 'Pattern cannot bound-repeat a group that contains a quantifier or alternation');
55
+ if (boundedRepeat) markQuantifier();
56
+ continue;
57
+ }
58
+ if (char === '*' || char === '+' || char === '?' || (char === '{' && /^\{\d+(,\d*)?\}/.test(pattern.slice(i)))) markQuantifier();
29
59
  if (char === '*' || char === '+' || (char === '{' && /^\{\d+,\}/.test(pattern.slice(i)))) unbounded++;
30
60
  }
31
61
  assert(unbounded <= maxUnboundedQuantifiers, `Pattern allows at most ${maxUnboundedQuantifiers} unbounded quantifiers`);
@@ -2,7 +2,7 @@ import { assert, ConfigError } from '../errors.js';
2
2
  import { lists as bundled } from '../../data/agents/index.js';
3
3
 
4
4
 
5
- // User-Agent policy. Contract in src/policies.ts. This module also runs inside
5
+ // User-Agent policy. Contract in packages/core/src/policies.ts. This module also runs inside
6
6
  // the Cloudflare Worker, so it has no Node imports and never touches the
7
7
  // filesystem: bundled lists arrive through the generated data/agents/index.js
8
8
  // and project-relative list files arrive already loaded (src/agent-lists.ts
@@ -14,15 +14,15 @@ import { assert, ConfigError } from '../errors.js';
14
14
  export const name = 'cache';
15
15
  export const phases = ['request','response'];
16
16
 
17
-
17
+
18
18
 
19
19
 
20
20
 
21
21
 
22
22
  /** A stored 200 representation: the result minus its body and headers, which are kept separately. */
23
-
23
+
24
24
  /** The in-flight fill for one key; waiters share its promise up to MAX_WAITERS. */
25
-
25
+
26
26
  /** The one origin cache per runtime, shared by every route with an origin-caching strategy. */
27
27
 
28
28
 
@@ -240,6 +240,14 @@ export function onResponse(state , req , result
240
240
  // no-store (a personalized answer under a public route stays private).
241
241
  const restrictive = directive(handlerControl, 'no-store') || directive(handlerControl, 'private');
242
242
  const owned = !state.yamlCacheControl && !state.inheritedAsset && !restrictive;
243
+ // The handler's own Vary, read before the declared names are merged in: the
244
+ // cache key only ever covers `state.vary`, so a handler that names another
245
+ // header (or `*`) has a representation this key cannot distinguish, and
246
+ // storing it would serve that variant to every caller regardless of what
247
+ // they actually sent for the undeclared header.
248
+ const handlerVaryNames = (header(result.headers, 'vary') || '').split(',').map(v => v.trim().toLowerCase()).filter(Boolean);
249
+ const declaredVary = new Set(state.vary);
250
+ const undeclaredVary = handlerVaryNames.some(name => name === '*' || !declaredVary.has(name));
243
251
  let headers = result.headers;
244
252
  if (owned) {
245
253
  headers = [...without(headers, 'cache-control', 'cdn-cache-control'), ['cache-control', state.cacheControl]];
@@ -255,7 +263,7 @@ export function onResponse(state , req , result
255
263
  // released either way, with the entry or with nothing.
256
264
  const { store } = state, body = bodyOf(out);
257
265
  const storable = !req.secrets && state.statuses.has(out.status) && !restrictive && body.length <= state.maxBytes
258
- && !out.headers.some(([k]) => String(k).toLowerCase() === 'set-cookie');
266
+ && !out.headers.some(([k]) => String(k).toLowerCase() === 'set-cookie') && !undeclaredVary;
259
267
  let entry = null;
260
268
  if (storable) {
261
269
  const previous = store.entries.get(flight.key);
@@ -265,6 +273,10 @@ export function onResponse(state , req , result
265
273
  store.entries.set(flight.key, entry); store.bytes += body.length;
266
274
  evict(store, state.maxEntries);
267
275
  log(state, 'store');
276
+ } else if (undeclaredVary) {
277
+ // A distinct bypass reason from a plain miss/no-store, so an operator can
278
+ // see a route whose handler needs its own `Vary` name added to the policy.
279
+ log(state, 'vary-bypass');
268
280
  }
269
281
  store.pending.delete(flight.key);
270
282
  flight.flight.resolve(entry);
@@ -14,7 +14,7 @@ import { assert, ConfigError } from '../errors.js';
14
14
  export const name = 'compression';
15
15
  export const phases = ['response'];
16
16
 
17
-
17
+
18
18
 
19
19
 
20
20
 
@@ -43,7 +43,7 @@ export const profiles
43
43
  });
44
44
 
45
45
  // Headers the runtime or a handler owns; `set` may not claim them. Mirrors
46
- // the set in src/http-policy.ts (that file uses the Node Buffer global, so the
46
+ // the set in packages/core/src/http-policy.ts (that file uses the Node Buffer global, so the
47
47
  // list is reproduced rather than imported).
48
48
  export const reservedHeaders = Object.freeze(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','content-type','set-cookie','cache-control','vary','ratelimit','ratelimit-policy','retry-after','age']));
49
49
 
@@ -11,7 +11,7 @@ import { assert } from '../errors.js';
11
11
  // group; multi-instance sharing is a plugin concern.
12
12
  export const name = 'throttle';
13
13
  export const phases = ['request','response'];
14
-
14
+
15
15
 
16
16
 
17
17
  /** The one counter table per runtime, shared by every route that declares a throttle. */
package/dist/policies.js CHANGED
@@ -13,7 +13,7 @@ import { assert, ConfigError } from './errors.js';
13
13
  // function/middleware execution entirely -- trusted routes and sandbox: true
14
14
  // routes alike. Every
15
15
  // module here follows one contract so a first-party policy and an operator
16
- // plugin share a code path (PolicyModule in src/types.ts):
16
+ // plugin share a code path (PolicyModule in packages/core/src/types.ts):
17
17
  //
18
18
  // name the YAML key under `policies`
19
19
  // phases 'request' | 'response' | both; fixed order below
package/dist/policy.js CHANGED
@@ -8,12 +8,12 @@ import { collectFunctionSources, collectTrustedSources, routeFunctions } from '.
8
8
  import { assert } from './errors.js';
9
9
 
10
10
 
11
-
12
-
11
+
12
+
13
13
  /** The operator's binding grants: which env and secret names each route may read, pinned to a project hash. */
14
14
 
15
15
  /** The function snapshot plus the hash operator grants are pinned to. */
16
-
16
+
17
17
 
18
18
  export async function prepareFunctionSnapshot(loaded ) {
19
19
  // Only `sandbox: true` routes are bundled into the QuickJS module snapshot
package/dist/prerender.js CHANGED
@@ -5,6 +5,7 @@ import {loadDocument, functionFile} from './config.js';
5
5
  import {applySite} from './site.js';
6
6
  import {collectFunctionSources, routeFunctions, MODULE_LIMIT, TOTAL_BYTE_LIMIT} from './function-sources.js';
7
7
  import {ConfigError, assert} from './errors.js';
8
+ import {isCode} from './object-guards.js';
8
9
 
9
10
 
10
11
 
@@ -38,8 +39,6 @@ const overlaps = (first , second ) => {
38
39
  const rel = relative(first, second);
39
40
  return rel === '' || !(isAbsolute(rel) || rel === '..' || rel.startsWith('..' + sep));
40
41
  };
41
- const isCode = (error , code ) => error instanceof Error && 'code' in error && error.code === code;
42
-
43
42
  // One route path to one flat filename, deterministically. `~` cannot occur in a
44
43
  // route segment, so it separates segments unambiguously: `/a/b` and `/a-b` are
45
44
  // distinct names rather than a silent collision. The root becomes `index`, and a
@@ -2,6 +2,7 @@ import { readFile } from 'node:fs/promises';
2
2
  import { dirname, isAbsolute, join, resolve } from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  import { ConfigError, assert } from './errors.js';
5
+ import { isRecord as record } from './object-guards.js';
5
6
 
6
7
  /**
7
8
  * Exact dependency pins for a generated application.
@@ -17,13 +18,13 @@ import { ConfigError, assert } from './errors.js';
17
18
  * a name the caller supplied -- so core's generic extension boundary is unchanged.
18
19
  */
19
20
 
20
- export const CORE_PACKAGE = '@jimhoyd/urlcode';
21
+ const CORE_PACKAGE = '@jimhoyd/urlcode';
21
22
  const namePattern = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/;
22
23
 
23
24
 
24
25
  const versionPattern = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/;
25
26
 
26
- export function parseVersion(value ) {
27
+ function parseVersion(value ) {
27
28
  const match = versionPattern.exec(value.trim());
28
29
  if (!match) return null;
29
30
  const pre = match[4] === undefined ? [] : match[4].split('.').map(part => /^\d+$/.test(part) ? Number(part) : part);
@@ -44,7 +45,7 @@ function comparePre(a , b
44
45
  }
45
46
  return 0;
46
47
  }
47
- export function compareVersions(a , b ) {
48
+ function compareVersions(a , b ) {
48
49
  for (const key of ['major', 'minor', 'patch'] ) if (a[key] !== b[key]) return a[key] < b[key] ? -1 : 1;
49
50
  return comparePre(a.pre, b.pre);
50
51
  }
@@ -101,12 +102,11 @@ export function satisfiesRange(version , range , context = 'peer r
101
102
  });
102
103
  }
103
104
 
104
-
105
+
105
106
 
106
107
 
107
108
 
108
109
 
109
- const record = (value ) => value !== null && typeof value === 'object' && !Array.isArray(value);
110
110
  const strings = (value ) => {
111
111
  const out = {};
112
112
  if (record(value)) for (const [key, item] of Object.entries(value)) if (typeof item === 'string') out[key] = item;
@@ -128,7 +128,7 @@ async function readManifest(file ) {
128
128
  * means a package whose `exports` does not expose `./package.json` is still inspectable, and nothing in the
129
129
  * package is loaded or executed.
130
130
  */
131
- export async function findInstalledPackage(name , from ) {
131
+ async function findInstalledPackage(name , from ) {
132
132
  assert(namePattern.test(name), `Invalid package name: ${name}`);
133
133
  let directory = resolve(from);
134
134
  for (;;) {
@@ -140,7 +140,7 @@ export async function findInstalledPackage(name , from )
140
140
  }
141
141
  }
142
142
  /** The version of the runtime executing this command; that is the version a generated site is pinned to. */
143
- export async function runningCore() {
143
+ async function runningCore() {
144
144
  const file = fileURLToPath(new URL('../package.json', import.meta.url));
145
145
  const manifest = await readManifest(file);
146
146
  assert(manifest && manifest.name === CORE_PACKAGE, `Could not read the running runtime manifest at ${file}`);
@@ -185,7 +185,7 @@ async function localSpecifiers(cwd ) {
185
185
 
186
186
 
187
187
 
188
-
188
+
189
189
 
190
190
 
191
191
 
package/dist/proxy.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { EgressError, egressUrl, safeEgressHeaders } from './egress.js';
2
2
 
3
3
 
4
-
4
+
5
5
 
6
6
  const sensitive=new Set(['authorization','cookie','set-cookie','proxy-authorization','forwarded','x-forwarded-for','x-forwarded-host','x-forwarded-proto']);
7
7
  export function validateProxy(definition ) {