Skip to content

[blob] implement client upload #179

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

Merged
merged 20 commits into from
Jun 15, 2023
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: 5 additions & 0 deletions .changeset/selfish-shrimps-jog.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@vercel/blob': minor
---

Implement Client Upload
157 changes: 156 additions & 1 deletion packages/blob/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,13 +35,14 @@ Upload a blob to the Vercel Blob API, and returns the URL of the blob.
```ts
async function put(
pathname: string,
body: ReadableStream | String | ArrayBuffer | Blob // All fetch body types are supported: https://developer.mozilla.org/en-US/docs/Web/API/fetch#body
body: ReadableStream | String | ArrayBuffer | Blob | File // All fetch body types are supported: https://developer.mozilla.org/en-US/docs/Web/API/fetch#body
options: {
access: 'public', // mandatory, as we will provide private blobs in the future
contentType?: string, // by default inferred from pathname
// `token` defaults to process.env.BLOB_READ_WRITE_TOKEN on Vercel
// and can be configured when you connect more stores to a project
// or using Vercel Blob outside of Vercel
// on the client `token` is mandatory and must be generated by "generateClientTokenFromReadWriteToken"
token?: string,
}): Promise<{
size: number;
Expand Down Expand Up @@ -128,6 +129,27 @@ async function list(options?: {
}> {}
```

### generateClientTokenFromReadWriteToken(options)

