@mdbase-dev/connect 0.1.0-beta.84 → 0.1.0-beta.85
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/README.md +33 -6
- package/dist/advanced.d.ts +1 -1
- package/dist/application-session.d.ts +31 -10
- package/dist/application-session.js +278 -64
- package/dist/authorization-refresh.d.ts +14 -0
- package/dist/authorization-refresh.js +41 -0
- package/dist/authorization-url.d.ts +1 -0
- package/dist/authorization-url.js +9 -0
- package/dist/browser/integrity.json +1 -1
- package/dist/browser/mdbase-connect.min.js +3 -3
- package/dist/browser/mdbase-connect.min.js.sha384 +1 -1
- package/dist/connect.d.ts +4 -1
- package/dist/connect.js +33 -26
- package/dist/connection-file-transport.d.ts +3 -0
- package/dist/connection-file-transport.js +59 -16
- package/dist/connection-transport-internals.d.ts +2 -0
- package/dist/connection-transport.d.ts +3 -1
- package/dist/connection-transport.js +83 -78
- package/dist/grant-key-leases.d.ts +24 -0
- package/dist/grant-key-leases.js +144 -0
- package/dist/hosted-file-request.d.ts +2 -2
- package/dist/hosted-file-request.js +4 -4
- package/dist/internal-types.d.ts +2 -0
- package/dist/local-file-transport.d.ts +8 -2
- package/dist/local-file-transport.js +71 -20
- package/dist/mdbase-connect.d.ts +1 -0
- package/dist/mdbase-connect.js +3 -0
- package/dist/outcomes.d.ts +12 -12
- package/dist/outcomes.js +6 -0
- package/dist/pending-recovery-transports.d.ts +1 -0
- package/dist/pending-recovery-transports.js +7 -3
- package/dist/retired-grant-keys.d.ts +9 -0
- package/dist/retired-grant-keys.js +56 -0
- package/dist/session.d.ts +36 -7
- package/dist/session.js +287 -34
- package/dist/shared-operation.d.ts +7 -0
- package/dist/shared-operation.js +41 -0
- package/dist/token-response.d.ts +2 -1
- package/dist/token-response.js +2 -1
- package/package.json +2 -2
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { pendingMutationKeyHandles, pendingMutationsUseKey } from "./pending-recovery-transports.js";
|
|
2
|
+
import { parseStored } from "./runtime-utils.js";
|
|
3
|
+
export function storedTokenKeyHandles(token, additional) {
|
|
4
|
+
const handles = new Set();
|
|
5
|
+
if (additional)
|
|
6
|
+
handles.add(additional);
|
|
7
|
+
if (token?.keyHandle)
|
|
8
|
+
handles.add(token.keyHandle);
|
|
9
|
+
if (Array.isArray(token?.retiredKeyHandles)) {
|
|
10
|
+
for (const handle of token.retiredKeyHandles) {
|
|
11
|
+
if (typeof handle === "string" && handle.length > 0)
|
|
12
|
+
handles.add(handle);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
return handles;
|
|
16
|
+
}
|
|
17
|
+
export function planRetiredGrantKeys(storage, pendingMutationKey, previous, current, leased = [], retainAll = false) {
|
|
18
|
+
const retained = [];
|
|
19
|
+
const disposable = [];
|
|
20
|
+
const candidates = storedTokenKeyHandles(previous);
|
|
21
|
+
for (const handle of pendingMutationKeyHandles(storage, pendingMutationKey)) {
|
|
22
|
+
candidates.add(handle);
|
|
23
|
+
}
|
|
24
|
+
const leasedHandles = new Set(leased);
|
|
25
|
+
for (const handle of leasedHandles)
|
|
26
|
+
candidates.add(handle);
|
|
27
|
+
for (const handle of candidates) {
|
|
28
|
+
if (handle === current)
|
|
29
|
+
continue;
|
|
30
|
+
(retainAll || leasedHandles.has(handle) || pendingMutationsUseKey(storage, pendingMutationKey, handle)
|
|
31
|
+
? retained
|
|
32
|
+
: disposable).push(handle);
|
|
33
|
+
}
|
|
34
|
+
return { retained, disposable };
|
|
35
|
+
}
|
|
36
|
+
export function removeRetiredGrantKeyMetadata(storage, tokenKey, handle) {
|
|
37
|
+
const token = parseStored(storage.getItem(tokenKey));
|
|
38
|
+
if (!token || !Array.isArray(token.retiredKeyHandles))
|
|
39
|
+
return;
|
|
40
|
+
const retiredKeyHandles = token.retiredKeyHandles.filter((candidate) => candidate !== handle);
|
|
41
|
+
if (retiredKeyHandles.length === token.retiredKeyHandles.length)
|
|
42
|
+
return;
|
|
43
|
+
if (retiredKeyHandles.length > 0)
|
|
44
|
+
token.retiredKeyHandles = retiredKeyHandles;
|
|
45
|
+
else
|
|
46
|
+
delete token.retiredKeyHandles;
|
|
47
|
+
storage.setItem(tokenKey, JSON.stringify(token));
|
|
48
|
+
}
|
|
49
|
+
export async function deleteUnusedGrantKey(storage, keyStore, pendingMutationKey, tokenKey, handle) {
|
|
50
|
+
const current = parseStored(storage.getItem(tokenKey));
|
|
51
|
+
if (current?.keyHandle === handle
|
|
52
|
+
|| pendingMutationsUseKey(storage, pendingMutationKey, handle))
|
|
53
|
+
return;
|
|
54
|
+
await keyStore.delete(handle);
|
|
55
|
+
removeRetiredGrantKeyMetadata(storage, tokenKey, handle);
|
|
56
|
+
}
|
package/dist/session.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { CollectionOperation, JsonObject } from "@mdbase-dev/connect-protocol";
|
|
1
|
+
import type { CollectionOperation, ConnectProblem, JsonObject } from "@mdbase-dev/connect-protocol";
|
|
2
2
|
import { MdbaseConnection, type MdbaseAuthorizationOutcome, type MdbaseAuthorizationResult, type MdbaseAuthorizeOptions } from "./connection.js";
|
|
3
3
|
import type { MdbaseAuthorizationCapabilities, MdbaseConnectionInfo } from "./connection-types.js";
|
|
4
4
|
import { type AuthorizationProblemCode, type ConnectOutcome, type SessionProblemCode } from "./outcomes.js";
|
|
@@ -19,6 +19,19 @@ export interface MdbaseSessionOptions {
|
|
|
19
19
|
}
|
|
20
20
|
export type MdbaseUnavailableReason = "not_authorized" | "authorization_lost" | "invalid_stored_grant";
|
|
21
21
|
export type MdbaseSessionSnapshot<Frontmatter extends JsonObject = JsonObject> = {
|
|
22
|
+
status: "not_started";
|
|
23
|
+
connections: MdbaseConnectionInfo[];
|
|
24
|
+
} | {
|
|
25
|
+
status: "starting";
|
|
26
|
+
connections: MdbaseConnectionInfo[];
|
|
27
|
+
} | {
|
|
28
|
+
status: "start_failed";
|
|
29
|
+
problem: ConnectProblem<SessionProblemCode>;
|
|
30
|
+
connections: MdbaseConnectionInfo[];
|
|
31
|
+
} | {
|
|
32
|
+
status: "destroyed";
|
|
33
|
+
connections: MdbaseConnectionInfo[];
|
|
34
|
+
} | {
|
|
22
35
|
status: "unselected";
|
|
23
36
|
connections: MdbaseConnectionInfo[];
|
|
24
37
|
} | {
|
|
@@ -48,22 +61,30 @@ export declare class MdbaseSession<Frontmatter extends JsonObject = JsonObject>
|
|
|
48
61
|
private activeCollectionId;
|
|
49
62
|
private unavailableReason;
|
|
50
63
|
private started;
|
|
64
|
+
private destroyed;
|
|
51
65
|
private refreshing;
|
|
52
66
|
private refreshQueued;
|
|
53
67
|
private transactionDepth;
|
|
54
|
-
private
|
|
68
|
+
private lifecycleGeneration;
|
|
69
|
+
private readonly operationControllers;
|
|
70
|
+
private startOperation;
|
|
71
|
+
private readonly callbackCompletions;
|
|
55
72
|
constructor(connect: MdbaseSessionConnect<Frontmatter>, options: MdbaseSessionOptions);
|
|
56
73
|
start(options?: ConnectRequestOptions): Promise<ConnectOutcome<MdbaseSessionSnapshot<Frontmatter>, SessionProblemCode>>;
|
|
74
|
+
private beginStart;
|
|
75
|
+
private waitForStart;
|
|
76
|
+
private performStart;
|
|
57
77
|
destroy(): void;
|
|
78
|
+
private stopLifecycleSubscriptions;
|
|
58
79
|
getSnapshot(): MdbaseSessionSnapshot<Frontmatter>;
|
|
59
80
|
subscribe(listener: () => void): () => void;
|
|
60
81
|
select(collectionId: string, options?: {
|
|
61
82
|
history?: MdbaseSelectionHistory;
|
|
62
|
-
}): ConnectOutcome<MdbaseConnection<Frontmatter>,
|
|
83
|
+
}): ConnectOutcome<MdbaseConnection<Frontmatter>, SessionProblemCode>;
|
|
63
84
|
clearSelection(options?: {
|
|
64
85
|
history?: MdbaseSelectionHistory;
|
|
65
|
-
}): void
|
|
66
|
-
forget(collectionId: string): void
|
|
86
|
+
}): ConnectOutcome<void, SessionProblemCode>;
|
|
87
|
+
forget(collectionId: string): ConnectOutcome<void, SessionProblemCode>;
|
|
67
88
|
authorize(target: "choose" | "selected" | {
|
|
68
89
|
collectionId: string;
|
|
69
90
|
}, options?: Omit<MdbaseAuthorizeOptions, "operations" | "returnTo" | "target">): Promise<ConnectOutcome<MdbaseAuthorizationOutcome<Frontmatter>, SessionProblemCode>>;
|
|
@@ -71,8 +92,16 @@ export declare class MdbaseSession<Frontmatter extends JsonObject = JsonObject>
|
|
|
71
92
|
kind: "unchanged";
|
|
72
93
|
connection: MdbaseConnection<Frontmatter>;
|
|
73
94
|
}, SessionProblemCode>>;
|
|
74
|
-
handleAuthorizationCallback(callbackUrl: string, options?: ConnectRequestOptions): Promise<ConnectOutcome<MdbaseConnection<Frontmatter>,
|
|
95
|
+
handleAuthorizationCallback(callbackUrl: string, options?: ConnectRequestOptions): Promise<ConnectOutcome<MdbaseConnection<Frontmatter>, SessionProblemCode>>;
|
|
96
|
+
private completeAuthorizationCallback;
|
|
97
|
+
private beginCallbackCompletion;
|
|
98
|
+
private waitForCallbackCompletion;
|
|
99
|
+
private lifecycleProblem;
|
|
100
|
+
private beginLifecycleOperation;
|
|
101
|
+
private lifecycleCurrent;
|
|
102
|
+
private destroyedOutcome;
|
|
75
103
|
private autoSelectOnlyConnection;
|
|
76
104
|
private refresh;
|
|
77
|
-
private
|
|
105
|
+
private publishCurrent;
|
|
106
|
+
private publish;
|
|
78
107
|
}
|
package/dist/session.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import { authorizationReturnToFromProblem, isAuthorizationCallbackUrl } from "./authorization-url.js";
|
|
2
|
-
import { connectProblem } from "./errors.js";
|
|
1
|
+
import { authorizationCallbackState, authorizationReturnToFromProblem, isAuthorizationCallbackUrl } from "./authorization-url.js";
|
|
2
|
+
import { MdbaseConnectError, connectProblem } from "./errors.js";
|
|
3
3
|
import { DEFAULT_OPERATIONS } from "./internal-types.js";
|
|
4
4
|
import { uniqueOperations } from "./operation-helpers.js";
|
|
5
5
|
import { connectFailure, connectSuccess } from "./outcomes.js";
|
|
6
|
+
import { DEFAULT_REQUEST_TIMEOUT_MS, DEFAULT_STARTUP_TIMEOUT_MS, requestAbortReason, withRequestBudget } from "./request-budget.js";
|
|
6
7
|
export class MdbaseSession {
|
|
7
8
|
connect;
|
|
8
9
|
operations;
|
|
@@ -17,39 +18,139 @@ export class MdbaseSession {
|
|
|
17
18
|
activeCollectionId = null;
|
|
18
19
|
unavailableReason = "not_authorized";
|
|
19
20
|
started = false;
|
|
21
|
+
destroyed = false;
|
|
20
22
|
refreshing = false;
|
|
21
23
|
refreshQueued = false;
|
|
22
24
|
transactionDepth = 0;
|
|
23
|
-
|
|
25
|
+
lifecycleGeneration = 0;
|
|
26
|
+
operationControllers = new Set();
|
|
27
|
+
startOperation = null;
|
|
28
|
+
callbackCompletions = new Map();
|
|
24
29
|
constructor(connect, options) {
|
|
25
30
|
this.connect = connect;
|
|
26
31
|
this.operations = uniqueOperations(options.operations ?? DEFAULT_OPERATIONS);
|
|
27
32
|
this.selection = options.selection;
|
|
28
33
|
this.autoSelect = options.autoSelect ?? "only";
|
|
29
|
-
this.snapshot = { status: "
|
|
30
|
-
this.refresh();
|
|
34
|
+
this.snapshot = { status: "not_started", connections: [] };
|
|
31
35
|
}
|
|
32
|
-
|
|
33
|
-
if (
|
|
36
|
+
start(options) {
|
|
37
|
+
if (this.destroyed) {
|
|
38
|
+
return Promise.resolve(connectFailure(connectProblem("session_destroyed", "This session has been destroyed.")));
|
|
39
|
+
}
|
|
40
|
+
if (this.started && !this.startOperation) {
|
|
41
|
+
return Promise.resolve(connectSuccess(this.snapshot));
|
|
42
|
+
}
|
|
43
|
+
const operation = this.startOperation ?? this.beginStart();
|
|
44
|
+
return this.waitForStart(operation, options);
|
|
45
|
+
}
|
|
46
|
+
beginStart() {
|
|
47
|
+
const controller = new AbortController();
|
|
48
|
+
const generation = ++this.lifecycleGeneration;
|
|
49
|
+
this.publish({ status: "starting", connections: this.connect.connections() });
|
|
50
|
+
const operation = {
|
|
51
|
+
promise: this.performStart({ signal: controller.signal, timeoutMs: null }, generation),
|
|
52
|
+
controller,
|
|
53
|
+
waiters: 0,
|
|
54
|
+
generation
|
|
55
|
+
};
|
|
56
|
+
this.startOperation = operation;
|
|
57
|
+
const settled = () => {
|
|
58
|
+
if (this.startOperation === operation)
|
|
59
|
+
this.startOperation = null;
|
|
60
|
+
};
|
|
61
|
+
operation.promise.then(settled, settled);
|
|
62
|
+
return operation;
|
|
63
|
+
}
|
|
64
|
+
async waitForStart(operation, options) {
|
|
65
|
+
operation.waiters += 1;
|
|
66
|
+
try {
|
|
67
|
+
return await withRequestBudget(options, DEFAULT_STARTUP_TIMEOUT_MS, () => operation.promise);
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
if (error instanceof MdbaseConnectError) {
|
|
71
|
+
return connectFailure(error.problem);
|
|
72
|
+
}
|
|
73
|
+
throw error;
|
|
74
|
+
}
|
|
75
|
+
finally {
|
|
76
|
+
operation.waiters -= 1;
|
|
77
|
+
if (operation.waiters === 0 && this.startOperation === operation) {
|
|
78
|
+
operation.controller.abort();
|
|
79
|
+
this.startOperation = null;
|
|
80
|
+
this.lifecycleGeneration += 1;
|
|
81
|
+
this.stopLifecycleSubscriptions();
|
|
82
|
+
this.started = false;
|
|
83
|
+
this.publish({ status: "not_started", connections: this.connect.connections() });
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
async performStart(options, generation) {
|
|
88
|
+
try {
|
|
34
89
|
this.started = true;
|
|
35
90
|
this.stopConnections = this.connect.onConnectionsChange(() => this.refresh());
|
|
36
91
|
this.stopSelection = this.selection.subscribe(() => {
|
|
37
92
|
this.unavailableReason = "not_authorized";
|
|
38
93
|
this.refresh();
|
|
39
94
|
});
|
|
95
|
+
const callback = this.selection.authorizationCallback();
|
|
96
|
+
if (callback) {
|
|
97
|
+
const completed = await this.completeAuthorizationCallback(callback, options);
|
|
98
|
+
if (!completed.ok) {
|
|
99
|
+
if (generation === this.lifecycleGeneration && !this.destroyed) {
|
|
100
|
+
this.stopLifecycleSubscriptions();
|
|
101
|
+
this.started = false;
|
|
102
|
+
this.publish({
|
|
103
|
+
status: "start_failed",
|
|
104
|
+
problem: completed.problem,
|
|
105
|
+
connections: this.connect.connections()
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
return completed;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
else
|
|
112
|
+
this.autoSelectOnlyConnection();
|
|
113
|
+
if (generation !== this.lifecycleGeneration || this.destroyed) {
|
|
114
|
+
return connectFailure(connectProblem("session_destroyed", "This session has been destroyed."));
|
|
115
|
+
}
|
|
116
|
+
this.publishCurrent();
|
|
117
|
+
return connectSuccess(this.snapshot);
|
|
40
118
|
}
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
119
|
+
catch (error) {
|
|
120
|
+
if (error instanceof MdbaseConnectError) {
|
|
121
|
+
const problem = error.problem;
|
|
122
|
+
if (generation === this.lifecycleGeneration && !this.destroyed) {
|
|
123
|
+
this.stopLifecycleSubscriptions();
|
|
124
|
+
this.started = false;
|
|
125
|
+
this.publish({ status: "start_failed", problem, connections: this.connect.connections() });
|
|
126
|
+
}
|
|
127
|
+
return connectFailure(problem);
|
|
128
|
+
}
|
|
129
|
+
if (generation === this.lifecycleGeneration && !this.destroyed) {
|
|
130
|
+
this.stopLifecycleSubscriptions();
|
|
131
|
+
this.started = false;
|
|
132
|
+
this.publish({ status: "not_started", connections: this.connect.connections() });
|
|
133
|
+
}
|
|
134
|
+
throw error;
|
|
46
135
|
}
|
|
47
|
-
else
|
|
48
|
-
this.autoSelectOnlyConnection();
|
|
49
|
-
this.refresh();
|
|
50
|
-
return connectSuccess(this.snapshot);
|
|
51
136
|
}
|
|
52
137
|
destroy() {
|
|
138
|
+
if (this.destroyed)
|
|
139
|
+
return;
|
|
140
|
+
this.lifecycleGeneration += 1;
|
|
141
|
+
this.startOperation?.controller.abort();
|
|
142
|
+
this.startOperation = null;
|
|
143
|
+
for (const controller of this.operationControllers)
|
|
144
|
+
controller.abort();
|
|
145
|
+
this.operationControllers.clear();
|
|
146
|
+
this.stopLifecycleSubscriptions();
|
|
147
|
+
this.started = false;
|
|
148
|
+
this.destroyed = true;
|
|
149
|
+
this.callbackCompletions.clear();
|
|
150
|
+
this.publish({ status: "destroyed", connections: [] });
|
|
151
|
+
this.listeners.clear();
|
|
152
|
+
}
|
|
153
|
+
stopLifecycleSubscriptions() {
|
|
53
154
|
this.stopConnections?.();
|
|
54
155
|
this.stopSelection?.();
|
|
55
156
|
this.stopActiveConnection?.();
|
|
@@ -57,7 +158,6 @@ export class MdbaseSession {
|
|
|
57
158
|
this.stopSelection = undefined;
|
|
58
159
|
this.stopActiveConnection = undefined;
|
|
59
160
|
this.activeCollectionId = null;
|
|
60
|
-
this.started = false;
|
|
61
161
|
}
|
|
62
162
|
getSnapshot() {
|
|
63
163
|
return this.snapshot;
|
|
@@ -67,6 +167,9 @@ export class MdbaseSession {
|
|
|
67
167
|
return () => this.listeners.delete(listener);
|
|
68
168
|
}
|
|
69
169
|
select(collectionId, options = {}) {
|
|
170
|
+
const lifecycle = this.lifecycleProblem();
|
|
171
|
+
if (lifecycle)
|
|
172
|
+
return connectFailure(lifecycle);
|
|
70
173
|
const connection = this.connect.connection(collectionId);
|
|
71
174
|
if (!connection) {
|
|
72
175
|
return connectFailure(connectProblem("unknown_collection", "This collection is not authorized for this application."));
|
|
@@ -77,11 +180,18 @@ export class MdbaseSession {
|
|
|
77
180
|
return connectSuccess(connection);
|
|
78
181
|
}
|
|
79
182
|
clearSelection(options = {}) {
|
|
183
|
+
const lifecycle = this.lifecycleProblem();
|
|
184
|
+
if (lifecycle)
|
|
185
|
+
return connectFailure(lifecycle);
|
|
80
186
|
this.unavailableReason = "not_authorized";
|
|
81
187
|
this.selection.select(null, options);
|
|
82
188
|
this.refresh();
|
|
189
|
+
return connectSuccess(undefined);
|
|
83
190
|
}
|
|
84
191
|
forget(collectionId) {
|
|
192
|
+
const lifecycle = this.lifecycleProblem();
|
|
193
|
+
if (lifecycle)
|
|
194
|
+
return connectFailure(lifecycle);
|
|
85
195
|
this.transactionDepth += 1;
|
|
86
196
|
try {
|
|
87
197
|
const selected = this.selection.selectedCollectionId() === collectionId;
|
|
@@ -93,8 +203,12 @@ export class MdbaseSession {
|
|
|
93
203
|
this.transactionDepth -= 1;
|
|
94
204
|
this.refresh();
|
|
95
205
|
}
|
|
206
|
+
return connectSuccess(undefined);
|
|
96
207
|
}
|
|
97
208
|
async authorize(target, options = {}) {
|
|
209
|
+
const lifecycle = this.lifecycleProblem();
|
|
210
|
+
if (lifecycle)
|
|
211
|
+
return connectFailure(lifecycle);
|
|
98
212
|
const selectedCollectionId = this.selection.selectedCollectionId();
|
|
99
213
|
if (target === "selected" && !selectedCollectionId) {
|
|
100
214
|
return connectFailure(connectProblem("collection_not_selected", "Choose a collection before updating its access."));
|
|
@@ -104,27 +218,34 @@ export class MdbaseSession {
|
|
|
104
218
|
: target === "selected"
|
|
105
219
|
? selectedCollectionId
|
|
106
220
|
: null;
|
|
221
|
+
const operation = this.beginLifecycleOperation(options);
|
|
107
222
|
this.transactionDepth += 1;
|
|
108
223
|
try {
|
|
109
224
|
const outcome = await this.connect.authorize({
|
|
110
|
-
...options,
|
|
225
|
+
...operation.options,
|
|
111
226
|
operations: this.operations,
|
|
112
227
|
target: targetCollectionId === null
|
|
113
228
|
? { kind: "choose" }
|
|
114
229
|
: { kind: "collection", collectionId: targetCollectionId },
|
|
115
230
|
returnTo: this.selection.authorizationReturnTo()
|
|
116
231
|
});
|
|
232
|
+
if (!this.lifecycleCurrent(operation.generation))
|
|
233
|
+
return this.destroyedOutcome(outcome);
|
|
117
234
|
if (outcome.ok && outcome.value.kind === "connected") {
|
|
118
235
|
this.selection.finishAuthorization(outcome.value.returnTo, outcome.value.connection.collectionId);
|
|
119
236
|
}
|
|
120
237
|
return outcome;
|
|
121
238
|
}
|
|
122
239
|
finally {
|
|
240
|
+
operation.dispose();
|
|
123
241
|
this.transactionDepth -= 1;
|
|
124
242
|
this.refresh();
|
|
125
243
|
}
|
|
126
244
|
}
|
|
127
245
|
async ensureOperations(requiredOperations, options) {
|
|
246
|
+
const lifecycle = this.lifecycleProblem();
|
|
247
|
+
if (lifecycle)
|
|
248
|
+
return connectFailure(lifecycle);
|
|
128
249
|
const current = this.snapshot;
|
|
129
250
|
if (current.status !== "ready") {
|
|
130
251
|
return connectFailure(connectProblem("collection_not_ready", "Choose an authorized collection first."));
|
|
@@ -134,36 +255,82 @@ export class MdbaseSession {
|
|
|
134
255
|
if (capabilities.sufficient) {
|
|
135
256
|
return connectSuccess({ kind: "unchanged", connection: current.connection });
|
|
136
257
|
}
|
|
258
|
+
const operation = this.beginLifecycleOperation(options);
|
|
137
259
|
this.transactionDepth += 1;
|
|
138
260
|
try {
|
|
139
261
|
const outcome = await this.connect.authorize({
|
|
140
|
-
...options,
|
|
262
|
+
...operation.options,
|
|
141
263
|
operations: uniqueOperations([...capabilities.grantedOperations, ...capabilities.missingOperations]),
|
|
142
264
|
target: { kind: "collection", collectionId: current.collectionId },
|
|
143
265
|
returnTo: this.selection.authorizationReturnTo()
|
|
144
266
|
});
|
|
267
|
+
if (!this.lifecycleCurrent(operation.generation))
|
|
268
|
+
return this.destroyedOutcome(outcome);
|
|
145
269
|
if (outcome.ok && outcome.value.kind === "connected") {
|
|
146
270
|
this.selection.finishAuthorization(outcome.value.returnTo, outcome.value.connection.collectionId);
|
|
147
271
|
}
|
|
148
272
|
return outcome;
|
|
149
273
|
}
|
|
150
274
|
finally {
|
|
275
|
+
operation.dispose();
|
|
151
276
|
this.transactionDepth -= 1;
|
|
152
277
|
this.refresh();
|
|
153
278
|
}
|
|
154
279
|
}
|
|
155
280
|
handleAuthorizationCallback(callbackUrl, options) {
|
|
281
|
+
const lifecycle = this.lifecycleProblem();
|
|
282
|
+
if (lifecycle)
|
|
283
|
+
return Promise.resolve(connectFailure(lifecycle));
|
|
156
284
|
if (!isAuthorizationCallbackUrl(callbackUrl)) {
|
|
157
285
|
return Promise.resolve(connectFailure(connectProblem("invalid_callback", "This URL is not an authorization callback.")));
|
|
158
286
|
}
|
|
159
|
-
|
|
160
|
-
|
|
287
|
+
return this.completeAuthorizationCallback(callbackUrl, options);
|
|
288
|
+
}
|
|
289
|
+
completeAuthorizationCallback(callbackUrl, options) {
|
|
290
|
+
if (options?.signal?.aborted) {
|
|
291
|
+
const error = requestAbortReason(options.signal);
|
|
292
|
+
return Promise.resolve(connectFailure(error.problem));
|
|
293
|
+
}
|
|
294
|
+
const state = authorizationCallbackState(callbackUrl);
|
|
295
|
+
if (!state) {
|
|
296
|
+
return Promise.resolve(connectFailure(connectProblem("invalid_callback", "Authorization callback is missing its state.")));
|
|
297
|
+
}
|
|
298
|
+
const operation = this.callbackCompletions.get(state)
|
|
299
|
+
?? this.beginCallbackCompletion(callbackUrl, state);
|
|
300
|
+
return this.waitForCallbackCompletion(operation, options);
|
|
301
|
+
}
|
|
302
|
+
beginCallbackCompletion(callbackUrl, state) {
|
|
303
|
+
const controller = new AbortController();
|
|
304
|
+
const generation = this.lifecycleGeneration;
|
|
161
305
|
this.transactionDepth += 1;
|
|
162
|
-
|
|
306
|
+
this.operationControllers.add(controller);
|
|
307
|
+
const operation = {
|
|
308
|
+
state,
|
|
309
|
+
controller,
|
|
310
|
+
waiters: 0,
|
|
311
|
+
settled: false,
|
|
312
|
+
abandoned: false,
|
|
313
|
+
promise: undefined
|
|
314
|
+
};
|
|
315
|
+
const completion = this.connect.completeAuthorization(callbackUrl, {
|
|
316
|
+
signal: controller.signal,
|
|
317
|
+
timeoutMs: null
|
|
318
|
+
}).catch((error) => {
|
|
319
|
+
if (error instanceof MdbaseConnectError) {
|
|
320
|
+
return connectFailure(error.problem);
|
|
321
|
+
}
|
|
322
|
+
throw error;
|
|
323
|
+
});
|
|
324
|
+
operation.promise = completion
|
|
163
325
|
.then((outcome) => {
|
|
326
|
+
if (generation !== this.lifecycleGeneration || this.destroyed) {
|
|
327
|
+
return this.destroyedOutcome(outcome);
|
|
328
|
+
}
|
|
164
329
|
if (!outcome.ok) {
|
|
165
|
-
|
|
166
|
-
|
|
330
|
+
if (!operation.abandoned && outcome.problem.recovery !== "retry") {
|
|
331
|
+
const returnTo = authorizationReturnToFromProblem(outcome.problem);
|
|
332
|
+
this.selection.clearAuthorizationCallback(returnTo);
|
|
333
|
+
}
|
|
167
334
|
this.refresh();
|
|
168
335
|
return outcome;
|
|
169
336
|
}
|
|
@@ -174,18 +341,87 @@ export class MdbaseSession {
|
|
|
174
341
|
return connectSuccess(result.connection, outcome.diagnostics);
|
|
175
342
|
})
|
|
176
343
|
.catch((error) => {
|
|
177
|
-
this.
|
|
178
|
-
|
|
344
|
+
if (generation !== this.lifecycleGeneration || this.destroyed) {
|
|
345
|
+
return connectFailure(connectProblem("session_destroyed", "This session was destroyed before authorization completion finished."));
|
|
346
|
+
}
|
|
347
|
+
if (!operation.abandoned) {
|
|
348
|
+
this.selection.clearAuthorizationCallback();
|
|
349
|
+
this.refresh();
|
|
350
|
+
}
|
|
179
351
|
throw error;
|
|
180
352
|
})
|
|
181
353
|
.finally(() => {
|
|
354
|
+
operation.settled = true;
|
|
355
|
+
this.operationControllers.delete(controller);
|
|
182
356
|
this.transactionDepth -= 1;
|
|
183
357
|
this.refresh();
|
|
184
|
-
if (this.
|
|
185
|
-
this.
|
|
358
|
+
if (this.callbackCompletions.get(state) === operation) {
|
|
359
|
+
this.callbackCompletions.delete(state);
|
|
360
|
+
}
|
|
186
361
|
});
|
|
187
|
-
this.
|
|
188
|
-
return
|
|
362
|
+
this.callbackCompletions.set(state, operation);
|
|
363
|
+
return operation;
|
|
364
|
+
}
|
|
365
|
+
async waitForCallbackCompletion(operation, options) {
|
|
366
|
+
operation.waiters += 1;
|
|
367
|
+
try {
|
|
368
|
+
return await withRequestBudget(options, DEFAULT_REQUEST_TIMEOUT_MS, () => operation.promise);
|
|
369
|
+
}
|
|
370
|
+
catch (error) {
|
|
371
|
+
if (error instanceof MdbaseConnectError) {
|
|
372
|
+
return connectFailure(error.problem);
|
|
373
|
+
}
|
|
374
|
+
throw error;
|
|
375
|
+
}
|
|
376
|
+
finally {
|
|
377
|
+
operation.waiters -= 1;
|
|
378
|
+
if (operation.waiters === 0
|
|
379
|
+
&& !operation.settled
|
|
380
|
+
&& this.callbackCompletions.get(operation.state) === operation) {
|
|
381
|
+
operation.abandoned = true;
|
|
382
|
+
this.callbackCompletions.delete(operation.state);
|
|
383
|
+
operation.controller.abort();
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
lifecycleProblem() {
|
|
388
|
+
if (this.destroyed) {
|
|
389
|
+
return connectProblem("session_destroyed", "This session has been destroyed.");
|
|
390
|
+
}
|
|
391
|
+
if (this.snapshot.status === "starting") {
|
|
392
|
+
return connectProblem("session_starting", "The session is still starting.");
|
|
393
|
+
}
|
|
394
|
+
if (this.snapshot.status === "start_failed")
|
|
395
|
+
return this.snapshot.problem;
|
|
396
|
+
if (!this.started) {
|
|
397
|
+
return connectProblem("session_not_started", "Start the session before using it.");
|
|
398
|
+
}
|
|
399
|
+
return null;
|
|
400
|
+
}
|
|
401
|
+
beginLifecycleOperation(options = {}) {
|
|
402
|
+
const controller = new AbortController();
|
|
403
|
+
const abort = () => controller.abort(options.signal?.reason);
|
|
404
|
+
if (options.signal?.aborted)
|
|
405
|
+
abort();
|
|
406
|
+
else
|
|
407
|
+
options.signal?.addEventListener("abort", abort, { once: true });
|
|
408
|
+
this.operationControllers.add(controller);
|
|
409
|
+
return {
|
|
410
|
+
generation: this.lifecycleGeneration,
|
|
411
|
+
options: { ...options, signal: controller.signal },
|
|
412
|
+
dispose: () => {
|
|
413
|
+
options.signal?.removeEventListener("abort", abort);
|
|
414
|
+
this.operationControllers.delete(controller);
|
|
415
|
+
}
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
lifecycleCurrent(generation) {
|
|
419
|
+
return !this.destroyed && generation === this.lifecycleGeneration;
|
|
420
|
+
}
|
|
421
|
+
destroyedOutcome(outcome) {
|
|
422
|
+
if (!outcome.ok && outcome.problem.code === "operation_outcome_unknown")
|
|
423
|
+
return outcome;
|
|
424
|
+
return connectFailure(connectProblem("session_destroyed", "This session was destroyed before the operation finished."));
|
|
189
425
|
}
|
|
190
426
|
autoSelectOnlyConnection() {
|
|
191
427
|
if (this.autoSelect !== "only" || this.selection.selectedCollectionId())
|
|
@@ -196,6 +432,11 @@ export class MdbaseSession {
|
|
|
196
432
|
}
|
|
197
433
|
}
|
|
198
434
|
refresh() {
|
|
435
|
+
if (!this.started
|
|
436
|
+
|| this.destroyed
|
|
437
|
+
|| this.snapshot.status === "starting"
|
|
438
|
+
|| this.snapshot.status === "start_failed")
|
|
439
|
+
return;
|
|
199
440
|
if (this.transactionDepth > 0) {
|
|
200
441
|
this.refreshQueued = true;
|
|
201
442
|
return;
|
|
@@ -208,14 +449,14 @@ export class MdbaseSession {
|
|
|
208
449
|
try {
|
|
209
450
|
do {
|
|
210
451
|
this.refreshQueued = false;
|
|
211
|
-
this.
|
|
452
|
+
this.publishCurrent();
|
|
212
453
|
} while (this.refreshQueued);
|
|
213
454
|
}
|
|
214
455
|
finally {
|
|
215
456
|
this.refreshing = false;
|
|
216
457
|
}
|
|
217
458
|
}
|
|
218
|
-
|
|
459
|
+
publishCurrent() {
|
|
219
460
|
const collectionId = this.selection.selectedCollectionId();
|
|
220
461
|
const connections = this.connect.connections();
|
|
221
462
|
const connection = collectionId ? this.connect.connection(collectionId) : null;
|
|
@@ -252,16 +493,28 @@ export class MdbaseSession {
|
|
|
252
493
|
connections
|
|
253
494
|
};
|
|
254
495
|
}
|
|
255
|
-
|
|
496
|
+
this.publish(next);
|
|
497
|
+
}
|
|
498
|
+
publish(snapshot) {
|
|
499
|
+
if (this.snapshot.status === "destroyed" && snapshot.status !== "destroyed")
|
|
500
|
+
return;
|
|
501
|
+
const key = sessionSnapshotKey(snapshot);
|
|
256
502
|
if (key === this.snapshotKey)
|
|
257
503
|
return;
|
|
258
|
-
this.snapshot =
|
|
504
|
+
this.snapshot = snapshot;
|
|
259
505
|
this.snapshotKey = key;
|
|
260
506
|
for (const listener of this.listeners)
|
|
261
507
|
listener();
|
|
262
508
|
}
|
|
263
509
|
}
|
|
264
510
|
function sessionSnapshotKey(snapshot) {
|
|
511
|
+
if (snapshot.status === "not_started"
|
|
512
|
+
|| snapshot.status === "starting"
|
|
513
|
+
|| snapshot.status === "destroyed")
|
|
514
|
+
return JSON.stringify(snapshot);
|
|
515
|
+
if (snapshot.status === "start_failed") {
|
|
516
|
+
return JSON.stringify({ status: snapshot.status, problem: snapshot.problem, connections: snapshot.connections });
|
|
517
|
+
}
|
|
265
518
|
if (snapshot.status === "unselected") {
|
|
266
519
|
return JSON.stringify({ status: snapshot.status, connections: snapshot.connections });
|
|
267
520
|
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { ConnectRequestOptions } from "./operation-types.js";
|
|
2
|
+
export declare class SharedOperationMap<Result> {
|
|
3
|
+
private readonly operations;
|
|
4
|
+
run(key: string, options: ConnectRequestOptions | undefined, defaultTimeoutMs: number | null, start: (signal: AbortSignal) => Promise<Result>): Promise<Result>;
|
|
5
|
+
private begin;
|
|
6
|
+
private wait;
|
|
7
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { requestAbortReason, withRequestBudget } from "./request-budget.js";
|
|
2
|
+
export class SharedOperationMap {
|
|
3
|
+
operations = new Map();
|
|
4
|
+
run(key, options, defaultTimeoutMs, start) {
|
|
5
|
+
if (options?.signal?.aborted)
|
|
6
|
+
return Promise.reject(requestAbortReason(options.signal));
|
|
7
|
+
const operation = this.operations.get(key) ?? this.begin(key, start);
|
|
8
|
+
return this.wait(key, operation, options, defaultTimeoutMs);
|
|
9
|
+
}
|
|
10
|
+
begin(key, start) {
|
|
11
|
+
const controller = new AbortController();
|
|
12
|
+
const operation = {
|
|
13
|
+
controller,
|
|
14
|
+
waiters: 0,
|
|
15
|
+
settled: false,
|
|
16
|
+
promise: undefined
|
|
17
|
+
};
|
|
18
|
+
operation.promise = Promise.resolve()
|
|
19
|
+
.then(() => start(controller.signal))
|
|
20
|
+
.finally(() => {
|
|
21
|
+
operation.settled = true;
|
|
22
|
+
if (this.operations.get(key) === operation)
|
|
23
|
+
this.operations.delete(key);
|
|
24
|
+
});
|
|
25
|
+
this.operations.set(key, operation);
|
|
26
|
+
return operation;
|
|
27
|
+
}
|
|
28
|
+
async wait(key, operation, options, defaultTimeoutMs) {
|
|
29
|
+
operation.waiters += 1;
|
|
30
|
+
try {
|
|
31
|
+
return await withRequestBudget(options, defaultTimeoutMs, () => operation.promise);
|
|
32
|
+
}
|
|
33
|
+
finally {
|
|
34
|
+
operation.waiters -= 1;
|
|
35
|
+
if (operation.waiters === 0 && !operation.settled && this.operations.get(key) === operation) {
|
|
36
|
+
this.operations.delete(key);
|
|
37
|
+
operation.controller.abort();
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|