From 92021d3ca03ae3141ccec2ffa5b2ab1972d7eaf2 Mon Sep 17 00:00:00 2001 From: Igor Zolotarenko Date: Wed, 15 Nov 2023 20:09:03 +0200 Subject: [PATCH] Add requests error handling. --- .../p2p-media-loader-core/src/http-loader.ts | 48 ++-- .../src/hybrid-loader.ts | 31 +-- .../src/internal-types.d.ts | 12 +- .../p2p-media-loader-core/src/p2p/peer.ts | 118 +++++----- packages/p2p-media-loader-core/src/request.ts | 211 ++++++++++++++---- packages/p2p-media-loader-core/src/types.d.ts | 7 +- 6 files changed, 265 insertions(+), 162 deletions(-) diff --git a/packages/p2p-media-loader-core/src/http-loader.ts b/packages/p2p-media-loader-core/src/http-loader.ts index 99300c16..6fd36dd7 100644 --- a/packages/p2p-media-loader-core/src/http-loader.ts +++ b/packages/p2p-media-loader-core/src/http-loader.ts @@ -1,9 +1,9 @@ import { Settings } from "./types"; -import { Request } from "./request"; +import { Request, RequestError, HttpRequestErrorType } from "./request"; export async function fulfillHttpSegmentRequest( request: Request, - settings: Pick + settings: Pick ) { const headers = new Headers(); const { segment } = request; @@ -16,23 +16,19 @@ export async function fulfillHttpSegmentRequest( } const abortController = new AbortController(); - - const requestAbortTimeout = setTimeout(() => { - const errorType: HttpLoaderError["type"] = "request-timeout"; - abortController.abort(errorType); - }, settings.httpRequestTimeout); - - const abortManually = () => { - const abortErrorType: HttpLoaderError["type"] = "manual-abort"; - abortController.abort(abortErrorType); - }; - - const requestControls = request.start("http", abortManually); + const requestControls = request.start( + { type: "http" }, + { + abort: (errorType) => abortController.abort(errorType), + fullLoadingTimeoutMs: settings.httpDownloadTimeoutMs, + } + ); try { const fetchResponse = await window.fetch(url, { headers, signal: abortController.signal, }); + requestControls.firstBytesReceived(); if (fetchResponse.ok) { if (!fetchResponse.body) return; @@ -45,20 +41,13 @@ export async function fulfillHttpSegmentRequest( requestControls.addLoadedChunk(chunk); } requestControls.completeOnSuccess(); - clearTimeout(requestAbortTimeout); } - throw new HttpLoaderError("fetch-error", fetchResponse.statusText); + throw new RequestError("fetch-error", fetchResponse.statusText); } catch (error) { if (error instanceof Error) { - let httpLoaderError: HttpLoaderError; - if ((error.name as HttpLoaderError["type"]) === "manual-abort") { - httpLoaderError = new HttpLoaderError("manual-abort"); - } else if ( - (error.name as HttpLoaderError["type"]) === "request-timeout" - ) { - httpLoaderError = new HttpLoaderError("request-timeout"); - } else if (!(error instanceof HttpLoaderError)) { - httpLoaderError = new HttpLoaderError("fetch-error", error.message); + let httpLoaderError: RequestError; + if (!(error instanceof RequestError)) { + httpLoaderError = new RequestError("fetch-error", error.message); } else { httpLoaderError = error; } @@ -76,12 +65,3 @@ async function* readStream( yield value; } } - -export class HttpLoaderError extends Error { - constructor( - readonly type: "request-timeout" | "fetch-error" | "manual-abort", - message?: string - ) { - super(message); - } -} diff --git a/packages/p2p-media-loader-core/src/hybrid-loader.ts b/packages/p2p-media-loader-core/src/hybrid-loader.ts index 3f5cc42a..ce6496f1 100644 --- a/packages/p2p-media-loader-core/src/hybrid-loader.ts +++ b/packages/p2p-media-loader-core/src/hybrid-loader.ts @@ -5,13 +5,12 @@ import { Settings, CoreEventHandlers } from "./types"; import { BandwidthApproximator } from "./bandwidth-approximator"; import { Playback, QueueItem } from "./internal-types"; import { RequestsContainer } from "./request-container"; -import { Request, EngineCallbacks, HybridLoaderRequest } from "./request"; +import { Request, EngineCallbacks, RequestError } from "./request"; import * as QueueUtils from "./utils/queue"; import * as LoggerUtils from "./utils/logger"; import * as StreamUtils from "./utils/stream"; import * as Utils from "./utils/utils"; import { P2PLoadersContainer } from "./p2p/loaders-container"; -import { PeerRequestError } from "./p2p/peer"; import debug from "debug"; export class HybridLoader { @@ -201,7 +200,7 @@ export class HybridLoader { abortSegment(segment: Segment) { const request = this.requests.get(segment); if (!request) return; - request.abort(); + request.abortEngineRequest(); this.createProcessQueueMicrotask(); this.logger.engine("abort: ", LoggerUtils.getSegmentString(segment)); } @@ -210,7 +209,7 @@ export class HybridLoader { const { segment } = item; const request = this.requests.getOrCreateRequest(segment); - request.subscribe("onCompleted", this.onRequestCompleted); + request.subscribe("onSuccess", this.onRequestSucceed); request.subscribe("onError", this.onRequestError); void fulfillHttpSegmentRequest(request, this.settings); @@ -226,21 +225,18 @@ export class HybridLoader { const request = p2pLoader.downloadSegment(item); if (request === undefined) return; - request.subscribe("onCompleted", this.onRequestCompleted); + request.subscribe("onSuccess", this.onRequestSucceed); request.subscribe("onError", this.onRequestError); } - private onRequestCompleted = (request: Request, data: ArrayBuffer) => { + private onRequestSucceed = (request: Request, data: ArrayBuffer) => { const { segment } = request; this.logger.loader(`http responses: ${segment.externalId}`); this.eventHandlers?.onSegmentLoaded?.(data.byteLength, "http"); this.createProcessQueueMicrotask(); }; - private onRequestError = (request: Request, error: Error) => { - if (!(error instanceof PeerRequestError) || error.type === "manual-abort") { - return; - } + private onRequestError = (request: Request, error: RequestError) => { this.createProcessQueueMicrotask(); }; @@ -278,21 +274,6 @@ export class HybridLoader { ); } - private onSegmentLoaded( - queueItem: QueueItem, - type: HybridLoaderRequest["type"], - data: ArrayBuffer - ) { - const { segment, statuses } = queueItem; - const byteLength = data.byteLength; - if (type === "http" && statuses.isHighDemand) { - this.refreshLevelBandwidth(true); - } - void this.segmentStorage.storeSegment(segment, data); - this.eventHandlers?.onSegmentLoaded?.(byteLength, type); - this.createProcessQueueMicrotask(); - } - private abortLastHttpLoadingAfter(queue: QueueItem[], segment: Segment) { for (const { segment: itemSegment } of arrayBackwards(queue)) { if (itemSegment.localId === segment.localId) break; diff --git a/packages/p2p-media-loader-core/src/internal-types.d.ts b/packages/p2p-media-loader-core/src/internal-types.d.ts index f11f12dd..74a8ab05 100644 --- a/packages/p2p-media-loader-core/src/internal-types.d.ts +++ b/packages/p2p-media-loader-core/src/internal-types.d.ts @@ -36,13 +36,18 @@ export type JsonSegmentAnnouncement = { }; export type PeerSegmentCommand = BasePeerCommand< - | PeerCommandType.SegmentRequest - | PeerCommandType.SegmentAbsent - | PeerCommandType.CancelSegmentRequest + PeerCommandType.SegmentAbsent | PeerCommandType.CancelSegmentRequest > & { i: string; }; +export type PeerSegmentRequestCommand = + BasePeerCommand & { + i: string; + // start byte of range + b?: number; + }; + export type PeerSegmentAnnouncementCommand = BasePeerCommand & { a: JsonSegmentAnnouncement; @@ -56,5 +61,6 @@ export type PeerSendSegmentCommand = export type PeerCommand = | PeerSegmentCommand + | PeerSegmentRequestCommand | PeerSegmentAnnouncementCommand | PeerSendSegmentCommand; diff --git a/packages/p2p-media-loader-core/src/p2p/peer.ts b/packages/p2p-media-loader-core/src/p2p/peer.ts index 89147315..3c175748 100644 --- a/packages/p2p-media-loader-core/src/p2p/peer.ts +++ b/packages/p2p-media-loader-core/src/p2p/peer.ts @@ -4,29 +4,22 @@ import { PeerCommand, PeerSegmentAnnouncementCommand, PeerSegmentCommand, + PeerSegmentRequestCommand, PeerSendSegmentCommand, } from "../internal-types"; import { PeerCommandType, PeerSegmentStatus } from "../enums"; import * as PeerUtil from "../utils/peer"; -import { Request, RequestControls } from "../request"; +import { + Request, + RequestControls, + RequestError, + PeerRequestErrorType, + RequestInnerErrorType, +} from "../request"; import { Segment, Settings } from "../types"; import * as Utils from "../utils/utils"; import debug from "debug"; -export class PeerRequestError extends Error { - constructor( - readonly type: - | "manual-abort" - | "request-timeout" - | "response-bytes-mismatch" - | "segment-absent" - | "peer-closed" - | "destroy" - ) { - super(); - } -} - type PeerEventHandlers = { onPeerClosed: (peer: Peer) => void; onSegmentRequested: (peer: Peer, segmentId: string) => void; @@ -34,13 +27,15 @@ type PeerEventHandlers = { type PeerSettings = Pick< Settings, - "p2pSegmentDownloadTimeout" | "webRtcMaxMessageSize" + | "p2pSegmentDownloadTimeoutMs" + | "p2pSegmentFirstBytesTimeoutMs" + | "webRtcMaxMessageSize" >; export class Peer { readonly id: string; private segments = new Map(); - private requestData?: { request: Request; controls: RequestControls }; + private requestContext?: { request: Request; controls: RequestControls }; private readonly logger = debug("core:peer"); private readonly bandwidthMeasurer = new BandwidthMeasurer(); private isUploadingSegment = false; @@ -55,7 +50,6 @@ export class Peer { connection.on("data", this.onReceiveData.bind(this)); connection.on("close", () => { - this.cancelSegmentRequest("peer-closed"); this.logger(`connection with peer closed: ${this.id}`); this.destroy(); this.eventHandlers.onPeerClosed(this); @@ -70,7 +64,7 @@ export class Peer { } get downloadingSegment(): Segment | undefined { - return this.requestData?.request.segment; + return this.requestContext?.request.segment; } get bandwidth(): number | undefined { @@ -99,14 +93,21 @@ export class Peer { break; case PeerCommandType.SegmentData: - if (this.requestData?.request.segment.externalId === command.i) { - this.requestData.request.setTotalBytes(command.s); + { + const request = this.requestContext?.request; + this.requestContext?.controls.firstBytesReceived(); + if ( + request?.segment.externalId === command.i && + request.totalBytes === undefined + ) { + request.setTotalBytes(command.s); + } } break; case PeerCommandType.SegmentAbsent: - if (this.requestData?.request.segment.externalId === command.i) { - this.cancelSegmentRequest("segment-absent"); + if (this.requestContext?.request.segment.externalId === command.i) { + this.cancelSegmentRequest("peer-segment-absent"); this.segments.delete(command.i); } break; @@ -122,19 +123,25 @@ export class Peer { } fulfillSegmentRequest(request: Request) { - if (this.requestData) { + if (this.requestContext) { throw new Error("Segment already is downloading"); } - this.requestData = { + this.requestContext = { request, - controls: request.start("p2p", () => - this.cancelSegmentRequest("manual-abort") + controls: request.start( + { type: "p2p", peerId: this.id }, + { + abort: this.abortRequest, + firstBytesTimeoutMs: this.settings.p2pSegmentFirstBytesTimeoutMs, + fullLoadingTimeoutMs: this.settings.p2pSegmentDownloadTimeoutMs, + } ), }; - const command: PeerSegmentCommand = { + const command: PeerSegmentRequestCommand = { c: PeerCommandType.SegmentRequest, i: request.segment.externalId, }; + if (request.loadedBytes) command.b = request.loadedBytes; this.sendCommand(command); } @@ -196,57 +203,50 @@ export class Peer { } private receiveSegmentChunk(chunk: Uint8Array): void { - if (!this.requestData) return; - const { request, controls } = this.requestData; + if (!this.requestContext) return; + const { request, controls } = this.requestContext; controls.addLoadedChunk(chunk); if (request.loadedBytes === request.totalBytes) { controls.completeOnSuccess(); - this.clearRequest(); + this.requestContext = undefined; } else if ( request.totalBytes !== undefined && request.loadedBytes > request.totalBytes ) { - this.cancelSegmentRequest("response-bytes-mismatch"); + this.cancelSegmentRequest("peer-response-bytes-mismatch"); } } - private cancelSegmentRequest(type: PeerRequestError["type"]) { - if (!this.requestData) return; - const { request, controls } = this.requestData; + private abortRequest = (reason: RequestInnerErrorType) => { + if (!this.requestContext) return; + const { request } = this.requestContext; + this.sendCancelSegmentRequestCommand(request.segment); + this.requestContext = undefined; + }; + + private cancelSegmentRequest(type: PeerRequestErrorType) { + if (!this.requestContext) return; + const { request, controls } = this.requestContext; const { segment } = request; this.logger(`cancel segment request ${segment.externalId} (${type})`); - const error = new PeerRequestError(type); - const sendCancelCommandTypes: PeerRequestError["type"][] = [ - "destroy", - "manual-abort", - "request-timeout", - "response-bytes-mismatch", - ]; - if (sendCancelCommandTypes.includes(type)) { - this.sendCommand({ - c: PeerCommandType.CancelSegmentRequest, - i: segment.externalId, - }); + const error = new RequestError(type); + if (type === "peer-response-bytes-mismatch") { + this.sendCancelSegmentRequestCommand(request.segment); } controls.cancelOnError(error); - this.clearRequest(); + this.requestContext = undefined; } - private setRequestTimeout(): number { - return window.setTimeout( - () => this.cancelSegmentRequest("request-timeout"), - this.settings.p2pSegmentDownloadTimeout - ); - } - - private clearRequest() { - clearTimeout(this.request?.responseTimeoutId); - this.request = undefined; + private sendCancelSegmentRequestCommand(segment: Segment) { + this.sendCommand({ + c: PeerCommandType.CancelSegmentRequest, + i: segment.externalId, + }); } destroy() { - this.cancelSegmentRequest("destroy"); + this.cancelSegmentRequest("peer-closed"); this.connection.destroy(); } diff --git a/packages/p2p-media-loader-core/src/request.ts b/packages/p2p-media-loader-core/src/request.ts index 253016aa..efa3cf7e 100644 --- a/packages/p2p-media-loader-core/src/request.ts +++ b/packages/p2p-media-loader-core/src/request.ts @@ -2,8 +2,6 @@ import { EventDispatcher } from "./event-dispatcher"; import { Segment, SegmentResponse } from "./types"; import { BandwidthApproximator } from "./bandwidth-approximator"; import * as Utils from "./utils/utils"; -import { HttpLoaderError } from "./http-loader"; -import { PeerRequestError } from "./p2p/peer"; export type EngineCallbacks = { onSuccess: (response: SegmentResponse) => void; @@ -14,37 +12,39 @@ export type EngineCallbacks = { export type LoadProgress = { startTimestamp: number; lastLoadedChunkTimestamp?: number; + startFromByte?: number; loadedBytes: number; - totalBytes?: number; }; -type HybridLoaderRequestBase = { - abort: () => void; - progress: LoadProgress; -}; - -type HttpRequest = HybridLoaderRequestBase & { +type HttpRequestAttempt = { type: "http"; - error?: HttpLoaderError; + error?: RequestError; }; -type P2PRequest = HybridLoaderRequestBase & { +type P2PRequestAttempt = { type: "p2p"; - error?: PeerRequestError; + peerId: string; + error?: RequestError; }; -export type HybridLoaderRequest = HttpRequest | P2PRequest; +export type RequestAttempt = HttpRequestAttempt | P2PRequestAttempt; export type RequestEvents = { - onCompleted: (request: Request, data: ArrayBuffer) => void; - onError: (request: Request, data: Error) => void; + onSuccess: (request: Request, data: ArrayBuffer) => void; + onError: (request: Request, data: RequestError) => void; }; -export type RequestControls = { +export type RequestControls = Readonly<{ + firstBytesReceived: Request["firstBytesReceived"]; addLoadedChunk: Request["addLoadedChunk"]; completeOnSuccess: Request["completeOnSuccess"]; cancelOnError: Request["cancelOnError"]; -}; +}>; + +type OmitEncapsulated = Omit; +type StartRequestParameters = + | OmitEncapsulated + | OmitEncapsulated; type RequestStatus = | "not-started" @@ -56,12 +56,16 @@ type RequestStatus = export class Request extends EventDispatcher { readonly id: string; private _engineCallbacks?: EngineCallbacks; - private hybridLoaderRequest?: HybridLoaderRequest; - private prevAttempts: HybridLoaderRequest[] = []; + private currentAttempt?: RequestAttempt; + private prevAttempts: RequestAttempt[] = []; private chunks: Uint8Array[] = []; private _loadedBytes = 0; private _totalBytes?: number; private _status: RequestStatus = "not-started"; + private progress?: LoadProgress; + private firstBytesTimeout: Timeout; + private fullBytesTimeout: Timeout; + private _abortRequestCallback?: (errorType: RequestInnerErrorType) => void; constructor( readonly segment: Segment, @@ -69,6 +73,8 @@ export class Request extends EventDispatcher { ) { super(); this.id = Request.getRequestItemId(segment); + this.firstBytesTimeout = new Timeout(this.onFirstBytesTimeout); + this.fullBytesTimeout = new Timeout(this.onFullBytesTimeout); } get status() { @@ -80,7 +86,7 @@ export class Request extends EventDispatcher { } get type() { - return this.hybridLoaderRequest?.type; + return this.currentAttempt?.type; } get loadedBytes() { @@ -110,22 +116,46 @@ export class Request extends EventDispatcher { return Utils.getPercent(this.loadedBytes, this._totalBytes); } - start(type: "http" | "p2p", abortLoading: () => void): RequestControls { + get requestAttempts(): ReadonlyArray> { + return this.prevAttempts; + } + + start( + requestData: StartRequestParameters, + controls: { + firstBytesTimeoutMs?: number; + fullLoadingTimeoutMs?: number; + abort: (errorType: RequestInnerErrorType) => void; + } + ): RequestControls { + if (this._status === "succeed") { + throw new Error("Request has been already succeed."); + } if (this._status === "loading") { throw new Error("Request has been already started."); } this._status = "loading"; - this.hybridLoaderRequest = { - type, - abort: abortLoading, - progress: { - loadedBytes: 0, - startTimestamp: performance.now(), - }, + const attempt: RequestAttempt = { + ...requestData, + }; + this.progress = { + startFromByte: this._loadedBytes, + loadedBytes: 0, + startTimestamp: performance.now(), }; + const { firstBytesTimeoutMs, fullLoadingTimeoutMs, abort } = controls; + this._abortRequestCallback = abort; + if (firstBytesTimeoutMs !== undefined) { + this.firstBytesTimeout.start(firstBytesTimeoutMs); + } + if (fullLoadingTimeoutMs !== undefined) { + this.fullBytesTimeout.start(fullLoadingTimeoutMs); + } + this.currentAttempt = attempt; return { + firstBytesReceived: this.firstBytesReceived, addLoadedChunk: this.addLoadedChunk, completeOnSuccess: this.completeOnSuccess, cancelOnError: this.cancelOnError, @@ -133,9 +163,11 @@ export class Request extends EventDispatcher { } abort() { - if (!this.hybridLoaderRequest) return; - this.hybridLoaderRequest.abort(); + this.throwErrorIfNotLoadingStatus(); + if (!this._abortRequestCallback) return; this._status = "aborted"; + this._abortRequestCallback("abort"); + this._abortRequestCallback = undefined; } abortEngineRequest() { @@ -145,31 +177,66 @@ export class Request extends EventDispatcher { private completeOnSuccess = () => { this.throwErrorIfNotLoadingStatus(); + if (!this.currentAttempt) return; + + this.fullBytesTimeout.stopAndClear(); const data = Utils.joinChunks(this.chunks); this._status = "succeed"; + this.prevAttempts.push(this.currentAttempt); + this.currentAttempt = undefined; + this._engineCallbacks?.onSuccess({ data, bandwidth: this.bandwidthApproximator.getBandwidth(), }); - this.dispatch("onCompleted", this, data); + this.dispatch("onSuccess", this, data); }; private addLoadedChunk = (chunk: Uint8Array) => { this.throwErrorIfNotLoadingStatus(); - const { hybridLoaderRequest: request } = this; - if (!request) return; + if (!this.currentAttempt || !this.progress) return; + this.chunks.push(chunk); - request.progress.lastLoadedChunkTimestamp = performance.now(); + this.progress.lastLoadedChunkTimestamp = performance.now(); + this.progress.loadedBytes += chunk.length; this._loadedBytes += chunk.length; }; - private cancelOnError = (error: Error) => { + private firstBytesReceived = () => { + this.throwErrorIfNotLoadingStatus(); + this.firstBytesTimeout.stopAndClear(); + }; + + private cancelOnError = (error: RequestError) => { + this.throwErrorIfNotLoadingStatus(); + this.throwRequestError(error, false); + }; + + private throwRequestError(error: RequestError, abort = true) { this.throwErrorIfNotLoadingStatus(); - if (!this.hybridLoaderRequest) return; + if (!this.currentAttempt) return; this._status = "failed"; - this.hybridLoaderRequest.error = error; - this.prevAttempts.push(this.hybridLoaderRequest); + if ( + abort && + this._abortRequestCallback && + RequestError.isRequestInnerErrorType(error) + ) { + this._abortRequestCallback(error.type); + } + this.currentAttempt.error = error; + this.prevAttempts.push(this.currentAttempt); + this.currentAttempt = undefined; this.dispatch("onError", this, error); + } + + private onFirstBytesTimeout = () => { + this.throwErrorIfNotLoadingStatus(); + this.throwRequestError(new RequestError("first-bytes-timeout"), true); + }; + + private onFullBytesTimeout = () => { + this.throwErrorIfNotLoadingStatus(); + this.throwRequestError(new RequestError("full-bytes-timeout"), true); }; private throwErrorIfNotLoadingStatus() { @@ -182,3 +249,71 @@ export class Request extends EventDispatcher { return segment.localId; } } + +const requestInnerErrorTypes = [ + "abort", + "first-bytes-timeout", + "full-bytes-timeout", +] as const; + +const httpRequestErrorTypes = ["fetch-error"] as const; + +const peerRequestErrorTypes = [ + "peer-response-bytes-mismatch", + "peer-segment-absent", + "peer-closed", +] as const; + +export type RequestInnerErrorType = (typeof requestInnerErrorTypes)[number]; +export type HttpRequestErrorType = (typeof httpRequestErrorTypes)[number]; +export type PeerRequestErrorType = (typeof peerRequestErrorTypes)[number]; + +export class RequestError< + T extends + | RequestInnerErrorType + | PeerRequestErrorType + | HttpRequestErrorType = + | RequestInnerErrorType + | PeerRequestErrorType + | HttpRequestErrorType +> extends Error { + constructor(readonly type: T, message?: string) { + super(message); + } + + static isRequestInnerErrorType( + error: RequestError + ): error is RequestError { + return requestInnerErrorTypes.includes(error.type as any); + } + + static isPeerErrorType( + error: RequestError + ): error is RequestError { + return peerRequestErrorTypes.includes(error.type as any); + } + + static isHttpErrorType( + error: RequestError + ): error is RequestError { + return peerRequestErrorTypes.includes(error.type as any); + } +} + +class Timeout { + private timeoutId?: number; + + constructor(private readonly action: () => void) {} + + start(ms: number) { + if (this.timeoutId) { + throw new Error("Timeout is already started."); + } + this.timeoutId = window.setTimeout(this.action, ms); + } + + stopAndClear() { + clearTimeout(this.timeoutId); + this.timeoutId = undefined; + } +} diff --git a/packages/p2p-media-loader-core/src/types.d.ts b/packages/p2p-media-loader-core/src/types.d.ts index 16e7d14d..2f113f1e 100644 --- a/packages/p2p-media-loader-core/src/types.d.ts +++ b/packages/p2p-media-loader-core/src/types.d.ts @@ -58,9 +58,10 @@ export type Settings = { cachedSegmentExpiration: number; cachedSegmentsCount: number; webRtcMaxMessageSize: number; - p2pSegmentDownloadTimeout: number; - p2pLoaderDestroyTimeout: number; - httpRequestTimeout: number; + p2pSegmentFirstBytesTimeoutMs: number; + p2pSegmentDownloadTimeoutMs: number; + p2pLoaderDestroyTimeoutMs: number; + httpDownloadTimeoutMs: number; }; export type CoreEventHandlers = {