Generates a single-use token that can be used from within the client. This is useful when [uploading directly from browsers](#uploading-directly-from-browsers) to circumvent the 4MB limitation of going through a Vercel-hosted route.

Once created, a client token is valid for 30 seconds. This means you have 30 seconds to initiate an upload with this token.

```ts
async function generateClientTokenFromReadWriteToken(options?: {
token?: string;
pathname?: string;
onUploadCompleted?: {
callbackUrl: string;
metadata?: string;
};
maximumSizeInBytes?: number;
allowedContentTypes?: string[];
}): string {}
```

Note: This method should be called server-side, not client-side.

## Examples

- [Next.js App Router examples](../../test/next/src/app/vercel/blob/)
Expand Down Expand Up @@ -202,6 +224,139 @@ export async function POST(request: Request) {
}
```

### Uploading directly from browsers

The above example uploads a file through a vercel route. This solution is limited to a 4Mb file size. In order to bypass this limit, it is possible to upload a file directly from within the client, after generating a single-use token.

```tsx
// /app/UploadForm.tsx

'use client';

import type { BlobResult, put } from '@vercel/blob';
import { useState } from 'react';

export default function UploadForm() {
const inputFileRef = useRef<HTMLInputElement>(null);
const [blob, setBlob] = useState<BlobResult | null>(null);
return (
<>
<h1>App Router Client Upload</h1>

<form
onSubmit={async (event): Promise<void> => {
event.preventDefault();

const file = inputFileRef.current?.files?.[0];
if (!file) {
return;
}

const clientTokenData = (await fetch(
'/api/generate-blob-client-token',
{
method: 'POST',
body: JSON.stringify({
pathname: file.name,
}),
},
).then((r) => r.json())) as { clientToken: string };

const blobResult = await put(file.name, file, {
access: 'public',
token: clientTokenData.clientToken,
});

setBlob(blobResult);
}}
>
<input name="file" ref={inputFileRef} type="file" />
<button type="submit">Upload</button>
</form>
{blob && (
<div>
Blob url: <a href={blob.url}>{blob.url}</a>
</div>
)}
</>
);
}
```

```ts
// /app/api/generate-blob-client-token/route.ts

import {
generateClientTokenFromReadWriteToken,
type GenerateClientTokenOptions,
} from '@vercel/blob';
import { NextResponse } from 'next/server';

export async function POST(request: Request): Promise<NextResponse> {
// On a real website, this route would be protected by authentication, see: https://nextjs.org/docs/pages/building-your-application/routing/authenticating
// Here, we accept the `pathname` from the browser, but in some situations, you may even craft the pathname
// based on the authentication result
const body = (await request.json()) as { pathname: string };

return NextResponse.json({
clientToken: await generateClientTokenFromReadWriteToken({
...body,
onUploadCompleted: {
callbackUrl: `https://${
process.env.VERCEL_URL ?? ''
}/api/file-upload-completed`,
metadata: JSON.stringify({ userId: 12345 }),
},
maximumSizeInBytes: 10_000_000, // 10 Mb
allowedContentTypes: 'text/plain',
}),
});
}
```

```ts
// /app/api/file-upload-completed/route.ts

import {
type BlobUploadCompletedEvent,
verifyCallbackSignature,
} from '@vercel/blob';
import { NextResponse } from 'next/server';

export async function POST(request: Request): Promise<NextResponse> {
const body = (await request.json()) as BlobUploadCompletedEvent;
console.log(body);
// { type: "blob.upload-completed", payload: { metadata: "{ foo: 'bar' }", blob: ... }}

if (
!(await verifyCallbackSignature({
signature: request.headers.get('x-vercel-signature') ?? '',
body: JSON.stringify(body),
}))
) {
return NextResponse.json(
{
response: 'invalid signature',
},
{
status: 403,
},
);
}
const metadata = JSON.parse(body.payload.metadata as string) as {
userId: string;
};
const blob = body.payload.blob;

console.log(metadata.userId); // 12345
console.log(blob); // { url: '...', size: ..., uploadedAt: ..., ... }

return NextResponse.json({
response: 'ok',
});
}
```

## How to list all your blobs

This will paginate through all your blobs in chunks of 1,000 blobs.
Expand Down
3 changes: 3 additions & 0 deletions packages/blob/jest/setup.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
const { TextEncoder, TextDecoder } = require('node:util');

Object.assign(global, { TextDecoder, TextEncoder });
23 changes: 8 additions & 15 deletions packages/blob/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,42 +11,35 @@
"license": "Apache-2.0",
"sideEffects": false,
"type": "module",
"exports": {
".": {
"import": {
"node": "./dist/index.js",
"default": "./dist/index.js"
},
"require": {
"node": "./dist/index.cjs",
"default": "./dist/index.cjs"
}
}
},
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"browser": {
"undici": "./dist/undici-browser.js"
"undici": "./dist/undici-browser.js",
"crypto": "./dist/crypto-browser.js"
},
"types": "./dist/index.d.ts",
"files": [
"dist"
],
"scripts": {
"build": "tsup && cp src/undici-browser.js dist/undici-browser.js",
"build": "tsup && cp src/undici-browser.js dist/undici-browser.js && cp src/crypto-browser.js dist/crypto-browser.js",
"dev": "cp src/undici-browser.js dist/undici-browser.js && tsup --watch --clean=false",
"lint": "eslint --max-warnings=0 .",
"prepublishOnly": "pnpm run build",
"prettier-check": "prettier --check .",
"publint": "npx publint",
"test": "jest",
"test": "pnpm run test:node && pnpm run test:edge && pnpm run test:browser",
"test:browser": "jest --env jsdom .browser.test.ts --setupFilesAfterEnv ./jest/setup.js",
"test:edge": "jest --env @edge-runtime/jest-environment .edge.test.ts",
"test:node": "jest --env node .node.test.ts",
"type-check": "tsc --noEmit"
},
"jest": {
"preset": "ts-jest",
"testEnvironment": "node"
},
"dependencies": {
"jest-environment-jsdom": "29.5.0",
"undici": "5.22.0"
},
"devDependencies": {
Expand Down
7 changes: 7 additions & 0 deletions packages/blob/src/crypto-browser.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export function createHmac() {
throw new Error('Not implemented');
}

export function timingSafeEqual() {
throw new Error('Not implemented');
}
60 changes: 60 additions & 0 deletions packages/blob/src/index.browser.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { put } from './index';

jest.mock('undici', () => ({
fetch: (): unknown =>
Promise.resolve({
status: 200,
json: () =>
Promise.resolve({
url: `${BASE_URL}/storeid/foo-id.txt`,
size: 12345,
uploadedAt: '2023-05-04T15:12:07.818Z',
pathname: 'foo.txt',
contentType: 'text/plain',
contentDisposition: 'attachment; filename="foo.txt"',
}),
}),
}));
const BASE_URL = 'https://blob.vercel-storage.com';

describe('blob client', () => {
beforeEach(() => {
jest.resetAllMocks();
});

describe('put', () => {
beforeEach(() => {
jest.resetAllMocks();
});

it('should upload a file from the client', async () => {
await expect(
put('foo.txt', 'Test Body', {
access: 'public',
token: 'vercel_blob_client_123_token',
}),
).resolves.toMatchInlineSnapshot(`
{
"contentDisposition": "attachment; filename="foo.txt"",
"contentType": "text/plain",
"pathname": "foo.txt",
"size": 12345,
"uploadedAt": 2023-05-04T15:12:07.818Z,
"url": "https://blob.vercel-storage.com/storeid/foo-id.txt",
}
`);
});

it('should throw when calling `put()` with a server token', async () => {
await expect(
put('foo.txt', 'Test Body', {
access: 'public',
contentType: 'text/plain',
token: 'vercel_blob_rw_123_TEST_TOKEN',
}),
).rejects.toThrow(
new Error('Vercel Blob: client upload only supports client tokens'),
);
});
});
});
Loading