@zackbart/connecta 0.18.3 → 0.19.0

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 (69) hide show
  1. package/CHANGELOG.md +73 -4
  2. package/dist/catalog-service.d.ts +16 -13
  3. package/dist/catalog-service.js +106 -115
  4. package/dist/catalog.js +29 -46
  5. package/dist/connector-scope.js +2 -7
  6. package/dist/connectors/api.d.ts +4 -16
  7. package/dist/connectors/api.js +19 -46
  8. package/dist/connectors/guarded-fetch.d.ts +9 -23
  9. package/dist/connectors/guarded-fetch.js +38 -76
  10. package/dist/connectors/remote-mcp.js +36 -79
  11. package/dist/errors.d.ts +6 -27
  12. package/dist/errors.js +8 -5
  13. package/dist/execute.d.ts +24 -22
  14. package/dist/execute.js +98 -145
  15. package/dist/executor-result.d.ts +1 -0
  16. package/dist/executor-result.js +4 -11
  17. package/dist/executors/quickjs-child.js +1 -3
  18. package/dist/executors/quickjs-runtime.js +1 -3
  19. package/dist/executors/quickjs.js +1 -3
  20. package/dist/index.js +27 -57
  21. package/dist/invocation.js +113 -183
  22. package/dist/meta-tools.d.ts +15 -28
  23. package/dist/meta-tools.js +33 -89
  24. package/dist/providers/cloudflare.d.ts +2 -18
  25. package/dist/providers/cloudflare.js +1460 -2451
  26. package/dist/providers/linear.d.ts +4 -41
  27. package/dist/providers/linear.js +8 -39
  28. package/dist/providers/mixpanel.d.ts +3 -25
  29. package/dist/providers/mixpanel.js +7 -22
  30. package/dist/providers/notion.d.ts +1 -15
  31. package/dist/providers/notion.js +44 -173
  32. package/dist/providers/revenuecat.d.ts +4 -57
  33. package/dist/providers/revenuecat.js +10 -93
  34. package/dist/providers/stripe.d.ts +1 -12
  35. package/dist/providers/stripe.js +7 -45
  36. package/dist/registry.d.ts +9 -34
  37. package/dist/registry.js +9 -103
  38. package/dist/routes/mcp.js +1 -1
  39. package/dist/routes/oauth.js +3 -3
  40. package/dist/routes/shared.d.ts +15 -15
  41. package/dist/routes/shared.js +1 -3
  42. package/dist/timeout.d.ts +8 -7
  43. package/dist/timeout.js +47 -38
  44. package/dist/types.d.ts +3 -3
  45. package/dist/ui.d.ts +1 -25
  46. package/dist/ui.js +18 -45
  47. package/dist/version.d.ts +1 -1
  48. package/dist/version.js +1 -1
  49. package/documentation/call-admission.md +1 -1
  50. package/documentation/cloudflare.md +1 -1
  51. package/documentation/code-mode.md +7 -7
  52. package/documentation/connectors.md +24 -1
  53. package/documentation/linear.md +1 -1
  54. package/documentation/mixpanel.md +1 -1
  55. package/documentation/notion.md +1 -1
  56. package/documentation/operations.md +18 -14
  57. package/documentation/provider-conventions.md +1 -1
  58. package/documentation/revenuecat.md +1 -1
  59. package/documentation/stripe.md +1 -1
  60. package/documentation/upgrading.md +13 -4
  61. package/ethos.md +75 -121
  62. package/package.json +3 -4
  63. package/templates/node/package.json +1 -1
  64. package/documentation/code-first-exploration.md +0 -292
  65. package/documentation/mcp-2026-07-28.md +0 -46
  66. package/documentation/mcp-ui-design.md +0 -382
  67. package/documentation/program-ui-read-calls.md +0 -213
  68. package/documentation/provider-audit.md +0 -198
  69. package/documentation/rich-output-design.md +0 -211
package/dist/catalog.js CHANGED
@@ -123,9 +123,6 @@ function indexFor(tools) {
123
123
  }
124
124
  return index;
125
125
  }
