Skip to content

Commit 0c98feb

Browse files
authored
fix(blob): allow client uploads in web workers (#817)
* fix(blob): allow client uploads in web workers Before this commit, we had guards so client uploads could only be used in browser environments, this prevented customers to use Vercel Blob in Web Workers, sometimes React Native or in general anywhere window is not really what we think it is. * changeset * actually remove this code * remove * update * debug * remove console.logs * ensure random tests * update
1 parent 6d0383b commit 0c98feb

File tree

10 files changed

+149
-21
lines changed

10 files changed

+149
-21
lines changed

.changeset/three-rockets-arrive.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@vercel/blob': patch
3+
---
4+
5+
fix(blob): allow client uploads in web workers
6+
7+
Before this change, we had guards so client uploads could only be used in
8+
browser environments, this prevented customers to use Vercel Blob in Web
9+
Workers, sometimes React Native or in general anywhere window is not really what
10+
we think it is.

packages/blob/src/client.ts

Lines changed: 2 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -55,12 +55,6 @@ function createPutExtraChecks<
5555
TOptions extends ClientTokenOptions & ClientCommonCreateBlobOptions,
5656
>(methodName: string) {
5757
return function extraChecks(options: TOptions) {
58-
if (typeof window === 'undefined') {
59-
throw new BlobError(
60-
`${methodName} must be called from a client environment`,
61-
);
62-
}
63-
6458
if (!options.token.startsWith('vercel_blob_client_')) {
6559
throw new BlobError(`${methodName} must be called with a client token`);
6660
}
@@ -163,12 +157,6 @@ export type UploadOptions = ClientCommonPutOptions & CommonUploadOptions;
163157
export const upload = createPutMethod<UploadOptions>({
164158
allowedOptions: ['contentType'],
165159
extraChecks(options) {
166-
if (typeof window === 'undefined') {
167-
throw new BlobError(
168-
'client/`upload` must be called from a client environment',
169-
);
170-
}
171-
172160
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- Runtime check for DX.
173161
if (options.handleUploadUrl === undefined) {
174162
throw new BlobError(
@@ -456,7 +444,8 @@ async function retrieveClientToken(options: {
456444
}
457445

458446
function toAbsoluteUrl(url: string): string {
459-
return new URL(url, window.location.href).href;
447+
// location is available in web workers too: https://developer.mozilla.org/en-US/docs/Web/API/Window/location
448+
return new URL(url, location.href).href;
460449
}
461450

462451
function isAbsoluteUrl(url: string): boolean {

test/next/.eslintrc.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ module.exports = {
1212
},
1313
rules: {
1414
'no-console': 'off',
15+
'@typescript-eslint/no-non-null-assertion': 'off',
1516
},
1617
overrides: [
1718
{
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
/* eslint-disable -- I gave up making TS and ESLint happy here for now */
2+
3+
'use client';
4+
import { useEffect, useRef, useCallback, useState } from 'react';
5+
import { useSearchParams } from 'next/navigation';
6+
7+
export default function Index() {
8+
const workerRef = useRef<Worker | null>(null);
9+
const [blobUrl, setBlobUrl] = useState<string | null>(null);
10+
const searchParams = useSearchParams();
11+
12+
useEffect(() => {
13+
workerRef.current = new Worker(
14+
new URL('../../../../worker.ts', import.meta.url),
15+
);
16+
workerRef.current.onmessage = (event: MessageEvent<string>) => {
17+
if (event.data.startsWith('Error:')) {
18+
alert(event.data);
19+
} else {
20+
setBlobUrl(event.data);
21+
}
22+
};
23+
return () => {
24+
workerRef.current?.terminate();
25+
};
26+
}, []);
27+
28+
function handleUpload() {
29+
const fileName = searchParams?.get('fileName');
30+
const fileContent = searchParams?.get('fileContent');
31+
if (fileName && fileContent) {
32+
workerRef.current?.postMessage({ fileName, fileContent });
33+
} else {
34+
alert('Missing fileName or fileContent in search params');
35+
}
36+
}
37+
38+
return (
39+
<>
40+
<h1 className="text-xl mb-4">
41+
App Router Client Upload using a Web Worker
42+
</h1>
43+
44+
<button onClick={handleUpload}>Upload from WebWorker</button>
45+
{blobUrl && (
46+
<div>
47+
<p>
48+
Blob URL:{' '}
49+
<a
50+
id="test-result"
51+
href={blobUrl}
52+
target="_blank"
53+
rel="noopener noreferrer"
54+
>
55+
{blobUrl}
56+
</a>
57+
</p>
58+
</div>
59+
)}
60+
</>
61+
);
62+
}

test/next/src/app/vercel/blob/page.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,10 @@ export default function Home(): React.JSX.Element {
4242
<li>
4343
Client Upload → <a href="/vercel/blob/app/client">/app/client</a>
4444
</li>
45+
<li>
46+
Client Upload in a Web Worker →{' '}
47+
<a href="/vercel/blob/app/client-webworker">/app/client-webworker</a>
48+
</li>
4549
<li>
4650
Client Upload (multipart) →{' '}
4751
<a href="/vercel/blob/app/client-multipart">/app/client-multipart</a>

test/next/src/app/worker.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { upload } from '@vercel/blob/client';
2+
3+
addEventListener(
4+
'message',
5+
async (event: MessageEvent<{ fileName: string; fileContent: string }>) => {
6+
const { fileName, fileContent } = event.data;
7+
const blob = await upload(fileName, fileContent, {
8+
access: 'public',
9+
handleUploadUrl: `/vercel/blob/api/app/handle-blob-upload/serverless`,
10+
});
11+
postMessage(blob.url);
12+
},
13+
);
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import crypto from 'node:crypto';
2+
import { test, expect } from '@playwright/test';
3+
4+
const prefix =
5+
process.env.GITHUB_PR_NUMBER || crypto.randomBytes(10).toString('hex');
6+
7+
test('web worker upload', async ({ browser }) => {
8+
const browserContext = await browser.newContext();
9+
await browserContext.addCookies([
10+
{
11+
name: 'clientUpload',
12+
value: process.env.BLOB_UPLOAD_SECRET ?? 'YOYOYOYO',
13+
path: '/',
14+
domain: (process.env.PLAYWRIGHT_TEST_BASE_URL ?? 'localhost').replace(
15+
'https://',
16+
'',
17+
),
18+
},
19+
]);
20+
21+
const page = await browserContext.newPage();
22+
23+
const random = Math.floor(Math.random() * 10000) + 1;
24+
const fileName = `${prefix}-webworker-test${random}`;
25+
const fileContent = `created from a webworker${random}`;
26+
27+
// Load the page with the specified search params
28+
await page.goto(
29+
`vercel/blob/app/client-webworker?fileName=${fileName}&fileContent=${fileContent}`,
30+
);
31+
32+
// Click the upload button
33+
await page.click('button:has-text("Upload from WebWorker")');
34+
35+
// Wait for the blob URL to appear
36+
const blobUrlElement = await page.waitForSelector('a#test-result');
37+
const blobUrl = await blobUrlElement.getAttribute('href');
38+
expect(blobUrl).toBeDefined();
39+
40+
// fetch the blob URL from the test, not the page, and verify its content
41+
const res = await fetch(blobUrl!);
42+
const response = await res.text();
43+
expect(response).toBe(fileContent);
44+
});
45+
46+
test.afterAll(async ({ request }) => {
47+
// cleanup all files
48+
await request.delete(`vercel/blob/api/app/clean?prefix=${prefix}`);
49+
});

test/next/test/@vercel/edge-config/index.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,15 @@ test.describe('@vercel/edge-config', () => {
99
await expect(page.locator('html#__next_error__')).toHaveCount(0);
1010
const textContent = await page.locator('pre').textContent();
1111
expect(textContent).not.toBeNull();
12-
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- [@vercel/style-guide@5 migration]
12+
1313
expect(JSON.parse(textContent!)).toEqual('valueForTest');
1414
});
1515
test('node', async ({ page }) => {
1616
await page.goto('vercel/edge-config/app/node');
1717
await expect(page.locator('html#__next_error__')).toHaveCount(0);
1818
const textContent = await page.locator('pre').textContent();
1919
expect(textContent).not.toBeNull();
20-
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- [@vercel/style-guide@5 migration]
20+
2121
expect(JSON.parse(textContent!)).toEqual('valueForTest');
2222
});
2323
});

test/next/test/@vercel/postgres-kysely/index.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,15 +45,15 @@ test.describe('@vercel/postgres-kysely', () => {
4545
await expect(page.locator('html#__next_error__')).toHaveCount(0);
4646
const textContent = await page.locator('pre').textContent();
4747
expect(textContent).not.toBeNull();
48-
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- [@vercel/style-guide@5 migration]
48+
4949
expect(JSON.parse(textContent!)).toEqual(expectedRows);
5050
});
5151
test('node', async ({ page }) => {
5252
await page.goto('vercel/postgres-kysely/app/node');
5353
await expect(page.locator('html#__next_error__')).toHaveCount(0);
5454
const textContent = await page.locator('pre').textContent();
5555
expect(textContent).not.toBeNull();
56-
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- [@vercel/style-guide@5 migration]
56+
5757
expect(JSON.parse(textContent!)).toEqual(expectedRows);
5858
});
5959
});

test/next/test/@vercel/postgres/index.test.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -46,15 +46,15 @@ test.describe('@vercel/postgres', () => {
4646
await expect(page.locator('html#__next_error__')).toHaveCount(0);
4747
const textContent = await page.locator('pre').textContent();
4848
expect(textContent).not.toBeNull();
49-
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- [@vercel/style-guide@5 migration]
49+
5050
expect(JSON.parse(textContent!)).toEqual(expectedRows);
5151
});
5252
test('node', async ({ page }) => {
5353
await page.goto('vercel/postgres/app/client/node');
5454
await expect(page.locator('html#__next_error__')).toHaveCount(0);
5555
const textContent = await page.locator('pre').textContent();
5656
expect(textContent).not.toBeNull();
57-
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- [@vercel/style-guide@5 migration]
57+
5858
expect(JSON.parse(textContent!)).toEqual(expectedRows);
5959
});
6060
});
@@ -76,15 +76,15 @@ test.describe('@vercel/postgres', () => {
7676
await expect(page.locator('html#__next_error__')).toHaveCount(0);
7777
const textContent = await page.locator('pre').textContent();
7878
expect(textContent).not.toBeNull();
79-
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- [@vercel/style-guide@5 migration]
79+
8080
expect(JSON.parse(textContent!)).toEqual(expectedRows);
8181
});
8282
test('node', async ({ page }) => {
8383
await page.goto('vercel/postgres/app/pool/node');
8484
await expect(page.locator('html#__next_error__')).toHaveCount(0);
8585
const textContent = await page.locator('pre').textContent();
8686
expect(textContent).not.toBeNull();
87-
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- [@vercel/style-guide@5 migration]
87+
8888
expect(JSON.parse(textContent!)).toEqual(expectedRows);
8989
});
9090
});

0 commit comments

Comments
 (0)