jmap-kit 0.0.0 → 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +138 -3
- package/dist/src/capabilities/blob/blob.d.ts +83 -0
- package/dist/src/capabilities/blob/blob.js +98 -0
- package/dist/src/capabilities/blob/blob.js.map +1 -0
- package/dist/src/capabilities/blob/types.d.ts +212 -0
- package/dist/src/capabilities/blob/types.js +16 -0
- package/dist/src/capabilities/blob/types.js.map +1 -0
- package/dist/src/capabilities/blob-capability.d.ts +195 -0
- package/dist/src/capabilities/blob-capability.js +277 -0
- package/dist/src/capabilities/blob-capability.js.map +1 -0
- package/dist/src/capabilities/core/core.d.ts +47 -0
- package/dist/src/capabilities/core/core.js +59 -0
- package/dist/src/capabilities/core/core.js.map +1 -0
- package/dist/src/capabilities/core/types.d.ts +13 -0
- package/dist/src/capabilities/core/types.js +2 -0
- package/dist/src/capabilities/core/types.js.map +1 -0
- package/dist/src/capabilities/core-capability.d.ts +307 -0
- package/dist/src/capabilities/core-capability.js +344 -0
- package/dist/src/capabilities/core-capability.js.map +1 -0
- package/dist/src/capabilities/email/email.d.ts +124 -0
- package/dist/src/capabilities/email/email.js +136 -0
- package/dist/src/capabilities/email/email.js.map +1 -0
- package/dist/src/capabilities/email/types.d.ts +776 -0
- package/dist/src/capabilities/email/types.js +2 -0
- package/dist/src/capabilities/email/types.js.map +1 -0
- package/dist/src/capabilities/email-capability.d.ts +266 -0
- package/dist/src/capabilities/email-capability.js +241 -0
- package/dist/src/capabilities/email-capability.js.map +1 -0
- package/dist/src/capabilities/emailsubmission/emailsubmission.d.ts +95 -0
- package/dist/src/capabilities/emailsubmission/emailsubmission.js +107 -0
- package/dist/src/capabilities/emailsubmission/emailsubmission.js.map +1 -0
- package/dist/src/capabilities/emailsubmission/types.d.ts +256 -0
- package/dist/src/capabilities/emailsubmission/types.js +2 -0
- package/dist/src/capabilities/emailsubmission/types.js.map +1 -0
- package/dist/src/capabilities/example/example.d.ts +80 -0
- package/dist/src/capabilities/example/example.js +91 -0
- package/dist/src/capabilities/example/example.js.map +1 -0
- package/dist/src/capabilities/example/types.d.ts +33 -0
- package/dist/src/capabilities/example/types.js +2 -0
- package/dist/src/capabilities/example/types.js.map +1 -0
- package/dist/src/capabilities/identity/identity.d.ts +71 -0
- package/dist/src/capabilities/identity/identity.js +83 -0
- package/dist/src/capabilities/identity/identity.js.map +1 -0
- package/dist/src/capabilities/identity/types.d.ts +110 -0
- package/dist/src/capabilities/identity/types.js +2 -0
- package/dist/src/capabilities/identity/types.js.map +1 -0
- package/dist/src/capabilities/mailbox/mailbox.d.ts +91 -0
- package/dist/src/capabilities/mailbox/mailbox.js +103 -0
- package/dist/src/capabilities/mailbox/mailbox.js.map +1 -0
- package/dist/src/capabilities/mailbox/types.d.ts +248 -0
- package/dist/src/capabilities/mailbox/types.js +2 -0
- package/dist/src/capabilities/mailbox/types.js.map +1 -0
- package/dist/src/capabilities/maskedemail/maskedemail.d.ts +60 -0
- package/dist/src/capabilities/maskedemail/maskedemail.js +72 -0
- package/dist/src/capabilities/maskedemail/maskedemail.js.map +1 -0
- package/dist/src/capabilities/maskedemail/types.d.ts +67 -0
- package/dist/src/capabilities/maskedemail/types.js +4 -0
- package/dist/src/capabilities/maskedemail/types.js.map +1 -0
- package/dist/src/capabilities/maskedemail-capability.d.ts +112 -0
- package/dist/src/capabilities/maskedemail-capability.js +166 -0
- package/dist/src/capabilities/maskedemail-capability.js.map +1 -0
- package/dist/src/capabilities/searchsnippet/searchsnippet.d.ts +51 -0
- package/dist/src/capabilities/searchsnippet/searchsnippet.js +63 -0
- package/dist/src/capabilities/searchsnippet/searchsnippet.js.map +1 -0
- package/dist/src/capabilities/searchsnippet/types.d.ts +88 -0
- package/dist/src/capabilities/searchsnippet/types.js +2 -0
- package/dist/src/capabilities/searchsnippet/types.js.map +1 -0
- package/dist/src/capabilities/submission-capability.d.ts +89 -0
- package/dist/src/capabilities/submission-capability.js +75 -0
- package/dist/src/capabilities/submission-capability.js.map +1 -0
- package/dist/src/capabilities/thread/thread.d.ts +58 -0
- package/dist/src/capabilities/thread/thread.js +70 -0
- package/dist/src/capabilities/thread/thread.js.map +1 -0
- package/dist/src/capabilities/thread/types.d.ts +43 -0
- package/dist/src/capabilities/thread/types.js +2 -0
- package/dist/src/capabilities/thread/types.js.map +1 -0
- package/dist/src/capabilities/utils/assert-invocation-datatype.d.ts +7 -0
- package/dist/src/capabilities/utils/assert-invocation-datatype.js +13 -0
- package/dist/src/capabilities/utils/assert-invocation-datatype.js.map +1 -0
- package/dist/src/capabilities/utils/assert-invocation-method.d.ts +7 -0
- package/dist/src/capabilities/utils/assert-invocation-method.js +13 -0
- package/dist/src/capabilities/utils/assert-invocation-method.js.map +1 -0
- package/dist/src/capabilities/utils/assert-invocation.d.ts +7 -0
- package/dist/src/capabilities/utils/assert-invocation.js +22 -0
- package/dist/src/capabilities/utils/assert-invocation.js.map +1 -0
- package/dist/src/capabilities/utils/assert-non-nullish.d.ts +1 -0
- package/dist/src/capabilities/utils/assert-non-nullish.js +6 -0
- package/dist/src/capabilities/utils/assert-non-nullish.js.map +1 -0
- package/dist/src/capabilities/utils/create-readonly-account-validator.d.ts +49 -0
- package/dist/src/capabilities/utils/create-readonly-account-validator.js +80 -0
- package/dist/src/capabilities/utils/create-readonly-account-validator.js.map +1 -0
- package/dist/src/capabilities/vacationresponse/types.d.ts +100 -0
- package/dist/src/capabilities/vacationresponse/types.js +2 -0
- package/dist/src/capabilities/vacationresponse/types.js.map +1 -0
- package/dist/src/capabilities/vacationresponse/vacationresponse.d.ts +61 -0
- package/dist/src/capabilities/vacationresponse/vacationresponse.js +73 -0
- package/dist/src/capabilities/vacationresponse/vacationresponse.js.map +1 -0
- package/dist/src/capabilities/vacationresponse-capability.d.ts +65 -0
- package/dist/src/capabilities/vacationresponse-capability.js +68 -0
- package/dist/src/capabilities/vacationresponse-capability.js.map +1 -0
- package/dist/src/capability-registry/capability-registry.d.ts +148 -0
- package/dist/src/capability-registry/capability-registry.js +360 -0
- package/dist/src/capability-registry/capability-registry.js.map +1 -0
- package/dist/src/capability-registry/types.d.ts +385 -0
- package/dist/src/capability-registry/types.js +2 -0
- package/dist/src/capability-registry/types.js.map +1 -0
- package/dist/src/capability-registry/utils.d.ts +71 -0
- package/dist/src/capability-registry/utils.js +163 -0
- package/dist/src/capability-registry/utils.js.map +1 -0
- package/dist/src/common/registry.d.ts +366 -0
- package/dist/src/common/registry.js +321 -0
- package/dist/src/common/registry.js.map +1 -0
- package/dist/src/common/types.d.ts +338 -0
- package/dist/src/common/types.js +21 -0
- package/dist/src/common/types.js.map +1 -0
- package/dist/src/common/utils.d.ts +20 -0
- package/dist/src/common/utils.js +26 -0
- package/dist/src/common/utils.js.map +1 -0
- package/dist/src/index.d.ts +40 -0
- package/dist/src/index.js +33 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/invocation/arguments-proxy.d.ts +14 -0
- package/dist/src/invocation/arguments-proxy.js +37 -0
- package/dist/src/invocation/arguments-proxy.js.map +1 -0
- package/dist/src/invocation/error-invocation.d.ts +27 -0
- package/dist/src/invocation/error-invocation.js +39 -0
- package/dist/src/invocation/error-invocation.js.map +1 -0
- package/dist/src/invocation/invocation.d.ts +111 -0
- package/dist/src/invocation/invocation.js +158 -0
- package/dist/src/invocation/invocation.js.map +1 -0
- package/dist/src/invocation/result-reference.d.ts +86 -0
- package/dist/src/invocation/result-reference.js +118 -0
- package/dist/src/invocation/result-reference.js.map +1 -0
- package/dist/src/invocation/types.d.ts +637 -0
- package/dist/src/invocation/types.js +2 -0
- package/dist/src/invocation/types.js.map +1 -0
- package/dist/src/invocation/utils.d.ts +21 -0
- package/dist/src/invocation/utils.js +30 -0
- package/dist/src/invocation/utils.js.map +1 -0
- package/dist/src/invocation-factory/invocation-factory-manager.d.ts +20 -0
- package/dist/src/invocation-factory/invocation-factory-manager.js +50 -0
- package/dist/src/invocation-factory/invocation-factory-manager.js.map +1 -0
- package/dist/src/invocation-factory/invocation-list.d.ts +32 -0
- package/dist/src/invocation-factory/invocation-list.js +77 -0
- package/dist/src/invocation-factory/invocation-list.js.map +1 -0
- package/dist/src/invocation-factory/types.d.ts +11 -0
- package/dist/src/invocation-factory/types.js +2 -0
- package/dist/src/invocation-factory/types.js.map +1 -0
- package/dist/src/jmap-client/jmap-client.d.ts +252 -0
- package/dist/src/jmap-client/jmap-client.js +777 -0
- package/dist/src/jmap-client/jmap-client.js.map +1 -0
- package/dist/src/jmap-client/types.d.ts +427 -0
- package/dist/src/jmap-client/types.js +21 -0
- package/dist/src/jmap-client/types.js.map +1 -0
- package/dist/src/jmap-client/utils/abort-controller.d.ts +8 -0
- package/dist/src/jmap-client/utils/abort-controller.js +24 -0
- package/dist/src/jmap-client/utils/abort-controller.js.map +1 -0
- package/dist/src/jmap-client/utils/assert-connected.d.ts +7 -0
- package/dist/src/jmap-client/utils/assert-connected.js +11 -0
- package/dist/src/jmap-client/utils/assert-connected.js.map +1 -0
- package/dist/src/jmap-client/utils/deep-freeze.d.ts +7 -0
- package/dist/src/jmap-client/utils/deep-freeze.js +17 -0
- package/dist/src/jmap-client/utils/deep-freeze.js.map +1 -0
- package/dist/src/jmap-client/utils/emitter.d.ts +9 -0
- package/dist/src/jmap-client/utils/emitter.js +18 -0
- package/dist/src/jmap-client/utils/emitter.js.map +1 -0
- package/dist/src/jmap-client/utils/filter-session-capabilities.d.ts +22 -0
- package/dist/src/jmap-client/utils/filter-session-capabilities.js +40 -0
- package/dist/src/jmap-client/utils/filter-session-capabilities.js.map +1 -0
- package/dist/src/jmap-client/utils/jmap-request-error.d.ts +28 -0
- package/dist/src/jmap-client/utils/jmap-request-error.js +48 -0
- package/dist/src/jmap-client/utils/jmap-request-error.js.map +1 -0
- package/dist/src/jmap-client/utils/logger.d.ts +6 -0
- package/dist/src/jmap-client/utils/logger.js +22 -0
- package/dist/src/jmap-client/utils/logger.js.map +1 -0
- package/dist/src/jmap-client/utils/merge-headers.d.ts +11 -0
- package/dist/src/jmap-client/utils/merge-headers.js +40 -0
- package/dist/src/jmap-client/utils/merge-headers.js.map +1 -0
- package/dist/src/jmap-client/utils/template-utils.d.ts +27 -0
- package/dist/src/jmap-client/utils/template-utils.js +61 -0
- package/dist/src/jmap-client/utils/template-utils.js.map +1 -0
- package/dist/src/jmap-client/utils/track-utils.d.ts +19 -0
- package/dist/src/jmap-client/utils/track-utils.js +35 -0
- package/dist/src/jmap-client/utils/track-utils.js.map +1 -0
- package/dist/src/jmap-client/utils/transport.d.ts +12 -0
- package/dist/src/jmap-client/utils/transport.js +38 -0
- package/dist/src/jmap-client/utils/transport.js.map +1 -0
- package/dist/src/jmap-client/utils/validate-session.d.ts +19 -0
- package/dist/src/jmap-client/utils/validate-session.js +29 -0
- package/dist/src/jmap-client/utils/validate-session.js.map +1 -0
- package/dist/src/request-builder/request-builder.d.ts +95 -0
- package/dist/src/request-builder/request-builder.js +343 -0
- package/dist/src/request-builder/request-builder.js.map +1 -0
- package/dist/src/request-builder/types.d.ts +32 -0
- package/dist/src/request-builder/types.js +2 -0
- package/dist/src/request-builder/types.js.map +1 -0
- package/package.json +69 -3
|
@@ -0,0 +1,777 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JMAP Client class implementation
|
|
3
|
+
* @module jmap-client
|
|
4
|
+
* @license MIT
|
|
5
|
+
*
|
|
6
|
+
* @example
|
|
7
|
+
* import { JMAPClient } from 'jmap-client';
|
|
8
|
+
* const transport = // ... create a transport
|
|
9
|
+
*
|
|
10
|
+
* const client = new JMAPClient(transport, {
|
|
11
|
+
* hostname: "api.example.com",
|
|
12
|
+
* });
|
|
13
|
+
*
|
|
14
|
+
* await client.connect();
|
|
15
|
+
*
|
|
16
|
+
* // Do something with the client
|
|
17
|
+
*/
|
|
18
|
+
import pLimit from "p-limit";
|
|
19
|
+
import { z } from "zod/v4";
|
|
20
|
+
import { CoreCapability } from "../capabilities/core-capability.js";
|
|
21
|
+
import { CapabilityRegistry } from "../capability-registry/capability-registry.js";
|
|
22
|
+
import { CORE_CAPABILITY_URI } from "../common/registry.js";
|
|
23
|
+
import { InvocationFactoryManager } from "../invocation-factory/invocation-factory-manager.js";
|
|
24
|
+
import { RequestBuilder } from "../request-builder/request-builder.js";
|
|
25
|
+
import { assertConnected } from "./utils/assert-connected.js";
|
|
26
|
+
import { createEmitter } from "./utils/emitter.js";
|
|
27
|
+
import { filterSessionCapabilities } from "./utils/filter-session-capabilities.js";
|
|
28
|
+
import { JMAPRequestError } from "./utils/jmap-request-error.js";
|
|
29
|
+
import { createLogger } from "./utils/logger.js";
|
|
30
|
+
import { mergeHeaders } from "./utils/merge-headers.js";
|
|
31
|
+
import { expandUrlWithParams } from "./utils/template-utils.js";
|
|
32
|
+
import { createTransport } from "./utils/transport.js";
|
|
33
|
+
import { parseAndValidateJMAPSession } from "./utils/validate-session.js";
|
|
34
|
+
const WELL_KNOWN_JMAP = "/.well-known/jmap";
|
|
35
|
+
/**
|
|
36
|
+
* JMAP Client for interacting with a JMAP server.
|
|
37
|
+
*
|
|
38
|
+
* This class manages the connection lifecycle, session state, and provides methods for
|
|
39
|
+
* interacting with the JMAP API, including capability checks, file downloads, and
|
|
40
|
+
* invocation requests and responses.
|
|
41
|
+
*/
|
|
42
|
+
export class JMAPClient {
|
|
43
|
+
// Internal state
|
|
44
|
+
#hostname;
|
|
45
|
+
#port;
|
|
46
|
+
#requestHeaders;
|
|
47
|
+
#session = null;
|
|
48
|
+
#sessionState = null;
|
|
49
|
+
#_connectionStatus = "disconnected";
|
|
50
|
+
// Capability registry
|
|
51
|
+
#capabilityRegistry;
|
|
52
|
+
/**
|
|
53
|
+
* Set of all active (not yet aborted) AbortControllers created internally.
|
|
54
|
+
* Controllers are removed from the set when aborted.
|
|
55
|
+
*/
|
|
56
|
+
#activeAbortControllers = new Set();
|
|
57
|
+
/**
|
|
58
|
+
* Set of all active (not yet settled) request promises.
|
|
59
|
+
*/
|
|
60
|
+
#activeRequests = new Set();
|
|
61
|
+
// Concurrency limiters
|
|
62
|
+
#uploadLimit = pLimit(4); // Default to 4, updated when capabilities are known
|
|
63
|
+
#requestLimit = pLimit(4); // Default to 4, updated when capabilities are known
|
|
64
|
+
// Utilities
|
|
65
|
+
#transport;
|
|
66
|
+
#responseFactory;
|
|
67
|
+
#currentLogger;
|
|
68
|
+
#logger = createLogger(() => this.#currentLogger);
|
|
69
|
+
#currentEmitter;
|
|
70
|
+
#emitter = createEmitter(() => this.#currentEmitter);
|
|
71
|
+
/**
|
|
72
|
+
* The current connection status of the client.
|
|
73
|
+
*/
|
|
74
|
+
get connectionStatus() {
|
|
75
|
+
return this.#_connectionStatus;
|
|
76
|
+
}
|
|
77
|
+
set #connectionStatus(status) {
|
|
78
|
+
/* v8 ignore if -- @preserve */
|
|
79
|
+
/* istanbul ignore next -- defensive: public API prevents setting to same value */
|
|
80
|
+
if (this.#_connectionStatus === status) {
|
|
81
|
+
return; // Do not emit or log if status is unchanged
|
|
82
|
+
}
|
|
83
|
+
this.#_connectionStatus = status;
|
|
84
|
+
const sessionState = this.#sessionState;
|
|
85
|
+
this.#logger.info(`JMAP Client changed connection status: ${status}.`, { sessionState });
|
|
86
|
+
this.#emitter("status-changed", { status, sessionState });
|
|
87
|
+
}
|
|
88
|
+
#connecting = null;
|
|
89
|
+
#disconnecting = null;
|
|
90
|
+
/**
|
|
91
|
+
* Create a new JMAPClient instance.
|
|
92
|
+
*
|
|
93
|
+
* The provided Transport is responsible for all HTTP request concerns, including authentication,
|
|
94
|
+
* network errors, and any custom headers or request logic required by the server. The client
|
|
95
|
+
* itself does not handle authentication or low-level HTTP details.
|
|
96
|
+
*
|
|
97
|
+
* If the port is not specified, it defaults to 443.
|
|
98
|
+
*
|
|
99
|
+
* @param transport The transport implementation for HTTP requests, including authentication.
|
|
100
|
+
* @param options JMAP client options including hostname, port, headers, logger, and emitter.
|
|
101
|
+
*/
|
|
102
|
+
constructor(transport, { hostname, port = 443, headers, logger, emitter }) {
|
|
103
|
+
// Wrap the transport so all requests and abort controllers are tracked
|
|
104
|
+
this.#transport = createTransport(transport, this.#activeRequests, this.#activeAbortControllers);
|
|
105
|
+
this.#currentLogger = logger;
|
|
106
|
+
this.#currentEmitter = emitter;
|
|
107
|
+
this.#hostname = hostname ?? null;
|
|
108
|
+
this.#port = port;
|
|
109
|
+
// Convert headers to Headers object if needed
|
|
110
|
+
this.#requestHeaders = new Headers(headers ?? {});
|
|
111
|
+
const clientContext = {
|
|
112
|
+
logger: this.#logger,
|
|
113
|
+
emitter: this.#emitter,
|
|
114
|
+
};
|
|
115
|
+
// Initialise the capability registry with the Core capability
|
|
116
|
+
this.#capabilityRegistry = new CapabilityRegistry(CoreCapability, clientContext);
|
|
117
|
+
this.#responseFactory = new InvocationFactoryManager(this, clientContext);
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Register one or more capabilities with the client.
|
|
121
|
+
*
|
|
122
|
+
* When called after the client is connected, each capability's schema is validated against
|
|
123
|
+
* the session data. Capabilities that fail validation are rejected and not registered.
|
|
124
|
+
* When called before connecting, capabilities are registered without validation.
|
|
125
|
+
*
|
|
126
|
+
* If called while the client is in the process of connecting, registration waits for the
|
|
127
|
+
* connection to complete before validating. If the connection fails, capabilities are
|
|
128
|
+
* registered without validation (no session to validate against).
|
|
129
|
+
*
|
|
130
|
+
* @param capabilities The capability definitions to register
|
|
131
|
+
* @returns A result object containing arrays of validation failures (empty arrays if all succeeded)
|
|
132
|
+
*/
|
|
133
|
+
async registerCapabilities(...capabilities) {
|
|
134
|
+
const allServerFailures = [];
|
|
135
|
+
const allAccountFailures = [];
|
|
136
|
+
await this.#awaitPendingConnection("registering capabilities");
|
|
137
|
+
for (const capability of capabilities) {
|
|
138
|
+
if (this.#capabilityRegistry.has(capability.uri)) {
|
|
139
|
+
this.#logger.debug(`Capability already registered: ${capability.uri}`);
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
if (await this.#validateCapability(capability, allServerFailures, allAccountFailures)) {
|
|
143
|
+
this.#capabilityRegistry.register(capability);
|
|
144
|
+
this.#logger.info(`Successfully registered capability: ${capability.uri}`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
if (allServerFailures.length > 0 || allAccountFailures.length > 0) {
|
|
148
|
+
this.#emitter("invalid-capabilities", {
|
|
149
|
+
context: "registration",
|
|
150
|
+
serverCapabilities: allServerFailures,
|
|
151
|
+
accountCapabilities: allAccountFailures,
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
return {
|
|
155
|
+
serverCapabilities: allServerFailures,
|
|
156
|
+
accountCapabilities: allAccountFailures,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* If a connection is in progress, wait for it to settle before proceeding.
|
|
161
|
+
*
|
|
162
|
+
* @param reason A description of why the caller is waiting, used in the debug log message.
|
|
163
|
+
* @param onError Called when the connection attempt fails. If omitted, the error is swallowed
|
|
164
|
+
* and the caller will see the client as disconnected.
|
|
165
|
+
*/
|
|
166
|
+
async #awaitPendingConnection(reason, onError) {
|
|
167
|
+
if (this.connectionStatus !== "connecting")
|
|
168
|
+
return;
|
|
169
|
+
this.#logger.debug(`Waiting for JMAP Client to finish connecting before ${reason}`);
|
|
170
|
+
try {
|
|
171
|
+
await this.#connecting;
|
|
172
|
+
}
|
|
173
|
+
catch (error) {
|
|
174
|
+
onError?.(error);
|
|
175
|
+
// Connection failed — client is now disconnected, no session to validate against
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Validate a capability's schema against the current session. Returns `true` when the
|
|
180
|
+
* capability is valid or no validation is needed. Returns `false` when validation fails,
|
|
181
|
+
* after logging errors and accumulating failures.
|
|
182
|
+
*/
|
|
183
|
+
async #validateCapability(capability, serverFailures, accountFailures) {
|
|
184
|
+
if (this.connectionStatus !== "connected" || !this.#session || !capability.schema) {
|
|
185
|
+
return true;
|
|
186
|
+
}
|
|
187
|
+
const { serverCapabilities, accountCapabilities } = await this.#capabilityRegistry.validateCapabilityDefinition(capability, this.#session.capabilities, this.#session.accounts);
|
|
188
|
+
if (serverCapabilities.length === 0 && accountCapabilities.length === 0) {
|
|
189
|
+
return true;
|
|
190
|
+
}
|
|
191
|
+
for (const failure of serverCapabilities) {
|
|
192
|
+
this.#logger.error(`Rejecting capability ${failure.uri}: ${failure.errors.map((e) => e.message).join("; ")}`);
|
|
193
|
+
}
|
|
194
|
+
for (const failure of accountCapabilities) {
|
|
195
|
+
this.#logger.error(`Rejecting capability ${failure.uri} for account ${failure.accountId}: ${failure.errors.map((e) => e.message).join("; ")}`);
|
|
196
|
+
}
|
|
197
|
+
serverFailures.push(...serverCapabilities);
|
|
198
|
+
accountFailures.push(...accountCapabilities);
|
|
199
|
+
return false;
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Set the hostname for the JMAP server. Only allowed while disconnected.
|
|
203
|
+
*
|
|
204
|
+
* @param hostname The hostname of the JMAP server
|
|
205
|
+
* @throws Error if called after connecting.
|
|
206
|
+
* @returns This client instance (for chaining).
|
|
207
|
+
*/
|
|
208
|
+
withHostname(hostname) {
|
|
209
|
+
if (this.connectionStatus !== "disconnected") {
|
|
210
|
+
throw new Error("Cannot change hostname after connecting to a JMAP server");
|
|
211
|
+
}
|
|
212
|
+
this.#logger.debug("Setting hostname to %s", hostname);
|
|
213
|
+
this.#hostname = hostname;
|
|
214
|
+
return this;
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Set the port for the JMAP server. Only allowed while disconnected.
|
|
218
|
+
*
|
|
219
|
+
* @param port Port number.
|
|
220
|
+
* @throws Error if called after connecting.
|
|
221
|
+
* @returns This client instance (for chaining).
|
|
222
|
+
*/
|
|
223
|
+
withPort(port) {
|
|
224
|
+
if (this.connectionStatus !== "disconnected") {
|
|
225
|
+
throw new Error("Cannot change port after connecting to a JMAP server");
|
|
226
|
+
}
|
|
227
|
+
this.#logger.debug("Setting port to %d", port);
|
|
228
|
+
this.#port = port;
|
|
229
|
+
return this;
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Set additional headers for requests.
|
|
233
|
+
*
|
|
234
|
+
* @param headers Additional headers to merge.
|
|
235
|
+
* @returns This client instance (for chaining).
|
|
236
|
+
*/
|
|
237
|
+
withHeaders(headers) {
|
|
238
|
+
this.#requestHeaders = mergeHeaders(this.#requestHeaders, headers);
|
|
239
|
+
return this;
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* Set a custom logger for the client.
|
|
243
|
+
*
|
|
244
|
+
* @param logger The logger instance or function.
|
|
245
|
+
* @returns This client instance (for chaining).
|
|
246
|
+
*/
|
|
247
|
+
withLogger(logger) {
|
|
248
|
+
this.#currentLogger = logger;
|
|
249
|
+
return this;
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* Set a custom event emitter for the client.
|
|
253
|
+
*
|
|
254
|
+
* @param emitter The event emitter function to handle events.
|
|
255
|
+
* @returns This client instance (for chaining).
|
|
256
|
+
*/
|
|
257
|
+
withEmitter(emitter) {
|
|
258
|
+
this.#currentEmitter = emitter;
|
|
259
|
+
return this;
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Update concurrency limits based on server capabilities
|
|
263
|
+
*/
|
|
264
|
+
#updateConcurrencyLimits() {
|
|
265
|
+
const coreCapabilities = this.serverCapabilities?.[CORE_CAPABILITY_URI];
|
|
266
|
+
if (coreCapabilities) {
|
|
267
|
+
this.#uploadLimit.concurrency = coreCapabilities.maxConcurrentUpload;
|
|
268
|
+
this.#requestLimit.concurrency = coreCapabilities.maxConcurrentRequests;
|
|
269
|
+
this.#logger.debug(`Updated concurrency limits: uploads=${coreCapabilities.maxConcurrentUpload}, requests=${coreCapabilities.maxConcurrentRequests}`);
|
|
270
|
+
/* v8 ignore start -- @preserve defensive: Core capabilities are always present when connected */
|
|
271
|
+
}
|
|
272
|
+
else {
|
|
273
|
+
this.#logger.warn("No core capabilities found, using default concurrency limits");
|
|
274
|
+
this.#uploadLimit.concurrency = 4;
|
|
275
|
+
this.#requestLimit.concurrency = 4;
|
|
276
|
+
}
|
|
277
|
+
/* v8 ignore stop -- @preserve */
|
|
278
|
+
}
|
|
279
|
+
async #connect(signal) {
|
|
280
|
+
if (!this.#hostname) {
|
|
281
|
+
this.#logger.error("JMAP Client attempted to connect without a specified hostname");
|
|
282
|
+
throw new Error("Cannot connect to JMAP server without a hostname");
|
|
283
|
+
}
|
|
284
|
+
this.#connectionStatus = "connecting";
|
|
285
|
+
const url = new URL(WELL_KNOWN_JMAP, `https://${this.#hostname}:${this.#port}`);
|
|
286
|
+
this.#logger.debug(`JMAP Client connecting to ${url.toString()}`);
|
|
287
|
+
try {
|
|
288
|
+
const jsonResponse = await this.#transport.get(url, {
|
|
289
|
+
headers: this.#requestHeaders,
|
|
290
|
+
responseType: "json",
|
|
291
|
+
...(signal ? { signal } : {}),
|
|
292
|
+
});
|
|
293
|
+
if (this.connectionStatus !== "connecting") {
|
|
294
|
+
// Do not transition to connected if not still connecting (e.g., disconnecting)
|
|
295
|
+
// It's possible that disconnect() was called while waiting for the session response
|
|
296
|
+
// We would normally expect #transport.get() to throw an AbortError in this case, but
|
|
297
|
+
// we handle it gracefully here because we can't guarantee the behaviour of the Transport implementation.
|
|
298
|
+
this.#logger.warn(`JMAP Client connection was interrupted before completion, client ${this.connectionStatus}`);
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
// Structural validation of the session response
|
|
302
|
+
const parsedSession = parseAndValidateJMAPSession(jsonResponse, this.#logger);
|
|
303
|
+
// Validate capability-specific session data against registered schemas
|
|
304
|
+
const [serverResults, accountResults] = await Promise.all([
|
|
305
|
+
this.#capabilityRegistry.validateServerCapabilities(parsedSession.capabilities),
|
|
306
|
+
this.#capabilityRegistry.validateAccountCapabilities(parsedSession.accounts),
|
|
307
|
+
]);
|
|
308
|
+
// Core capability failure is fatal — the client cannot operate without it
|
|
309
|
+
const coreFailure = serverResults.find((r) => !r.valid && r.uri === CORE_CAPABILITY_URI);
|
|
310
|
+
if (coreFailure && !coreFailure.valid) {
|
|
311
|
+
const message = `Core server capability validation failed:\n${coreFailure.errors.map((e) => ` - ${e.message}`).join("\n")}`;
|
|
312
|
+
this.#logger.error(message);
|
|
313
|
+
throw new Error(message);
|
|
314
|
+
}
|
|
315
|
+
// Collect non-Core failures
|
|
316
|
+
const serverFailures = serverResults.filter((r) => !r.valid && r.uri !== CORE_CAPABILITY_URI);
|
|
317
|
+
const accountFailures = accountResults.filter((r) => !r.valid);
|
|
318
|
+
// Filter invalid capabilities and freeze
|
|
319
|
+
this.#session = filterSessionCapabilities(parsedSession, serverFailures, accountFailures);
|
|
320
|
+
// Emit a single event if any capabilities were stripped
|
|
321
|
+
if (serverFailures.length > 0 || accountFailures.length > 0) {
|
|
322
|
+
for (const failure of serverFailures) {
|
|
323
|
+
this.#logger.warn(`Stripping server capability ${failure.uri}: ${failure.errors.map((e) => e.message).join("; ")}`);
|
|
324
|
+
}
|
|
325
|
+
for (const failure of accountFailures) {
|
|
326
|
+
this.#logger.warn(`Stripping account capability ${failure.uri} from account ${failure.accountId}: ${failure.errors.map((e) => e.message).join("; ")}`);
|
|
327
|
+
}
|
|
328
|
+
this.#emitter("invalid-capabilities", {
|
|
329
|
+
context: "connection",
|
|
330
|
+
serverCapabilities: serverFailures,
|
|
331
|
+
accountCapabilities: accountFailures,
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
this.#sessionState = this.#session.state;
|
|
335
|
+
this.#updateConcurrencyLimits();
|
|
336
|
+
this.#connectionStatus = "connected";
|
|
337
|
+
}
|
|
338
|
+
catch (error) {
|
|
339
|
+
let warningMessage;
|
|
340
|
+
let errorMessage;
|
|
341
|
+
if (error instanceof Error && error.cause instanceof Error && error.cause.name === "ZodError") {
|
|
342
|
+
warningMessage = "JMAP Client is disconnecting due to an invalid session response";
|
|
343
|
+
errorMessage = "JMAP Client disconnected due to an invalid session response";
|
|
344
|
+
}
|
|
345
|
+
else if (error instanceof DOMException && error.name === "AbortError") {
|
|
346
|
+
warningMessage = "JMAP Client is disconnecting due to connection being aborted";
|
|
347
|
+
errorMessage = "JMAP Client disconnected due to connection being aborted";
|
|
348
|
+
}
|
|
349
|
+
else {
|
|
350
|
+
warningMessage = "JMAP Client is disconnecting due to a transport error";
|
|
351
|
+
errorMessage = "JMAP Client disconnected due to a transport error";
|
|
352
|
+
}
|
|
353
|
+
this.#logger.warn(warningMessage, { error });
|
|
354
|
+
await this.disconnect();
|
|
355
|
+
this.#logger.error(errorMessage, { error });
|
|
356
|
+
throw error;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
/**
|
|
360
|
+
* Connect to the JMAP server and fetch the session object.
|
|
361
|
+
*
|
|
362
|
+
* This method is idempotent: if called multiple times while a connection is already in progress,
|
|
363
|
+
* each call will return a new Promise that resolves or rejects with the same result as the in-progress connection.
|
|
364
|
+
* Only one connection attempt will be made at a time; concurrent calls will not trigger multiple connections.
|
|
365
|
+
*
|
|
366
|
+
* @throws Error if hostname is not set or connection fails.
|
|
367
|
+
*/
|
|
368
|
+
async connect(signal) {
|
|
369
|
+
if (this.connectionStatus === "disconnecting") {
|
|
370
|
+
this.#logger.error("JMAP Client attempted to connect while in the process of disconnecting");
|
|
371
|
+
throw new Error("Cannot reconnect while disconnecting");
|
|
372
|
+
}
|
|
373
|
+
if (this.#connecting) {
|
|
374
|
+
this.#logger.debug("JMAP Client is already connecting, returning existing promise");
|
|
375
|
+
return this.#connecting;
|
|
376
|
+
}
|
|
377
|
+
try {
|
|
378
|
+
this.#connecting = this.#connect(signal);
|
|
379
|
+
await this.#connecting;
|
|
380
|
+
}
|
|
381
|
+
finally {
|
|
382
|
+
this.#logger.info("JMAP Client connected");
|
|
383
|
+
this.#connecting = null;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
async #disconnect() {
|
|
387
|
+
this.#connectionStatus = "disconnecting";
|
|
388
|
+
// Abort all active requests
|
|
389
|
+
for (const controller of this.#activeAbortControllers) {
|
|
390
|
+
controller.abort();
|
|
391
|
+
}
|
|
392
|
+
// Wait for all in-flight requests to settle
|
|
393
|
+
if (this.#activeRequests.size > 0) {
|
|
394
|
+
await Promise.allSettled(Array.from(this.#activeRequests));
|
|
395
|
+
}
|
|
396
|
+
this.#session = null;
|
|
397
|
+
this.#sessionState = null;
|
|
398
|
+
this.#connectionStatus = "disconnected";
|
|
399
|
+
}
|
|
400
|
+
/**
|
|
401
|
+
* Disconnect from the JMAP server and clear session state.
|
|
402
|
+
*
|
|
403
|
+
* This method is asynchronous and will not set the status to 'disconnected' until all
|
|
404
|
+
* in-flight requests have settled (resolved or rejected) after being aborted.
|
|
405
|
+
* It is idempotent: if already disconnecting, it returns the same promise.
|
|
406
|
+
*/
|
|
407
|
+
async disconnect() {
|
|
408
|
+
if (this.connectionStatus === "disconnected") {
|
|
409
|
+
this.#logger.debug("JMAP Client is already disconnected");
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
if (this.#disconnecting) {
|
|
413
|
+
this.#logger.debug("JMAP Client is already disconnecting, returning existing promise");
|
|
414
|
+
return this.#disconnecting;
|
|
415
|
+
}
|
|
416
|
+
try {
|
|
417
|
+
this.#disconnecting = this.#disconnect();
|
|
418
|
+
await this.#disconnecting;
|
|
419
|
+
}
|
|
420
|
+
finally {
|
|
421
|
+
this.#logger.info("JMAP Client disconnected");
|
|
422
|
+
this.#disconnecting = null;
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
/**
|
|
426
|
+
* Get the capability registry.
|
|
427
|
+
*
|
|
428
|
+
* This provides read-only access to the registry of JMAP capabilities.
|
|
429
|
+
* To register new capabilities, use the registerCapability method.
|
|
430
|
+
*/
|
|
431
|
+
get capabilityRegistry() {
|
|
432
|
+
return this.#capabilityRegistry;
|
|
433
|
+
}
|
|
434
|
+
/**
|
|
435
|
+
* The server capabilities from the session object, or null if not connected.
|
|
436
|
+
*/
|
|
437
|
+
get serverCapabilities() {
|
|
438
|
+
return this.#session?.capabilities ?? null;
|
|
439
|
+
}
|
|
440
|
+
/**
|
|
441
|
+
* Determines if a given capability identified by its URN is supported by the server.
|
|
442
|
+
* @param urn The URN of the JMAP capability being queried.
|
|
443
|
+
* @returns True if supported, false otherwise.
|
|
444
|
+
*/
|
|
445
|
+
isSupported(urn) {
|
|
446
|
+
return Object.hasOwn(this.serverCapabilities ?? { [CORE_CAPABILITY_URI]: {} }, urn);
|
|
447
|
+
}
|
|
448
|
+
/**
|
|
449
|
+
* The accounts from the session object, or null if not connected.
|
|
450
|
+
*/
|
|
451
|
+
get accounts() {
|
|
452
|
+
return this.#session?.accounts ?? null;
|
|
453
|
+
}
|
|
454
|
+
/**
|
|
455
|
+
* The primary accounts from the session object, or an empty object if not connected.
|
|
456
|
+
*/
|
|
457
|
+
get primaryAccounts() {
|
|
458
|
+
return this.#session?.primaryAccounts ?? {};
|
|
459
|
+
}
|
|
460
|
+
/**
|
|
461
|
+
* The username from the session object, or an empty string if not connected.
|
|
462
|
+
*/
|
|
463
|
+
get username() {
|
|
464
|
+
return this.#session?.username ?? "";
|
|
465
|
+
}
|
|
466
|
+
/**
|
|
467
|
+
* The API URL from the session object, or an empty string if not connected.
|
|
468
|
+
*/
|
|
469
|
+
get apiUrl() {
|
|
470
|
+
return this.#session?.apiUrl ?? "";
|
|
471
|
+
}
|
|
472
|
+
/**
|
|
473
|
+
* The download URL template from the session object, or an empty string if not connected.
|
|
474
|
+
*/
|
|
475
|
+
get downloadUrl() {
|
|
476
|
+
return this.#session?.downloadUrl ?? "";
|
|
477
|
+
}
|
|
478
|
+
/**
|
|
479
|
+
* The upload URL template from the session object, or an empty string if not connected.
|
|
480
|
+
*/
|
|
481
|
+
get uploadUrl() {
|
|
482
|
+
return this.#session?.uploadUrl ?? "";
|
|
483
|
+
}
|
|
484
|
+
/**
|
|
485
|
+
* The event source URL template from the session object, or an empty string if not connected.
|
|
486
|
+
*/
|
|
487
|
+
get eventSourceUrl() {
|
|
488
|
+
return this.#session?.eventSourceUrl ?? "";
|
|
489
|
+
}
|
|
490
|
+
/**
|
|
491
|
+
* Download a file from the server.
|
|
492
|
+
*
|
|
493
|
+
* @param accountId The id of the account to which the record with the blobId belongs.
|
|
494
|
+
* @param blobId The blobId representing the data of the file to download.
|
|
495
|
+
* @param name The name for the file; the server MUST return this as the filename if it sets a Content-Disposition header.
|
|
496
|
+
* @param type The type for the server to set in the Content-Type header of the response; the blobId only represents the binary data and does not have a content-type innately associated with it.
|
|
497
|
+
* @param signal Optional AbortSignal to cancel the request.
|
|
498
|
+
* @throws Error if the client is not connected, downloadUrl is missing from session, accountId is not listed in the session's accounts.
|
|
499
|
+
* @throws JMAPRequestError for JMAP protocol errors (non-200 status codes with {@link https://www.rfc-editor.org/rfc/rfc7807.html RFC 7807} Problem Details).
|
|
500
|
+
* @throws TypeError If parsing the response fails.
|
|
501
|
+
* @throws If a network error, timeout, or other transport failure occurs.
|
|
502
|
+
* @returns A promise that resolves to a Blob containing the data of the file.
|
|
503
|
+
*/
|
|
504
|
+
async downloadFile(accountId, blobId, name, type, signal) {
|
|
505
|
+
assertConnected(this.connectionStatus);
|
|
506
|
+
const url = this.getDownloadUrl(accountId, blobId, name, type);
|
|
507
|
+
this.#logger.debug(`JMAP Client downloading file from ${url.toString()}`);
|
|
508
|
+
try {
|
|
509
|
+
const headers = mergeHeaders(this.#requestHeaders, { Accept: type });
|
|
510
|
+
return await this.#transport.get(url, {
|
|
511
|
+
headers,
|
|
512
|
+
responseType: "blob",
|
|
513
|
+
...(signal ? { signal } : {}),
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
catch (error) {
|
|
517
|
+
// Handle different types of errors
|
|
518
|
+
if (error instanceof JMAPRequestError) {
|
|
519
|
+
// JMAP request-level error (Problem Details object)
|
|
520
|
+
this.#logger.error(`Download request error: ${error.type}`, { error });
|
|
521
|
+
this.#emitter("download-error", { accountId, blobId, error });
|
|
522
|
+
}
|
|
523
|
+
else {
|
|
524
|
+
// Transport or network error
|
|
525
|
+
this.#logger.error(`File download from account ${accountId} failed`, { error });
|
|
526
|
+
this.#emitter("transport-error", { error });
|
|
527
|
+
}
|
|
528
|
+
throw error;
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
/**
|
|
532
|
+
* Upload a file to the server.
|
|
533
|
+
*
|
|
534
|
+
* @param accountId The id of the account to upload to.
|
|
535
|
+
* @param file The file data to upload. Can be a Blob, ArrayBuffer, or File.
|
|
536
|
+
* @param signal Optional AbortSignal to cancel the request.
|
|
537
|
+
* @throws Error if the file size exceeds the maxSizeUpload limit from server capabilities.
|
|
538
|
+
* @throws Error if the client is not connected, uploadUrl is missing from session, or accountId is not listed in the session's accounts.
|
|
539
|
+
* @throws JMAPRequestError for JMAP protocol errors (non-200 status codes with {@link https://www.rfc-editor.org/rfc/rfc7807.html RFC 7807} Problem Details).
|
|
540
|
+
* @throws TypeError If parsing the response fails.
|
|
541
|
+
* @throws If a network error, timeout, or other transport failure occurs.
|
|
542
|
+
* @returns A promise that resolves to the upload response from the server.
|
|
543
|
+
*/
|
|
544
|
+
async uploadFile(accountId, file, signal) {
|
|
545
|
+
assertConnected(this.connectionStatus);
|
|
546
|
+
// Check file size against server capability limit
|
|
547
|
+
const capabilities = this.serverCapabilities;
|
|
548
|
+
/* v8 ignore start -- defensive: assertConnected() above guarantees serverCapabilities is non-null */
|
|
549
|
+
if (!capabilities) {
|
|
550
|
+
throw new Error("Server capabilities not available");
|
|
551
|
+
}
|
|
552
|
+
/* v8 ignore stop */
|
|
553
|
+
const coreCapabilities = capabilities[CORE_CAPABILITY_URI];
|
|
554
|
+
const maxSize = coreCapabilities.maxSizeUpload;
|
|
555
|
+
const fileSize = file instanceof ArrayBuffer ? file.byteLength : file.size;
|
|
556
|
+
if (fileSize > maxSize) {
|
|
557
|
+
throw new Error(`File size (${fileSize}) exceeds server's maximum upload size (${maxSize})`);
|
|
558
|
+
}
|
|
559
|
+
// Get the file type, defaulting to application/octet-stream for ArrayBuffer or empty type
|
|
560
|
+
const fileType = file instanceof Blob && file.type ? file.type : "application/octet-stream";
|
|
561
|
+
const url = this.getUploadUrl(accountId);
|
|
562
|
+
this.#logger.debug(`JMAP Client uploading file to ${url.toString()}`);
|
|
563
|
+
try {
|
|
564
|
+
const { pendingCount, activeCount, concurrency } = this.#uploadLimit;
|
|
565
|
+
if (activeCount - concurrency + pendingCount >= 0) {
|
|
566
|
+
this.#logger.debug(`Preparing to queue file upload (${pendingCount} pending, ${activeCount} active of ${concurrency} max concurrent uploads)`);
|
|
567
|
+
}
|
|
568
|
+
const headers = mergeHeaders(this.#requestHeaders, { "Content-Type": fileType });
|
|
569
|
+
return await this.#uploadLimit(() => this.#transport.post(url, {
|
|
570
|
+
body: file,
|
|
571
|
+
responseType: "json",
|
|
572
|
+
...(signal ? { signal } : {}),
|
|
573
|
+
headers,
|
|
574
|
+
}));
|
|
575
|
+
}
|
|
576
|
+
catch (error) {
|
|
577
|
+
// Handle different types of errors
|
|
578
|
+
if (error instanceof JMAPRequestError) {
|
|
579
|
+
// JMAP request-level error (Problem Details object)
|
|
580
|
+
this.#logger.error(`Upload request error: ${error.type}`, { error });
|
|
581
|
+
this.#emitter("upload-error", { accountId, error });
|
|
582
|
+
}
|
|
583
|
+
else {
|
|
584
|
+
// Transport or network error
|
|
585
|
+
this.#logger.error(`File upload to account ${accountId} failed`, { error });
|
|
586
|
+
this.#emitter("transport-error", { error });
|
|
587
|
+
}
|
|
588
|
+
throw error;
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
/**
|
|
592
|
+
* Get a URL from a template in the session object.
|
|
593
|
+
*
|
|
594
|
+
* @param params The parameters for URL template validation and expansion
|
|
595
|
+
* @param params.urlTemplate The name of the URL template property in the session object
|
|
596
|
+
* @param params.options The parameter values to use for template expansion
|
|
597
|
+
* @param params.schema The Zod schema to validate the options
|
|
598
|
+
* @throws Error if the Client is not connected to a JMAP server
|
|
599
|
+
* @throws Error if the specified URL template is missing from session
|
|
600
|
+
* @throws ZodError if the options fail schema validation
|
|
601
|
+
* @returns The expanded URL
|
|
602
|
+
*/
|
|
603
|
+
#getUrlFromTemplate({ urlTemplate, options, schema, }) {
|
|
604
|
+
assertConnected(this.connectionStatus);
|
|
605
|
+
const template = this[urlTemplate];
|
|
606
|
+
if (!template) {
|
|
607
|
+
throw new Error(`Missing ${urlTemplate} in session`);
|
|
608
|
+
}
|
|
609
|
+
// Validate options against the provided schema
|
|
610
|
+
const validatedOptions = schema.parse(options);
|
|
611
|
+
// Expand the template with parameters
|
|
612
|
+
return expandUrlWithParams(template, validatedOptions);
|
|
613
|
+
}
|
|
614
|
+
/**
|
|
615
|
+
* Get the parsed download URL for a file.
|
|
616
|
+
*
|
|
617
|
+
* @param accountId The id of the account to which the record with the blobId belongs.
|
|
618
|
+
* @param blobId The blobId representing the data of the file to download.
|
|
619
|
+
* @param name The name for the file; the server MUST return this as the filename if it sets a Content-Disposition header.
|
|
620
|
+
* @param type The type for the server to set in the Content-Type header of the response; the blobId only represents the binary data and does not have a content-type innately associated with it.
|
|
621
|
+
* @throws Error if the Client is not connected to a JMAP server
|
|
622
|
+
* @throws Error if downloadUrl is missing from session
|
|
623
|
+
* @throws Error if accountId is not listed in the session's accounts
|
|
624
|
+
* @returns the download URL with variables expanded
|
|
625
|
+
*/
|
|
626
|
+
getDownloadUrl(accountId, blobId, name, type) {
|
|
627
|
+
// Define schema for download URL parameters with account validation
|
|
628
|
+
const downloadUrlSchema = z.object({
|
|
629
|
+
accountId: z.string().refine((id) => this.accounts && Object.hasOwn(this.accounts, id), {
|
|
630
|
+
message: `Account ${accountId} not found in session`,
|
|
631
|
+
}),
|
|
632
|
+
blobId: z.string(),
|
|
633
|
+
name: z.string(),
|
|
634
|
+
type: z.string(),
|
|
635
|
+
});
|
|
636
|
+
// Create options object with required parameters
|
|
637
|
+
const options = { accountId, blobId, name, type };
|
|
638
|
+
return this.#getUrlFromTemplate({
|
|
639
|
+
urlTemplate: "downloadUrl",
|
|
640
|
+
options,
|
|
641
|
+
schema: downloadUrlSchema,
|
|
642
|
+
});
|
|
643
|
+
}
|
|
644
|
+
/**
|
|
645
|
+
* Get the parsed upload URL for a file.
|
|
646
|
+
*
|
|
647
|
+
* @param accountId The id of the account to upload the file to.
|
|
648
|
+
* @throws Error if the Client is not connected to a JMAP server
|
|
649
|
+
* @throws Error if uploadUrl is missing from session
|
|
650
|
+
* @throws Error if accountId is not listed in the session's accounts
|
|
651
|
+
* @returns the upload URL with variables expanded
|
|
652
|
+
*/
|
|
653
|
+
getUploadUrl(accountId) {
|
|
654
|
+
// Define schema for upload URL parameters with account validation
|
|
655
|
+
const uploadUrlSchema = z.object({
|
|
656
|
+
accountId: z.string().refine((id) => this.accounts && Object.hasOwn(this.accounts, id), {
|
|
657
|
+
message: `Account ${accountId} not found in session`,
|
|
658
|
+
}),
|
|
659
|
+
});
|
|
660
|
+
// Create options object with required parameters
|
|
661
|
+
const options = { accountId };
|
|
662
|
+
return this.#getUrlFromTemplate({
|
|
663
|
+
urlTemplate: "uploadUrl",
|
|
664
|
+
options,
|
|
665
|
+
schema: uploadUrlSchema,
|
|
666
|
+
});
|
|
667
|
+
}
|
|
668
|
+
/**
|
|
669
|
+
* Get the parsed event source URL.
|
|
670
|
+
*
|
|
671
|
+
* @param types Either an array of event types to listen for, or "*" to listen for all types.
|
|
672
|
+
* @param closeafter Either "state" (end response after pushing a state event) or "no" (persist connection).
|
|
673
|
+
* @param ping Positive integer value in seconds. If non-zero, the server will send a ping event after this time elapses since the previous event.
|
|
674
|
+
* @throws Error if the Client is not connected to a JMAP server
|
|
675
|
+
* @throws Error if eventSourceUrl is missing from session
|
|
676
|
+
* @throws Error if ping is negative
|
|
677
|
+
* @returns the event source URL with variables expanded
|
|
678
|
+
*/
|
|
679
|
+
getEventSourceUrl(types, closeafter, ping) {
|
|
680
|
+
// Define schema for event source URL parameters
|
|
681
|
+
const eventSourceUrlSchema = z.object({
|
|
682
|
+
types: z.string(),
|
|
683
|
+
closeafter: z.enum(["state", "no"]),
|
|
684
|
+
ping: z.number().nonnegative(),
|
|
685
|
+
});
|
|
686
|
+
// Create options object with required parameters
|
|
687
|
+
const options = {
|
|
688
|
+
types: Array.isArray(types) ? types.join(",") : types,
|
|
689
|
+
closeafter,
|
|
690
|
+
ping,
|
|
691
|
+
};
|
|
692
|
+
return this.#getUrlFromTemplate({
|
|
693
|
+
urlTemplate: "eventSourceUrl",
|
|
694
|
+
options,
|
|
695
|
+
schema: eventSourceUrlSchema,
|
|
696
|
+
});
|
|
697
|
+
}
|
|
698
|
+
/**
|
|
699
|
+
* Create a new RequestBuilder instance linked to this client.
|
|
700
|
+
*
|
|
701
|
+
* The builder will have access to the client's current capabilities and session state,
|
|
702
|
+
* allowing it to validate requests and enforce server limits.
|
|
703
|
+
*
|
|
704
|
+
* @returns A new RequestBuilder instance
|
|
705
|
+
*/
|
|
706
|
+
createRequestBuilder() {
|
|
707
|
+
return new RequestBuilder(this, { logger: this.#logger, emitter: this.#emitter });
|
|
708
|
+
}
|
|
709
|
+
/**
|
|
710
|
+
* Send an API request to the JMAP server.
|
|
711
|
+
*
|
|
712
|
+
* @param jmapRequest The request builder instance.
|
|
713
|
+
* @param signal Optional AbortSignal to cancel the request.
|
|
714
|
+
* @throws Error if the client is not connected, disconnecting, or failed to connect.
|
|
715
|
+
* @throws JMAPRequestError for JMAP protocol errors (non-200 status codes with {@link https://www.rfc-editor.org/rfc/rfc7807.html RFC 7807} Problem Details).
|
|
716
|
+
* @throws TypeError If parsing the response fails.
|
|
717
|
+
* @throws If a network error, timeout, or other transport failure occurs.
|
|
718
|
+
* @returns The parsed response from the server.
|
|
719
|
+
*/
|
|
720
|
+
async sendAPIRequest(jmapRequest, signal) {
|
|
721
|
+
if (this.connectionStatus === "disconnected" || this.connectionStatus === "disconnecting") {
|
|
722
|
+
this.#logger.error(`Failed to send API request because the JMAP Client is ${this.connectionStatus}`);
|
|
723
|
+
throw new Error(`Cannot send API request, client ${this.connectionStatus}`);
|
|
724
|
+
}
|
|
725
|
+
await this.#awaitPendingConnection("sending API request", (error) => {
|
|
726
|
+
this.#logger.error("Failed to send API request because the JMAP Client failed to connect", { error });
|
|
727
|
+
throw new Error("Failed to send API request, client failed to connect");
|
|
728
|
+
});
|
|
729
|
+
assertConnected(this.connectionStatus);
|
|
730
|
+
try {
|
|
731
|
+
const { pendingCount, activeCount, concurrency } = this.#requestLimit;
|
|
732
|
+
if (activeCount - concurrency + pendingCount >= 0) {
|
|
733
|
+
this.#logger.debug(`Preparing to queue JMAP API request (${pendingCount} pending, ${activeCount} active of ${concurrency} max concurrent requests)`);
|
|
734
|
+
}
|
|
735
|
+
this.#logger.info("Sending JMAP API request");
|
|
736
|
+
const result = await this.#requestLimit(async () => {
|
|
737
|
+
const { body, headers } = await jmapRequest.serialize();
|
|
738
|
+
// Merge client headers with serialisation headers using clean pattern
|
|
739
|
+
const finalHeaders = mergeHeaders(this.#requestHeaders, headers);
|
|
740
|
+
return this.#transport.post(this.apiUrl, {
|
|
741
|
+
body,
|
|
742
|
+
headers: finalHeaders,
|
|
743
|
+
responseType: "json",
|
|
744
|
+
...(signal ? { signal } : {}),
|
|
745
|
+
});
|
|
746
|
+
});
|
|
747
|
+
this.#logger.info("API response received, checking session state");
|
|
748
|
+
if (this.#sessionState !== result.sessionState) {
|
|
749
|
+
this.#logger.warn("JMAP Server session state has changed; client may be out of sync. Reconnection recommended.");
|
|
750
|
+
this.#emitter("session-stale", {
|
|
751
|
+
oldSessionState: this.#sessionState,
|
|
752
|
+
newSessionState: result.sessionState,
|
|
753
|
+
});
|
|
754
|
+
}
|
|
755
|
+
else {
|
|
756
|
+
this.#logger.debug("JMAP Server session state is unchanged");
|
|
757
|
+
}
|
|
758
|
+
const methodResponses = this.#responseFactory.createInvocations(result.methodResponses, jmapRequest.reverseIdMap);
|
|
759
|
+
return { methodResponses, sessionState: result.sessionState, createdIds: result.createdIds ?? {} };
|
|
760
|
+
}
|
|
761
|
+
catch (error) {
|
|
762
|
+
// Handle different types of errors
|
|
763
|
+
if (error instanceof JMAPRequestError) {
|
|
764
|
+
// JMAP request-level error
|
|
765
|
+
this.#logger.error(`JMAP request error: ${error.type}`, { error });
|
|
766
|
+
this.#emitter("request-error", { error });
|
|
767
|
+
}
|
|
768
|
+
else {
|
|
769
|
+
// Transport or network error
|
|
770
|
+
this.#logger.error("API request failed", { error });
|
|
771
|
+
this.#emitter("transport-error", { error });
|
|
772
|
+
}
|
|
773
|
+
throw error;
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
//# sourceMappingURL=jmap-client.js.map
|