Skip to content
This repository was archived by the owner on Jan 23, 2026. It is now read-only.

Commit e44d6d1

Browse files
committed
feat: fluent api for downloading as stream
1 parent cbc6251 commit e44d6d1

File tree

5 files changed

+115
-45
lines changed

5 files changed

+115
-45
lines changed

src/lib/types.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { StorageError } from './errors'
2+
13
export type BucketType = 'STANDARD' | 'ANALYTICS'
24

35
export interface Bucket {
@@ -164,3 +166,13 @@ type CamelCase<S extends string> = S extends `${infer P1}_${infer P2}${infer P3}
164166
export type Camelize<T> = {
165167
[K in keyof T as CamelCase<Extract<K, string>>]: T[K]
166168
}
169+
170+
export type DownloadResult<T> =
171+
| {
172+
data: T
173+
error: null
174+
}
175+
| {
176+
data: null
177+
error: StorageError
178+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { isStorageError } from '../lib/errors'
2+
import { DownloadResult } from '../lib/types'
3+
import StreamDownloadBuilder from './StreamDownloadBuilder'
4+
5+
export default class BlobDownloadBuilder implements PromiseLike<DownloadResult<Blob>> {
6+
constructor(private downloadFn: () => Promise<Response>, private shouldThrowOnError: boolean) {}
7+
8+
asStream(): StreamDownloadBuilder {
9+
return new StreamDownloadBuilder(this.downloadFn, this.shouldThrowOnError)
10+
}
11+
12+
then<TResult1 = DownloadResult<Blob>, TResult2 = never>(
13+
onfulfilled?: ((value: DownloadResult<Blob>) => TResult1 | PromiseLike<TResult1>) | null,
14+
onrejected?: ((reason: any) => TResult2 | PromiseLike<TResult2>) | null
15+
): Promise<TResult1 | TResult2> {
16+
return this.execute().then(onfulfilled, onrejected)
17+
}
18+
19+
private async execute(): Promise<DownloadResult<Blob>> {
20+
try {
21+
const result = await this.downloadFn()
22+
23+
return {
24+
data: await result.blob(),
25+
error: null,
26+
}
27+
} catch (error) {
28+
if (this.shouldThrowOnError) {
29+
throw error
30+
}
31+
32+
if (isStorageError(error)) {
33+
return { data: null, error }
34+
}
35+
36+
throw error
37+
}
38+
}
39+
}

src/packages/StorageFileApi.ts

Lines changed: 7 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
SearchV2Options,
1414
SearchV2Result,
1515
} from '../lib/types'
16+
import BlobDownloadBuilder from './BlobDownloadBuilder'
1617

1718
const DEFAULT_SEARCH_OPTIONS = {
1819
limit: 100,
@@ -521,52 +522,22 @@ export default class StorageFileApi {
521522
*
522523
* @param path The full path and file name of the file to be downloaded. For example `folder/image.png`.
523524
* @param options.transform Transform the asset before serving it to the client.
524-
* @param options.stream If set to true, the response will be a ReadableStream. Otherwise, it will be a Blob (default).
525525
*/
526-
async download<Options extends { transform?: TransformOptions, stream?: boolean }>(
526+
download<Options extends { transform?: TransformOptions }>(
527527
path: string,
528528
options?: Options
529-
): Promise<
530-
| {
531-
data: Options['stream'] extends true ? ReadableStream : Blob
532-
error: null
533-
}
534-
| {
535-
data: null
536-
error: StorageError
537-
}
538-
> {
529+
): BlobDownloadBuilder {
539530
const wantsTransformation = typeof options?.transform !== 'undefined'
540531
const renderPath = wantsTransformation ? 'render/image/authenticated' : 'object'
541532
const transformationQuery = this.transformOptsToQueryString(options?.transform || {})
542533
const queryString = transformationQuery ? `?${transformationQuery}` : ''
543-
544-
try {
545-
const _path = this._getFinalPath(path)
546-
const res = await get(this.fetch, `${this.url}/${renderPath}/${_path}${queryString}`, {
534+
const _path = this._getFinalPath(path)
535+
const downloadFn = () =>
536+
get(this.fetch, `${this.url}/${renderPath}/${_path}${queryString}`, {
547537
headers: this.headers,
548538
noResolveJson: true,
549539
})
550-
551-
if (!options?.stream) {
552-
const data = await res.blob()
553-
return { data, error: null }
554-
}
555-
556-
return {
557-
data: res.body,
558-
error: null,
559-
}
560-
} catch (error) {
561-
if (this.shouldThrowOnError) {
562-
throw error
563-
}
564-
if (isStorageError(error)) {
565-
return { data: null, error }
566-
}
567-
568-
throw error
569-
}
540+
return new BlobDownloadBuilder(downloadFn, this.shouldThrowOnError)
570541
}
571542

572543
/**
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { isStorageError } from '../lib/errors'
2+
import { DownloadResult } from '../lib/types'
3+
4+
export default class StreamDownloadBuilder implements PromiseLike<DownloadResult<ReadableStream>> {
5+
constructor(private downloadFn: () => Promise<Response>, private shouldThrowOnError: boolean) {}
6+
7+
then<TResult1 = DownloadResult<ReadableStream>, TResult2 = never>(
8+
onfulfilled?:
9+
| ((value: DownloadResult<ReadableStream>) => TResult1 | PromiseLike<TResult1>)
10+
| null,
11+
onrejected?: ((reason: any) => TResult2 | PromiseLike<TResult2>) | null
12+
): Promise<TResult1 | TResult2> {
13+
return this.execute().then(onfulfilled, onrejected)
14+
}
15+
16+
private async execute(): Promise<DownloadResult<ReadableStream>> {
17+
try {
18+
const result = await this.downloadFn()
19+
20+
return {
21+
data: result.body as ReadableStream,
22+
error: null,
23+
}
24+
} catch (error) {
25+
if (this.shouldThrowOnError) {
26+
throw error
27+
}
28+
29+
if (isStorageError(error)) {
30+
return { data: null, error }
31+
}
32+
33+
throw error
34+
}
35+
}
36+
}

test/storageFileApi.test.ts

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,12 @@ import * as fsp from 'fs/promises'
33
import * as fs from 'fs'
44
import * as path from 'path'
55
import assert from 'assert'
6+
import ReadableStream from 'node:stream'
67
// @ts-ignore
78
import fetch, { Response } from '@supabase/node-fetch'
89
import { StorageApiError, StorageError } from '../src/lib/errors'
10+
import BlobDownloadBuilder from '../src/packages/BlobDownloadBuilder'
11+
import StreamDownloadBuilder from '../src/packages/StreamDownloadBuilder'
912

1013
// TODO: need to setup storage-api server for this test
1114
const URL = 'http://localhost:8000/storage/v1'
@@ -408,11 +411,14 @@ describe('Object API', () => {
408411

409412
test('downloads an object', async () => {
410413
await storage.from(bucketName).upload(uploadPath, file)
411-
const res = await storage.from(bucketName).download(uploadPath)
412414

413-
expect(res.error).toBeNull()
414-
expect(res.data?.size).toBeGreaterThan(0)
415-
expect(res.data?.type).toEqual('text/plain;charset=utf-8')
415+
const blobBuilder = storage.from(bucketName).download(uploadPath)
416+
expect(blobBuilder).toBeInstanceOf(BlobDownloadBuilder)
417+
418+
const blobResponse = await blobBuilder
419+
expect(blobResponse.error).toBeNull()
420+
expect(blobResponse.data?.size).toBeGreaterThan(0)
421+
expect(blobResponse.data?.type).toEqual('text/plain;charset=utf-8')
416422

417423
// throws when .throwOnError is enabled
418424
await expect(
@@ -422,12 +428,18 @@ describe('Object API', () => {
422428

423429
test('downloads an object as a stream', async () => {
424430
await storage.from(bucketName).upload(uploadPath, file)
425-
const res = await storage.from(bucketName).download(uploadPath, {
426-
stream: true,
427-
})
428431

429-
expect(res.error).toBeNull()
430-
expect(res.data).toBeInstanceOf(ReadableStream)
432+
const streamBuilder = storage.from(bucketName).download(uploadPath).asStream()
433+
expect(streamBuilder).toBeInstanceOf(StreamDownloadBuilder)
434+
435+
const streamResponse = await streamBuilder
436+
expect(streamResponse.error).toBeNull()
437+
expect(streamResponse.data).toBeInstanceOf(ReadableStream)
438+
439+
// throws when .throwOnError is enabled
440+
await expect(
441+
storage.from(bucketName).throwOnError().download('non-existent-file').asStream()
442+
).rejects.toThrow()
431443
})
432444

433445
test('removes an object', async () => {

0 commit comments

Comments
 (0)