@calimero-network/mero-js 25.3.1 → 25.3.3
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 +3 -3
- package/dist/auth/index.d.ts +11 -2
- package/dist/auth/index.js +20 -3
- package/dist/cloud/enrol-redirect.d.ts +10 -24
- package/dist/cloud/enrol-redirect.js +16 -8
- package/dist/cloud/link-state.d.ts +5 -1
- package/dist/cloud/link-state.js +28 -21
- package/dist/events/sse.d.ts +8 -0
- package/dist/events/sse.js +19 -8
- package/dist/index.browser.mjs +2 -2
- package/dist/index.browser.mjs.map +4 -4
- package/dist/index.cjs +111 -78
- package/dist/index.cjs.map +4 -4
- package/dist/index.d.ts +1 -1
- package/dist/index.mjs +111 -78
- package/dist/index.mjs.map +4 -4
- package/dist/mero-js.d.ts +2 -2
- package/dist/mero-js.js +2 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -210,10 +210,10 @@ import {
|
|
|
210
210
|
} from '@calimero-network/mero-js';
|
|
211
211
|
|
|
212
212
|
// 1. Enrol: the wallet certifies a device key this app generated and keeps.
|
|
213
|
-
// (first visit) beginDeviceEnrolment({ walletUrl, devicePublicKey, kemPublicKey, returnTo
|
|
213
|
+
// (first visit) beginDeviceEnrolment({ walletUrl, devicePublicKey, kemPublicKey, returnTo });
|
|
214
214
|
// (on return)
|
|
215
|
-
const callback = readEnrolmentCallback(
|
|
216
|
-
const enrolled = await completeDeviceEnrolment({ ...callback,
|
|
215
|
+
const callback = readEnrolmentCallback(); // null on an ordinary load
|
|
216
|
+
const enrolled = await completeDeviceEnrolment({ ...callback, devicePublicKey, kemPublicKey });
|
|
217
217
|
let session: DelegatedAccountSession = {
|
|
218
218
|
account: enrolled.account, credential: enrolled.credential, deviceSecret, relayUrl: null,
|
|
219
219
|
};
|
package/dist/auth/index.d.ts
CHANGED
|
@@ -14,12 +14,21 @@ export interface AuthLoginOptions {
|
|
|
14
14
|
registryUrl?: string;
|
|
15
15
|
packageVersion?: string;
|
|
16
16
|
}
|
|
17
|
+
export interface ParseAuthCallbackOptions {
|
|
18
|
+
/**
|
|
19
|
+
* Accept tokens this browser did not ask for, for a host that injects them itself
|
|
20
|
+
* (Calimero Desktop). Anyone who can open the page can then choose the session.
|
|
21
|
+
*/
|
|
22
|
+
allowUnsolicited?: boolean;
|
|
23
|
+
}
|
|
17
24
|
/**
|
|
18
25
|
* Parse an auth callback URL hash fragment.
|
|
19
|
-
* Returns null if access_token is missing
|
|
26
|
+
* Returns null if access_token is missing, or if the callback does not answer a
|
|
27
|
+
* login begun here with {@link buildAuthLoginUrl} (single use, expires).
|
|
20
28
|
*/
|
|
21
|
-
export declare function parseAuthCallback(url: string): AuthCallbackResult | null;
|
|
29
|
+
export declare function parseAuthCallback(url: string, options?: ParseAuthCallbackOptions): AuthCallbackResult | null;
|
|
22
30
|
/**
|
|
23
31
|
* Build the auth login URL for redirecting users to the node's auth page.
|
|
32
|
+
* Remembers the login so {@link parseAuthCallback} can match the callback to it.
|
|
24
33
|
*/
|
|
25
34
|
export declare function buildAuthLoginUrl(nodeUrl: string, opts: AuthLoginOptions): string;
|
package/dist/auth/index.js
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
|
+
import { beginState, takeState } from '../cloud/link-state.js';
|
|
2
|
+
const PENDING_KEY = 'calimero.auth.login.pending';
|
|
3
|
+
const STATE_PARAM = 'auth_state';
|
|
1
4
|
/**
|
|
2
5
|
* Parse an auth callback URL hash fragment.
|
|
3
|
-
* Returns null if access_token is missing
|
|
6
|
+
* Returns null if access_token is missing, or if the callback does not answer a
|
|
7
|
+
* login begun here with {@link buildAuthLoginUrl} (single use, expires).
|
|
4
8
|
*/
|
|
5
|
-
export function parseAuthCallback(url) {
|
|
9
|
+
export function parseAuthCallback(url, options = {}) {
|
|
6
10
|
try {
|
|
7
11
|
const hashIndex = url.indexOf('#');
|
|
8
12
|
if (hashIndex === -1)
|
|
@@ -12,6 +16,10 @@ export function parseAuthCallback(url) {
|
|
|
12
16
|
const accessToken = params.get('access_token');
|
|
13
17
|
if (!accessToken)
|
|
14
18
|
return null;
|
|
19
|
+
if (!options.allowUnsolicited && !takeState(PENDING_KEY, stateOf(url, hashIndex))) {
|
|
20
|
+
console.warn('[mero-js] ignoring auth callback tokens: they answer no login begun in this browser');
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
15
23
|
return {
|
|
16
24
|
accessToken,
|
|
17
25
|
refreshToken: params.get('refresh_token') ?? '',
|
|
@@ -25,12 +33,21 @@ export function parseAuthCallback(url) {
|
|
|
25
33
|
return null;
|
|
26
34
|
}
|
|
27
35
|
}
|
|
36
|
+
function stateOf(url, hashIndex) {
|
|
37
|
+
const queryIndex = url.indexOf('?');
|
|
38
|
+
if (queryIndex === -1 || queryIndex > hashIndex)
|
|
39
|
+
return null;
|
|
40
|
+
return new URLSearchParams(url.substring(queryIndex + 1, hashIndex)).get(STATE_PARAM);
|
|
41
|
+
}
|
|
28
42
|
/**
|
|
29
43
|
* Build the auth login URL for redirecting users to the node's auth page.
|
|
44
|
+
* Remembers the login so {@link parseAuthCallback} can match the callback to it.
|
|
30
45
|
*/
|
|
31
46
|
export function buildAuthLoginUrl(nodeUrl, opts) {
|
|
47
|
+
const callback = new URL(opts.callbackUrl);
|
|
48
|
+
callback.searchParams.set(STATE_PARAM, beginState(PENDING_KEY, {}));
|
|
32
49
|
const params = new URLSearchParams();
|
|
33
|
-
params.set('callback-url',
|
|
50
|
+
params.set('callback-url', callback.toString());
|
|
34
51
|
if (opts.permissions && opts.permissions.length > 0) {
|
|
35
52
|
params.set('permissions', opts.permissions.join(','));
|
|
36
53
|
}
|
|
@@ -44,9 +44,10 @@
|
|
|
44
44
|
* {@link completeDeviceEnrolment} does all three and throws on any of them,
|
|
45
45
|
* before the caller has anything to store. What none of them establish is that
|
|
46
46
|
* the account is the *person's* — anyone can mint a root offline and certify
|
|
47
|
-
* any public key with it
|
|
48
|
-
*
|
|
49
|
-
*
|
|
47
|
+
* any public key with it, and a device key is public once enrolled. That is
|
|
48
|
+
* what the state narrows: {@link deviceEnrolmentUrl} remembers each request, and
|
|
49
|
+
* {@link completeDeviceEnrolment} refuses an answer to a request this browser did
|
|
50
|
+
* not start, rather than to a link someone sent.
|
|
50
51
|
*/
|
|
51
52
|
import { type DeviceCredential } from '../device-cert/index.js';
|
|
52
53
|
export interface DeviceEnrolmentOptions {
|
|
@@ -72,17 +73,6 @@ export interface DeviceEnrolmentOptions {
|
|
|
72
73
|
* localhost — see {@link assertSafeReturnTo}.
|
|
73
74
|
*/
|
|
74
75
|
returnTo: string;
|
|
75
|
-
/**
|
|
76
|
-
* An opaque value echoed back unchanged, to be compared on return.
|
|
77
|
-
*
|
|
78
|
-
* Optional, and worth setting: without it, any link that lands on the app's
|
|
79
|
-
* callback with a well-formed fragment enrols an account of the sender's
|
|
80
|
-
* choosing, and everything the app then does is filed under it. Store it
|
|
81
|
-
* where the round trip survives but another origin cannot read it —
|
|
82
|
-
* `sessionStorage` — and pass it to {@link completeDeviceEnrolment} as
|
|
83
|
-
* `expectState`.
|
|
84
|
-
*/
|
|
85
|
-
state?: string;
|
|
86
76
|
}
|
|
87
77
|
/**
|
|
88
78
|
* The URL to send a person to.
|
|
@@ -90,6 +80,9 @@ export interface DeviceEnrolmentOptions {
|
|
|
90
80
|
* Built rather than documented as a string so the parameter names live in one
|
|
91
81
|
* place: a wallet and an app that disagree about them fail as a blank consent
|
|
92
82
|
* screen, which looks like an outage rather than a contract mismatch.
|
|
83
|
+
*
|
|
84
|
+
* Remembers the request under a fresh state the wallet echoes back, so
|
|
85
|
+
* {@link completeDeviceEnrolment} can match the answer to it.
|
|
93
86
|
*/
|
|
94
87
|
export declare function deviceEnrolmentUrl(options: DeviceEnrolmentOptions): string;
|
|
95
88
|
/** Send the person to the wallet to approve this device. Does not return. */
|
|
@@ -102,7 +95,7 @@ export interface DeviceEnrolmentCallback {
|
|
|
102
95
|
account: string;
|
|
103
96
|
/** The device id the wallet minted, 64 hex. */
|
|
104
97
|
device: string;
|
|
105
|
-
/**
|
|
98
|
+
/** The state the wallet echoed back from {@link deviceEnrolmentUrl}. */
|
|
106
99
|
state?: string;
|
|
107
100
|
}
|
|
108
101
|
/**
|
|
@@ -131,15 +124,6 @@ export interface CompleteDeviceEnrolmentOptions extends DeviceEnrolmentCallback
|
|
|
131
124
|
devicePublicKey: string;
|
|
132
125
|
/** The delivery key asked about, 64 hex. Checked when supplied. */
|
|
133
126
|
kemPublicKey?: string;
|
|
134
|
-
/**
|
|
135
|
-
* The {@link DeviceEnrolmentOptions.state} this app sent, if it sent one.
|
|
136
|
-
*
|
|
137
|
-
* Supplied and mismatched is a refusal. Not supplied is accepted — an app may
|
|
138
|
-
* legitimately have none to compare against, having been reloaded — and the
|
|
139
|
-
* gap is documented rather than closed here, because forcing it would break
|
|
140
|
-
* the app whose session storage the reload cleared.
|
|
141
|
-
*/
|
|
142
|
-
expectState?: string;
|
|
143
127
|
}
|
|
144
128
|
/** A device this app may now sign as. */
|
|
145
129
|
export interface EnrolledDevice {
|
|
@@ -160,6 +144,8 @@ export interface EnrolledDevice {
|
|
|
160
144
|
* deliberate: the cheap local comparisons first, so a fragment aimed at the
|
|
161
145
|
* wrong app is rejected without a signature verification, and the message names
|
|
162
146
|
* what disagreed rather than reporting "invalid credential".
|
|
147
|
+
*
|
|
148
|
+
* The state is spent on the first call, so an answer is accepted at most once.
|
|
163
149
|
*/
|
|
164
150
|
export declare function completeDeviceEnrolment(options: CompleteDeviceEnrolmentOptions): Promise<EnrolledDevice>;
|
|
165
151
|
/**
|
|
@@ -44,22 +44,28 @@
|
|
|
44
44
|
* {@link completeDeviceEnrolment} does all three and throws on any of them,
|
|
45
45
|
* before the caller has anything to store. What none of them establish is that
|
|
46
46
|
* the account is the *person's* — anyone can mint a root offline and certify
|
|
47
|
-
* any public key with it
|
|
48
|
-
*
|
|
49
|
-
*
|
|
47
|
+
* any public key with it, and a device key is public once enrolled. That is
|
|
48
|
+
* what the state narrows: {@link deviceEnrolmentUrl} remembers each request, and
|
|
49
|
+
* {@link completeDeviceEnrolment} refuses an answer to a request this browser did
|
|
50
|
+
* not start, rather than to a link someone sent.
|
|
50
51
|
*/
|
|
51
52
|
import { verifyDeviceCredential, } from '../device-cert/index.js';
|
|
53
|
+
import { beginState, takeState } from './link-state.js';
|
|
52
54
|
/** Query parameters the wallet reads. Mirrors the cloud's `link-account` shape. */
|
|
53
55
|
const DEVICE_PARAM = 'enrol-device';
|
|
54
56
|
const KEM_PARAM = 'enrol-kem';
|
|
55
57
|
const CALLBACK_PARAM = 'callback-url';
|
|
56
58
|
const STATE_PARAM = 'state';
|
|
59
|
+
const PENDING_KEY = 'calimero.device.enrol.pending';
|
|
57
60
|
/**
|
|
58
61
|
* The URL to send a person to.
|
|
59
62
|
*
|
|
60
63
|
* Built rather than documented as a string so the parameter names live in one
|
|
61
64
|
* place: a wallet and an app that disagree about them fail as a blank consent
|
|
62
65
|
* screen, which looks like an outage rather than a contract mismatch.
|
|
66
|
+
*
|
|
67
|
+
* Remembers the request under a fresh state the wallet echoes back, so
|
|
68
|
+
* {@link completeDeviceEnrolment} can match the answer to it.
|
|
63
69
|
*/
|
|
64
70
|
export function deviceEnrolmentUrl(options) {
|
|
65
71
|
assertHex(options.devicePublicKey, 'devicePublicKey');
|
|
@@ -70,8 +76,7 @@ export function deviceEnrolmentUrl(options) {
|
|
|
70
76
|
url.searchParams.set(DEVICE_PARAM, options.devicePublicKey);
|
|
71
77
|
url.searchParams.set(KEM_PARAM, options.kemPublicKey);
|
|
72
78
|
url.searchParams.set(CALLBACK_PARAM, options.returnTo);
|
|
73
|
-
|
|
74
|
-
url.searchParams.set(STATE_PARAM, options.state);
|
|
79
|
+
url.searchParams.set(STATE_PARAM, beginState(PENDING_KEY, {}));
|
|
75
80
|
return url.toString();
|
|
76
81
|
}
|
|
77
82
|
/** Send the person to the wallet to approve this device. Does not return. */
|
|
@@ -97,6 +102,7 @@ export function readEnrolmentCallback(location = window.location) {
|
|
|
97
102
|
const params = new URLSearchParams(location.hash.replace(/^#/, ''));
|
|
98
103
|
const error = params.get('error');
|
|
99
104
|
if (error) {
|
|
105
|
+
takeState(PENDING_KEY, params.get(STATE_PARAM));
|
|
100
106
|
stripFragment();
|
|
101
107
|
throw new Error(error === 'denied' || error === 'cancelled'
|
|
102
108
|
? 'the device was not approved at the wallet'
|
|
@@ -127,11 +133,13 @@ function stripFragment() {
|
|
|
127
133
|
* deliberate: the cheap local comparisons first, so a fragment aimed at the
|
|
128
134
|
* wrong app is rejected without a signature verification, and the message names
|
|
129
135
|
* what disagreed rather than reporting "invalid credential".
|
|
136
|
+
*
|
|
137
|
+
* The state is spent on the first call, so an answer is accepted at most once.
|
|
130
138
|
*/
|
|
131
139
|
export async function completeDeviceEnrolment(options) {
|
|
132
|
-
if (
|
|
133
|
-
throw new Error('this enrolment answers a request this app did not make
|
|
134
|
-
'
|
|
140
|
+
if (!takeState(PENDING_KEY, options.state)) {
|
|
141
|
+
throw new Error('this enrolment answers a request this app did not make - start the ' +
|
|
142
|
+
'enrolment again from here');
|
|
135
143
|
}
|
|
136
144
|
const certificate = await verifyDeviceCredential(options.credential);
|
|
137
145
|
if (certificate.signPublicKey !== normaliseHex(options.devicePublicKey)) {
|
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Redirects this browser began, so a callback can be matched to one. `localStorage`,
|
|
3
3
|
* not `sessionStorage`: consent opens in a `noopener` tab with empty session storage.
|
|
4
4
|
*/
|
|
5
5
|
export declare const ACCOUNT_HEX: RegExp;
|
|
6
|
+
/** Remember `payload` in the `key` store under a fresh random state, and return the state. */
|
|
7
|
+
export declare function beginState<T extends object>(key: string, payload: T): string;
|
|
8
|
+
/** Removes and returns what `state` was begun with, or null when this browser did not begin it. */
|
|
9
|
+
export declare function takeState<T extends object>(key: string, state: string | null | undefined): T | null;
|
|
6
10
|
/** Remember a link for `account` and return `returnTo` carrying its state. */
|
|
7
11
|
export declare function beginLinkState(account: string, returnTo: string): string;
|
|
8
12
|
/** Removes and returns the link a callback answers, or null when this browser did not begin it. */
|
package/dist/cloud/link-state.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Redirects this browser began, so a callback can be matched to one. `localStorage`,
|
|
3
3
|
* not `sessionStorage`: consent opens in a `noopener` tab with empty session storage.
|
|
4
4
|
*/
|
|
5
5
|
const KEY = "calimero.cloud.link.pending";
|
|
@@ -7,9 +7,9 @@ const STATE_PARAM = "link_state";
|
|
|
7
7
|
const LIFETIME_MS = 10 * 60 * 1000; // a grant lives 5 minutes, plus time to consent
|
|
8
8
|
export const ACCOUNT_HEX = /^[0-9a-f]{64}$/;
|
|
9
9
|
const MAX_PENDING = 20; // bounds storage when a page builds a consent URL on every render
|
|
10
|
-
function readPending(now) {
|
|
10
|
+
function readPending(key, now) {
|
|
11
11
|
try {
|
|
12
|
-
const raw = localStorage.getItem(
|
|
12
|
+
const raw = localStorage.getItem(key);
|
|
13
13
|
const pending = raw ? JSON.parse(raw) : {};
|
|
14
14
|
return Object.fromEntries(Object.entries(pending).filter(([, p]) => p && typeof p.expiresAt === "number" && p.expiresAt > now));
|
|
15
15
|
}
|
|
@@ -17,39 +17,46 @@ function readPending(now) {
|
|
|
17
17
|
return {};
|
|
18
18
|
}
|
|
19
19
|
}
|
|
20
|
-
function writePending(pending) {
|
|
20
|
+
function writePending(key, pending) {
|
|
21
21
|
try {
|
|
22
|
-
localStorage.setItem(
|
|
22
|
+
localStorage.setItem(key, JSON.stringify(pending));
|
|
23
23
|
}
|
|
24
24
|
catch {
|
|
25
|
-
// Storage blocked: the
|
|
25
|
+
// Storage blocked: the redirect cannot be matched later and fails closed.
|
|
26
26
|
}
|
|
27
27
|
}
|
|
28
|
+
/** Remember `payload` in the `key` store under a fresh random state, and return the state. */
|
|
29
|
+
export function beginState(key, payload) {
|
|
30
|
+
const now = Date.now();
|
|
31
|
+
const pending = readPending(key, now);
|
|
32
|
+
const bytes = crypto.getRandomValues(new Uint8Array(16));
|
|
33
|
+
const state = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
34
|
+
pending[state] = { ...payload, expiresAt: now + LIFETIME_MS };
|
|
35
|
+
writePending(key, Object.fromEntries(Object.entries(pending).slice(-MAX_PENDING)));
|
|
36
|
+
return state;
|
|
37
|
+
}
|
|
38
|
+
/** Removes and returns what `state` was begun with, or null when this browser did not begin it. */
|
|
39
|
+
export function takeState(key, state) {
|
|
40
|
+
const pending = readPending(key, Date.now());
|
|
41
|
+
if (!state || !Object.prototype.hasOwnProperty.call(pending, state))
|
|
42
|
+
return null;
|
|
43
|
+
const entry = pending[state];
|
|
44
|
+
delete pending[state];
|
|
45
|
+
writePending(key, pending);
|
|
46
|
+
return entry;
|
|
47
|
+
}
|
|
28
48
|
/** Remember a link for `account` and return `returnTo` carrying its state. */
|
|
29
49
|
export function beginLinkState(account, returnTo) {
|
|
30
50
|
if (!ACCOUNT_HEX.test(account)) {
|
|
31
51
|
throw new Error("accountId must be 64 lowercase hex characters");
|
|
32
52
|
}
|
|
33
53
|
const url = new URL(returnTo);
|
|
34
|
-
|
|
35
|
-
const pending = readPending(now);
|
|
36
|
-
const bytes = crypto.getRandomValues(new Uint8Array(16));
|
|
37
|
-
const state = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
38
|
-
pending[state] = { account, expiresAt: now + LIFETIME_MS };
|
|
39
|
-
writePending(Object.fromEntries(Object.entries(pending).slice(-MAX_PENDING)));
|
|
40
|
-
url.searchParams.set(STATE_PARAM, state);
|
|
54
|
+
url.searchParams.set(STATE_PARAM, beginState(KEY, { account }));
|
|
41
55
|
return url.toString();
|
|
42
56
|
}
|
|
43
57
|
/** Removes and returns the link a callback answers, or null when this browser did not begin it. */
|
|
44
58
|
export function takeLinkState(search) {
|
|
45
|
-
|
|
46
|
-
const pending = readPending(Date.now());
|
|
47
|
-
if (!state || !Object.prototype.hasOwnProperty.call(pending, state))
|
|
48
|
-
return null;
|
|
49
|
-
const entry = pending[state];
|
|
50
|
-
delete pending[state];
|
|
51
|
-
writePending(pending);
|
|
52
|
-
return entry;
|
|
59
|
+
return takeState(KEY, new URLSearchParams(search).get(STATE_PARAM));
|
|
53
60
|
}
|
|
54
61
|
/** The query with the link state removed, for rewriting the address bar. */
|
|
55
62
|
export function withoutLinkState(search) {
|
package/dist/events/sse.d.ts
CHANGED
|
@@ -91,6 +91,14 @@ export declare class SseClient {
|
|
|
91
91
|
onMigrationEvent(handler: (e: GroupMigrationEventData) => void): () => void;
|
|
92
92
|
private emit;
|
|
93
93
|
connect(): Promise<void>;
|
|
94
|
+
/**
|
|
95
|
+
* Whether `controller`'s connection is no longer the one this client holds:
|
|
96
|
+
* closed, or replaced by a later connect. Its abort lands asynchronously,
|
|
97
|
+
* after a `close()` + `connect()` has already cleared `closed`, so `closed`
|
|
98
|
+
* alone cannot tell; reporting it, and reconnecting for it, would abort the
|
|
99
|
+
* live connection, whose own abort would do the same, without end.
|
|
100
|
+
*/
|
|
101
|
+
private superseded;
|
|
94
102
|
private readStream;
|
|
95
103
|
private handleMessage;
|
|
96
104
|
subscribe(idsOrOpts: string[] | {
|
package/dist/events/sse.js
CHANGED
|
@@ -142,7 +142,8 @@ export class SseClient {
|
|
|
142
142
|
return;
|
|
143
143
|
}
|
|
144
144
|
this.closed = false;
|
|
145
|
-
|
|
145
|
+
const controller = new AbortController();
|
|
146
|
+
this.abortController = controller;
|
|
146
147
|
try {
|
|
147
148
|
const url = `${this.baseUrl}/sse`;
|
|
148
149
|
const headers = {
|
|
@@ -152,7 +153,7 @@ export class SseClient {
|
|
|
152
153
|
const proofAttached = carriesProof(headers);
|
|
153
154
|
const response = await this.fetchImpl(url, {
|
|
154
155
|
headers,
|
|
155
|
-
signal:
|
|
156
|
+
signal: controller.signal,
|
|
156
157
|
...(proofAttached && { redirect: 'manual' }),
|
|
157
158
|
});
|
|
158
159
|
if (proofAttached)
|
|
@@ -174,8 +175,8 @@ export class SseClient {
|
|
|
174
175
|
if (!response.body) {
|
|
175
176
|
throw new Error('SSE response has no body');
|
|
176
177
|
}
|
|
177
|
-
this.readStream(response.body).catch((err) => {
|
|
178
|
-
if (this.
|
|
178
|
+
this.readStream(response.body, controller).catch((err) => {
|
|
179
|
+
if (this.superseded(controller))
|
|
179
180
|
return;
|
|
180
181
|
const error = err instanceof Error ? err : new Error(String(err));
|
|
181
182
|
this.emit('error', error);
|
|
@@ -183,7 +184,7 @@ export class SseClient {
|
|
|
183
184
|
});
|
|
184
185
|
}
|
|
185
186
|
catch (err) {
|
|
186
|
-
if (this.
|
|
187
|
+
if (this.superseded(controller))
|
|
187
188
|
return;
|
|
188
189
|
const error = err instanceof Error ? err : new Error(String(err));
|
|
189
190
|
this.emit('error', error);
|
|
@@ -195,7 +196,17 @@ export class SseClient {
|
|
|
195
196
|
this.scheduleReconnect();
|
|
196
197
|
}
|
|
197
198
|
}
|
|
198
|
-
|
|
199
|
+
/**
|
|
200
|
+
* Whether `controller`'s connection is no longer the one this client holds:
|
|
201
|
+
* closed, or replaced by a later connect. Its abort lands asynchronously,
|
|
202
|
+
* after a `close()` + `connect()` has already cleared `closed`, so `closed`
|
|
203
|
+
* alone cannot tell; reporting it, and reconnecting for it, would abort the
|
|
204
|
+
* live connection, whose own abort would do the same, without end.
|
|
205
|
+
*/
|
|
206
|
+
superseded(controller) {
|
|
207
|
+
return this.closed || controller.signal.aborted || this.abortController !== controller;
|
|
208
|
+
}
|
|
209
|
+
async readStream(body, controller) {
|
|
199
210
|
const reader = body.getReader();
|
|
200
211
|
const decoder = new TextDecoder();
|
|
201
212
|
let buffer = '';
|
|
@@ -227,13 +238,13 @@ export class SseClient {
|
|
|
227
238
|
}
|
|
228
239
|
}
|
|
229
240
|
catch (err) {
|
|
230
|
-
if (this.
|
|
241
|
+
if (this.superseded(controller))
|
|
231
242
|
return;
|
|
232
243
|
const error = err instanceof Error ? err : new Error(String(err));
|
|
233
244
|
this.emit('error', error);
|
|
234
245
|
}
|
|
235
246
|
// Stream ended, reconnect
|
|
236
|
-
if (!this.
|
|
247
|
+
if (!this.superseded(controller)) {
|
|
237
248
|
this.scheduleReconnect();
|
|
238
249
|
}
|
|
239
250
|
}
|