Skip to content

Commit

Permalink
fix(blob): allow client uploads in web workers (#817)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
vvo authored Jan 17, 2025
1 parent 6d0383b commit 0c98feb
Show file tree
Hide file tree
Showing 10 changed files with 149 additions and 21 deletions.
10 changes: 10 additions & 0 deletions .changeset/three-rockets-arrive.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@vercel/blob': patch
---

fix(blob): allow client uploads in web workers

Before this change, 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.
15 changes: 2 additions & 13 deletions packages/blob/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,12 +55,6 @@ function createPutExtraChecks<
TOptions extends ClientTokenOptions & ClientCommonCreateBlobOptions,
>(methodName: string) {
return function extraChecks(options: TOptions) {
if (typeof window === 'undefined') {
throw new BlobError(
`${methodName} must be called from a client environment`,
);
}

if (!options.token.startsWith('vercel_blob_client_')) {
throw new BlobError(`${methodName} must be called with a client token`);
}
Expand Down Expand Up @@ -163,12 +157,6 @@ export type UploadOptions = ClientCommonPutOptions & CommonUploadOptions;
export const upload = createPutMethod<UploadOptions>({
allowedOptions: ['contentType'],
extraChecks(options) {
if (typeof window === 'undefined') {
throw new BlobError(
'client/`upload` must be called from a client environment',
);
}

// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- Runtime check for DX.
if (options.handleUploadUrl === undefined) {
throw new BlobError(
Expand Down Expand Up @@ -456,7 +444,8 @@ async function retrieveClientToken(options: {
}

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

function isAbsoluteUrl(url: string): boolean {
Expand Down
1 change: 1 addition & 0 deletions test/next/.eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ module.exports = {
},
rules: {
'no-console': 'off',
'@typescript-eslint/no-non-null-assertion': 'off',
},
overrides: [
{
Expand Down
62 changes: 62 additions & 0 deletions test/next/src/app/vercel/blob/app/client-webworker/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/* eslint-disable -- I gave up making TS and ESLint happy here for now */

'use client';
import { useEffect, useRef, useCallback, useState } from 'react';
import { useSearchParams } from 'next/navigation';

export default function Index() {
const workerRef = useRef<Worker | null>(null);
const [blobUrl, setBlobUrl] = useState<string | null>(null);
const searchParams = useSearchParams();

useEffect(() => {
workerRef.current = new Worker(
new URL('../../../../worker.ts', import.meta.url),
);
workerRef.current.onmessage = (event: MessageEvent<string>) => {
if (event.data.startsWith('Error:')) {
alert(event.data);
} else {
setBlobUrl(event.data);
}
};
return () => {
workerRef.current?.terminate();
};
}, []);

function handleUpload() {
const fileName = searchParams?.get('fileName');
const fileContent = searchParams?.get('fileContent');
if (fileName && fileContent) {
workerRef.current?.postMessage({ fileName, fileContent });
} else {
alert('Missing fileName or fileContent in search params');
}
}

return (
<>
<h1 className="text-xl mb-4">
App Router Client Upload using a Web Worker
</h1>

<button onClick={handleUpload}>Upload from WebWorker</button>
{blobUrl && (
<div>
<p>
Blob URL:{' '}
<a
id="test-result"
href={blobUrl}
target="_blank"
rel="noopener noreferrer"
>
{blobUrl}
</a>
</p>
</div>
)}
</>
);
}
4 changes: 4 additions & 0 deletions test/next/src/app/vercel/blob/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ export default function Home(): React.JSX.Element {
<li>
Client Upload → <a href="/vercel/blob/app/client">/app/client</a>
</li>
<li>
Client Upload in a Web Worker →{' '}
<a href="/vercel/blob/app/client-webworker">/app/client-webworker</a>
</li>
<li>
Client Upload (multipart) →{' '}
<a href="/vercel/blob/app/client-multipart">/app/client-multipart</a>
Expand Down
13 changes: 13 additions & 0 deletions test/next/src/app/worker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { upload } from '@vercel/blob/client';

addEventListener(
'message',
async (event: MessageEvent<{ fileName: string; fileContent: string }>) => {
const { fileName, fileContent } = event.data;
const blob = await upload(fileName, fileContent, {
access: 'public',
handleUploadUrl: `/vercel/blob/api/app/handle-blob-upload/serverless`,
});
postMessage(blob.url);
},
);
49 changes: 49 additions & 0 deletions test/next/test/@vercel/blob/webworker.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import crypto from 'node:crypto';
import { test, expect } from '@playwright/test';

const prefix =
process.env.GITHUB_PR_NUMBER || crypto.randomBytes(10).toString('hex');

test('web worker upload', async ({ browser }) => {
const browserContext = await browser.newContext();
await browserContext.addCookies([
{
name: 'clientUpload',
value: process.env.BLOB_UPLOAD_SECRET ?? 'YOYOYOYO',
path: '/',
domain: (process.env.PLAYWRIGHT_TEST_BASE_URL ?? 'localhost').replace(
'https://',
'',
),
},
]);

const page = await browserContext.newPage();

const random = Math.floor(Math.random() * 10000) + 1;
const fileName = `${prefix}-webworker-test${random}`;
const fileContent = `created from a webworker${random}`;

// Load the page with the specified search params
await page.goto(
`vercel/blob/app/client-webworker?fileName=${fileName}&fileContent=${fileContent}`,
);

// Click the upload button
await page.click('button:has-text("Upload from WebWorker")');

// Wait for the blob URL to appear
const blobUrlElement = await page.waitForSelector('a#test-result');
const blobUrl = await blobUrlElement.getAttribute('href');
expect(blobUrl).toBeDefined();

// fetch the blob URL from the test, not the page, and verify its content
const res = await fetch(blobUrl!);
const response = await res.text();
expect(response).toBe(fileContent);
});

test.afterAll(async ({ request }) => {
// cleanup all files
await request.delete(`vercel/blob/api/app/clean?prefix=${prefix}`);
});
4 changes: 2 additions & 2 deletions test/next/test/@vercel/edge-config/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,15 @@ test.describe('@vercel/edge-config', () => {
await expect(page.locator('html#__next_error__')).toHaveCount(0);
const textContent = await page.locator('pre').textContent();
expect(textContent).not.toBeNull();
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- [@vercel/style-guide@5 migration]

expect(JSON.parse(textContent!)).toEqual('valueForTest');
});
test('node', async ({ page }) => {
await page.goto('vercel/edge-config/app/node');
await expect(page.locator('html#__next_error__')).toHaveCount(0);
const textContent = await page.locator('pre').textContent();
expect(textContent).not.toBeNull();
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- [@vercel/style-guide@5 migration]

expect(JSON.parse(textContent!)).toEqual('valueForTest');
});
});
Expand Down
4 changes: 2 additions & 2 deletions test/next/test/@vercel/postgres-kysely/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,15 +45,15 @@ test.describe('@vercel/postgres-kysely', () => {
await expect(page.locator('html#__next_error__')).toHaveCount(0);
const textContent = await page.locator('pre').textContent();
expect(textContent).not.toBeNull();
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- [@vercel/style-guide@5 migration]

expect(JSON.parse(textContent!)).toEqual(expectedRows);
});
test('node', async ({ page }) => {
await page.goto('vercel/postgres-kysely/app/node');
await expect(page.locator('html#__next_error__')).toHaveCount(0);
const textContent = await page.locator('pre').textContent();
expect(textContent).not.toBeNull();
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- [@vercel/style-guide@5 migration]

expect(JSON.parse(textContent!)).toEqual(expectedRows);
});
});
Expand Down
8 changes: 4 additions & 4 deletions test/next/test/@vercel/postgres/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,15 +46,15 @@ test.describe('@vercel/postgres', () => {
await expect(page.locator('html#__next_error__')).toHaveCount(0);
const textContent = await page.locator('pre').textContent();
expect(textContent).not.toBeNull();
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- [@vercel/style-guide@5 migration]

expect(JSON.parse(textContent!)).toEqual(expectedRows);
});
test('node', async ({ page }) => {
await page.goto('vercel/postgres/app/client/node');
await expect(page.locator('html#__next_error__')).toHaveCount(0);
const textContent = await page.locator('pre').textContent();
expect(textContent).not.toBeNull();
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- [@vercel/style-guide@5 migration]

expect(JSON.parse(textContent!)).toEqual(expectedRows);
});
});
Expand All @@ -76,15 +76,15 @@ test.describe('@vercel/postgres', () => {
await expect(page.locator('html#__next_error__')).toHaveCount(0);
const textContent = await page.locator('pre').textContent();
expect(textContent).not.toBeNull();
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- [@vercel/style-guide@5 migration]

expect(JSON.parse(textContent!)).toEqual(expectedRows);
});
test('node', async ({ page }) => {
await page.goto('vercel/postgres/app/pool/node');
await expect(page.locator('html#__next_error__')).toHaveCount(0);
const textContent = await page.locator('pre').textContent();
expect(textContent).not.toBeNull();
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- [@vercel/style-guide@5 migration]

expect(JSON.parse(textContent!)).toEqual(expectedRows);
});
});
Expand Down

0 comments on commit 0c98feb

Please sign in to comment.