From 83284722d9df7239ff4357cbd52e17b1e6f13a6e Mon Sep 17 00:00:00 2001 From: marknesh Date: Fri, 21 Aug 2026 10:44:52 +0300 Subject: [PATCH] Add `downloadStream()` method for streaming large downloads with progress tracking and cancellation support. --- .changeset/stream-large-downloads.md | 5 + .../docs/content/docs/api/storage-manager.mdx | 26 ++++ apps/docs/content/docs/api/types.mdx | 27 ++++ .../content/docs/guides/querying-files.mdx | 33 +++++ packages/firebase-storage-kit/README.md | 14 +++ .../src/core/storage-manager.ts | 48 ++++++++ packages/firebase-storage-kit/src/index.ts | 1 + .../src/types/download.ts | 15 +++ .../tests/storage-manager.test.ts | 115 +++++++++++++++++- 9 files changed, 283 insertions(+), 1 deletion(-) create mode 100644 .changeset/stream-large-downloads.md create mode 100644 packages/firebase-storage-kit/src/types/download.ts diff --git a/.changeset/stream-large-downloads.md b/.changeset/stream-large-downloads.md new file mode 100644 index 0000000..b9a66eb --- /dev/null +++ b/.changeset/stream-large-downloads.md @@ -0,0 +1,5 @@ +--- +"firebase-storage-kit": patch +--- + +Add `downloadStream()` for progress-aware, cancellable downloads without buffering the entire object in memory. diff --git a/apps/docs/content/docs/api/storage-manager.mdx b/apps/docs/content/docs/api/storage-manager.mdx index 34c6c8b..6d118d8 100644 --- a/apps/docs/content/docs/api/storage-manager.mdx +++ b/apps/docs/content/docs/api/storage-manager.mdx @@ -76,6 +76,32 @@ getDownloadURL(path: string): Promise Returns a download URL for the object at `path`. +### downloadStream + +```ts +downloadStream( + path: string, + options?: DownloadStreamOptions +): Promise +``` + +Fetches the object and returns a `ReadableStream` without buffering the whole download in memory. `onProgress(loaded, total)` runs as the caller consumes chunks from the stream, and `signal` accepts an `AbortSignal` for cancellation. + +```ts +const controller = new AbortController(); +const { stream, totalBytes, contentType } = await manager.downloadStream( + "videos/launch-demo.mp4", + { + signal: controller.signal, + onProgress: (loaded, total) => { + console.log(`${Math.round((loaded / total) * 100)}%`); + }, + } +); +``` + +The returned `totalBytes` comes from file's object metadata. Progress does not advance until `stream` is read, piped, or otherwise consumed. + ### delete ```ts diff --git a/apps/docs/content/docs/api/types.mdx b/apps/docs/content/docs/api/types.mdx index 4703a9e..13afdef 100644 --- a/apps/docs/content/docs/api/types.mdx +++ b/apps/docs/content/docs/api/types.mdx @@ -7,6 +7,8 @@ Key types re-exported from `firebase-storage-kit`. Import them alongside classes ```ts import type { + DownloadStreamOptions, + DownloadStreamResult, UploadOptions, UploadItem, StorageState, @@ -30,6 +32,31 @@ interface UploadOptions { `onConflict` defaults to `"overwrite"`. See [Conflict handling](/docs/guides/conflict-handling) for behavior, examples, and cross-client limitations. +## DownloadStreamOptions + +Options for `downloadStream`: + +```ts +interface DownloadStreamOptions { + onProgress?: (loaded: number, total: number) => void; + signal?: AbortSignal; +} +``` + +`onProgress` runs when the returned stream is consumed. Abort `signal` to cancel both the request and further stream reads. + +## DownloadStreamResult + +```ts +interface DownloadStreamResult { + stream: ReadableStream; + totalBytes: number; + contentType?: string; +} +``` + +Pipe or read `stream` to receive the object without first collecting the entire download into a `Blob`. + ## UploadValidationOptions Pre-upload validation rules. See [Validation](/docs/guides/validation) for usage and error handling. diff --git a/apps/docs/content/docs/guides/querying-files.mdx b/apps/docs/content/docs/guides/querying-files.mdx index 48fb358..a33bd21 100644 --- a/apps/docs/content/docs/guides/querying-files.mdx +++ b/apps/docs/content/docs/guides/querying-files.mdx @@ -38,6 +38,39 @@ const url = await manager.getDownloadURL("uploads/photo.jpg"); Returns a public or tokenized URL depending on your bucket rules and object ACLs. +## Stream a large download + +Use `downloadStream` when the object should flow to another destination without first becoming one large in-memory `Blob`: + +```ts +const fileHandle = await window.showSaveFilePicker({ + suggestedName: "launch-demo.mp4", +}); +const writable = await fileHandle.createWritable(); + +const { stream } = await manager.downloadStream("videos/launch-demo.mp4", { + onProgress: (loaded, total) => { + console.log(`${Math.round((loaded / total) * 100)}%`); + }, +}); + +await stream.pipeTo(writable); +``` + +Progress starts when `pipeTo` consumes the stream. The File System Access API used above is primarily available in Chromium-based browsers; in other browsers, pipe the stream to a supported destination or use `getDownloadURL` for normal browser playback and downloads. + +Cancel an active request with an `AbortController`: + +```ts +const controller = new AbortController(); +const { stream } = await manager.downloadStream("exports/account-data.zip", { + signal: controller.signal, +}); + +cancelButton.addEventListener("click", () => controller.abort()); +await stream.pipeTo(writable); +``` + ## Delete a file ```ts diff --git a/packages/firebase-storage-kit/README.md b/packages/firebase-storage-kit/README.md index 1e1adeb..9bc9eb5 100644 --- a/packages/firebase-storage-kit/README.md +++ b/packages/firebase-storage-kit/README.md @@ -54,6 +54,20 @@ handle.on("error", (upload) => { }); ``` +### Stream large downloads + +```ts +const { stream } = await manager.downloadStream("videos/launch-demo.mp4", { + onProgress: (loaded, total) => { + console.log(`${Math.round((loaded / total) * 100)}%`); + }, +}); + +await stream.pipeTo(writable); +``` + +The object is delivered as a `ReadableStream`, so it can be piped without buffering the full file in memory. + ### React ```tsx diff --git a/packages/firebase-storage-kit/src/core/storage-manager.ts b/packages/firebase-storage-kit/src/core/storage-manager.ts index 0cd4180..5355289 100644 --- a/packages/firebase-storage-kit/src/core/storage-manager.ts +++ b/packages/firebase-storage-kit/src/core/storage-manager.ts @@ -1,4 +1,8 @@ import type { StorageProvider } from "../providers/provider"; +import type { + DownloadStreamOptions, + DownloadStreamResult, +} from "../types/download"; import type { ListOptions, StorageListResult } from "../types/list"; import type { FileMetadata } from "../types/metadata"; import type { ProviderUploadTask, UploadOptions } from "../types/provider"; @@ -69,6 +73,50 @@ export class StorageManager { return await this.provider.getDownloadURL(path); } + /** + * Streams the object at `path` without buffering it in memory. + * + * Progress is reported as the returned stream is consumed. + */ + async downloadStream( + path: string, + options: DownloadStreamOptions = {} + ): Promise { + const [downloadURL, metadata] = await Promise.all([ + this.provider.getDownloadURL(path), + this.provider.getMetadata(path), + ]); + const response = await fetch(downloadURL, { signal: options.signal }); + + if (!response.ok) { + throw new Error( + `Download failed with ${response.status} ${response.statusText}`.trim() + ); + } + if (!response.body) { + throw new Error("Download failed because the response body is empty"); + } + + let loaded = 0; + const stream = response.body.pipeThrough( + new TransformStream({ + transform: (chunk, controller) => { + loaded += chunk.byteLength; + options.onProgress?.(loaded, metadata.size); + controller.enqueue(chunk); + }, + }) + ); + const contentType = + response.headers.get("content-type") ?? metadata.contentType; + + return { + stream, + totalBytes: metadata.size, + ...(contentType === undefined ? {} : { contentType }), + }; + } + /** Deletes the object at `path`. */ async delete(path: string): Promise { await this.provider.delete(path); diff --git a/packages/firebase-storage-kit/src/index.ts b/packages/firebase-storage-kit/src/index.ts index fc32b3e..c588e0e 100644 --- a/packages/firebase-storage-kit/src/index.ts +++ b/packages/firebase-storage-kit/src/index.ts @@ -22,6 +22,7 @@ export type { ValidationErrorCode } from "./core/validation"; export * from "./core/upload-handle"; export { StorageManager } from "./firebase-storage-manager"; +export type * from "./types/download"; export type * from "./types/list"; export type * from "./types/metadata"; export type * from "./types/provider"; diff --git a/packages/firebase-storage-kit/src/types/download.ts b/packages/firebase-storage-kit/src/types/download.ts new file mode 100644 index 0000000..d609aef --- /dev/null +++ b/packages/firebase-storage-kit/src/types/download.ts @@ -0,0 +1,15 @@ +export interface DownloadStreamOptions { + /** Called as chunks are consumed from the returned stream. */ + onProgress?: (loaded: number, total: number) => void; + /** Cancels the request and stream when aborted. */ + signal?: AbortSignal; +} + +export interface DownloadStreamResult { + /** The object's MIME type when available. */ + contentType?: string; + /** The streamed object bytes. */ + stream: ReadableStream; + /** Total object size in bytes. */ + totalBytes: number; +} diff --git a/packages/firebase-storage-kit/tests/storage-manager.test.ts b/packages/firebase-storage-kit/tests/storage-manager.test.ts index c972fcd..26b1b6f 100644 --- a/packages/firebase-storage-kit/tests/storage-manager.test.ts +++ b/packages/firebase-storage-kit/tests/storage-manager.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, mock } from "bun:test"; +import { describe, expect, it, mock, spyOn } from "bun:test"; import { StorageManager } from "../src/core/storage-manager"; import { @@ -134,6 +134,119 @@ describe("StorageManager", () => { }); }); + describe("downloadStream", () => { + it("streams bytes and reports cumulative progress as they are consumed", async () => { + const { provider, spies } = createMockProvider({ + getDownloadURL: async () => { + await Promise.resolve(); + return "https://cdn.example/large-video.mp4"; + }, + getMetadata: async (path) => { + await Promise.resolve(); + return { + contentType: "video/mp4", + createdAt: new Date("2024-01-01T00:00:00Z"), + path, + size: 11, + updatedAt: new Date("2024-01-02T00:00:00Z"), + }; + }, + }); + const encoder = new TextEncoder(); + const responseBody = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode("hello ")); + controller.enqueue(encoder.encode("world")); + controller.close(); + }, + }); + const fetchMock = spyOn(globalThis, "fetch").mockResolvedValue( + new Response(responseBody, { + headers: { "content-type": "video/mp4" }, + }) + ); + const manager = new StorageManager(provider); + const progress: [number, number][] = []; + const abortController = new AbortController(); + + try { + const result = await manager.downloadStream("videos/large-video.mp4", { + onProgress: (loaded, total) => { + progress.push([loaded, total]); + }, + signal: abortController.signal, + }); + + expect(progress).toEqual([]); + expect(result.totalBytes).toBe(11); + expect(result.contentType).toBe("video/mp4"); + expect( + new TextDecoder().decode( + await new Response(result.stream).arrayBuffer() + ) + ).toBe("hello world"); + expect(progress).toEqual([ + [6, 11], + [11, 11], + ]); + expect(spies.getDownloadURL).toHaveBeenCalledWith( + "videos/large-video.mp4" + ); + expect(spies.getMetadata).toHaveBeenCalledWith( + "videos/large-video.mp4" + ); + expect(fetchMock).toHaveBeenCalledWith( + "https://cdn.example/large-video.mp4", + { signal: abortController.signal } + ); + } finally { + fetchMock.mockRestore(); + } + }); + + it("rejects unsuccessful download responses", async () => { + const { provider } = createMockProvider(); + const fetchMock = spyOn(globalThis, "fetch").mockResolvedValue( + new Response("Forbidden", { status: 403, statusText: "Forbidden" }) + ); + const manager = new StorageManager(provider); + + try { + let caught: unknown; + try { + await manager.downloadStream("private/report.pdf"); + } catch (error) { + caught = error; + } + expect(caught).toEqual(new Error("Download failed with 403 Forbidden")); + } finally { + fetchMock.mockRestore(); + } + }); + + it("rejects successful responses without a body", async () => { + const { provider } = createMockProvider(); + const fetchMock = spyOn(globalThis, "fetch").mockResolvedValue( + new Response(null) + ); + const manager = new StorageManager(provider); + + try { + let caught: unknown; + try { + await manager.downloadStream("empty/object"); + } catch (error) { + caught = error; + } + expect(caught).toEqual( + new Error("Download failed because the response body is empty") + ); + } finally { + fetchMock.mockRestore(); + } + }); + }); + describe("query delegation", () => { it("delegates exists to the provider", async () => { const { provider, spies } = createMockProvider({