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
5 changes: 4 additions & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,10 @@ left waiting for a `controllerchange` that is never coming.
returns a `WritableStream` backed by the MessagePort sink (one `PULL` credit
per `WRITE`, so the service worker's 4-chunk queue is the backpressure
boundary). Aborting the writable errors the response so the browser shows a
failed download instead of a truncated file.
failed download instead of a truncated file. The iframe is only removed on
failure, or when the next download starts: iOS Safari finalises a download
when its initiating frame goes away, even while accepted bytes are still
being flushed to disk.

### `export` (browser only, covered by e2e)

Expand Down
6 changes: 6 additions & 0 deletions src/lib/download/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,12 @@ activation from the click is still live. The worker cannot see a navigation
that never reached it, so the page waits for its `downloadStarted` message
before handing over the `MessagePort` and returning the writable.

The iframe outlives the writable. The worker's `PULL`s only mean the
browser's network layer has read the bytes, not that they are on disk, and
iOS Safari finalises a download the moment its initiating frame is removed,
saving only what had been flushed. So `cleanup()` is for failures; after
success the iframe stays until the next `startDownload` replaces it.

## Backpressure and errors

The sink posts one `WRITE` per chunk and waits for the worker's `PULL` before
Expand Down
14 changes: 12 additions & 2 deletions src/lib/download/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,15 @@ export interface DownloadOptions {

export interface Download {
writable: WritableStream<Uint8Array>;
/** Removes the iframe that started the download. */
/** Removes the iframe that started the download. Call it only after a
* failure: iOS Safari finalises a download when its initiating frame goes
* away, even while bytes it has already accepted are still being flushed
* to disk, so after success the iframe stays until the next download. */
cleanup(): void;
}

let activeIframe: HTMLIFrameElement | undefined;

export function registerDownloadWorker(): void {
if (!("serviceWorker" in navigator)) return;
// `updateViaCache: 'none'` bypasses the browser's 24h HTTP-cache rule for sw.js.
Expand Down Expand Up @@ -143,11 +148,16 @@ async function prepareSwDownload(
// Navigate first and synchronously, before any await: Safari only starts a
// download while the click's user activation is live. The service worker
// holds the request open until the stream below reaches it.
activeIframe?.remove();
const iframe = document.createElement("iframe");
iframe.hidden = true;
iframe.src = url;
document.body.appendChild(iframe);
const cleanup = () => iframe.remove();
activeIframe = iframe;
const cleanup = () => {
iframe.remove();
if (activeIframe === iframe) activeIframe = undefined;
};

// Nothing is generated until the request is known to have arrived.
if (!(await waitForDownloadStart(url, DOWNLOAD_START_TIMEOUT_MS))) {
Expand Down
6 changes: 4 additions & 2 deletions src/lib/export/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,5 +51,7 @@ last band is rendered rather than as the last byte is written.

Aborting `signal` stops the mosaic and aborts the download, so the browser
reports a failed download instead of leaving a truncated PNG. Any other
failure does the same. The renderer is destroyed and the download's iframe
removed in `finally`, whatever happened.
failure does the same, and removes the download's iframe. After success the
iframe is left in place: iOS Safari finalises a download when its initiating
frame is removed, even while accepted bytes are still being flushed to disk.
The renderer is destroyed in `finally`, whatever happened.
2 changes: 1 addition & 1 deletion src/lib/export/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,10 +125,10 @@ export async function exportMap(opts: ExportOptions): Promise<ExportResult> {
// The response only errors if the worker is told; a failure before or
// outside `pipeTo` would otherwise leave a truncated file on disk.
await writable.abort(err).catch(() => {});
cleanup();
throw err;
} finally {
renderer?.destroy();
cleanup();
}

return { bbox: viewportBbox(viewport), zoom: viewport.zoom };
Expand Down