diff --git a/src/lib/download-request.ts b/src/lib/download-request.ts index 43d14a0..9a836dc 100644 --- a/src/lib/download-request.ts +++ b/src/lib/download-request.ts @@ -7,11 +7,14 @@ import { StatusError } from './errors.js' import { errors, jsonError } from './errors.js' import { secretStreamFetch } from './secret-stream-fetch.js' import { StateUpdateEvent } from './state-update-event.js' +import { throttle } from './throttle.js' import { addTrailingSlash, generateId, getErrorCode, noop } from './utils.js' export type DownloadState = DownloadStateUpdate & Omit & { downloadId: string } +const PROGRESS_THROTTLE_MS = 100 + export class DownloadRequest extends TypedEventTarget< InstanceType> > { @@ -134,8 +137,22 @@ export class DownloadRequest extends TypedEventTarget< this.#abortController.abort() } + #dispatchProgress = throttle((update: DownloadStateUpdate) => { + this.dispatchEvent(new StateUpdateEvent(update)) + }, PROGRESS_THROTTLE_MS) + #updateState(update: DownloadStateUpdate) { + // Update #state synchronously so the transform stream's running byte + // count always reads the latest value; only the progress event dispatch + // is throttled. this.#state = { ...this.#state, ...update } - this.dispatchEvent(new StateUpdateEvent(update)) + if (update.status === 'downloading') { + this.#dispatchProgress(update) + } else { + // Emit any pending progress update before the terminal state so + // consumers always see the final bytesDownloaded value. + this.#dispatchProgress.flush() + this.dispatchEvent(new StateUpdateEvent(update)) + } } } diff --git a/src/lib/map-share.ts b/src/lib/map-share.ts index ba64627..0704abc 100644 --- a/src/lib/map-share.ts +++ b/src/lib/map-share.ts @@ -7,8 +7,11 @@ import { } from '../types.js' import { errors, jsonError } from './errors.js' import { StateUpdateEvent } from './state-update-event.js' +import { throttle } from './throttle.js' import { addTrailingSlash, generateId, getErrorCode } from './utils.js' +const PROGRESS_THROTTLE_MS = 100 + export type MapShareOptions = MapInfo & { /** * Base URLs to construct the download URLs for the map share. Multiple URLs @@ -69,6 +72,10 @@ export class MapShare extends TypedEventTarget< this.#download.addEventListener('update', (event) => { this.#updateState(event) }) + // Synchronously transition to 'downloading' so that any concurrent + // download attempt observes the non-pending state immediately, without + // waiting for the DownloadResponse's async transform start callback. + this.#updateState({ status: 'downloading', bytesDownloaded: 0 }) return this.#download.response } @@ -119,7 +126,7 @@ export class MapShare extends TypedEventTarget< * share in the future (multiple downloads per share will make the "state" of a * MapShare harder to reason about and define). */ -export class DownloadResponse extends TypedEventTarget< +class DownloadResponse extends TypedEventTarget< InstanceType> > { #stream: TransformStream @@ -181,8 +188,19 @@ export class DownloadResponse extends TypedEventTarget< this.#abortController.abort() } + #dispatchProgress = throttle((update: DownloadStateUpdate) => { + this.dispatchEvent(new StateUpdateEvent(update)) + }, PROGRESS_THROTTLE_MS) + #updateState(update: DownloadStateUpdate) { this.#state = update - this.dispatchEvent(new StateUpdateEvent(update)) + if (update.status === 'downloading') { + this.#dispatchProgress(update) + } else { + // Emit any pending progress update before the terminal state so + // consumers always see the final bytesDownloaded value. + this.#dispatchProgress.flush() + this.dispatchEvent(new StateUpdateEvent(update)) + } } } diff --git a/src/lib/throttle.ts b/src/lib/throttle.ts new file mode 100644 index 0000000..dfe3ec3 --- /dev/null +++ b/src/lib/throttle.ts @@ -0,0 +1,50 @@ +/** + * Leading-edge throttle: the first call fires immediately, subsequent calls + * within `wait` ms are coalesced and dispatched on a trailing edge once the + * window expires. + * + * The returned function exposes `flush()` to dispatch any pending call + * immediately and `cancel()` to discard it. + */ +export function throttle( + fn: (...args: A) => void, + wait: number, +): ((...args: A) => void) & { flush(): void; cancel(): void } { + let lastDispatchedAt = 0 + let timer: ReturnType | undefined + let pendingArgs: A | undefined + + const flush = () => { + if (timer !== undefined) { + clearTimeout(timer) + timer = undefined + } + if (!pendingArgs) return + const args = pendingArgs + pendingArgs = undefined + lastDispatchedAt = Date.now() + fn(...args) + } + + const throttled = (...args: A) => { + pendingArgs = args + if (timer !== undefined) return + const remaining = wait - (Date.now() - lastDispatchedAt) + if (remaining <= 0) { + flush() + } else { + timer = setTimeout(flush, remaining) + } + } + + throttled.flush = flush + throttled.cancel = () => { + if (timer !== undefined) { + clearTimeout(timer) + timer = undefined + } + pendingArgs = undefined + } + + return throttled +}