@atproto/oauth-client 0.5.13 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -42,36 +42,38 @@ import { OAuthSession } from './oauth-session.js'
42
42
  import { RuntimeImplementation } from './runtime-implementation.js'
43
43
  import { Runtime } from './runtime.js'
44
44
  import {
45
- SessionEventMap,
46
45
  SessionGetter,
46
+ SessionHooks,
47
47
  SessionStore,
48
+ isExpectedSessionError,
48
49
  } from './session-getter.js'
49
50
  import { InternalStateData, StateStore } from './state-store.js'
50
51
  import { AuthorizeOptions, CallbackOptions, ClientMetadata } from './types.js'
51
- import { CustomEventTarget } from './util.js'
52
52
  import { validateClientMetadata } from './validate-client-metadata.js'
53
53
 
54
54
  // Export all types needed to construct OAuthClientOptions
55
- export {
56
- type AuthorizationServerMetadataCache,
57
- type DidCache,
58
- type DpopNonceCache,
59
- type Fetch,
60
- type HandleCache,
61
- type HandleResolver,
62
- type InternalStateData,
63
- Key,
64
- Keyset,
65
- type OAuthClientMetadata,
66
- type OAuthClientMetadataInput,
67
- type OAuthResponseMode,
68
- type ProtectedResourceMetadataCache,
69
- type RuntimeImplementation,
70
- type SessionStore,
71
- type StateStore,
55
+ export type {
56
+ AuthorizationServerMetadataCache,
57
+ CreateIdentityResolverOptions,
58
+ DidCache,
59
+ DpopNonceCache,
60
+ Fetch,
61
+ HandleCache,
62
+ HandleResolver,
63
+ InternalStateData,
64
+ OAuthClientMetadata,
65
+ OAuthClientMetadataInput,
66
+ OAuthResponseMode,
67
+ ProtectedResourceMetadataCache,
68
+ RuntimeImplementation,
69
+ SessionHooks,
70
+ SessionStore,
71
+ StateStore,
72
72
  }
73
73
 
74
- export type OAuthClientOptions = CreateIdentityResolverOptions & {
74
+ export { Key, Keyset }
75
+
76
+ export type OAuthClientOptions = {
75
77
  // Config
76
78
  responseMode: OAuthResponseMode
77
79
  clientMetadata: Readonly<OAuthClientMetadataInput>
@@ -102,9 +104,8 @@ export type OAuthClientOptions = CreateIdentityResolverOptions & {
102
104
  // Services
103
105
  runtimeImplementation: RuntimeImplementation
104
106
  fetch?: Fetch
105
- }
106
-
107
- export type OAuthClientEventMap = SessionEventMap
107
+ } & CreateIdentityResolverOptions &
108
+ SessionHooks
108
109
 
109
110
  export type OAuthClientFetchMetadataOptions = {
110
111
  clientId: OAuthClientIdDiscoverable
@@ -112,7 +113,7 @@ export type OAuthClientFetchMetadataOptions = {
112
113
  signal?: AbortSignal
113
114
  }
114
115
 
115
- export class OAuthClient extends CustomEventTarget<OAuthClientEventMap> {
116
+ export class OAuthClient {
116
117
  static async fetchMetadata({
117
118
  clientId,
118
119
  fetch = globalThis.fetch,
@@ -181,8 +182,6 @@ export class OAuthClient extends CustomEventTarget<OAuthClientEventMap> {
181
182
  keyset,
182
183
  } = options
183
184
 
184
- super()
185
-
186
185
  this.keyset = keyset
187
186
  ? keyset instanceof Keyset
188
187
  ? keyset
@@ -215,21 +214,13 @@ export class OAuthClient extends CustomEventTarget<OAuthClientEventMap> {
215
214
  dpopNonceCache,
216
215
  )
217
216
 
217
+ this.stateStore = stateStore
218
218
  this.sessionGetter = new SessionGetter(
219
219
  sessionStore,
220
220
  this.serverFactory,
221
221
  this.runtime,
222
+ options,
222
223
  )
223
- this.stateStore = stateStore
224
-
225
- // Proxy sessionGetter events
226
- for (const type of ['deleted', 'updated'] as const) {
227
- this.sessionGetter.addEventListener(type, (event) => {
228
- if (!this.dispatchCustomEvent(type, event.detail)) {
229
- event.preventDefault()
230
- }
231
- })
232
- }
233
224
  }
234
225
 
235
226
  // Exposed as public API for convenience
@@ -411,8 +402,7 @@ export class OAuthClient extends CustomEventTarget<OAuthClientEventMap> {
411
402
 
412
403
  const server = await this.serverFactory.fromIssuer(
413
404
  stateData.iss,
414
- // Using the literal 'legacy' if the authMethod is not defined (because stateData was created through an old version of this lib)
415
- stateData.authMethod ?? 'legacy',
405
+ stateData.authMethod,
416
406
  stateData.dpopKey,
417
407
  )
418
408
 
@@ -446,6 +436,15 @@ export class OAuthClient extends CustomEventTarget<OAuthClientEventMap> {
446
436
  stateData.verifier,
447
437
  options?.redirect_uri ?? server.clientMetadata.redirect_uris[0],
448
438
  )
439
+
440
+ // We revoke any existing session first to avoid leaving orphaned sessions
441
+ // on the AS.
442
+ try {
443
+ await this.revoke(tokenSet.sub)
444
+ } catch {
445
+ // No existing session, or failed to get it. This is fine.
446
+ }
447
+
449
448
  try {
450
449
  await this.sessionGetter.setStored(tokenSet.sub, {
451
450
  dpopKey: stateData.dpopKey,
@@ -472,7 +471,7 @@ export class OAuthClient extends CustomEventTarget<OAuthClientEventMap> {
472
471
  * Load a stored session. This will refresh the token only if needed (about to
473
472
  * expire) by default.
474
473
  *
475
- * @param refresh See {@link SessionGetter.getSession}
474
+ * @see {@link SessionGetter.restore}
476
475
  */
477
476
  async restore(
478
477
  sub: string,
@@ -481,11 +480,8 @@ export class OAuthClient extends CustomEventTarget<OAuthClientEventMap> {
481
480
  // sub arg is lightly typed for convenience of library user
482
481
  assertAtprotoDid(sub)
483
482
 
484
- const {
485
- dpopKey,
486
- authMethod = 'legacy',
487
- tokenSet,
488
- } = await this.sessionGetter.getSession(sub, refresh)
483
+ const { dpopKey, authMethod, tokenSet } =
484
+ await this.sessionGetter.getSession(sub, refresh)
489
485
 
490
486
  try {
491
487
  const server = await this.serverFactory.fromIssuer(
@@ -512,14 +508,15 @@ export class OAuthClient extends CustomEventTarget<OAuthClientEventMap> {
512
508
  // sub arg is lightly typed for convenience of library user
513
509
  assertAtprotoDid(sub)
514
510
 
515
- const {
516
- dpopKey,
517
- authMethod = 'legacy',
518
- tokenSet,
519
- } = await this.sessionGetter.get(sub, {
520
- allowStale: true,
511
+ const res = await this.sessionGetter.getSession(sub, false).catch((err) => {
512
+ if (isExpectedSessionError(err)) return null
513
+ throw err
521
514
  })
522
515
 
516
+ if (!res) return
517
+
518
+ const { dpopKey, authMethod, tokenSet } = res
519
+
523
520
  // NOT using `;(await this.restore(sub, false)).signOut()` because we want
524
521
  // the tokens to be deleted even if it was not possible to fetch the issuer
525
522
  // data.
@@ -2,10 +2,7 @@ import { Key, Keyset } from '@atproto/jwk'
2
2
  import { OAuthAuthorizationServerMetadata } from '@atproto/oauth-types'
3
3
  import { Fetch } from '@atproto-labs/fetch'
4
4
  import { GetCachedOptions } from './oauth-authorization-server-metadata-resolver.js'
5
- import {
6
- ClientAuthMethod,
7
- negotiateClientAuthMethod,
8
- } from './oauth-client-auth.js'
5
+ import { ClientAuthMethod } from './oauth-client-auth.js'
9
6
  import { OAuthResolver } from './oauth-resolver.js'
10
7
  import { DpopNonceCache, OAuthServerAgent } from './oauth-server-agent.js'
11
8
  import { Runtime } from './runtime.js'
@@ -32,7 +29,7 @@ export class OAuthServerFactory {
32
29
  */
33
30
  async fromIssuer(
34
31
  issuer: string,
35
- authMethod: 'legacy' | ClientAuthMethod,
32
+ authMethod: ClientAuthMethod,
36
33
  dpopKey: Key,
37
34
  options?: GetCachedOptions,
38
35
  ) {
@@ -41,17 +38,6 @@ export class OAuthServerFactory {
41
38
  options,
42
39
  )
43
40
 
44
- if (authMethod === 'legacy') {
45
- // @NOTE Because we were previously not storing the authMethod in the
46
- // session data, we provide a backwards compatible implementation by
47
- // computing it here.
48
- authMethod = negotiateClientAuthMethod(
49
- serverMetadata,
50
- this.clientMetadata,
51
- this.keyset,
52
- )
53
- }
54
-
55
41
  return this.fromMetadata(serverMetadata, authMethod, dpopKey)
56
42
  }
57
43
 
@@ -58,10 +58,7 @@ export class OAuthSession {
58
58
  * if, and only if, they are (about to be) expired. Defaults to `undefined`.
59
59
  */
60
60
  protected async getTokenSet(refresh: boolean | 'auto'): Promise<TokenSet> {
61
- const { tokenSet } = await this.sessionGetter.get(this.sub, {
62
- noCache: refresh === true,
63
- allowStale: refresh === false,
64
- })
61
+ const { tokenSet } = await this.sessionGetter.getSession(this.sub, refresh)
65
62
 
66
63
  return tokenSet
67
64
  }
@@ -3,6 +3,7 @@ import { Key } from '@atproto/jwk'
3
3
  import {
4
4
  CachedGetter,
5
5
  GetCachedOptions,
6
+ GetOptions,
6
7
  SimpleStore,
7
8
  } from '@atproto-labs/simple-store'
8
9
  import { AuthMethodUnsatisfiableError } from './errors/auth-method-unsatisfiable-error.js'
@@ -14,32 +15,35 @@ import { OAuthResponseError } from './oauth-response-error.js'
14
15
  import { TokenSet } from './oauth-server-agent.js'
15
16
  import { OAuthServerFactory } from './oauth-server-factory.js'
16
17
  import { Runtime } from './runtime.js'
17
- import { CustomEventTarget, combineSignals } from './util.js'
18
+ import { combineSignals } from './util.js'
18
19
 
19
20
  export type Session = {
20
21
  dpopKey: Key
21
- /**
22
- * Previous implementation of this lib did not define an `authMethod`
23
- */
24
- authMethod?: ClientAuthMethod
22
+ authMethod: ClientAuthMethod
25
23
  tokenSet: TokenSet
26
24
  }
27
25
 
28
26
  export type SessionStore = SimpleStore<string, Session>
29
27
 
30
- export type SessionEventMap = {
31
- updated: {
32
- sub: string
33
- } & Session
34
- deleted: {
35
- sub: string
36
- cause: TokenRefreshError | TokenRevokedError | TokenInvalidError | unknown
37
- }
28
+ export type SessionHooks = {
29
+ onUpdate?: (sub: AtprotoDid, session: Session) => void
30
+ onDelete?: (
31
+ sub: AtprotoDid,
32
+ cause: TokenRefreshError | TokenRevokedError | TokenInvalidError | unknown,
33
+ ) => void
38
34
  }
39
35
 
40
- export type SessionEventListener<
41
- T extends keyof SessionEventMap = keyof SessionEventMap,
42
- > = (event: CustomEvent<SessionEventMap[T]>) => void
36
+ export function isExpectedSessionError(err: unknown) {
37
+ return (
38
+ err instanceof TokenRefreshError ||
39
+ err instanceof TokenRevokedError ||
40
+ err instanceof TokenInvalidError ||
41
+ err instanceof AuthMethodUnsatisfiableError ||
42
+ // The stored session is invalid (e.g. missing properties) and cannot
43
+ // be used properly
44
+ err instanceof TypeError
45
+ )
46
+ }
43
47
 
44
48
  /**
45
49
  * There are several advantages to wrapping the sessionStore in a (single)
@@ -49,15 +53,14 @@ export type SessionEventListener<
49
53
  * localStorage/indexedDB, will sync across multiple tabs (for a given sub).
50
54
  */
51
55
  export class SessionGetter extends CachedGetter<AtprotoDid, Session> {
52
- private readonly eventTarget = new CustomEventTarget<SessionEventMap>()
53
-
54
56
  constructor(
55
57
  sessionStore: SessionStore,
56
58
  serverFactory: OAuthServerFactory,
57
59
  private readonly runtime: Runtime,
60
+ private readonly hooks: SessionHooks = {},
58
61
  ) {
59
62
  super(
60
- async (sub, options, storedSession) => {
63
+ async (sub, { signal }, storedSession) => {
61
64
  // There needs to be a previous session to be able to refresh. If
62
65
  // storedSession is undefined, it means that the store does not contain
63
66
  // a session for the given sub.
@@ -69,17 +72,15 @@ export class SessionGetter extends CachedGetter<AtprotoDid, Session> {
69
72
  // make sure an event is dispatched here if this occurs.
70
73
  const msg = 'The session was deleted by another process'
71
74
  const cause = new TokenRefreshError(sub, msg)
72
- this.dispatchEvent('deleted', { sub, cause })
75
+ await hooks.onDelete?.call(null, sub, cause)
73
76
  throw cause
74
77
  }
75
78
 
76
- // From this point forward, throwing a TokenRefreshError will result in
77
- // this.delStored() being called, resulting in an event being
78
- // dispatched, even if the session was removed from the store through a
79
- // concurrent access (which, normally, should not happen if a proper
80
- // runtime lock was provided).
79
+ // @NOTE Throwing a TokenRefreshError (or any other error class defined
80
+ // in the deleteOnError options) will result in this.delStored() being
81
+ // called.
81
82
 
82
- const { dpopKey, authMethod = 'legacy', tokenSet } = storedSession
83
+ const { dpopKey, authMethod, tokenSet } = storedSession
83
84
 
84
85
  if (sub !== tokenSet.sub) {
85
86
  // Fool-proofing (e.g. against invalid session storage)
@@ -90,16 +91,6 @@ export class SessionGetter extends CachedGetter<AtprotoDid, Session> {
90
91
  throw new TokenRefreshError(sub, 'No refresh token available')
91
92
  }
92
93
 
93
- // Since refresh tokens can only be used once, we might run into
94
- // concurrency issues if multiple instances (e.g. browser tabs) are
95
- // trying to refresh the same token simultaneously. The chances of this
96
- // happening when multiple instances are started simultaneously is
97
- // reduced by randomizing the expiry time (see isStale() below). The
98
- // best solution is to use a mutex/lock to ensure that only one instance
99
- // is refreshing the token at a time (runtime.usingLock) but that is not
100
- // always possible. If no lock implementation is provided, we will use
101
- // the store to check if a concurrent refresh occurred.
102
-
103
94
  const server = await serverFactory.fromIssuer(
104
95
  tokenSet.iss,
105
96
  authMethod,
@@ -111,7 +102,7 @@ export class SessionGetter extends CachedGetter<AtprotoDid, Session> {
111
102
  // point. Any thrown error beyond this point will prevent the
112
103
  // TokenGetter from obtaining, and storing, the new token set,
113
104
  // effectively rendering the currently saved session unusable.
114
- options?.signal?.throwIfAborted()
105
+ signal?.throwIfAborted()
115
106
 
116
107
  try {
117
108
  const newTokenSet = await server.refresh(tokenSet)
@@ -127,9 +118,16 @@ export class SessionGetter extends CachedGetter<AtprotoDid, Session> {
127
118
  authMethod: server.authMethod,
128
119
  }
129
120
  } catch (cause) {
130
- // If the refresh token is invalid, let's try to recover from
131
- // concurrency issues, or make sure the session is deleted by throwing
132
- // a TokenRefreshError.
121
+ // Since refresh tokens can only be used once, we might run into
122
+ // concurrency issues if multiple instances (e.g. browser tabs) are
123
+ // trying to refresh the same token simultaneously. The chances of
124
+ // this happening when multiple instances are started simultaneously
125
+ // is reduced by randomizing the expiry time (see isStale() below).
126
+ // The best solution is to use a mutex/lock to ensure that only one
127
+ // instance is refreshing the token at a time (runtime.usingLock) but
128
+ // that is not always possible. Let's try to recover from concurrency
129
+ // issues, or force the session to be deleted by throwing a
130
+ // TokenRefreshError.
133
131
  if (
134
132
  cause instanceof OAuthResponseError &&
135
133
  cause.status === 400 &&
@@ -187,91 +185,63 @@ export class SessionGetter extends CachedGetter<AtprotoDid, Session> {
187
185
  30e3 * Math.random()
188
186
  )
189
187
  },
190
- onStoreError: async (
191
- err,
192
- sub,
193
- { tokenSet, dpopKey, authMethod = 'legacy' as const },
194
- ) => {
195
- if (!(err instanceof AuthMethodUnsatisfiableError)) {
196
- // If the error was an AuthMethodUnsatisfiableError, there is no
197
- // point in trying to call `fromIssuer`.
198
- try {
199
- // If the token data cannot be stored, let's revoke it
200
- const server = await serverFactory.fromIssuer(
201
- tokenSet.iss,
202
- authMethod,
203
- dpopKey,
204
- )
205
- await server.revoke(
206
- tokenSet.refresh_token ?? tokenSet.access_token,
207
- )
208
- } catch {
209
- // Let the original error propagate
210
- }
188
+ onStoreError: async (err, sub, { tokenSet, dpopKey, authMethod }) => {
189
+ // If the token data cannot be stored, let's revoke it
190
+ try {
191
+ const server = await serverFactory.fromIssuer(
192
+ tokenSet.iss,
193
+ authMethod,
194
+ dpopKey,
195
+ )
196
+ await server.revoke(tokenSet.refresh_token ?? tokenSet.access_token)
197
+ } catch {
198
+ // At least we tried...
199
+ }
200
+
201
+ // Attempt to delete the session from the store. Note that this might
202
+ // fail if the store is not available, which is fine.
203
+ try {
204
+ await this.delStored(sub, err)
205
+ } catch {
206
+ // Ignore (better to propagate the original storage error)
211
207
  }
212
208
 
213
209
  throw err
214
210
  },
215
- deleteOnError: async (err) =>
216
- err instanceof TokenRefreshError ||
217
- err instanceof TokenRevokedError ||
218
- err instanceof TokenInvalidError ||
219
- err instanceof AuthMethodUnsatisfiableError,
211
+ deleteOnError: isExpectedSessionError,
220
212
  },
221
213
  )
222
214
  }
223
215
 
224
- addEventListener<T extends keyof SessionEventMap>(
225
- type: T,
226
- callback: SessionEventListener<T>,
227
- options?: AddEventListenerOptions | boolean,
228
- ) {
229
- this.eventTarget.addEventListener(type, callback, options)
230
- }
231
-
232
- removeEventListener<T extends keyof SessionEventMap>(
233
- type: T,
234
- callback: SessionEventListener<T>,
235
- options?: EventListenerOptions | boolean,
236
- ) {
237
- this.eventTarget.removeEventListener(type, callback, options)
238
- }
239
-
240
- dispatchEvent<T extends keyof SessionEventMap>(
241
- type: T,
242
- detail: SessionEventMap[T],
243
- ): boolean {
244
- return this.eventTarget.dispatchCustomEvent(type, detail)
216
+ override async getStored(
217
+ sub: AtprotoDid,
218
+ options?: GetOptions,
219
+ ): Promise<Session | undefined> {
220
+ return super.getStored(sub, options)
245
221
  }
246
222
 
247
- async setStored(sub: string, session: Session) {
223
+ override async setStored(sub: AtprotoDid, session: Session) {
248
224
  // Prevent tampering with the stored value
249
225
  if (sub !== session.tokenSet.sub) {
250
226
  throw new TypeError('Token set does not match the expected sub')
251
227
  }
252
228
  await super.setStored(sub, session)
253
- this.dispatchEvent('updated', { sub, ...session })
229
+ await this.hooks.onUpdate?.call(null, sub, session)
254
230
  }
255
231
 
256
232
  override async delStored(sub: AtprotoDid, cause?: unknown): Promise<void> {
257
233
  await super.delStored(sub, cause)
258
- this.dispatchEvent('deleted', { sub, cause })
234
+ await this.hooks.onDelete?.call(null, sub, cause)
259
235
  }
260
236
 
261
237
  /**
262
- * @param refresh When `true`, the credentials will be refreshed even if they
263
- * are not expired. When `false`, the credentials will not be refreshed even
264
- * if they are expired. When `undefined`, the credentials will be refreshed
265
- * if, and only if, they are (about to be) expired. Defaults to `undefined`.
238
+ * @deprecated Use {@link getSession} instead
239
+ * @internal (not really deprecated)
266
240
  */
267
- async getSession(sub: AtprotoDid, refresh: boolean | 'auto' = 'auto') {
268
- return this.get(sub, {
269
- noCache: refresh === true,
270
- allowStale: refresh === false,
271
- })
272
- }
273
-
274
- async get(sub: AtprotoDid, options?: GetCachedOptions): Promise<Session> {
241
+ override async get(
242
+ sub: AtprotoDid,
243
+ options?: GetCachedOptions,
244
+ ): Promise<Session> {
275
245
  const session = await this.runtime.usingLock(
276
246
  `@atproto-oauth-client-${sub}`,
277
247
  async () => {
@@ -295,4 +265,17 @@ export class SessionGetter extends CachedGetter<AtprotoDid, Session> {
295
265
 
296
266
  return session
297
267
  }
268
+
269
+ /**
270
+ * @param refresh When `true`, the credentials will be refreshed even if they
271
+ * are not expired. When `false`, the credentials will not be refreshed even
272
+ * if they are expired. When `undefined`, the credentials will be refreshed
273
+ * if, and only if, they are (about to be) expired. Defaults to `undefined`.
274
+ */
275
+ async getSession(sub: AtprotoDid, refresh: boolean | 'auto' = 'auto') {
276
+ return this.get(sub, {
277
+ noCache: refresh === true,
278
+ allowStale: refresh === false,
279
+ })
280
+ }
298
281
  }
@@ -5,10 +5,20 @@ import { ClientAuthMethod } from './oauth-client-auth.js'
5
5
  export type InternalStateData = {
6
6
  iss: string
7
7
  dpopKey: Key
8
- /** @note optional for legacy reasons */
9
- authMethod?: ClientAuthMethod
10
- verifier?: string
8
+ authMethod: ClientAuthMethod
9
+ verifier: string
11
10
  appState?: string
12
11
  }
13
12
 
13
+ /**
14
+ * A store pending oauth authorization flows. The key is the "state" parameter
15
+ * used in the authorization request, and the value is an object containing the
16
+ * necessary information to complete the flow once the user is redirected back
17
+ * to the client.
18
+ *
19
+ * @note The data stored in this store is typically short-lived. It should be
20
+ * automatically cleared after a certain period of time (e.g. 1 hour) to prevent
21
+ * the store from growing indefinitely. It is up to the implementation to
22
+ * implement this cleanup mechanism.
23
+ */
14
24
  export type StateStore = SimpleStore<string, InternalStateData>
package/src/util.ts CHANGED
@@ -7,73 +7,6 @@ export function contentMime(headers: Headers): string | undefined {
7
7
  return headers.get('content-type')?.split(';')[0]!.trim()
8
8
  }
9
9
 
10
- /**
11
- * Ponyfill for `CustomEvent` constructor.
12
- */
13
- export const CustomEvent: typeof globalThis.CustomEvent =
14
- globalThis.CustomEvent ??
15
- (() => {
16
- class CustomEvent<T> extends Event {
17
- #detail: T | null
18
- constructor(type: string, options?: CustomEventInit<T>) {
19
- if (!arguments.length) throw new TypeError('type argument is required')
20
- super(type, options)
21
- this.#detail = options?.detail ?? null
22
- }
23
- get detail() {
24
- return this.#detail
25
- }
26
- }
27
-
28
- Object.defineProperties(CustomEvent.prototype, {
29
- [Symbol.toStringTag]: {
30
- writable: false,
31
- enumerable: false,
32
- configurable: true,
33
- value: 'CustomEvent',
34
- },
35
- detail: {
36
- enumerable: true,
37
- },
38
- })
39
-
40
- return CustomEvent
41
- })()
42
-
43
- export class CustomEventTarget<EventDetailMap extends Record<string, unknown>> {
44
- readonly eventTarget = new EventTarget()
45
-
46
- addEventListener<T extends Extract<keyof EventDetailMap, string>>(
47
- type: T,
48
- callback: (event: CustomEvent<EventDetailMap[T]>) => void,
49
- options?: AddEventListenerOptions | boolean,
50
- ): void {
51
- this.eventTarget.addEventListener(type, callback as EventListener, options)
52
- }
53
-
54
- removeEventListener<T extends Extract<keyof EventDetailMap, string>>(
55
- type: T,
56
- callback: (event: CustomEvent<EventDetailMap[T]>) => void,
57
- options?: EventListenerOptions | boolean,
58
- ): void {
59
- this.eventTarget.removeEventListener(
60
- type,
61
- callback as EventListener,
62
- options,
63
- )
64
- }
65
-
66
- dispatchCustomEvent<T extends Extract<keyof EventDetailMap, string>>(
67
- type: T,
68
- detail: EventDetailMap[T],
69
- init?: EventInit,
70
- ): boolean {
71
- return this.eventTarget.dispatchEvent(
72
- new CustomEvent(type, { ...init, detail }),
73
- )
74
- }
75
- }
76
-
77
10
  export function combineSignals(
78
11
  signals: readonly (AbortSignal | undefined)[],
79
12
  ): AbortController & Disposable {