126
- function documentsFor(tools) {
127
- return indexFor(tools).documents;
128
- }
129
126
  /**
130
127
  * Whole-token equality is the ordinary lexical match. A deliberately narrow
131
128
  * inflection check retains useful singular/plural and past-tense recall
@@ -293,7 +290,7 @@ export function rankTools(tools, query, mode = "all", statistics = lexicalCorpus
293
290
  const exactNamePhrase = normalized(exactNameQuery);
294
291
  const terms = [...new Set(phrase.split(/\s+/).filter(Boolean))];
295
292
  const ranked = [];
296
- documentsFor(tools).forEach((doc, order) => {
293
+ indexFor(tools).documents.forEach((doc, order) => {
297
294
  const scored = scoreDocument(doc, phrase, terms, mode, statistics);
298
295
  if (scored !== null) {
299
296
  ranked.push({
@@ -425,6 +422,9 @@ function renderSchema(schema, defs, seen, depth, options) {
425
422
  return JSON.stringify(schema);
426
423
  }
427
424
  const s = schema;
425
+ const constrain = (rendered) => options.renderConstraints
426
+ ? renderConstraints(rendered, s, options.constraintByteLimit, options.onConstraintTruncated)
427
+ : rendered;
428
428
  // allOf composes rather than replaces: it is checked before every other
429
429
  // keyword, and renders the schema's own shape alongside its members instead
430
430
  // of returning early. A schema carrying both allOf and properties (the usual
@@ -457,9 +457,7 @@ function renderSchema(schema, defs, seen, depth, options) {
457
457
  seen.add(name);
458
458
  const rendered = renderSchema(target, defs, seen, depth, options);
459
459
  seen.delete(name);
460
- return options.renderConstraints
461
- ? renderConstraints(rendered, s, options.constraintByteLimit, options.onConstraintTruncated)
462
- : rendered;
460
+ return constrain(rendered);
463
461
  }
464
462
  const union = (s.oneOf ?? s.anyOf);
465
463
  if (Array.isArray(union)) {
@@ -467,15 +465,11 @@ function renderSchema(schema, defs, seen, depth, options) {
467
465
  .map((u) => renderSchema(u, defs, seen, depth + 1, options))
468
466
  .join(" | ") ||
469
467
  "unknown";
470
- return options.renderConstraints
471
- ? renderConstraints(rendered, s, options.constraintByteLimit, options.onConstraintTruncated)
472
- : rendered;
468
+ return constrain(rendered);
473
469
  }
474
470
  if (Array.isArray(s.enum)) {
475
471
  const rendered = renderEnum(s.enum, options.enumByteLimit, options.onEnumTruncated);
476
- return options.renderConstraints
477
- ? renderConstraints(rendered, s, options.constraintByteLimit, options.onConstraintTruncated)
478
- : rendered;
472
+ return constrain(rendered);
479
473
  }
480
474
  // Checked before type/properties so a discriminator like
481
475
  // { type: "string", const: "emoji" } renders as "emoji" rather than string.
@@ -483,9 +477,7 @@ function renderSchema(schema, defs, seen, depth, options) {
483
477
  // `const: undefined` must fall through to the regular type rendering.
484
478
  if (s.const !== undefined) {
485
479
  const rendered = JSON.stringify(s.const);
486
- return options.renderConstraints
487
- ? renderConstraints(rendered, s, options.constraintByteLimit, options.onConstraintTruncated)
488
- : rendered;
480
+ return constrain(rendered);
489
481
  }
490
482
  const type = s.type;
491
483
  if (type === "array" || s.items) {
@@ -519,15 +511,11 @@ function renderSchema(schema, defs, seen, depth, options) {
519
511
  .join(", ")} }`;
520
512
  }
521
513
  if (typeof type === "string") {
522
- return options.renderConstraints
523
- ? renderConstraints(type, s, options.constraintByteLimit, options.onConstraintTruncated)
524
- : type;
514
+ return constrain(type);
525
515
  }
526
516
  if (Array.isArray(type)) {
527
517
  const rendered = type.join(" | ");
528
- return options.renderConstraints
529
- ? renderConstraints(rendered, s, options.constraintByteLimit, options.onConstraintTruncated)
530
- : rendered;
518
+ return constrain(rendered);
531
519
  }
532
520
  if (options.renderConstraints && constraintEntries(s).length > 0) {
533
521
  return renderConstraints("unknown", s, options.constraintByteLimit, options.onConstraintTruncated);
@@ -535,15 +523,18 @@ function renderSchema(schema, defs, seen, depth, options) {
535
523
  return JSON.stringify(schema);
536
524
  }
537
525
  const compactSchemas = new WeakMap();
526
+ function defsOf(schema) {
527
+ return {
528
+ ...schema.$defs,
529
+ ...schema.definitions,
530
+ };
531
+ }
538
532
  /** Render and cache a compact TypeScript-like representation of JSON Schema. */
