@zackbart/connecta 0.37.1 → 0.39.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 +94 -0
- package/README.md +5 -4
- package/bin/connecta.mjs +1 -39
- package/bin/version.mjs +1 -1
- package/dist/access-tokens.js +2 -4
- package/dist/agent-identity.js +1 -1
- package/dist/agent-routes.js +231 -0
- package/dist/auth/clerk-sdk/generated.js +2 -1
- package/dist/auth/downstream-oauth/consent.js +519 -0
- package/dist/auth/downstream-oauth/grant-store.js +439 -0
- package/dist/auth/downstream-oauth/refresh-coordinator.js +562 -0
- package/dist/auth/downstream-oauth/refresh-records.js +447 -0
- package/dist/auth/downstream-oauth/sdk-hooks.js +242 -0
- package/dist/auth/downstream-oauth.js +66 -2516
- package/dist/auth/downstream-revocation.js +1 -1
- package/dist/auth/native.js +27 -0
- package/dist/auth/oauth-grant.js +24 -0
- package/dist/auth/oauth-token-response.js +1 -1
- package/dist/auth/static-oauth.js +153 -131
- package/dist/auth/vercel-agent.js +2 -1
- package/dist/auth-elicitation.js +9 -3
- package/dist/authorization-handoff.js +11 -5
- package/dist/branding.d.ts +1 -15
- package/dist/branding.js +2 -2
- package/dist/bytes.js +42 -0
- package/dist/call-admission-context.js +2 -5
- package/dist/call-recovery.js +0 -3
- package/dist/catalog-cache.js +130 -43
- package/dist/catalog-fingerprint.js +2 -18
- package/dist/catalog-policy.js +18 -0
- package/dist/catalog-service.js +285 -48
- package/dist/catalog.js +18 -2
- package/dist/config-defaults.d.ts +1 -0
- package/dist/config-defaults.js +1 -0
- package/dist/config-schema.d.ts +1 -1
- package/dist/config-schema.js +6 -2
- package/dist/config.d.ts +57 -25
- package/dist/config.js +30 -77
- package/dist/connector-caller.d.ts +1 -1
- package/dist/connector-health.js +59 -0
- package/dist/connector-status.js +117 -0
- package/dist/connectors/api-connector.d.ts +7 -2
- package/dist/connectors/api-connector.js +11 -7
- package/dist/connectors/guarded-fetch.d.ts +0 -6
- package/dist/connectors/guarded-fetch.js +1 -11
- package/dist/connectors/negotiation-cache.js +22 -4
- package/dist/connectors/remote-mcp/call.js +164 -0
- package/dist/connectors/remote-mcp/catalog.js +278 -0
- package/dist/connectors/remote-mcp/config.js +263 -0
- package/dist/connectors/remote-mcp/connection.js +671 -0
- package/dist/connectors/remote-mcp/errors.js +488 -0
- package/dist/connectors/remote-mcp/oauth.js +469 -0
- package/dist/connectors/remote-mcp/resources.js +473 -0
- package/dist/connectors/remote-mcp/runtime.js +1 -0
- package/dist/connectors/remote-mcp/transport.js +544 -0
- package/dist/connectors/remote-mcp.js +61 -2898
- package/dist/connectors/resource-uri.js +0 -4
- package/dist/credential-contract.d.ts +23 -0
- package/dist/credential-reads.js +17 -0
- package/dist/credential-verification.js +53 -0
- package/dist/credentials.d.ts +7 -0
- package/dist/credentials.js +60 -25
- package/dist/d1.d.ts +0 -89
- package/dist/d1.js +0 -34
- package/dist/describe-config.d.ts +12 -2
- package/dist/describe-config.js +2 -0
- package/dist/discovery-health.js +199 -0
- package/dist/downstream-input.js +4 -2
- package/dist/errors.d.ts +55 -17
- package/dist/errors.js +95 -41
- package/dist/execute.js +172 -125
- package/dist/executor-result.js +5 -2
- package/dist/executors/quickjs-runtime.js +2 -25
- package/dist/executors/quickjs.js +1 -1
- package/dist/guest-functions.js +83 -0
- package/dist/guest-runtime.js +63 -20
- package/dist/guest-types.d.ts +24 -10
- package/dist/http/argument-problems.js +35 -0
- package/dist/http/client.js +1 -1
- package/dist/http/errors.js +7 -6
- package/dist/http/fields.js +192 -13
- package/dist/http/hatch-response.js +73 -0
- package/dist/http/hatch.js +88 -23
- package/dist/http/json-schema.js +1 -1
- package/dist/http/node-transport.js +0 -1
- package/dist/http/oauth.js +4 -3
- package/dist/http/paging.js +6 -9
- package/dist/http/provider.js +33 -5
- package/dist/http/safety.js +9 -1
- package/dist/http/services.js +1 -8
- package/dist/http/tools.js +45 -30
- package/dist/http/vendor.js +7 -7
- package/dist/identity.js +2 -6
- package/dist/index.d.ts +1 -1
- package/dist/index.js +9 -2
- package/dist/invocation-auth.js +2 -1
- package/dist/invocation.js +21 -6
- package/dist/membership.d.ts +13 -5
- package/dist/membership.js +34 -2
- package/dist/meta-tools.js +50 -35
- package/dist/native-recovery.js +4 -16
- package/dist/native-tools.js +6 -2
- package/dist/oauth-handoff.js +2 -2
- package/dist/oauth-sealing.js +10 -0
- package/dist/operation-vocabulary.js +136 -14
- package/dist/operator-record.d.ts +2 -2
- package/dist/operator-record.js +2 -49
- package/dist/operator-ui/contract.d.ts +12 -2
- package/dist/operator-ui/generated.js +3 -3
- package/dist/operator-ui/model.d.ts +22 -1
- package/dist/program-writes.js +3 -8
- package/dist/provider-call.js +0 -7
- package/dist/provider.d.ts +1 -1
- package/dist/providers/_shared/google/kit.js +1 -6
- package/dist/providers/_shared/google/service-account.js +1 -3
- package/dist/providers/_shared/google/workspace.d.ts +1 -1
- package/dist/providers/_shared/guide.js +3 -0
- package/dist/providers/_shared/json.js +12 -0
- package/dist/providers/_shared/rest/compact-index.js +1 -1
- package/dist/providers/_shared/rest/kit.js +1 -3
- package/dist/providers/_shared/rest/operation-index.js +49 -53
- package/dist/providers/_shared/rest/value-safety.js +106 -28
- package/dist/providers/breeze/api.js +9 -8
- package/dist/providers/breeze/index.js +4 -2
- package/dist/providers/breeze/skill.generated.js +1 -1
- package/dist/providers/breeze/tools.js +7 -7
- package/dist/providers/ccb/api.js +3 -7
- package/dist/providers/ccb/index.js +7 -1
- package/dist/providers/ccb/skill.generated.js +1 -1
- package/dist/providers/ccb/tools.js +1 -6
- package/dist/providers/cloudflare/api.js +10 -3
- package/dist/providers/cloudflare/index.js +2 -1
- package/dist/providers/cloudflare/safety.js +3 -3
- package/dist/providers/cloudflare/skill.generated.js +1 -1
- package/dist/providers/docs/lib/schemas.js +24 -22
- package/dist/providers/docs/safety.js +2 -0
- package/dist/providers/docs/skill.generated.js +1 -1
- package/dist/providers/docs/tools.js +4 -14
- package/dist/providers/drive/api.js +1 -0
- package/dist/providers/drive/lib/contracts.js +8 -5
- package/dist/providers/drive/skill.generated.js +1 -1
- package/dist/providers/drive/tools.js +23 -41
- package/dist/providers/forms/api.js +1 -0
- package/dist/providers/forms/skill.generated.js +1 -1
- package/dist/providers/forms/tools.js +11 -16
- package/dist/providers/github/api.js +1 -0
- package/dist/providers/github/lib/discovery.js +1 -1
- package/dist/providers/github/skill.generated.js +1 -1
- package/dist/providers/github/tools.js +61 -17
- package/dist/providers/gmail/lib/schemas.js +60 -50
- package/dist/providers/gmail/skill.generated.js +1 -1
- package/dist/providers/gmail/tools.js +12 -26
- package/dist/providers/granola/index.js +2 -0
- package/dist/providers/granola/skill.generated.js +1 -1
- package/dist/providers/overflow/api.js +8 -4
- package/dist/providers/overflow/index.js +2 -1
- package/dist/providers/overflow/skill.generated.js +1 -1
- package/dist/providers/overflow/tools.js +3 -7
- package/dist/providers/planning-center/api.js +5 -2
- package/dist/providers/planning-center/lib/constants.d.ts +1 -1
- package/dist/providers/planning-center/lib/json-api.js +1 -9
- package/dist/providers/planning-center/skill.generated.js +1 -1
- package/dist/providers/planning-center/tools.js +2 -1
- package/dist/providers/quickbooks/safety.js +80 -39
- package/dist/providers/quickbooks/skill.generated.js +1 -1
- package/dist/providers/sheets/api.js +1 -0
- package/dist/providers/sheets/skill.generated.js +1 -1
- package/dist/providers/sheets/tools.js +14 -23
- package/dist/providers/slides/skill.generated.js +1 -1
- package/dist/providers/slides/tools.js +19 -26
- package/dist/providers/stripe/index.js +2 -1
- package/dist/providers/stripe/safety.js +2 -2
- package/dist/providers/stripe/skill.generated.js +1 -1
- package/dist/providers/tithely/api.js +5 -8
- package/dist/providers/tithely/index.js +3 -2
- package/dist/providers/tithely/skill.generated.js +1 -1
- package/dist/providers/tithely/tools.js +7 -5
- package/dist/providers/vercel/api.js +2 -7
- package/dist/providers/vercel/lib/pin.js +1 -1
- package/dist/providers/vercel/safety.js +1 -1
- package/dist/providers/vercel/skill.generated.js +1 -1
- package/dist/providers/vercel/tools.js +6 -4
- package/dist/redaction-marker.js +2 -2
- package/dist/registry.d.ts +11 -5
- package/dist/registry.js +46 -20
- package/dist/request-principal.js +1 -1
- package/dist/request-state.js +2 -2
- package/dist/routes/access-tokens.js +1 -1
- package/dist/routes/activity-disclosure.js +1 -1
- package/dist/routes/connect.js +2 -2
- package/dist/routes/credentials.js +130 -61
- package/dist/routes/dispatch.js +5 -1
- package/dist/routes/mcp.js +34 -17
- package/dist/routes/oauth-management.js +1 -1
- package/dist/routes/operator.js +6 -5
- package/dist/routes/shared.d.ts +13 -8
- package/dist/routes/shared.js +15 -9
- package/dist/routes/ui-config.js +3 -3
- package/dist/routes/ui-data.js +126 -20
- package/dist/routes/ui.js +17 -1
- package/dist/runtime/call-admission.js +1 -1
- package/dist/runtime/call.js +8 -3
- package/dist/runtime/native-storage.js +9 -0
- package/dist/runtime/request-reads.js +182 -0
- package/dist/runtime/request-scope.js +16 -0
- package/dist/runtime/settlement.js +1 -1
- package/dist/runtime/shared-read.js +2 -15
- package/dist/runtime/storage.js +12 -0
- package/dist/sent-secrets.d.ts +1 -2
- package/dist/sent-secrets.js +3 -8
- package/dist/server.js +4 -4
- package/dist/skills.js +44 -54
- package/dist/sqlite.d.ts +0 -19
- package/dist/sqlite.js +2 -83
- package/dist/storage/keys.js +69 -128
- package/dist/storage/memory.js +8 -2
- package/dist/storage/sql-native.js +27 -101
- package/dist/storage/sql.d.ts +1 -24
- package/dist/storage/sql.js +13 -14
- package/dist/storage-digest.js +32 -0
- package/dist/timeout.js +8 -0
- package/dist/tool-error.js +4 -0
- package/dist/types.d.ts +33 -0
- package/dist/url-safety.js +0 -4
- package/dist/usage-guide.js +18 -37
- package/dist/validate.d.ts +3 -0
- package/dist/validate.js +220 -158
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/worker.js +1 -1
- package/dist/write-outcome.js +0 -3
- package/documentation/README.md +19 -55
- package/documentation/architecture.md +436 -515
- package/documentation/auth.md +229 -88
- package/documentation/code-mode.md +201 -94
- package/documentation/deploying.md +25 -43
- package/documentation/integrating.md +18 -19
- package/documentation/meta-tools.md +225 -51
- package/documentation/migration-0.33.md +10 -6
- package/documentation/migration-0.34.md +5 -5
- package/documentation/migration-retired-layouts.md +74 -0
- package/documentation/operator-tests.md +20 -120
- package/documentation/operator-ui.md +108 -16
- package/documentation/pool-membership-upgrade.md +44 -0
- package/documentation/provider-auth-migration.md +4 -10
- package/documentation/provider-migration-0.29.md +4 -15
- package/documentation/providers.md +105 -0
- package/examples/worker/AGENTS.md +14 -16
- package/examples/worker/README.md +24 -118
- package/examples/worker/src/connecta.config.ts +1 -0
- package/guides/connecting-agents.md +9 -31
- package/guides/how-it-works.md +6 -4
- package/guides/safety-and-trust.md +4 -1
- package/package.json +19 -19
- package/templates/node/AGENTS.md +13 -12
- package/templates/node/README.md +9 -26
- package/templates/node/package.json +1 -1
- package/templates/node/src/connecta.config.ts +1 -0
- package/dist/auth/oauth-v2-migration.js +0 -135
- package/dist/provider-call-facts.d.ts +0 -115
- package/dist/provider-call.d.ts +0 -112
- package/dist/storage/d1-copy.js +0 -255
- package/dist/storage/sql-schema.d.ts +0 -6
- package/examples/worker/kv-to-d1.wrangler.jsonc +0 -26
- package/examples/worker/scripts/copy-kv-to-d1.mjs +0 -162
|
@@ -0,0 +1,544 @@
|
|
|
1
|
+
// The transport each connect builds, and write-once dispatch: every write's
|
|
2
|
+
// call reaches the wire at most once (INV-1, INV-9), and session close is bounded.
|
|
3
|
+
import { authView } from "../../auth/native-call.js";
|
|
4
|
+
import { authFlow, authCall, authInvoke, authValue, authMethod, authCatch } from "../../auth/native.js";
|
|
5
|
+
import { isOAuthCredentialRequest } from "../../auth/oauth-token-response.js";
|
|
6
|
+
import { deriveContext } from "../../derived-context.js";
|
|
7
|
+
import { operationContext, viewServices, viewSignal } from "../../runtime/call.js";
|
|
8
|
+
import { trackRemoteClientRequest } from "../../auth/downstream-client-metadata.js";
|
|
9
|
+
import { SdkHttpError, StreamableHTTPClientTransport } from "@modelcontextprotocol/client";
|
|
10
|
+
import { Deferred, Duration, Effect, Exit } from "effect";
|
|
11
|
+
import { codeExchangeResponse, earlyRenewalFetch, OAuthRequestNotSentError, } from "../../auth/downstream-oauth.js";
|
|
12
|
+
import { byteReadResponse } from "../../byte-read-response.js";
|
|
13
|
+
import { sentSecretsFor } from "../../sent-secrets.js";
|
|
14
|
+
import { MAX_SERIALIZED_CATALOG_BYTES } from "../../catalog-limits.js";
|
|
15
|
+
import { ConnectorCallError } from "../../errors.js";
|
|
16
|
+
import { attachFailureFacts, failureRecord, labelErrorClass, logFailure } from "../../operator-record.js";
|
|
17
|
+
import { learnedUrlRefusal } from "../../url-safety.js";
|
|
18
|
+
import { RequestScope } from "../../runtime/request-scope.js";
|
|
19
|
+
import { Context } from "effect";
|
|
20
|
+
import { downstreamWriteContinuation } from "../../downstream-input-context.js";
|
|
21
|
+
import { guardedTransport, sendGuarded, sendCredentialPost } from "../../guarded-transport.js";
|
|
22
|
+
import { dispatchProviderRequest, recordProviderResponse, providerRequest, renewedAfterUnauthorized, } from "../../provider-call.js";
|
|
23
|
+
import { recordRequestField } from "../../provider-call-facts.js";
|
|
24
|
+
import { AUTH_OR_THROTTLE, errorBodyText, jsonRpcErrorBody, keepsModernVerdict, promised } from "./errors.js";
|
|
25
|
+
import { MAX_SKILL_READ_RPC_BYTES, boundedSkillResponse, skillObject } from "./resources.js";
|
|
26
|
+
// The redirect guard keeps its published path; nothing here reads it while
|
|
27
|
+
// modules load, so this import cycle with the entry is inert.
|
|
28
|
+
import { redirectSafeFetch } from "../remote-mcp.js";
|
|
29
|
+
/**
|
|
30
|
+
* How long a downstream gets to answer the session-termination DELETE before
|
|
31
|
+
* teardown stops waiting. This is a network round-trip budget, deliberately
|
|
32
|
+
* independent of the core's 100 ms caller-facing scope-close window: an
|
|
33
|
+
* already-established cross-internet connection avoids setup, but 50 ms is
|
|
34
|
+
* still too short for an ordinary round trip plus modest provider scheduling.
|
|
35
|
+
* The bounded tail is deferred on runtimes that can keep it alive after the
|
|
36
|
+
* response, while callers continue to wait at most 100 ms.
|
|
37
|
+
*/
|
|
38
|
+
const TERMINATE_SESSION_BUDGET_MS = 1_000;
|
|
39
|
+
/**
|
|
40
|
+
* How long the local close gets after the DELETE: the second of the two
|
|
41
|
+
* seconds the core's deferred scope-close tail allows (see
|
|
42
|
+
* `src/runtime/connector-scope.ts`). The SDK's own transports close at once;
|
|
43
|
+
* this bounds one that never does.
|
|
44
|
+
*/
|
|
45
|
+
export const LOCAL_CLOSE_BUDGET_MS = 1_000;
|
|
46
|
+
/**
|
|
47
|
+
* End the downstream's session before the connection is torn down.
|
|
48
|
+
*
|
|
49
|
+
* `Client.close()` only unwinds our side — it aborts the transport's controller
|
|
50
|
+
* and fires `onclose`. Spec session termination is a separate DELETE carrying
|
|
51
|
+
* `Mcp-Session-Id`, and without it a stateful provider keeps the session alive
|
|
52
|
+
* until its own (often hour-long) timeout, accumulating abandoned sessions.
|
|
53
|
+
*
|
|
54
|
+
* Ordering is load-bearing: the SDK sends that DELETE on the transport's
|
|
55
|
+
* AbortSignal, so calling this *after* close would abort the request on issue
|
|
56
|
+
* and silently do nothing. Everything else is best-effort — a transport with no
|
|
57
|
+
* `terminateSession` (a custom one, or an older SDK), a downstream that refuses
|
|
58
|
+
* (405 is a legal answer), errors, or never replies all fall through to the
|
|
59
|
+
* close with the session left to age out as it did before.
|
|
60
|
+
*/
|
|
61
|
+
function terminateSession(transport, logger, connectorId) {
|
|
62
|
+
// SDK v2's Client.close() does not send the legacy session DELETE on our
|
|
63
|
+
// behalf. Connecta's own endpoint creates no protocol session, but a stateful
|
|
64
|
+
// legacy downstream can still issue `Mcp-Session-Id`, and every path that
|
|
65
|
+
// abandons one — scope teardown, credential rotation, OAuth retirement, an
|
|
66
|
+
// abandoned connect — owes it this best-effort, one-second DELETE.
|
|
67
|
+
const terminate = transport.terminateSession;
|
|
68
|
+
if (typeof terminate !== "function")
|
|
69
|
+
return Effect.void;
|
|
70
|
+
const warn = (log) => Effect.sync(() => {
|
|
71
|
+
try {
|
|
72
|
+
log();
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
// A diagnostic sink cannot make best-effort teardown observable to the
|
|
76
|
+
// caller in the one way this contract forbids: by replacing its result.
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
// Whichever finishes first wins and the other is interrupted: an answer
|
|
80
|
+
// clears the timer, and a failure that lands after the timeout is consumed
|
|
81
|
+
// by the interrupted wait rather than warned about a second time.
|
|
82
|
+
return Effect.raceAllFirst([
|
|
83
|
+
// The SDK issues no request at all when no `mcp-session-id` was captured,
|
|
84
|
+
// so a stateless downstream never sees a spurious DELETE.
|
|
85
|
+
promised(authMethod(() => authInvoke(Reflect, "apply", [terminate, transport, []]))).pipe(Effect.catch((error) =>
|
|
86
|
+
// The SDK's error quotes the downstream's status text, and its `data`
|
|
87
|
+
// the body, so the record keeps only the status it answered with.
|
|
88
|
+
warn(() => logFailure(logger, "session termination refused or failed; the downstream session may remain until its provider timeout", failureRecord({ connector: connectorId }, error instanceof SdkHttpError ? attachFailureFacts(error, { httpStatus: error.status }) : error))))),
|
|
89
|
+
Effect.sleep(Duration.millis(TERMINATE_SESSION_BUDGET_MS)).pipe(Effect.andThen(warn(() => logger.warn(`[connecta] connector "${connectorId}" session termination was not ` +
|
|
90
|
+
`acknowledged within ${TERMINATE_SESSION_BUDGET_MS} ms; the ` +
|
|
91
|
+
"downstream may still finish the headers-only DELETE, otherwise " +
|
|
92
|
+
"the session will remain until its provider timeout.")))),
|
|
93
|
+
]);
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* The local half of a close: the SDK client's close once a client owns the
|
|
97
|
+
* transport, the bare transport's until then. Never fails, and stops waiting
|
|
98
|
+
* after LOCAL_CLOSE_BUDGET_MS — a transport whose close never settles would
|
|
99
|
+
* otherwise hold every caller that awaits the scope close, the credential Test
|
|
100
|
+
* action among them.
|
|
101
|
+
*/
|
|
102
|
+
function closeLocally(client, transport) {
|
|
103
|
+
if (!client && !transport)
|
|
104
|
+
return Effect.void;
|
|
105
|
+
return Effect.raceAllFirst([
|
|
106
|
+
promised(authMethod(() => (client ? authInvoke(client, "close", []) : authValue(() => transport?.close())))).pipe(Effect.ignore),
|
|
107
|
+
Effect.sleep(Duration.millis(LOCAL_CLOSE_BUDGET_MS)),
|
|
108
|
+
]);
|
|
109
|
+
}
|
|
110
|
+
export const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
|
|
111
|
+
export class RemoteMcpDestinationError extends OAuthRequestNotSentError {
|
|
112
|
+
constructor(connectorId, reason) {
|
|
113
|
+
super("connector_call_failed", `Connector "${connectorId}" refused an OAuth URL the downstream advertised: ${reason}.`);
|
|
114
|
+
this.name = "RemoteMcpDestinationError";
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
labelErrorClass(RemoteMcpDestinationError, "RemoteMcpDestinationError");
|
|
118
|
+
/**
|
|
119
|
+
* Refuse, before any request leaves, a URL the downstream taught the OAuth
|
|
120
|
+
* flow that points somewhere connecta must not go (see `url-safety.ts`).
|
|
121
|
+
*
|
|
122
|
+
* The SDK learns authorization-server, token, and registration URLs from the
|
|
123
|
+
* downstream's own metadata and fetches them through the transport's fetch,
|
|
124
|
+
* so this sits below the refresh coordinator on both send paths. Resource
|
|
125
|
+
* redirects stay same-origin; credential-bearing token requests bypass the
|
|
126
|
+
* redirect wrapper. The typed local refusal proves that this guard sent nothing;
|
|
127
|
+
* failures after an HTTP dispatch keep their permanent ambiguous verdict.
|
|
128
|
+
*/
|
|
129
|
+
export function learnedUrlSafeFetch(connectorId, configured, baseFetch) {
|
|
130
|
+
return authMethod((input, init) => authFlow(function* () {
|
|
131
|
+
const reason = learnedUrlRefusal(configured, new URL(input));
|
|
132
|
+
if (reason !== undefined) {
|
|
133
|
+
throw new RemoteMcpDestinationError(connectorId, reason);
|
|
134
|
+
}
|
|
135
|
+
return byteReadResponse((yield authCall(baseFetch, [input, init])));
|
|
136
|
+
}));
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* The key a `tools/call`'s params carry its dispatch under. Object spread
|
|
140
|
+
* copies a symbol-keyed property, and the pinned SDK only spreads a request's
|
|
141
|
+
* params (to add a progress token or its `_meta` envelope); `JSON.stringify`
|
|
142
|
+
* skips symbol keys, so nothing of it reaches the wire.
|
|
143
|
+
*/
|
|
144
|
+
const DISPATCH = Symbol("connecta.dispatch");
|
|
145
|
+
export function newDispatch(write, sourceWrite) {
|
|
146
|
+
return { write, sourceWrite, sent: false, unauthorized: false, redirected: false, resendRefused: false };
|
|
147
|
+
}
|
|
148
|
+
export function withDispatch(params, dispatch) {
|
|
149
|
+
Object.defineProperty(params, DISPATCH, { value: dispatch, enumerable: true });
|
|
150
|
+
return params;
|
|
151
|
+
}
|
|
152
|
+
function dispatchOf(params) {
|
|
153
|
+
return params !== null && typeof params === "object"
|
|
154
|
+
? params[DISPATCH]
|
|
155
|
+
: undefined;
|
|
156
|
+
}
|
|
157
|
+
/** A further request for a write that already left, stopped before it does. */
|
|
158
|
+
function resendRefused(dispatch) {
|
|
159
|
+
dispatch.resendRefused = true;
|
|
160
|
+
return new ConnectorCallError("connector_call_failed", "This write was already sent once; it was not sent again.", {
|
|
161
|
+
retryable: false,
|
|
162
|
+
writeOutcome: "unknown",
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
/** Lost tracking cannot prove whether an earlier message already sent a write. */
|
|
166
|
+
function missingDispatch() {
|
|
167
|
+
return new ConnectorCallError("connector_call_failed", "The downstream call lost its dispatch record and cannot be proven read-only. It was not sent; whether an earlier write took effect is unknown. Check the target before repeating it.", { retryable: false, writeOutcome: "unknown" });
|
|
168
|
+
}
|
|
169
|
+
/** One connector's transports, sent-secret tracking and session close. */
|
|
170
|
+
export function remoteMcpTransport(rt) {
|
|
171
|
+
const { extraHeaders, opts, isOauth, customFetch, destination, id, customSender, requestToken, skillsEnabled, credentialAuth, requestAuth, credentialHeader, } = rt;
|
|
172
|
+
// Each OAuth transport's early renewal: its bound 401 flow, run before a
|
|
173
|
+
// call sends a token about to expire, owned by that call's signal.
|
|
174
|
+
const earlyRenewals = new WeakMap();
|
|
175
|
+
// A cached transport may dispatch concurrent calls and listings. Register
|
|
176
|
+
// its actual sent auth with every operation using that client, including
|
|
177
|
+
// handshake/discovery requests and OAuth rotations. Remove settled users.
|
|
178
|
+
const activeContexts = new WeakMap();
|
|
179
|
+
// Resource headers are non-secret context by contract. They never join the
|
|
180
|
+
// sent set, so arguments and results equal to them pass unchanged.
|
|
181
|
+
// An argument mirrored as `Mcp-Param-*` carrying a resource value is the
|
|
182
|
+
// same context under a name the tracker may read as credential-shaped.
|
|
183
|
+
const resourceValues = new Set(Object.values(extraHeaders ?? {}));
|
|
184
|
+
const withoutResourceHeaders = (input, init) => {
|
|
185
|
+
if (!extraHeaders)
|
|
186
|
+
return init;
|
|
187
|
+
const headers = new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined));
|
|
188
|
+
for (const [name, value] of Object.entries(extraHeaders))
|
|
189
|
+
if (headers.get(name) === value)
|
|
190
|
+
headers.delete(name);
|
|
191
|
+
const mirrored = [];
|
|
192
|
+
for (const [name, value] of headers)
|
|
193
|
+
if (name.startsWith("mcp-param-") && resourceValues.has(value))
|
|
194
|
+
mirrored.push(name);
|
|
195
|
+
for (const name of mirrored)
|
|
196
|
+
headers.delete(name);
|
|
197
|
+
return { ...init, headers };
|
|
198
|
+
};
|
|
199
|
+
const trackSentRequest = (ctx, input, init) => {
|
|
200
|
+
const active = activeContexts.get(rt.entryFor(ctx));
|
|
201
|
+
const recipients = new Set([sentSecretsFor(ctx), ...[...(active ?? [])].map(sentSecretsFor)]);
|
|
202
|
+
const tracked = withoutResourceHeaders(input, init);
|
|
203
|
+
for (const secrets of recipients) {
|
|
204
|
+
if (opts.auth?.type === "oauth")
|
|
205
|
+
trackRemoteClientRequest(secrets, input, tracked);
|
|
206
|
+
else
|
|
207
|
+
secrets.request(input, tracked);
|
|
208
|
+
}
|
|
209
|
+
};
|
|
210
|
+
const buildTransport = (ctx, provider,
|
|
211
|
+
/**
|
|
212
|
+
* This attempt's assembled header value, for credential auth only — framed
|
|
213
|
+
* by the caller so the raw secret is not passed around twice. Never
|
|
214
|
+
* retained past the transport it configures.
|
|
215
|
+
*/
|
|
216
|
+
credentialFramed = null, signal = viewSignal(ctx, true),
|
|
217
|
+
/** The raw value behind `credentialFramed`, under the same terms. */
|
|
218
|
+
credentialValue = null,
|
|
219
|
+
/** A code exchange never transfers ownership to the refresh coordinator. */
|
|
220
|
+
exchangeOnly = false,
|
|
221
|
+
/** The admitted principal `credentialValue` was resolved for, under the same terms. */
|
|
222
|
+
principal = undefined) => {
|
|
223
|
+
const resourceInit = (input, init) => {
|
|
224
|
+
if (!extraHeaders)
|
|
225
|
+
return init;
|
|
226
|
+
const target = new URL(input instanceof Request ? input.url : String(input));
|
|
227
|
+
const resource = new URL(opts.url);
|
|
228
|
+
if (target.origin !== resource.origin || target.pathname !== resource.pathname)
|
|
229
|
+
return init;
|
|
230
|
+
const headers = new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined));
|
|
231
|
+
for (const [name, value] of Object.entries(extraHeaders))
|
|
232
|
+
headers.set(name, value);
|
|
233
|
+
return { ...init, headers };
|
|
234
|
+
};
|
|
235
|
+
const responseOwner = Context.get(viewServices(ctx), RequestScope);
|
|
236
|
+
const initiatingSignal = ctx.signal;
|
|
237
|
+
ctx = deriveContext(operationContext(ctx), signal ? { signal } : {});
|
|
238
|
+
if (opts._transportFactory)
|
|
239
|
+
return opts._transportFactory(ctx);
|
|
240
|
+
const url = new URL(opts.url);
|
|
241
|
+
// Missing records never acquire a fresh write attempt. Only active calls
|
|
242
|
+
// for this exact tool, all classified read, can prove a recordless message
|
|
243
|
+
// safe. Catalog hints alone cannot prove the invocation's served verdict.
|
|
244
|
+
const untrackedRead = (params) => {
|
|
245
|
+
if (!skillObject(params) || typeof params.name !== "string")
|
|
246
|
+
return false;
|
|
247
|
+
let read = false;
|
|
248
|
+
for (const [dispatch, name] of rt.entryFor(ctx).dispatches) {
|
|
249
|
+
if (name !== params.name)
|
|
250
|
+
continue;
|
|
251
|
+
if (dispatch.write)
|
|
252
|
+
return false;
|
|
253
|
+
read = true;
|
|
254
|
+
}
|
|
255
|
+
return read;
|
|
256
|
+
};
|
|
257
|
+
// This transport's calls on the wire, by JSON-RPC id, while each is sent.
|
|
258
|
+
const flights = new Map();
|
|
259
|
+
const era = {};
|
|
260
|
+
const trackedFetch = authMethod((input, init) => authFlow(function* () {
|
|
261
|
+
let rpc;
|
|
262
|
+
if (typeof init?.body === "string") {
|
|
263
|
+
try {
|
|
264
|
+
rpc = JSON.parse(init.body);
|
|
265
|
+
}
|
|
266
|
+
catch { }
|
|
267
|
+
}
|
|
268
|
+
const toolCall = init?.method === "POST" && skillObject(rpc) && rpc.method === "tools/call" ? rpc : undefined;
|
|
269
|
+
const writeContinuation = downstreamWriteContinuation(ctx.requestScope ?? ctx) && toolCall !== undefined;
|
|
270
|
+
const flight = toolCall && (typeof toolCall.id === "string" || typeof toolCall.id === "number")
|
|
271
|
+
? flights.get(toolCall.id)
|
|
272
|
+
: undefined;
|
|
273
|
+
if (toolCall && !flight && !untrackedRead(toolCall.params))
|
|
274
|
+
throw missingDispatch();
|
|
275
|
+
const credentialPost = isOauth && (init?.method ?? "GET").toUpperCase() === "POST" && (!skillObject(rpc) || rpc.jsonrpc !== "2.0");
|
|
276
|
+
// A custom transport reaches only the configured origin; redirects are
|
|
277
|
+
// already same-origin, and OAuth cannot be combined with it.
|
|
278
|
+
if (customFetch && new URL(input instanceof Request ? input.url : String(input)).origin !== destination.origin)
|
|
279
|
+
throw new ConnectorCallError("connector_call_failed", `Connector "${id}" refused a request outside its origin; nothing was sent.`, { retryable: false });
|
|
280
|
+
// The deployment's fetch owns the wire: each write is handed over once.
|
|
281
|
+
const sender = flight?.write ? (customSender ?? guardedTransport(true)) : undefined;
|
|
282
|
+
if (flight?.write) {
|
|
283
|
+
if (flight.sent) {
|
|
284
|
+
if (flight.request && flight.authorization)
|
|
285
|
+
renewedAfterUnauthorized(flight.request.entry, flight.authorization, new Headers(init?.headers).get("authorization") ?? flight.authorization);
|
|
286
|
+
throw resendRefused(flight);
|
|
287
|
+
}
|
|
288
|
+
flight.authorization = new Headers(init?.headers).get("authorization");
|
|
289
|
+
}
|
|
290
|
+
const request = flight?.request;
|
|
291
|
+
if (request?.entry.dispatched)
|
|
292
|
+
request.entry = providerRequest(request.ctx, !flight?.write, request.entry.provider, {
|
|
293
|
+
method: init?.method ?? "GET",
|
|
294
|
+
});
|
|
295
|
+
const entry = request?.entry;
|
|
296
|
+
const dispatch = () => {
|
|
297
|
+
if (flight?.write) {
|
|
298
|
+
if (flight.sent)
|
|
299
|
+
throw resendRefused(flight);
|
|
300
|
+
flight.sent = true;
|
|
301
|
+
}
|
|
302
|
+
if (request && entry) {
|
|
303
|
+
if (flight?.sourceWrite)
|
|
304
|
+
recordRequestField(entry, "sourceWrite", true);
|
|
305
|
+
dispatchProviderRequest(request.ctx, entry, init?.headers, init?.method ?? "GET");
|
|
306
|
+
}
|
|
307
|
+
};
|
|
308
|
+
trackSentRequest(ctx, input, init);
|
|
309
|
+
// Attach retained settlement facts at the physical Promise leaf.
|
|
310
|
+
// Cancellation may end the native waiter before it resumes; neither
|
|
311
|
+
// the status nor a 401 verdict may disappear with that live authority.
|
|
312
|
+
const response = (yield authValue(() => {
|
|
313
|
+
const pending = credentialPost
|
|
314
|
+
? sendCredentialPost(input, init ?? {}, dispatch)
|
|
315
|
+
: sender
|
|
316
|
+
? sendGuarded(sender, new URL(input instanceof Request ? input.url : String(input)), { ...init, redirect: "manual" }, dispatch)
|
|
317
|
+
: (() => {
|
|
318
|
+
dispatch();
|
|
319
|
+
const send = customFetch ?? fetch;
|
|
320
|
+
return writeContinuation ? send(input, { ...init, redirect: "manual" }) : send(input, init);
|
|
321
|
+
})();
|
|
322
|
+
return pending.then((response) => {
|
|
323
|
+
// RPC fetch adapters can return a disposable Response. Own it at
|
|
324
|
+
// the physical Promise leaf, including a late answer after close,
|
|
325
|
+
// and keep its body live until the existing request boundary.
|
|
326
|
+
const dispose = response[Symbol.dispose];
|
|
327
|
+
if (dispose)
|
|
328
|
+
responseOwner.own(() => dispose.call(response));
|
|
329
|
+
if (entry)
|
|
330
|
+
recordProviderResponse(entry, response.status);
|
|
331
|
+
if (flight?.write) {
|
|
332
|
+
if (response.status === 401)
|
|
333
|
+
flight.unauthorized = true;
|
|
334
|
+
else if (REDIRECT_STATUSES.has(response.status))
|
|
335
|
+
flight.redirected = true;
|
|
336
|
+
}
|
|
337
|
+
if (init?.signal?.aborted || request?.ctx.signal?.aborted)
|
|
338
|
+
void response.body?.cancel().catch(() => { });
|
|
339
|
+
return response;
|
|
340
|
+
});
|
|
341
|
+
}));
|
|
342
|
+
const refused = response.status === 400 ? era.refused : undefined;
|
|
343
|
+
if ((flight?.write || refused) &&
|
|
344
|
+
response.status >= 400 &&
|
|
345
|
+
response.status < 500 &&
|
|
346
|
+
response.status !== 408 &&
|
|
347
|
+
!AUTH_OR_THROTTLE.includes(response.status)) {
|
|
348
|
+
const lifetimes = [init?.signal ?? undefined, request?.ctx.signal, signal];
|
|
349
|
+
const text = (yield authCall(errorBodyText, [response, lifetimes]));
|
|
350
|
+
const ended = lifetimes.find((lifetime) => lifetime?.aborted);
|
|
351
|
+
if (ended) {
|
|
352
|
+
// Nothing will consume the original either: release its source.
|
|
353
|
+
void response.body?.cancel().catch(() => { });
|
|
354
|
+
throw ended.reason;
|
|
355
|
+
}
|
|
356
|
+
const error = jsonRpcErrorBody(text);
|
|
357
|
+
// Only a JSON-RPC error proves the server refused the write.
|
|
358
|
+
if (flight?.write && !error)
|
|
359
|
+
flight.ambiguous = true;
|
|
360
|
+
// Per the transport's backward-compatibility rule, a 400 that is no
|
|
361
|
+
// recognized modern error is a legacy refusal, and an
|
|
362
|
+
// UnsupportedProtocolVersion that drops the selected version leaves
|
|
363
|
+
// the verdict unusable; either ends it. The call itself still fails.
|
|
364
|
+
if (refused &&
|
|
365
|
+
text !== undefined &&
|
|
366
|
+
!keepsModernVerdict(error, new Headers(init?.headers).get("mcp-protocol-version")))
|
|
367
|
+
(yield authCall(refused, []));
|
|
368
|
+
}
|
|
369
|
+
if (response.status === 401 && requestToken?.invalidate && credentialValue !== null) {
|
|
370
|
+
// Invalidate the exact bytes this transport sent, never a newer token.
|
|
371
|
+
// Preserve the original 401 even if an operator hook fails. No replay.
|
|
372
|
+
(yield authCatch(authInvoke(requestToken, "invalidate", principal ? [credentialValue, ctx, Object.freeze({ principal })] : [credentialValue, ctx]), () => { }));
|
|
373
|
+
}
|
|
374
|
+
if (credentialPost && !isOAuthCredentialRequest(init) && REDIRECT_STATUSES.has(response.status)) {
|
|
375
|
+
(yield authCatch(authValue(() => response.body?.cancel()), () => { }));
|
|
376
|
+
throw new ConnectorCallError("connector_call_failed", "OAuth registration endpoint redirected; the request was not repeated.", { retryable: false });
|
|
377
|
+
}
|
|
378
|
+
if (writeContinuation && (response.status === 401 || (response.status >= 300 && response.status < 400))) {
|
|
379
|
+
(yield authCatch(authValue(() => response.body?.cancel()), () => { }));
|
|
380
|
+
if (response.status === 401)
|
|
381
|
+
throw rt.authRequiredError(ctx);
|
|
382
|
+
throw new ConnectorCallError("connector_call_failed", "The downstream redirected a write continuation; it was not repeated.", { retryable: false, writeOutcome: "unknown" });
|
|
383
|
+
}
|
|
384
|
+
if (flight?.redirected) {
|
|
385
|
+
(yield authCatch(authValue(() => response.body?.cancel()), () => { }));
|
|
386
|
+
throw new ConnectorCallError("connector_call_failed", "The downstream answered this write with a redirect; it was not sent again.", { retryable: false, writeOutcome: "unknown" });
|
|
387
|
+
}
|
|
388
|
+
if (skillsEnabled && skillObject(rpc) && (rpc.method === "skills/list" || rpc.method === "resources/read")) {
|
|
389
|
+
return (yield authCall(boundedSkillResponse, [
|
|
390
|
+
response,
|
|
391
|
+
rpc.method === "skills/list" ? MAX_SERIALIZED_CATALOG_BYTES : MAX_SKILL_READ_RPC_BYTES,
|
|
392
|
+
rpc.id,
|
|
393
|
+
init?.signal,
|
|
394
|
+
]));
|
|
395
|
+
}
|
|
396
|
+
return response;
|
|
397
|
+
}));
|
|
398
|
+
// A runtime refusing the assembled header quotes it, and the transport
|
|
399
|
+
// error below keeps none of what the runtime said. So whether the
|
|
400
|
+
// rejection quoted the credential is decided here, first, and survives
|
|
401
|
+
// as the class of the error rather than as its text.
|
|
402
|
+
const guardedFetch = redirectSafeFetch(id, opts.redirects, credentialFramed === null
|
|
403
|
+
? trackedFetch
|
|
404
|
+
: authMethod((input, init) => authFlow(function* () {
|
|
405
|
+
try {
|
|
406
|
+
return (yield authCall(trackedFetch, [input, init]));
|
|
407
|
+
}
|
|
408
|
+
catch (err) {
|
|
409
|
+
throw rt.withoutCredential(ctx, err, credentialValue, credentialFramed);
|
|
410
|
+
}
|
|
411
|
+
})));
|
|
412
|
+
if (opts.auth?.type === "oauth") {
|
|
413
|
+
const oauthProvider = provider ?? rt.newProvider(ctx, undefined, signal);
|
|
414
|
+
// The session/provider belong to the request. A renewal belongs to the
|
|
415
|
+
// exact call whose HTTP response challenged it, until claim submission.
|
|
416
|
+
// Carry that ownership on the response the SDK passes to onUnauthorized;
|
|
417
|
+
// a shared mutable "current call" would borrow a concurrent call's lease.
|
|
418
|
+
const renewalSignals = new WeakMap();
|
|
419
|
+
const exchangeFetch = (send) => exchangeOnly
|
|
420
|
+
? authMethod((input, init) => authView(ctx, authMethod((requestSignal) => authFlow(function* () {
|
|
421
|
+
const response = (yield authCall(send, [
|
|
422
|
+
input,
|
|
423
|
+
{
|
|
424
|
+
...init,
|
|
425
|
+
signal: init?.signal ? AbortSignal.any([requestSignal, init.signal]) : requestSignal,
|
|
426
|
+
},
|
|
427
|
+
]));
|
|
428
|
+
if (requestSignal.aborted) {
|
|
429
|
+
void response.body?.cancel().catch(() => { });
|
|
430
|
+
throw requestSignal.reason;
|
|
431
|
+
}
|
|
432
|
+
return codeExchangeResponse(response, requestSignal);
|
|
433
|
+
})), true))
|
|
434
|
+
: send;
|
|
435
|
+
const coordinatedFetch = rt.refreshCoordinatorFor(ctx).coordinatedFetch(oauthProvider, exchangeFetch(learnedUrlSafeFetch(id, url, trackedFetch)), signal, ctx.defer, exchangeFetch(learnedUrlSafeFetch(id, url, authMethod((input, init) => authCall(guardedFetch, [input, resourceInit(input, init)])))), () => {
|
|
436
|
+
guardedTransport(true);
|
|
437
|
+
});
|
|
438
|
+
const transport = new StreamableHTTPClientTransport(url, {
|
|
439
|
+
authProvider: oauthProvider,
|
|
440
|
+
onInsufficientScope: "throw",
|
|
441
|
+
fetch: authMethod((input, init) => authFlow(function* () {
|
|
442
|
+
const response = (yield authCall(coordinatedFetch, [input, init]));
|
|
443
|
+
if (response.status === 401) {
|
|
444
|
+
let owner;
|
|
445
|
+
if (typeof init?.body === "string") {
|
|
446
|
+
try {
|
|
447
|
+
const rpc = JSON.parse(init.body);
|
|
448
|
+
if (skillObject(rpc)) {
|
|
449
|
+
if (rpc.method === "initialize" || rpc.method === "server/discover")
|
|
450
|
+
owner = initiatingSignal;
|
|
451
|
+
else if (typeof rpc.id === "string" || typeof rpc.id === "number")
|
|
452
|
+
owner = flights.get(rpc.id)?.request?.ctx.signal;
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
catch { }
|
|
456
|
+
}
|
|
457
|
+
const ownedSignal = owner && init?.signal ? AbortSignal.any([owner, init.signal]) : (owner ?? init?.signal);
|
|
458
|
+
if (ownedSignal)
|
|
459
|
+
renewalSignals.set(response, ownedSignal);
|
|
460
|
+
}
|
|
461
|
+
return response;
|
|
462
|
+
})),
|
|
463
|
+
});
|
|
464
|
+
rt.boundOAuthFlows(ctx, transport, url, [signal, ctx.signal], (response) => renewalSignals.get(response));
|
|
465
|
+
earlyRenewals.set(transport, authMethod((due, owner) => {
|
|
466
|
+
// Exactly the flow a 401 starts, through the same refresh coordinator,
|
|
467
|
+
// with nothing sent to the MCP endpoint. The token request carries
|
|
468
|
+
// `due`, so a rotation another request finished is adopted instead.
|
|
469
|
+
const internals = transport;
|
|
470
|
+
const response = new Response(null, { status: 401 });
|
|
471
|
+
if (owner)
|
|
472
|
+
renewalSignals.set(response, owner);
|
|
473
|
+
return authInvoke(internals._authProvider, "onUnauthorized", [
|
|
474
|
+
{ response, serverUrl: url, fetchFn: earlyRenewalFetch(internals._fetchWithInit, due) },
|
|
475
|
+
]);
|
|
476
|
+
}));
|
|
477
|
+
return sendingEachWriteOnce(transport, flights, untrackedRead, era);
|
|
478
|
+
}
|
|
479
|
+
const headers = opts.auth?.type === "headers"
|
|
480
|
+
? opts.auth.headers
|
|
481
|
+
: (credentialAuth || requestAuth) && credentialFramed !== null
|
|
482
|
+
? { ...requestAuth?.headers, [credentialHeader]: credentialFramed }
|
|
483
|
+
: undefined;
|
|
484
|
+
return sendingEachWriteOnce(new StreamableHTTPClientTransport(url, {
|
|
485
|
+
...(headers ? { requestInit: { headers } } : {}),
|
|
486
|
+
fetch: authMethod((input, init) => authCall(guardedFetch, [input, resourceInit(input, init)])),
|
|
487
|
+
}), flights, untrackedRead, era);
|
|
488
|
+
};
|
|
489
|
+
/**
|
|
490
|
+
* Admit a write's call to the wire once, whatever JSON-RPC id a later
|
|
491
|
+
* message for it carries, and show `trackedFetch` which call each message
|
|
492
|
+
* belongs to while it is sent. A missing record fails closed unless active
|
|
493
|
+
* calls prove the tool is read (INV-1, INV-9).
|
|
494
|
+
*/
|
|
495
|
+
const sendingEachWriteOnce = (transport, flights, untrackedRead, era) => {
|
|
496
|
+
eraRefusals.set(transport, era);
|
|
497
|
+
const send = transport.send.bind(transport);
|
|
498
|
+
transport.send = authMethod((message, options) => authFlow(function* () {
|
|
499
|
+
const request = !Array.isArray(message) && "method" in message && message.method === "tools/call" && "id" in message
|
|
500
|
+
? message
|
|
501
|
+
: undefined;
|
|
502
|
+
if (!request)
|
|
503
|
+
return (yield authCall(send, [message, options]));
|
|
504
|
+
const dispatch = dispatchOf(request.params);
|
|
505
|
+
if (!dispatch) {
|
|
506
|
+
if (!untrackedRead(request.params))
|
|
507
|
+
throw missingDispatch();
|
|
508
|
+
return (yield authCall(send, [message, options]));
|
|
509
|
+
}
|
|
510
|
+
if (dispatch.write && dispatch.sent)
|
|
511
|
+
throw resendRefused(dispatch);
|
|
512
|
+
flights.set(request.id, dispatch);
|
|
513
|
+
try {
|
|
514
|
+
return (yield authCall(send, [message, options]));
|
|
515
|
+
}
|
|
516
|
+
finally {
|
|
517
|
+
flights.delete(request.id);
|
|
518
|
+
}
|
|
519
|
+
}));
|
|
520
|
+
return transport;
|
|
521
|
+
};
|
|
522
|
+
// Detached exits start this bounded best-effort tail immediately;
|
|
523
|
+
// closeScope awaits its own tail so the core
|
|
524
|
+
// can pass it to the runtime's deferred channel. Terminating and closing are
|
|
525
|
+
// each bounded, so no close waits more than two seconds in all.
|
|
526
|
+
const closingSessions = new WeakMap();
|
|
527
|
+
// On a modern connection, adopted from a stored verdict or just negotiated, a 400 whose body is
|
|
528
|
+
// no recognized modern error is a legacy refusal (see `trackedFetch`): the
|
|
529
|
+
// verdict is forgotten and the client retired.
|
|
530
|
+
const eraRefusals = new WeakMap();
|
|
531
|
+
const eraRefused = new WeakSet();
|
|
532
|
+
const closeConnection = (client, transport, logger) => Effect.suspend(() => {
|
|
533
|
+
const previous = transport && closingSessions.get(transport);
|
|
534
|
+
if (previous)
|
|
535
|
+
return Deferred.await(previous);
|
|
536
|
+
const closed = Deferred.makeUnsafe();
|
|
537
|
+
// A connect can acquire a session after an early close. Deduplicate only
|
|
538
|
+
// once that session exists, so its late abandonment still sends DELETE.
|
|
539
|
+
if (transport?.sessionId)
|
|
540
|
+
closingSessions.set(transport, closed);
|
|
541
|
+
return (transport ? terminateSession(transport, logger, id) : Effect.void).pipe(Effect.andThen(closeLocally(client, transport)), Effect.ensuring(Deferred.done(closed, Exit.void)));
|
|
542
|
+
});
|
|
543
|
+
return { trackSentRequest, eraRefused, closeConnection, buildTransport, eraRefusals, earlyRenewals, activeContexts };
|
|
544
|
+
}
|