Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion src/lib/download-request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<DownloadCreateParams, 'mapShareUrls'> & { downloadId: string }

const PROGRESS_THROTTLE_MS = 100

export class DownloadRequest extends TypedEventTarget<
InstanceType<typeof StateUpdateEvent<DownloadStateUpdate>>
> {
Expand Down Expand Up @@ -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))
}
}
}
22 changes: 20 additions & 2 deletions src/lib/map-share.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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<typeof StateUpdateEvent<DownloadStateUpdate>>
> {
#stream: TransformStream
Expand Down Expand Up @@ -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))
}
}
}
50 changes: 50 additions & 0 deletions src/lib/throttle.ts
Original file line number Diff line number Diff line change
@@ -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<A extends readonly unknown[]>(
fn: (...args: A) => void,
wait: number,
): ((...args: A) => void) & { flush(): void; cancel(): void } {
let lastDispatchedAt = 0
let timer: ReturnType<typeof setTimeout> | 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
}
Loading