@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.
- package/CHANGELOG.md +73 -4
- package/dist/catalog-service.d.ts +16 -13
- package/dist/catalog-service.js +106 -115
- package/dist/catalog.js +29 -46
- package/dist/connector-scope.js +2 -7
- package/dist/connectors/api.d.ts +4 -16
- package/dist/connectors/api.js +19 -46
- package/dist/connectors/guarded-fetch.d.ts +9 -23
- package/dist/connectors/guarded-fetch.js +38 -76
- package/dist/connectors/remote-mcp.js +36 -79
- package/dist/errors.d.ts +6 -27
- package/dist/errors.js +8 -5
- package/dist/execute.d.ts +24 -22
- package/dist/execute.js +98 -145
- package/dist/executor-result.d.ts +1 -0
- package/dist/executor-result.js +4 -11
- package/dist/executors/quickjs-child.js +1 -3
- package/dist/executors/quickjs-runtime.js +1 -3
- package/dist/executors/quickjs.js +1 -3
- package/dist/index.js +27 -57
- package/dist/invocation.js +113 -183
- package/dist/meta-tools.d.ts +15 -28
- package/dist/meta-tools.js +33 -89
- package/dist/providers/cloudflare.d.ts +2 -18
- package/dist/providers/cloudflare.js +1460 -2451
- package/dist/providers/linear.d.ts +4 -41
- package/dist/providers/linear.js +8 -39
- package/dist/providers/mixpanel.d.ts +3 -25
- package/dist/providers/mixpanel.js +7 -22
- package/dist/providers/notion.d.ts +1 -15
- package/dist/providers/notion.js +44 -173
- package/dist/providers/revenuecat.d.ts +4 -57
- package/dist/providers/revenuecat.js +10 -93
- package/dist/providers/stripe.d.ts +1 -12
- package/dist/providers/stripe.js +7 -45
- package/dist/registry.d.ts +9 -34
- package/dist/registry.js +9 -103
- package/dist/routes/mcp.js +1 -1
- package/dist/routes/oauth.js +3 -3
- package/dist/routes/shared.d.ts +15 -15
- package/dist/routes/shared.js +1 -3
- package/dist/timeout.d.ts +8 -7
- package/dist/timeout.js +47 -38
- package/dist/types.d.ts +3 -3
- package/dist/ui.d.ts +1 -25
- package/dist/ui.js +18 -45
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/documentation/call-admission.md +1 -1
- package/documentation/cloudflare.md +1 -1
- package/documentation/code-mode.md +7 -7
- package/documentation/connectors.md +24 -1
- package/documentation/linear.md +1 -1
- package/documentation/mixpanel.md +1 -1
- package/documentation/notion.md +1 -1
- package/documentation/operations.md +18 -14
- package/documentation/provider-conventions.md +1 -1
- package/documentation/revenuecat.md +1 -1
- package/documentation/stripe.md +1 -1
- package/documentation/upgrading.md +13 -4
- package/ethos.md +75 -121
- package/package.json +3 -4
- package/templates/node/package.json +1 -1
- package/documentation/code-first-exploration.md +0 -292
- package/documentation/mcp-2026-07-28.md +0 -46
- package/documentation/mcp-ui-design.md +0 -382
- package/documentation/program-ui-read-calls.md +0 -213
- package/documentation/provider-audit.md +0 -198
- 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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/connector-scope.js
CHANGED
|
@@ -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
|
|
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.
|
package/dist/connectors/api.d.ts
CHANGED
|
@@ -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;
|
package/dist/connectors/api.js
CHANGED
|
@@ -1,14 +1,8 @@
|
|
|
1
1
|
import { compileValidator, validateToolInput } from "../validate.js";
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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
|
-
...(
|
|
51
|
-
|
|
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
|
-
...(
|
|
39
|
+
...defined({ title: opts.title }),
|
|
59
40
|
kind: "api",
|
|
60
|
-
...(
|
|
61
|
-
|
|
62
|
-
:
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
:
|
|
66
|
-
|
|
67
|
-
|
|
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:
|
|
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
|
-
|
|
185
|
-
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
//
|
|
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
|
});
|