From 0c98feb12750a0ca50e0c4b449b7759cecb86e2b Mon Sep 17 00:00:00 2001 From: Vincent Voyer Date: Fri, 17 Jan 2025 16:14:19 +0100 Subject: [PATCH] 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 --- .changeset/three-rockets-arrive.md | 10 +++ packages/blob/src/client.ts | 15 +---- test/next/.eslintrc.js | 1 + .../vercel/blob/app/client-webworker/page.tsx | 62 +++++++++++++++++++ test/next/src/app/vercel/blob/page.tsx | 4 ++ test/next/src/app/worker.ts | 13 ++++ test/next/test/@vercel/blob/webworker.test.ts | 49 +++++++++++++++ .../test/@vercel/edge-config/index.test.ts | 4 +- .../@vercel/postgres-kysely/index.test.ts | 4 +- test/next/test/@vercel/postgres/index.test.ts | 8 +-- 10 files changed, 149 insertions(+), 21 deletions(-) create mode 100644 .changeset/three-rockets-arrive.md create mode 100644 test/next/src/app/vercel/blob/app/client-webworker/page.tsx create mode 100644 test/next/src/app/worker.ts create mode 100644 test/next/test/@vercel/blob/webworker.test.ts diff --git a/.changeset/three-rockets-arrive.md b/.changeset/three-rockets-arrive.md new file mode 100644 index 000000000..fe6b376ac --- /dev/null +++ b/.changeset/three-rockets-arrive.md @@ -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. diff --git a/packages/blob/src/client.ts b/packages/blob/src/client.ts index e2b9c3069..ea9fa024e 100644 --- a/packages/blob/src/client.ts +++ b/packages/blob/src/client.ts @@ -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`); } @@ -163,12 +157,6 @@ export type UploadOptions = ClientCommonPutOptions & CommonUploadOptions; export const upload = createPutMethod({ 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( @@ -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 { diff --git a/test/next/.eslintrc.js b/test/next/.eslintrc.js index 34c0be4c9..3c9e1ac11 100644 --- a/test/next/.eslintrc.js +++ b/test/next/.eslintrc.js @@ -12,6 +12,7 @@ module.exports = { }, rules: { 'no-console': 'off', + '@typescript-eslint/no-non-null-assertion': 'off', }, overrides: [ { diff --git a/test/next/src/app/vercel/blob/app/client-webworker/page.tsx b/test/next/src/app/vercel/blob/app/client-webworker/page.tsx new file mode 100644 index 000000000..34ac55422 --- /dev/null +++ b/test/next/src/app/vercel/blob/app/client-webworker/page.tsx @@ -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(null); + const [blobUrl, setBlobUrl] = useState(null); + const searchParams = useSearchParams(); + + useEffect(() => { + workerRef.current = new Worker( + new URL('../../../../worker.ts', import.meta.url), + ); + workerRef.current.onmessage = (event: MessageEvent) => { + 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 ( + <> +

+ App Router Client Upload using a Web Worker +

+ + + {blobUrl && ( +
+

+ Blob URL:{' '} + + {blobUrl} + +

+
+ )} + + ); +} diff --git a/test/next/src/app/vercel/blob/page.tsx b/test/next/src/app/vercel/blob/page.tsx index 30e80cb34..8f01cefd6 100644 --- a/test/next/src/app/vercel/blob/page.tsx +++ b/test/next/src/app/vercel/blob/page.tsx @@ -42,6 +42,10 @@ export default function Home(): React.JSX.Element {
  • Client Upload → /app/client
  • +
  • + Client Upload in a Web Worker →{' '} + /app/client-webworker +
  • Client Upload (multipart) →{' '} /app/client-multipart diff --git a/test/next/src/app/worker.ts b/test/next/src/app/worker.ts new file mode 100644 index 000000000..a849b5a4a --- /dev/null +++ b/test/next/src/app/worker.ts @@ -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); + }, +); diff --git a/test/next/test/@vercel/blob/webworker.test.ts b/test/next/test/@vercel/blob/webworker.test.ts new file mode 100644 index 000000000..8d25f14f9 --- /dev/null +++ b/test/next/test/@vercel/blob/webworker.test.ts @@ -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}`); +}); diff --git a/test/next/test/@vercel/edge-config/index.test.ts b/test/next/test/@vercel/edge-config/index.test.ts index 2bef288dd..e1467afd6 100644 --- a/test/next/test/@vercel/edge-config/index.test.ts +++ b/test/next/test/@vercel/edge-config/index.test.ts @@ -9,7 +9,7 @@ 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 }) => { @@ -17,7 +17,7 @@ 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'); }); }); diff --git a/test/next/test/@vercel/postgres-kysely/index.test.ts b/test/next/test/@vercel/postgres-kysely/index.test.ts index 18f9dcba2..0ea0020ac 100644 --- a/test/next/test/@vercel/postgres-kysely/index.test.ts +++ b/test/next/test/@vercel/postgres-kysely/index.test.ts @@ -45,7 +45,7 @@ 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 }) => { @@ -53,7 +53,7 @@ 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); }); }); diff --git a/test/next/test/@vercel/postgres/index.test.ts b/test/next/test/@vercel/postgres/index.test.ts index ba373faf3..442ed7622 100644 --- a/test/next/test/@vercel/postgres/index.test.ts +++ b/test/next/test/@vercel/postgres/index.test.ts @@ -46,7 +46,7 @@ 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 }) => { @@ -54,7 +54,7 @@ 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); }); }); @@ -76,7 +76,7 @@ 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 }) => { @@ -84,7 +84,7 @@ 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); }); });