From 3285dad9a9a5d43faff588a5cfa72164dbb97e04 Mon Sep 17 00:00:00 2001 From: Luis Meyer Date: Fri, 23 May 2025 15:01:56 +0200 Subject: [PATCH] chore: remove undici --- packages/blob/jest/setup.js | 5 - packages/blob/package.json | 4 +- packages/blob/src/api.node.test.ts | 53 ++-- packages/blob/src/api.ts | 1 - packages/blob/src/client.browser.test.ts | 181 ++++++------ packages/blob/src/client.ts | 6 +- packages/blob/src/fetch.ts | 17 +- packages/blob/src/helpers.ts | 1 - packages/blob/src/index.browser.test.ts | 41 +-- packages/blob/src/index.node.test.ts | 357 ++++++++++------------- packages/blob/src/multipart/upload.ts | 1 - packages/blob/src/put-helpers.ts | 4 - packages/blob/src/request.ts | 4 +- packages/blob/src/undici-browser.js | 8 - packages/blob/src/xhr.ts | 15 +- pnpm-lock.yaml | 17 -- test/next/src/app/vercel/blob/script.mts | 1 - 17 files changed, 309 insertions(+), 407 deletions(-) delete mode 100644 packages/blob/src/undici-browser.js diff --git a/packages/blob/jest/setup.js b/packages/blob/jest/setup.js index 127808885..ac756d6d8 100644 --- a/packages/blob/jest/setup.js +++ b/packages/blob/jest/setup.js @@ -2,11 +2,6 @@ // but they are available everywhere else. // See https://stackoverflow.com/questions/68468203/why-am-i-getting-textencoder-is-not-defined-in-jest const { TextEncoder, TextDecoder } = require('node:util'); -// eslint-disable-next-line import/order -- On purpose to make requiring undici work const { ReadableStream } = require('node:stream/web'); Object.assign(global, { TextDecoder, TextEncoder, ReadableStream }); - -const { Request, Response } = require('undici'); - -Object.assign(global, { Request, Response }); diff --git a/packages/blob/package.json b/packages/blob/package.json index 79a562a84..9064f58ce 100644 --- a/packages/blob/package.json +++ b/packages/blob/package.json @@ -24,7 +24,6 @@ "main": "./dist/index.cjs", "module": "./dist/index.js", "browser": { - "undici": "./dist/undici-browser.js", "crypto": "./dist/crypto-browser.js", "stream": "./dist/stream-browser.js" }, @@ -63,8 +62,7 @@ "async-retry": "^1.3.3", "is-buffer": "^2.0.5", "is-node-process": "^1.2.0", - "throttleit": "^2.1.0", - "undici": "^5.28.4" + "throttleit": "^2.1.0" }, "devDependencies": { "@edge-runtime/jest-environment": "2.3.10", diff --git a/packages/blob/src/api.node.test.ts b/packages/blob/src/api.node.test.ts index a2107ea3f..c727ac630 100644 --- a/packages/blob/src/api.node.test.ts +++ b/packages/blob/src/api.node.test.ts @@ -1,4 +1,3 @@ -import undici from 'undici'; import { BlobAccessError, BlobNotFoundError, @@ -12,6 +11,8 @@ import { import { BlobError } from './helpers'; describe('api', () => { + const fetchMock = jest.fn(); + describe('request api', () => { const OLD_ENV = process.env; @@ -21,16 +22,16 @@ describe('api', () => { jest.restoreAllMocks(); process.env = { ...OLD_ENV }; + + globalThis.fetch = fetchMock; }); it('should throw if no token is provided', async () => { - const fetchMock = jest.spyOn(undici, 'fetch').mockImplementation( - jest.fn().mockResolvedValue({ - status: 200, - ok: true, - json: () => Promise.resolve({ success: true }), - }), - ); + fetchMock.mockResolvedValueOnce({ + status: 200, + ok: true, + json: () => Promise.resolve({ success: true }), + }); process.env.BLOB_READ_WRITE_TOKEN = undefined; @@ -42,13 +43,11 @@ describe('api', () => { }); it('should not retry successful request', async () => { - const fetchMock = jest.spyOn(undici, 'fetch').mockImplementation( - jest.fn().mockResolvedValue({ - status: 200, - ok: true, - json: () => Promise.resolve({ success: true }), - }), - ); + fetchMock.mockResolvedValueOnce({ + status: 200, + ok: true, + json: () => Promise.resolve({ success: true }), + }); const res = await requestApi<{ success: boolean }>( '/method', @@ -81,13 +80,11 @@ describe('api', () => { ])( `should retry '%s %s' error response`, async (status, code, error) => { - const fetchMock = jest.spyOn(undici, 'fetch').mockImplementation( - jest.fn().mockResolvedValue({ - status, - ok: false, - json: () => Promise.resolve({ error: { code } }), - }), - ); + fetchMock.mockResolvedValue({ + status, + ok: false, + json: () => Promise.resolve({ error: { code } }), + }); process.env.VERCEL_BLOB_RETRIES = '1'; process.env.BLOB_READ_WRITE_TOKEN = 'test-token'; @@ -118,13 +115,11 @@ describe('api', () => { ])( `should not retry '%s %s' response error response`, async (status, code, error, message = '') => { - const fetchMock = jest.spyOn(undici, 'fetch').mockImplementation( - jest.fn().mockResolvedValue({ - status, - ok: false, - json: () => Promise.resolve({ error: { code, message } }), - }), - ); + fetchMock.mockResolvedValue({ + status, + ok: false, + json: () => Promise.resolve({ error: { code, message } }), + }); await expect( requestApi('/api', { method: 'GET' }, { token: '123' }), diff --git a/packages/blob/src/api.ts b/packages/blob/src/api.ts index 6414a1072..4d360048b 100644 --- a/packages/blob/src/api.ts +++ b/packages/blob/src/api.ts @@ -1,4 +1,3 @@ -import type { Response } from 'undici'; import retry from 'async-retry'; import isNetworkError from './is-network-error'; import { debug } from './debug'; diff --git a/packages/blob/src/client.browser.test.ts b/packages/blob/src/client.browser.test.ts index 1b9feb12e..c205d6e52 100644 --- a/packages/blob/src/client.browser.test.ts +++ b/packages/blob/src/client.browser.test.ts @@ -1,4 +1,3 @@ -import undici from 'undici'; import { completeMultipartUpload, createMultipartUpload, @@ -9,6 +8,7 @@ import { } from './client'; describe('client', () => { + const fetchMock = jest.fn(); let requestId = ''; beforeEach(() => { @@ -24,6 +24,8 @@ describe('client', () => { jest.spyOn(global.Math, 'random').mockReturnValue(Math.random()); requestId = Math.random().toString(16).slice(2); + + global.fetch = fetchMock; }); afterEach(() => { @@ -31,32 +33,30 @@ describe('client', () => { }); describe('upload()', () => { - it('should upload a file from the client', async () => { - const fetchMock = jest.spyOn(undici, 'fetch').mockImplementation( - jest - .fn() - .mockResolvedValueOnce({ - status: 200, - ok: true, - json: () => - Promise.resolve({ - type: 'blob.generate-client-token', - clientToken: 'vercel_blob_client_fake_123', - }), - }) - .mockResolvedValueOnce({ - status: 200, - ok: true, - json: () => - Promise.resolve({ - url: `https://storeId.public.blob.vercel-storage.com/superfoo.txt`, - downloadUrl: `https://storeId.public.blob.vercel-storage.com/superfoo.txt?download=1`, - pathname: 'foo.txt', - contentType: 'text/plain', - contentDisposition: 'attachment; filename="foo.txt"', - }), - }), - ); + it.only('should upload a file from the client', async () => { + fetchMock + .mockResolvedValueOnce({ + status: 200, + ok: true, + json: () => + Promise.resolve({ + type: 'blob.generate-client-token', + clientToken: 'vercel_blob_client_fake_123', + }), + }) + .mockResolvedValueOnce({ + status: 200, + ok: true, + json: () => + Promise.resolve({ + url: 'https://storeId.public.blob.vercel-storage.com/superfoo.txt', + downloadUrl: + 'https://storeId.public.blob.vercel-storage.com/superfoo.txt?download=1', + pathname: 'foo.txt', + contentType: 'text/plain', + contentDisposition: 'attachment; filename="foo.txt"', + }), + }); await expect( upload('foo.txt', 'Test file data', { @@ -118,36 +118,33 @@ describe('client', () => { }); it('should upload a file using the manual functions', async () => { - const fetchMock = jest.spyOn(undici, 'fetch').mockImplementation( - jest - .fn() - .mockResolvedValueOnce({ - status: 200, - ok: true, - json: () => Promise.resolve({ key: 'key', uploadId: 'uploadId' }), - }) - .mockResolvedValueOnce({ - status: 200, - ok: true, - json: () => Promise.resolve({ etag: 'etag1' }), - }) - .mockResolvedValueOnce({ - status: 200, - ok: true, - json: () => Promise.resolve({ etag: 'etag2' }), - }) - .mockResolvedValueOnce({ - status: 200, - ok: true, - json: () => - Promise.resolve({ - url: `https://storeId.public.blob.vercel-storage.com/foo.txt`, - pathname: 'foo.txt', - contentType: 'text/plain', - contentDisposition: 'attachment; filename="foo.txt"', - }), - }), - ); + fetchMock + .mockResolvedValueOnce({ + status: 200, + ok: true, + json: () => Promise.resolve({ key: 'key', uploadId: 'uploadId' }), + }) + .mockResolvedValueOnce({ + status: 200, + ok: true, + json: () => Promise.resolve({ etag: 'etag1' }), + }) + .mockResolvedValueOnce({ + status: 200, + ok: true, + json: () => Promise.resolve({ etag: 'etag2' }), + }) + .mockResolvedValueOnce({ + status: 200, + ok: true, + json: () => + Promise.resolve({ + url: 'https://storeId.public.blob.vercel-storage.com/foo.txt', + pathname: 'foo.txt', + contentType: 'text/plain', + contentDisposition: 'attachment; filename="foo.txt"', + }), + }); const pathname = 'foo.txt'; const token = 'vercel_blob_client_fake_token_for_test'; @@ -278,36 +275,33 @@ describe('client', () => { }); it('should upload a file using the uploader', async () => { - const fetchMock = jest.spyOn(undici, 'fetch').mockImplementation( - jest - .fn() - .mockResolvedValueOnce({ - status: 200, - ok: true, - json: () => Promise.resolve({ key: 'key', uploadId: 'uploadId' }), - }) - .mockResolvedValueOnce({ - status: 200, - ok: true, - json: () => Promise.resolve({ etag: 'etag1' }), - }) - .mockResolvedValueOnce({ - status: 200, - ok: true, - json: () => Promise.resolve({ etag: 'etag2' }), - }) - .mockResolvedValueOnce({ - status: 200, - ok: true, - json: () => - Promise.resolve({ - url: `https://storeId.public.blob.vercel-storage.com/foo.txt`, - pathname: 'foo.txt', - contentType: 'text/plain', - contentDisposition: 'attachment; filename="foo.txt"', - }), - }), - ); + fetchMock + .mockResolvedValueOnce({ + status: 200, + ok: true, + json: () => Promise.resolve({ key: 'key', uploadId: 'uploadId' }), + }) + .mockResolvedValueOnce({ + status: 200, + ok: true, + json: () => Promise.resolve({ etag: 'etag1' }), + }) + .mockResolvedValueOnce({ + status: 200, + ok: true, + json: () => Promise.resolve({ etag: 'etag2' }), + }) + .mockResolvedValueOnce({ + status: 200, + ok: true, + json: () => + Promise.resolve({ + url: 'https://storeId.public.blob.vercel-storage.com/foo.txt', + pathname: 'foo.txt', + contentType: 'text/plain', + contentDisposition: 'attachment; filename="foo.txt"', + }), + }); const pathname = 'foo.txt'; const token = 'vercel_blob_client_fake_token_for_test'; @@ -421,13 +415,11 @@ describe('client', () => { it('should reject incorrect body in uploader.uploadPart()', async () => { // Mock the createMultipartUploader to return a minimal uploader object - jest.spyOn(undici, 'fetch').mockImplementation( - jest.fn().mockResolvedValueOnce({ - status: 200, - ok: true, - json: () => Promise.resolve({ key: 'key', uploadId: 'uploadId' }), - }), - ); + fetchMock.mockResolvedValueOnce({ + status: 200, + ok: true, + json: () => Promise.resolve({ key: 'key', uploadId: 'uploadId' }), + }); const uploader = await createMultipartUploader('foo.txt', { access: 'public', @@ -469,6 +461,7 @@ describe('client', () => { { access: 'public', multipart: true, + token: '123', }, ), ], diff --git a/packages/blob/src/client.ts b/packages/blob/src/client.ts index 1c9f3069c..c05c5ea62 100644 --- a/packages/blob/src/client.ts +++ b/packages/blob/src/client.ts @@ -1,10 +1,6 @@ // eslint-disable-next-line unicorn/prefer-node-protocol -- node:crypto does not resolve correctly in browser and edge runtime import * as crypto from 'crypto'; import type { IncomingMessage } from 'node:http'; -// When bundled via a bundler supporting the `browser` field, then -// the `undici` module will be replaced with https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API -// for browser contexts. See ./undici-browser.js and ./package.json -import { fetch } from 'undici'; import type { BlobCommandOptions, WithUploadProgress } from './helpers'; import { BlobError, getTokenFromOptionsOrEnv } from './helpers'; import { createPutMethod } from './put'; @@ -384,7 +380,7 @@ function hexToArrayByte(input: string): Buffer { const view = new Uint8Array(input.length / 2); for (let i = 0; i < input.length; i += 2) { - view[i / 2] = parseInt(input.substring(i, i + 2), 16); + view[i / 2] = Number.parseInt(input.substring(i, i + 2), 16); } return Buffer.from(view); diff --git a/packages/blob/src/fetch.ts b/packages/blob/src/fetch.ts index 514350498..e478fce0d 100644 --- a/packages/blob/src/fetch.ts +++ b/packages/blob/src/fetch.ts @@ -1,5 +1,3 @@ -import type { BodyInit } from 'undici'; -import { fetch } from 'undici'; import type { BlobRequest } from './helpers'; import { createChunkTransformStream, @@ -52,13 +50,10 @@ export const blobFetch: BlobRequest = async ({ ? 'half' : undefined; - return fetch( - input, - // @ts-expect-error -- Blob and Nodejs Blob are triggering type errors, fine with it - { - ...init, - ...(init.body ? { body } : {}), - duplex, - }, - ); + return fetch(input, { + ...init, + ...(init.body ? { body } : {}), + // @ts-expect-error -- not typed + duplex, + }); }; diff --git a/packages/blob/src/helpers.ts b/packages/blob/src/helpers.ts index 679aeb36c..ca5cb74b3 100644 --- a/packages/blob/src/helpers.ts +++ b/packages/blob/src/helpers.ts @@ -2,7 +2,6 @@ // this is why it's not exported from index/client import type { Readable } from 'node:stream'; -import type { RequestInit, Response } from 'undici'; import { isNodeProcess } from 'is-node-process'; import { isNodeJsReadableStream } from './multipart/helpers'; import type { PutBody } from './put-helpers'; diff --git a/packages/blob/src/index.browser.test.ts b/packages/blob/src/index.browser.test.ts index ef072218d..5e9197497 100644 --- a/packages/blob/src/index.browser.test.ts +++ b/packages/blob/src/index.browser.test.ts @@ -4,34 +4,35 @@ import { put } from './index'; const BLOB_STORE_BASE_URL = 'https://storeId.public.blob.vercel-storage.com'; -// Can't use the usual undici mocking utilities because they don't work with jsdom environment -jest.mock('undici', () => ({ - fetch: (): unknown => - Promise.resolve({ - status: 200, - ok: true, - json: () => - Promise.resolve({ - url: `${BLOB_STORE_BASE_URL}/foo-id.txt`, - downloadUrl: `${BLOB_STORE_BASE_URL}/foo-id.txt?download=1`, - pathname: 'foo.txt', - contentType: 'text/plain', - contentDisposition: 'attachment; filename="foo.txt"', - }), - }), -})); - describe('blob client', () => { - beforeEach(() => { - jest.resetAllMocks(); - }); + const fetchMock = jest.fn(); describe('put', () => { beforeEach(() => { jest.resetAllMocks(); + + globalThis.fetch = fetchMock; }); it('should upload a file from the client', async () => { + fetchMock.mockResolvedValueOnce({ + status: 200, + ok: true, + json: () => + Promise.resolve({ + status: 200, + ok: true, + json: () => + Promise.resolve({ + url: `${BLOB_STORE_BASE_URL}/foo-id.txt`, + downloadUrl: `${BLOB_STORE_BASE_URL}/foo-id.txt?download=1`, + pathname: 'foo.txt', + contentType: 'text/plain', + contentDisposition: 'attachment; filename="foo.txt"', + }), + }), + }); + await expect( put('foo.txt', 'Test Body', { access: 'public', diff --git a/packages/blob/src/index.node.test.ts b/packages/blob/src/index.node.test.ts index f8198b81c..e62f44c0d 100644 --- a/packages/blob/src/index.node.test.ts +++ b/packages/blob/src/index.node.test.ts @@ -1,4 +1,3 @@ -import { type Interceptable, MockAgent, setGlobalDispatcher } from 'undici'; import { BlobRequestAbortedError, BlobServiceNotAvailable } from './api'; import { list, @@ -11,7 +10,6 @@ import { completeMultipartUpload, } from './index'; -const BLOB_API_URL_AGENT = 'https://vercel.com'; const BLOB_STORE_BASE_URL = 'https://storeId.public.blob.vercel-storage.com'; const mockedFileMeta = { @@ -25,34 +23,34 @@ const mockedFileMeta = { }; describe('blob client', () => { - let mockClient: Interceptable; + const realFetch = globalThis.fetch; + const fetchMock = jest.fn(); beforeEach(() => { process.env.BLOB_READ_WRITE_TOKEN = 'vercel_blob_rw_12345fakeStoreId_30FakeRandomCharacters12345678'; - const mockAgent = new MockAgent(); - mockAgent.disableNetConnect(); - setGlobalDispatcher(mockAgent); - mockClient = mockAgent.get(BLOB_API_URL_AGENT); jest.resetAllMocks(); process.env.VERCEL_BLOB_RETRIES = '0'; + globalThis.fetch = fetchMock; + }); + + afterEach(() => { + fetchMock.mockClear(); }); describe('head', () => { it('should return Blob metadata when calling `head()`', async () => { let path: string | null = null; let headers: Record = {}; - mockClient - .intercept({ - path: () => true, - method: 'GET', - }) - .reply(200, (req) => { - path = req.path; - headers = req.headers as Record; - return mockedFileMeta; - }); + fetchMock.mockImplementationOnce((input: string, init: RequestInit) => { + const url = new URL(input); + + path = url.pathname + url.search; + headers = init.headers as Record; + + return Promise.resolve(Response.json(mockedFileMeta)); + }); await expect(head(`${BLOB_STORE_BASE_URL}/foo-id.txt`)).resolves .toMatchInlineSnapshot(` @@ -76,12 +74,14 @@ describe('blob client', () => { }); it('should return null when calling `head()` with an url that does not exist', async () => { - mockClient - .intercept({ - path: () => true, - method: 'GET', - }) - .reply(404, { error: { code: 'not_found', message: 'Not found' } }); + fetchMock.mockResolvedValueOnce({ + status: 404, + ok: false, + json: () => + Promise.resolve({ + error: { code: 'not_found', message: 'Not found' }, + }), + }); await expect(head(`${BLOB_STORE_BASE_URL}/foo-id.txt`)).rejects.toThrow( new Error('Vercel Blob: The requested blob does not exist'), @@ -89,12 +89,14 @@ describe('blob client', () => { }); it('should throw when calling `head()` with an invalid token', async () => { - mockClient - .intercept({ - path: () => true, - method: 'GET', - }) - .reply(403, { error: { code: 'forbidden' } }); + fetchMock.mockResolvedValueOnce({ + status: 403, + ok: false, + json: () => + Promise.resolve({ + error: { code: 'forbidden', message: 'Forbidden' }, + }), + }); await expect(head(`${BLOB_STORE_BASE_URL}/foo-id.txt`)).rejects.toThrow( new Error( @@ -104,12 +106,11 @@ describe('blob client', () => { }); it('should throw a generic error when the worker returns a 500 status code', async () => { - mockClient - .intercept({ - path: () => true, - method: 'GET', - }) - .reply(500, 'Invalid token'); + fetchMock.mockResolvedValueOnce({ + status: 500, + ok: false, + json: () => Promise.resolve({ error: { code: 'unknown_error' } }), + }); await expect(head(`${BLOB_STORE_BASE_URL}/foo-id.txt`)).rejects.toThrow( new Error( @@ -129,12 +130,11 @@ describe('blob client', () => { }); it('should throw when store is suspended', async () => { - mockClient - .intercept({ - path: () => true, - method: 'GET', - }) - .reply(403, { error: { code: 'store_suspended' } }); + fetchMock.mockResolvedValueOnce({ + status: 403, + ok: false, + json: () => Promise.resolve({ error: { code: 'store_suspended' } }), + }); await expect(head(`${BLOB_STORE_BASE_URL}/foo-id.txt`)).rejects.toThrow( new Error('Vercel Blob: This store has been suspended.'), @@ -142,12 +142,11 @@ describe('blob client', () => { }); it('should throw when store does NOT exist', async () => { - mockClient - .intercept({ - path: () => true, - method: 'GET', - }) - .reply(403, { error: { code: 'store_not_found' } }); + fetchMock.mockResolvedValueOnce({ + status: 403, + ok: false, + json: () => Promise.resolve({ error: { code: 'store_not_found' } }), + }); await expect(head(`${BLOB_STORE_BASE_URL}/foo-id.txt`)).rejects.toThrow( new Error('Vercel Blob: This store does not exist.'), @@ -155,12 +154,11 @@ describe('blob client', () => { }); it('should throw when service unavailable', async () => { - mockClient - .intercept({ - path: () => true, - method: 'GET', - }) - .reply(502, { error: { code: 'service_unavailable' } }); + fetchMock.mockResolvedValueOnce({ + status: 502, + ok: false, + json: () => Promise.resolve({ error: { code: 'service_unavailable' } }), + }); await expect(head(`${BLOB_STORE_BASE_URL}/foo-id.txt`)).rejects.toThrow( new BlobServiceNotAvailable(), @@ -173,17 +171,15 @@ describe('blob client', () => { let path: string | null = null; let headers: Record = {}; let body = ''; - mockClient - .intercept({ - path: () => true, - method: 'POST', - }) - .reply(200, (req) => { - path = req.path; - headers = req.headers as Record; - body = req.body as string; - return [mockedFileMeta.url]; - }); + fetchMock.mockImplementationOnce((input: string, init: RequestInit) => { + const url = new URL(input); + + path = url.pathname + url.search; + headers = init.headers as Record; + body = init.body as string; + + return Promise.resolve(Response.json(mockedFileMeta)); + }); await expect( del(`${BLOB_STORE_BASE_URL}/foo-id.txt`), @@ -202,17 +198,15 @@ describe('blob client', () => { let path: string | null = null; let headers: Record = {}; let body = ''; - mockClient - .intercept({ - path: () => true, - method: 'POST', - }) - .reply(200, (req) => { - path = req.path; - headers = req.headers as Record; - body = req.body as string; - return [mockedFileMeta.url, mockedFileMeta.url]; - }); + fetchMock.mockImplementationOnce((input: string, init: RequestInit) => { + const url = new URL(input); + + path = url.pathname + url.search; + headers = init.headers as Record; + body = init.body as string; + + return Promise.resolve(Response.json(mockedFileMeta)); + }); await expect( del([ @@ -230,12 +224,11 @@ describe('blob client', () => { }); it('should throw when calling `del()` with an invalid token', async () => { - mockClient - .intercept({ - path: () => true, - method: 'POST', - }) - .reply(403, { error: { code: 'forbidden' } }); + fetchMock.mockResolvedValueOnce({ + status: 403, + ok: false, + json: () => Promise.resolve({ error: { code: 'forbidden' } }), + }); await expect(del(`${BLOB_STORE_BASE_URL}/foo-id.txt`)).rejects.toThrow( new Error( @@ -245,12 +238,11 @@ describe('blob client', () => { }); it('should throw a generic error when the worker returns a 500 status code', async () => { - mockClient - .intercept({ - path: () => true, - method: 'POST', - }) - .reply(500, 'Invalid token'); + fetchMock.mockResolvedValueOnce({ + status: 500, + ok: false, + json: () => Promise.resolve({ error: { code: 'unknown_error' } }), + }); await expect(del(`${BLOB_STORE_BASE_URL}/foo-id.txt`)).rejects.toThrow( new Error( @@ -272,20 +264,20 @@ describe('blob client', () => { it('should return a list of Blob metadata when calling `list()`', async () => { let path: string | null = null; let headers: Record = {}; - mockClient - .intercept({ - path: () => true, - method: 'GET', - }) - .reply(200, (req) => { - path = req.path; - headers = req.headers as Record; - return { + fetchMock.mockImplementationOnce((input: string, init: RequestInit) => { + const url = new URL(input); + + path = url.pathname + url.search; + headers = init.headers as Record; + + return Promise.resolve( + Response.json({ blobs: [mockedFileMetaList, mockedFileMetaList], cursor: 'cursor-123', hasMore: true, - }; - }); + }), + ); + }); await expect( list({ cursor: 'cursor-abc', limit: 10, prefix: 'test-prefix' }), @@ -320,12 +312,11 @@ describe('blob client', () => { }); it('should throw when calling `list()` with an invalid token', async () => { - mockClient - .intercept({ - path: () => true, - method: 'GET', - }) - .reply(403, { error: { code: 'forbidden' } }); + fetchMock.mockResolvedValueOnce({ + status: 403, + ok: false, + json: () => Promise.resolve({ error: { code: 'forbidden' } }), + }); await expect(list()).rejects.toThrow( new Error( @@ -335,12 +326,12 @@ describe('blob client', () => { }); it('should throw a generic error when the worker returns a 500 status code', async () => { - mockClient - .intercept({ - path: () => true, - method: 'GET', - }) - .reply(500, 'Invalid token'); + fetchMock.mockResolvedValueOnce({ + status: 500, + ok: false, + json: () => Promise.resolve({ error: { code: 'unknown_error' } }), + }); + await expect(list()).rejects.toThrow( new Error( 'Vercel Blob: Unknown error, please visit https://vercel.com/help.', @@ -350,20 +341,18 @@ describe('blob client', () => { it('list should pass the mode param and return folders array', async () => { let path: string | null = null; + fetchMock.mockImplementationOnce((input: string) => { + const url = new URL(input); + path = url.pathname + url.search; - mockClient - .intercept({ - path: () => true, - method: 'GET', - }) - .reply(200, (req) => { - path = req.path; - return { + return Promise.resolve( + Response.json({ blobs: [mockedFileMetaList], folders: ['foo', 'bar'], hasMore: false, - }; - }); + }), + ); + }); await expect(list({ mode: 'folded' })).resolves.toMatchInlineSnapshot(` { @@ -399,14 +388,11 @@ describe('blob client', () => { }; it('has an onUploadProgress option', async () => { - mockClient - .intercept({ - path: () => true, - method: 'PUT', - }) - .reply(200, () => { - return mockedFileMetaPut; - }); + fetchMock.mockResolvedValueOnce({ + status: 200, + ok: true, + json: () => Promise.resolve(mockedFileMetaPut), + }); const onUploadProgress = jest.fn(); @@ -431,17 +417,15 @@ describe('blob client', () => { let path: string | null = null; let headers: Record = {}; let body = ''; - mockClient - .intercept({ - path: () => true, - method: 'PUT', - }) - .reply(200, (req) => { - path = req.path; - headers = req.headers as Record; - body = req.body as string; - return mockedFileMetaPut; - }); + fetchMock.mockImplementationOnce((input: string, init: RequestInit) => { + const url = new URL(input); + + path = url.pathname + url.search; + headers = init.headers as Record; + body = init.body as string; + + return Promise.resolve(Response.json(mockedFileMetaPut)); + }); await expect( put('foo.txt', 'Test Body', { @@ -464,16 +448,11 @@ describe('blob client', () => { it('should upload a file with a custom content-type', async () => { let headers: Record = {}; + fetchMock.mockImplementationOnce((_, init: RequestInit) => { + headers = init.headers as Record; - mockClient - .intercept({ - path: () => true, - method: 'PUT', - }) - .reply(200, (req) => { - headers = req.headers as Record; - return mockedFileMetaPut; - }); + return Promise.resolve(Response.json(mockedFileMeta)); + }); await put('foo.txt', 'Test Body', { access: 'public', @@ -483,12 +462,11 @@ describe('blob client', () => { }); it('should throw when calling `put()` with an invalid token', async () => { - mockClient - .intercept({ - path: () => true, - method: 'PUT', - }) - .reply(403, { error: { code: 'forbidden' } }); + fetchMock.mockResolvedValueOnce({ + status: 403, + ok: false, + json: () => Promise.resolve({ error: { code: 'forbidden' } }), + }); await expect( put('foo.txt', 'Test Body', { @@ -503,12 +481,10 @@ describe('blob client', () => { }); it('should throw a generic error when the worker returns a 500 status code', async () => { - mockClient - .intercept({ - path: () => true, - method: 'PUT', - }) - .reply(500, 'Generic Error'); + fetchMock.mockResolvedValueOnce({ + status: 500, + ok: false, + }); await expect( put('foo.txt', 'Test Body', { access: 'public', @@ -522,12 +498,11 @@ describe('blob client', () => { }); it('should fail when the filepath is missing', async () => { - mockClient - .intercept({ - path: () => true, - method: 'PUT', - }) - .reply(200, mockedFileMetaPut); + fetchMock.mockResolvedValueOnce({ + status: 200, + ok: true, + json: () => Promise.resolve(mockedFileMetaPut), + }); await expect( put('', 'Test Body', { @@ -537,12 +512,11 @@ describe('blob client', () => { }); it('should fail when the body is missing', async () => { - mockClient - .intercept({ - path: () => true, - method: 'PUT', - }) - .reply(200, mockedFileMetaPut); + fetchMock.mockResolvedValueOnce({ + status: 200, + ok: true, + json: () => Promise.resolve(mockedFileMetaPut), + }); await expect( put('path.txt', '', { @@ -552,12 +526,11 @@ describe('blob client', () => { }); it('should throw when uploading a private file', async () => { - mockClient - .intercept({ - path: () => true, - method: 'PUT', - }) - .reply(200, mockedFileMetaPut); + fetchMock.mockResolvedValueOnce({ + status: 200, + ok: true, + json: () => Promise.resolve(mockedFileMetaPut), + }); await expect( put('foo.txt', 'Test Body', { @@ -569,16 +542,11 @@ describe('blob client', () => { it('sets the correct header when using the addRandomSuffix option', async () => { let headers: Record = {}; + fetchMock.mockImplementationOnce((input, init) => { + headers = init.headers as Record; - mockClient - .intercept({ - path: () => true, - method: 'PUT', - }) - .reply(200, (req) => { - headers = req.headers as Record; - return mockedFileMetaPut; - }); + return Promise.resolve(Response.json(mockedFileMeta)); + }); await put('foo.txt', 'Test Body', { access: 'public', @@ -589,16 +557,11 @@ describe('blob client', () => { it('sets the correct header when using the cacheControlMaxAge option', async () => { let headers: Record = {}; + fetchMock.mockImplementationOnce((input, init) => { + headers = init.headers as Record; - mockClient - .intercept({ - path: () => true, - method: 'PUT', - }) - .reply(200, (req) => { - headers = req.headers as Record; - return mockedFileMetaPut; - }); + return Promise.resolve(Response.json(mockedFileMeta)); + }); await put('foo.txt', 'Test Body', { access: 'public', @@ -702,6 +665,8 @@ describe('blob client', () => { 'cancels requests with an abort controller: %s', async (_, operation) => { await expect(async () => { + globalThis.fetch = realFetch; + const controller = new AbortController(); const promise = operation(controller.signal); diff --git a/packages/blob/src/multipart/upload.ts b/packages/blob/src/multipart/upload.ts index f73322459..a2c7be43e 100644 --- a/packages/blob/src/multipart/upload.ts +++ b/packages/blob/src/multipart/upload.ts @@ -109,7 +109,6 @@ export async function uploadPart({ 'x-mpu-upload-id': uploadId, 'x-mpu-part-number': part.partNumber.toString(), }, - // weird things between undici types and native fetch types body: part.blob, }, options, diff --git a/packages/blob/src/put-helpers.ts b/packages/blob/src/put-helpers.ts index eaba0c4fa..2af5f081b 100644 --- a/packages/blob/src/put-helpers.ts +++ b/packages/blob/src/put-helpers.ts @@ -1,9 +1,5 @@ // eslint-disable-next-line unicorn/prefer-node-protocol -- node:stream does not resolve correctly in browser and edge import type { Readable } from 'stream'; -// We use the undici types to ensure TS doesn't complain about native types (like ReadableStream) vs -// undici types fetch expects (like Blob is from node:buffer..) -// import type { Blob } from 'node:buffer'; -import type { File } from 'undici'; import type { ClientCommonCreateBlobOptions } from './client'; import type { CommonCreateBlobOptions } from './helpers'; import { BlobError, disallowedPathnameCharacters } from './helpers'; diff --git a/packages/blob/src/request.ts b/packages/blob/src/request.ts index d3ce6467f..fbab8ce1e 100644 --- a/packages/blob/src/request.ts +++ b/packages/blob/src/request.ts @@ -1,4 +1,3 @@ -import type { Response } from 'undici'; import { blobFetch, hasFetch, hasFetchWithUploadProgress } from './fetch'; import { hasXhr, blobXhr } from './xhr'; import type { BlobRequest } from './helpers'; @@ -18,6 +17,9 @@ export const blobRequest: BlobRequest = async ({ } } + console.log('hasFetch', hasFetch); + console.log('hasXhr', hasXhr); + if (hasFetch) { return blobFetch({ input, init }); } diff --git a/packages/blob/src/undici-browser.js b/packages/blob/src/undici-browser.js deleted file mode 100644 index 5a243ab0d..000000000 --- a/packages/blob/src/undici-browser.js +++ /dev/null @@ -1,8 +0,0 @@ -// this file gets copied to the dist folder -// it makes undici work in the browser by reusing the global fetch -// it's the simplest way I've found to make http requests work in Node.js, Serverles Functions, Edge Functions, and the browser -// this should work as long as this module is used via Next.js/Webpack -// moving forward we will have to solve this problem in a more robust way -// reusing https://github.com/inrupt/universal-fetch -// or seeing how/if cross-fetch solves https://github.com/lquixada/cross-fetch/issues/69 -export const fetch = globalThis.fetch.bind(globalThis); diff --git a/packages/blob/src/xhr.ts b/packages/blob/src/xhr.ts index 2bb713547..5a1dd0b62 100644 --- a/packages/blob/src/xhr.ts +++ b/packages/blob/src/xhr.ts @@ -1,6 +1,7 @@ -import type { Response as UndiciResponse } from 'undici'; +import type { Readable } from 'stream'; import { isReadableStream, type BlobRequest } from './helpers'; import { debug } from './debug'; +import type { PutBody } from './put-helpers'; export const hasXhr = typeof XMLHttpRequest !== 'undefined'; @@ -23,13 +24,7 @@ export const blobXhr: BlobRequest = async ({ if (isReadableStream(init.body)) { body = await new Response(init.body).blob(); } else { - // We "type lie" here, what we should do instead: - // Exclude ReadableStream: - // body = init.body as Exclude; - // We can't do this because init.body (PutBody) relies on Blob (node:buffer) - // while XMLHttpRequestBodyInit relies on native Blob type. - // If we get rid of undici we can remove this trick. - body = init.body as XMLHttpRequestBodyInit; + body = init.body as Exclude; } } @@ -72,7 +67,7 @@ export const blobXhr: BlobRequest = async ({ status: xhr.status, statusText: xhr.statusText, headers, - }) as unknown as UndiciResponse; + }); resolve(response); }; @@ -94,7 +89,7 @@ export const blobXhr: BlobRequest = async ({ // Set headers if (init.headers) { - const headers = new Headers(init.headers as HeadersInit); + const headers = new Headers(init.headers); headers.forEach((value, key) => { xhr.setRequestHeader(key, value); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 82d09b737..cfef88efc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -59,9 +59,6 @@ importers: throttleit: specifier: ^2.1.0 version: 2.1.0 - undici: - specifier: ^5.28.4 - version: 5.28.4 devDependencies: '@edge-runtime/jest-environment': specifier: 2.3.10 @@ -1138,10 +1135,6 @@ packages: resolution: {integrity: sha512-gMsVel9D7f2HLkBma9VbtzZRehRogVRfbr++f06nL2vnCGCNlzOD+/MUov/F4p8myyAHspEhVobgjpX64q5m6A==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - '@fastify/busboy@2.1.1': - resolution: {integrity: sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==} - engines: {node: '>=14'} - '@humanwhocodes/config-array@0.11.14': resolution: {integrity: sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==} engines: {node: '>=10.10.0'} @@ -5446,10 +5439,6 @@ packages: undici-types@6.20.0: resolution: {integrity: sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==} - undici@5.28.4: - resolution: {integrity: sha512-72RFADWFqKmUb2hmmvNODKL3p9hcB6Gt2DOQMis1SEBaV6a4MH8soBvzg+95CYhCKPFedut2JY9bMfrDl9D23g==} - engines: {node: '>=14.0'} - undici@6.20.1: resolution: {integrity: sha512-AjQF1QsmqfJys+LXfGTNum+qw4S88CojRInG/6t31W/1fk6G59s92bnAvGz5Cmur+kQv2SURXEvvudLmbrE8QA==} engines: {node: '>=18.17'} @@ -6511,8 +6500,6 @@ snapshots: '@eslint/js@8.56.0': {} - '@fastify/busboy@2.1.1': {} - '@humanwhocodes/config-array@0.11.14': dependencies: '@humanwhocodes/object-schema': 2.0.2 @@ -11916,10 +11903,6 @@ snapshots: undici-types@6.20.0: {} - undici@5.28.4: - dependencies: - '@fastify/busboy': 2.1.1 - undici@6.20.1: {} universalify@0.1.2: {} diff --git a/test/next/src/app/vercel/blob/script.mts b/test/next/src/app/vercel/blob/script.mts index 0d2f2f3da..4cfac2ba7 100644 --- a/test/next/src/app/vercel/blob/script.mts +++ b/test/next/src/app/vercel/blob/script.mts @@ -6,7 +6,6 @@ import { createReadStream, readFile, readFileSync } from 'node:fs'; import type { IncomingMessage } from 'node:http'; import https from 'node:https'; -import { fetch } from 'undici'; import axios from 'axios'; import got from 'got'; import * as vercelBlob from '@vercel/blob';