Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(blob): multipart upload #71

Merged
merged 20 commits into from
May 31, 2024
Merged
Show file tree
Hide file tree
Changes from 8 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
281 changes: 281 additions & 0 deletions docs/content/docs/2.storage/3.blob.md
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,150 @@ You can also use the `delete()` method as alias of `del()`.

Returns nothing.

### `createMultipartUpload()`

Start a new multipart upload.

```ts [server/api/files/multipart/[...pathname\\].post.ts]
export default eventHandler(async (event) => {
const { pathname } = getRouterParams(event)

const mpu = await hubBlob().createMultipartUpload(pathname)

return {
uploadId: mpu.uploadId,
pathname: mpu.pathname,
}
})
```

#### Params

::field-group
::field{name="pathname" type="String"}
The name of the blob to serve.
::
::field{name="options" type="Object"}
The put options. Any other provided field will be stored in the blob's metadata.
::field{name="contentType" type="String"}
The content type of the blob. If not given, it will be inferred from the Blob or the file extension.
::
::field{name="contentLength" type="String"}
The content length of the blob.
::
::field{name="addRandomSuffix" type="Boolean"}
If `true`, a random suffix will be added to the blob's name. Defaults to `true`.
::
::
::

#### Return

Returns a `BlobMultipartUpload`

### `resumeMultipartUpload()`

Continue processing of unfinished multipart upload.

To upload a part of the multipart upload, you can use the `uploadPart()` method:

```ts [server/api/files/multipart/[...pathname\\].put.ts]
export default eventHandler(async (event) => {
const { pathname } = getRouterParams(event)
const { uploadId, partNumber } = getQuery(event)

const stream = getRequestWebStream(event)!
const body = await streamToArrayBuffer(stream, contentLength)

const mpu = hubBlob().resumeMultipartUpload(pathname, uploadId)
return await mpu.uploadPart(partNumber, body)
})
```

Complete the upload by calling `complete()` method:

```ts [server/api/files/multipart/complete.post.ts]
export default eventHandler(async (event) => {
const { pathname, uploadId } = getQuery(event)
const parts = await readBody(event)

const mpu = hubBlob().resumeMultipartUpload(pathname, uploadId)
return await mpu.complete(parts)
})
```

If you want to cancel the upload, you need to call `abort()` method:

```ts [server/api/files/multipart/[...pathname\\].delete.ts]
export default eventHandler(async (event) => {
const { pathname } = getRouterParams(event)
const { uploadId } = getQuery(event)

const mpu = hubBlob().resumeMultipartUpload(pathname, uploadId)
await mpu.abort()

return sendNoContent(event)
})
```

A simple example of multipart upload in client with above routes:

```ts [utils/multipart-upload.ts]
async function uploadLargeFile(file: File) {
const chunkSize = 10 * 1024 * 1024 // 10MB

const count = Math.ceil(file.size / chunkSize)
const { pathname, uploadId } = await $fetch(
`/api/files/multipart/${file.name}`,
{ method: 'POST' },
)

const uploaded = []

for (let i = 0; i < count; i++) {
const start = i * chunkSize
const end = Math.min(start + chunkSize, file.size)
const partNumber = i + 1
const chunk = file.slice(start, end)

const part = await $fetch(
`/api/files/multipart/${pathname}`,
{
method: 'PUT',
query: { uploadId, partNumber },
body: chunk,
},
)

uploaded.push(part)
}

return await $fetch(
'/api/files/multipart/complete',
{
method: 'POST',
query: { pathname, uploadId },
body: { parts: uploaded },
},
)
}
```

#### Params

::field-group
::field{name="pathname" type="String"}
The name of the blob to serve.
::
::field{name="uploadId" type="String"}
The upload ID of the multipart upload.
::
::

#### Return

Returns a `BlobMultipartUpload`


## `ensureBlob()`

Expand Down Expand Up @@ -224,6 +368,86 @@ Returns nothing.

Throws an error if `file` doesn't meet the requirements.


## `createMultipartUploader()`

Client composable that creates a multipart upload helper.