539
533
  export function compactSchema(schema) {
540
534
  const cached = compactSchemas.get(schema);
541
535
  if (cached)
542
536
  return cached;
543
- const defs = {
544
- ...schema.$defs,
545
- ...schema.definitions,
546
- };
537
+ const defs = defsOf(schema);
547
538
  let rendered;
548
539
  try {
549
540
  rendered = renderSchema(schema, defs, new Set(), 0, {
@@ -603,24 +594,24 @@ export function compactDiscoverySchema(schema) {
603
594
  const cached = compactDiscoverySchemas.get(schema);
604
595
  if (cached)
605
596
  return cached;
606
- const defs = {
607
- ...schema.$defs,
608
- ...schema.definitions,
609
- };
597
+ const defs = defsOf(schema);
610
598
  let rendered;
611
599
  let enumTruncated = false;
612
600
  let constraintTruncated = false;
601
+ const base = {
602
+ propertyDescriptions: false,
603
+ requiredFirst: true,
604
+ enumByteLimit: MAX_COMPACT_DISCOVERY_ENUM_BYTES,
605
+ onEnumTruncated: () => {
606
+ enumTruncated = true;
607
+ },
608
+ };
613
609
  try {
614
610
  rendered = renderSchema(schema, defs, new Set(), 0, {
615
- propertyDescriptions: false,
616
- requiredFirst: true,
611
+ ...base,
617
612
  // Three near-cap enums spend about three quarters of the complete shape
618
613
  // budget, leaving the final quarter for surrounding syntax before the
619
614
  // unchanged global fallback applies. Whole values keep this UTF-8 safe.
620
- enumByteLimit: MAX_COMPACT_DISCOVERY_ENUM_BYTES,
621
- onEnumTruncated: () => {
622
- enumTruncated = true;
623
- },
624
615
  renderConstraints: true,
625
616
  constraintByteLimit: MAX_COMPACT_DISCOVERY_CONSTRAINT_BYTES,
626
617
  onConstraintTruncated: () => {
@@ -635,12 +626,7 @@ export function compactDiscoverySchema(schema) {
635
626
  MAX_COMPACT_DISCOVERY_SCHEMA_BYTES) {
636
627
  try {
637
628
  rendered = renderSchema(schema, defs, new Set(), 0, {
638
- propertyDescriptions: false,
639
- requiredFirst: true,
640
- enumByteLimit: MAX_COMPACT_DISCOVERY_ENUM_BYTES,
641
- onEnumTruncated: () => {
642
- enumTruncated = true;
643
- },
629
+ ...base,
644
630
  renderConstraints: false,
645
631
  });
646
632
  constraintTruncated = true;
@@ -683,10 +669,7 @@ export function compactDiscoverySchema(schema) {
683
669
  export function schemaObjectKeys(schema) {
684
670
  if (!schema)
685
671
  return undefined;
686
- const defs = {
687
- ...schema.$defs,
688
- ...schema.definitions,
689
- };
672
+ const defs = defsOf(schema);
690
673
  try {
691
674
  return objectKeys(schema, defs, new Set(), 0);
692
675
  }
@@ -1,3 +1,4 @@
1
+ import { sleep } from "./timeout.js";
1
2
  /** Enough for local transport abort/close without letting cleanup own latency. */
2
3
  const CONNECTOR_SCOPE_CLOSE_BUDGET_MS = 100;
3
4
  /**
@@ -11,13 +12,7 @@ const CONNECTOR_SCOPE_CLOSE_BUDGET_MS = 100;
11
12
  const CONNECTOR_SCOPE_DEFER_BUDGET_MS = 2_000;
12
13
  /** Resolve when `work` settles or `budgetMs` expires; never reject. */
13
14
  function waitAtMost(work, budgetMs) {
14
- return new Promise((resolve) => {
15
- const timer = setTimeout(resolve, budgetMs);
16
- work.then(() => {
17
- clearTimeout(timer);
18
- resolve();
19
- });
20
- });
15
+ return Promise.race([work, sleep(budgetMs)]).then(() => { });
21
16
  }
22
17
  /**
23
18
  * Tell a connector that a scope owned by the core has ended.
@@ -1,4 +1,7 @@
1
1
  import type { Connector, ConnectorCallAdmissionPolicy, ConnectorCredentialConfig, ConnectorCredentialValues, ConnectorContext, ConnectorUsageGuide, CredentialTestResult, JsonSchema, ToolAnnotations } from "../types.js";
2
+ export declare function defined<T extends object>(value: T): {
3
+ [K in keyof T]?: Exclude<T[K], undefined>;
4
+ };
2
5
  export interface ApiTool {
3
6
  name: string;
4
7
  /**
@@ -63,20 +66,5 @@ export interface ApiOptions {
63
66
  validateArgs?: boolean;
64
67
  tools: ApiTool[];
65
68
  }
66
- /**
67
- * A connector defined entirely in code: static tool defs + fetch handlers.
68
- * Tool inputs are plain JSON Schema objects (bring your own zod-to-json-schema
69
- * conversion if you prefer zod). call_tool JSON-wraps the handler's return.
70
- *
71
- * Every tool declares a description and an explicit `annotations.readOnlyHint`,
72
- * and any `inputSchema` it carries must compile — a tool that fails the
73
- * contract throws here rather than reaching a catalog. Arguments are then
74
- * validated against `inputSchema` before the handler runs (disable with
75
- * `validateArgs: false`, which opts out of enforcement, not out of the schema
76
- * being real), and a schema that only reveals itself as unenforceable on first
77
- * use — an unresolvable `$ref`, say — fails the call rather than passing raw
78
- * arguments through. Remote MCP inputs are also validated, but in the shared
79
- * invocation path against the request-local downstream catalog, where a
80
- * downstream's schema is its own affair and stays fail-open.
81
- */
69
+ /** A static connector; see provider conventions' two construction-time checks. */
82
70
  export declare function api(id: string, opts: ApiOptions): Connector;
@@ -1,14 +1,8 @@
1
1
  import { compileValidator, validateToolInput } from "../validate.js";
2
- /**
3
- * Enforce the construction contract for one hand-written tool.
4
- *
5
- * Everything here is something only the author can supply and no runtime can
6
- * guess: what the tool does, and whether calling it needs a human's blessing.
7
- * Guessing either one is how a deployment boots into the wrong shape, so this
8
- * throws instead. Note what it does *not* do — it never reads a name, verb, or
9
- * HTTP method to infer a safety class. An unclassified tool is a bug in the
10
- * deployment, not a puzzle for connecta to solve.
11
- */
2
+ export function defined(value) {
3
+ return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined));
4
+ }
5
+ /** Enforce provider conventions' two construction-time checks. */
12
6
  function checkToolContract(id, tool) {
13
7
  const address = `${id}.${tool.name}`;
14
8
  if (typeof tool.description !== "string" || tool.description.trim() === "") {
@@ -25,55 +19,34 @@ function checkToolContract(id, tool) {
25
19
  if (tool.inputSchema)
26
20
  compileValidator(tool.inputSchema, { address });
27
21
  }
28
- /**
29
- * A connector defined entirely in code: static tool defs + fetch handlers.
30
- * Tool inputs are plain JSON Schema objects (bring your own zod-to-json-schema
31
- * conversion if you prefer zod). call_tool JSON-wraps the handler's return.
32
- *
33
- * Every tool declares a description and an explicit `annotations.readOnlyHint`,
34
- * and any `inputSchema` it carries must compile — a tool that fails the
35
- * contract throws here rather than reaching a catalog. Arguments are then
36
- * validated against `inputSchema` before the handler runs (disable with
37
- * `validateArgs: false`, which opts out of enforcement, not out of the schema
38
- * being real), and a schema that only reveals itself as unenforceable on first
39
- * use — an unresolvable `$ref`, say — fails the call rather than passing raw
40
- * arguments through. Remote MCP inputs are also validated, but in the shared
41
- * invocation path against the request-local downstream catalog, where a
42
- * downstream's schema is its own affair and stays fail-open.
43
- */
22
+ /** A static connector; see provider conventions' two construction-time checks. */
44
23
  export function api(id, opts) {
45
24
  for (const t of opts.tools)
46
25
  checkToolContract(id, t);
47
26
  const defs = opts.tools.map((t) => ({
48
27
  name: t.name,
49
28
  description: t.description,
50
- ...(t.inputSchema !== undefined ? { inputSchema: t.inputSchema } : {}),
51
- ...(t.outputSchema !== undefined ? { outputSchema: t.outputSchema } : {}),
29
+ ...defined({
30
+ inputSchema: t.inputSchema,
31
+ outputSchema: t.outputSchema,
32
+ }),
52
33
  annotations: t.annotations,
53
34
  }));
54
35
  const byName = new Map(opts.tools.map((t) => [t.name, t]));
55
36
  const validateArgs = opts.validateArgs ?? true;
56
37
  return {
57
38
  id,
58
- ...(opts.title !== undefined ? { title: opts.title } : {}),
39
+ ...defined({ title: opts.title }),
59
40
  kind: "api",
60
- ...(opts.description !== undefined
61
- ? { description: opts.description }
62
- : {}),
63
- ...(opts.maxResultBytes !== undefined
64
- ? { maxResultBytes: opts.maxResultBytes }
65
- : {}),
66
- ...(opts.callAdmission !== undefined
67
- ? { callAdmission: opts.callAdmission }
68
- : {}),
69
- ...(opts.usageGuide !== undefined ? { usageGuide: opts.usageGuide } : {}),
70
- ...(opts.credential !== undefined ? { credential: opts.credential } : {}),
71
- ...(opts.testCredential !== undefined
72
- ? { testCredential: opts.testCredential }
73
- : {}),
74
- ...(opts.testCredentials !== undefined
75
- ? { testCredentials: opts.testCredentials }
76
- : {}),
41
+ ...defined({
42
+ description: opts.description,
43
+ maxResultBytes: opts.maxResultBytes,
44
+ callAdmission: opts.callAdmission,
45
+ usageGuide: opts.usageGuide,
46
+ credential: opts.credential,
47
+ testCredential: opts.testCredential,
48
+ testCredentials: opts.testCredentials,
49
+ }),
77
50
  staticTools: defs,
78
51
  async listTools() {
79
52
  return defs;
@@ -44,7 +44,15 @@ interface GuardedResponse {
44
44
  text(): Promise<string>;
45
45
  /** The body parsed as JSON; `undefined` for an empty body, throws on junk. */
46
46
  json(): Promise<unknown>;
47
+ /** Parse JSON while distinguishing malformed content from transport failure. */
48
+ jsonResult(): Promise<{
49
+ value: unknown;
50
+ } | {
51
+ parseError: unknown;
52
+ }>;
47
53
  }
54
+ /** Parse a decimal `Retry-After` header in seconds into milliseconds. */
55
+ export declare function retryAfterMs(headers: Headers): number | undefined;
48
56
  /**
49
57
  * Turn one response into the provider's own result, or throw the provider's
50
58
  * own typed failure. This is where status codes acquire meaning, and it is
@@ -79,28 +87,6 @@ export interface GuardedFetchOptions {
79
87
  }
80
88
  /** Send one guarded request and map its response with provider knowledge. */
81
89
  export type GuardedTransport = <T>(request: GuardedRequest, ctx: ConnectorContext, map: GuardedResponseMapper<T>) => Promise<T>;
82
- /**
83
- * Build the guarded transport one hand-written connector sends every request
84
- * through.
85
- *
86
- * ```ts
87
- * const send = guardedFetch({
88
- * provider: "Billing",
89
- * baseUrl: "https://billing.internal.example/v1",
90
- * maxResponseBytes: 4 * 1024 * 1024,
91
- * headers: { Accept: "application/json" },
92
- * authenticate: async (ctx) => {
93
- * const token = await ctx.credential?.get();
94
- * if (!token) throw new ConnectorCallError("auth_required", "…");
95
- * return { Authorization: `Bearer ${token}` };
96
- * },
97
- * });
98
- *
99
- * const invoice = await send({ method: "GET", path: `/invoices/${id}` }, ctx, (response) => {
100
- * if (!response.ok) throw billingFailure(response.status);
101
- * return response.json();
102
- * });
103
- * ```
104
- */
90
+ /** Build the guarded transport described in documentation/connectors.md. */
105
91
  export declare function guardedFetch(options: GuardedFetchOptions): GuardedTransport;
106
92
  export {};
@@ -1,26 +1,15 @@
1
- /**
2
- * The mechanical half of a hand-written connector's HTTP transport.
3
- *
4
- * Every `api()` surface that speaks HTTP re-derives the same handful of safety
5
- * properties: the request lands inside the base URL and nowhere else, the
6
- * credential rides exactly one origin, an unreachable provider becomes a typed
7
- * failure instead of a raw `TypeError`, and the response is read with a
8
- * ceiling on it. Cloudflare and Notion each grew a private copy of that
9
- * machinery, subtly different in ways neither author intended, and a third
10
- * author would have grown a third.
11
- *
12
- * What this deliberately does not own is *meaning*. It never reads a status
13
- * code, never decides a 403 is recoverable, and never invents an
14
- * authentication scheme: `authenticate` supplies the headers, and the caller's
15
- * mapper reads the response. That split is not fastidiousness — Notion's 403
16
- * (a capability the integration was never granted, which re-authorizing cannot
17
- * fix) and Cloudflare's 403 (a token scope) want opposite next moves, and no
18
- * shared helper can tell them apart without provider knowledge it has no
19
- * business holding.
20
- *
21
- * Web APIs only: this is reachable from the root entry and stays that way.
22
- */
1
+ /** See documentation/connectors.md#the-guarded-fetch-transport. Web APIs only. */
23
2
  import { ConnectorCallError } from "../errors.js";
3
+ /** Parse a decimal `Retry-After` header in seconds into milliseconds. */
4
+ export function retryAfterMs(headers) {
5
+ const raw = headers.get("retry-after");
6
+ if (!raw)
7
+ return undefined;
8
+ const seconds = Number(raw.trim());
9
+ if (!Number.isFinite(seconds) || seconds < 0)
10
+ return undefined;
11
+ return Math.trunc(seconds * 1000);
12
+ }
24
13
  /** Statuses that instruct a client to re-send somewhere else. Never followed. */
25
14
  const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
26
15
  /** Statuses the HTTP spec defines as carrying no body at all. */
@@ -128,30 +117,18 @@ async function drain(provider, stream, limit) {
128
117
  }
129
118
  return body;
130
119
  }
131
- /**
132
- * Wrap a `Response` in the bounded read surface.
133
- *
134
- * Three paths, for one honest reason. A status the spec says carries no body
135
- * is answered as empty without touching the response at all. Otherwise, when
136
- * the runtime hands back a readable body — every real response on Node and on
137
- * Workers — the ceiling is enforced *while* reading, so an oversized payload
138
- * is abandoned rather than buffered. When it does not, there is nothing to
139
- * meter mid-flight: `bytes()` and `text()` read the body whole and check the
140
- * ceiling against what came back, and `json()` — with no bytes of its own to
141
- * count — takes the stand-in at its word. That last path is the weaker
142
- * guarantee and says so, but a real `fetch` Response with a body always
143
- * streams, so it is not a path a provider takes in production; it is what a
144
- * hand-built test double gets.
145
- */
120
+ /** See documentation/connectors.md#the-guarded-fetch-transport. */
146
121
  function boundedResponse(provider, response, limit) {
147
122
  if (BODILESS_STATUSES.has(response.status)) {
123
+ const emptyJson = async () => undefined;
148
124
  return {
149
125
  status: response.status,
150
126
  ok: response.ok,
151
127
  headers: response.headers,
152
128
  bytes: async () => new Uint8Array(),
153
129
  text: async () => "",
154
- json: async () => undefined,
130
+ json: emptyJson,
131
+ jsonResult: () => jsonResult(emptyJson),
155
132
  };
156
133
  }
157
134
  const stream = readableBody(response);
@@ -167,6 +144,15 @@ function boundedResponse(provider, response, limit) {
167
144
  });
168
145
  return read;
169
146
  };
147
+ const json = async () => {
148
+ // No stream means no bytes to count: a stand-in that answers `json()`
149
+ // directly is taken at its word, which is the one accessor on the one
150
+ // path where the ceiling cannot be applied.
151
+ if (!stream)
152
+ return await response.json();
153
+ const body = decoder.decode(await bytes());
154
+ return body.trim() === "" ? undefined : JSON.parse(body);
155
+ };
170
156
  return {
171
157
  status: response.status,
172
158
  ok: response.ok,
@@ -181,40 +167,21 @@ function boundedResponse(provider, response, limit) {
181
167
  throw oversized(provider, limit, `${size} bytes`);
182
168
  return body;
183
169
  },
184
- async json() {
185
- // No stream means no bytes to count: a stand-in that answers `json()`
186
- // directly is taken at its word, which is the one accessor on the one
187
- // path where the ceiling cannot be applied.
188
- if (!stream)
189
- return await response.json();
190
- const body = decoder.decode(await bytes());
191
- return body.trim() === "" ? undefined : JSON.parse(body);
192
- },
170
+ json,
171
+ jsonResult: () => jsonResult(json),
193
172
  };
194
173
  }
195
- /**
196
- * Build the guarded transport one hand-written connector sends every request
197
- * through.
198
- *
199
- * ```ts
200
- * const send = guardedFetch({
201
- * provider: "Billing",
202
- * baseUrl: "https://billing.internal.example/v1",
203
- * maxResponseBytes: 4 * 1024 * 1024,
204
- * headers: { Accept: "application/json" },
205
- * authenticate: async (ctx) => {
206
- * const token = await ctx.credential?.get();
207
- * if (!token) throw new ConnectorCallError("auth_required", "…");
208
- * return { Authorization: `Bearer ${token}` };
209
- * },
210
- * });
211
- *
212
- * const invoice = await send({ method: "GET", path: `/invoices/${id}` }, ctx, (response) => {
213
- * if (!response.ok) throw billingFailure(response.status);
214
- * return response.json();
215
- * });
216
- * ```
217
- */
174
+ async function jsonResult(read) {
175
+ try {
176
+ return { value: await read() };
177
+ }
178
+ catch (cause) {
179
+ if (cause instanceof ConnectorCallError)
180
+ throw cause;
181
+ return { parseError: cause };
182
+ }
183
+ }
184
+ /** Build the guarded transport described in documentation/connectors.md. */
218
185
  export function guardedFetch(options) {
219
186
  const { provider, maxResponseBytes: limit } = options;
220
187
  if (!Number.isInteger(limit) || limit < 1) {
@@ -257,12 +224,7 @@ export function guardedFetch(options) {
257
224
  : request.rawBody !== undefined
258
225
  ? { body: request.rawBody }
259
226
  : {}),
260
- // Never follow a redirect. A 3xx is an instruction to re-send the
261
- // connector's credential to whatever origin the Location names, and a
262
- // confinement a redirect can undo was never a confinement. There is no
263
- // ambient credential to omit besides that — neither runtime's `fetch`
264
- // keeps a cookie jar — so the headers `authenticate` returned are the
265
- // only authority the request carries.
227
+ // Rationale: documentation/connectors.md#the-guarded-fetch-transport.
266
228
  redirect: "manual",
267
229
  ...(ctx.signal ? { signal: ctx.signal } : {}),
268
230
  });