```ts
export const useMultipartUpload = createMultipartUploader({
create: (file) => $fetch(
`/api/files/multipart/${file.name}`,
{ method: 'POST' },
),
upload: ({ partNumber, chunkBody }, { pathname, uploadId }) =>
$fetch(
`/api/files/multipart/${pathname}`,
{
method: 'PUT',
query: { uploadId, partNumber },
body: chunkBody,
},
),
complete: (parts, { pathname, uploadId }) =>
$fetch('/api/files/multipart/complete',
{
method: 'POST',
query: { pathname, uploadId },
body: { parts },
},
),
abort: ({ pathname, uploadId }) =>
$fetch(`/api/files/multipart/${pathname}`, {
method: 'DELETE',
query: { uploadId },
}),
})
```

### Params

::field-group
::field{name="options" type="MultipartUploaderOptions<TCreateResponse, TUploadResponse, TCompleteResponse>" required}
The options for the multipart upload helper.
::field{name="create" type="(file: File) => Promise<TCreateResponse> | TCreateResponse" required}
Provide a function to create the initial data for the upload.
::
::field{name="upload" type="(chunk: MultipartUploadChunk, data: TCreateResponse, file: File) => Promise<TUploadResponse> | TUploadResponse" required}
Provide a function to upload a chunk of the file.
::
::field{name="complete" type="(parts: TUploadResponse[], data: TCreateResponse, file: File) => void" required}
Provide a function to complete the upload.
::
::field{name="abort" type="(data: TCreateResponse, file: File) => Promise<void> | void"}
Provide a function to abort the upload.
::
::field{name="verify" type="(response: TUploadResponse, chunk: MultipartUploadChunk) => Promise<boolean> | boolean"}
Provide a function to verify the upload response. Defaults to `() => true`.
::
::field{name="partSize" type="number"}
The size of each part of the file to be uploaded. Defaults to `10MB`.
::
::field{name="concurrent" type="number"}
The maximum number of concurrent uploads. Defaults to `1`.
::
::field{name="maxRetry" type="number"}
The maximum number of retry attempts for the whole upload. Defaults to `3`.
::
::
::

### Return

Return a `MultipartUploader` function that can be used to upload a file in parts.

```ts
export function upload(file: File) {
const { completed, progress, cancel } = useMultipartUpload(file)
return await completed
}
```


## Types

### `BlobObject`
Expand All @@ -236,3 +460,60 @@ interface BlobObject {
uploadedAt: Date
}
```

### `BlobMultipartUpload`

```ts
export interface BlobMultipartUpload {
pathname: string
uploadId: string
uploadPart(
partNumber: number,
value: string | ReadableStream<any> | ArrayBuffer | ArrayBufferView | Blob
): Promise<BlobUploadedPart>
abort(): Promise<void>
complete(uploadedParts: BlobUploadedPart[]): Promise<BlobObject>
}
```

### `BlobUploadedPart`

```ts
export interface BlobUploadedPart {
partNumber: number;
etag: string;
}
```

### `MultipartUploader`

```ts
export type MultipartUploader<
TCreateResponse,
TUploadResponse,
TCompleteResponse,
> = (
file: File,
optionsOverride?: Partial<Omit<
MultipartUploaderOptions<
TCreateResponse,
TUploadResponse,
TCompleteResponse
>,
'create' | 'upload' | 'complete' | 'abort'
>>
) => {
completed: Promise<TCompleteResponse | undefined>
progress: Readonly<Ref<number>>
cancel: () => Promise<void>
}
```

### `MultipartUploadChunk`

```ts
export interface MultipartUploadChunk {
partNumber: number
chunkBody: Blob
}
```
33 changes: 33 additions & 0 deletions playground/composables/multipart.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import type { SerializeObject } from 'nitropack'

export const useMultipartUpload = createMultipartUploader({
create: (file) => $fetch<{pathname: string, uploadId: string}>(
`/api/blob/multipart/${file.name}`,
{ method: 'POST' },
),
upload: ({ partNumber, chunkBody }, { pathname, uploadId }) =>
$fetch<BlobUploadedPart>(
`/api/blob/multipart/${pathname}`,
{
method: 'PUT',
query: { uploadId, partNumber },
body: chunkBody,
},
),
complete: (parts, { pathname, uploadId }) =>
$fetch<SerializeObject<BlobObject>>(
'/api/blob/multipart/complete',
{
method: 'POST',
query: { pathname, uploadId },
body: { parts },
},
),

abort: ({ pathname, uploadId }) =>
$fetch<void>(`/api/blob/multipart/${pathname}`, {
method: 'DELETE',
query: { uploadId },
}),
concurrent: 2,
})
Loading