diff --git a/.changeset/fix-blob-handle-upload-callback-url.md b/.changeset/fix-blob-handle-upload-callback-url.md new file mode 100644 index 000000000..77e8de687 --- /dev/null +++ b/.changeset/fix-blob-handle-upload-callback-url.md @@ -0,0 +1,10 @@ +--- +"@vercel/blob": patch +--- + +fix(blob): infer handleUpload callback urls from node request headers + +When `handleUpload()` is used with `onUploadCompleted()` and no explicit +`callbackUrl` is returned from `onBeforeGenerateToken()`, the SDK now falls back +to Node request headers such as `x-forwarded-host`, `x-forwarded-proto`, and +`host` to infer the callback URL. diff --git a/packages/blob/src/client.node.test.ts b/packages/blob/src/client.node.test.ts index a1d8c9e95..91f965b47 100644 --- a/packages/blob/src/client.node.test.ts +++ b/packages/blob/src/client.node.test.ts @@ -519,6 +519,104 @@ describe('client uploads', () => { process.env = originalEnv; }); + it('should infer callbackUrl from forwarded host headers for node requests', async () => { + const originalEnv = process.env; + process.env = { + NODE_ENV: 'test', + }; + + const token = + 'vercel_blob_rw_12345fakeStoreId_30FakeRandomCharacters12345678'; + + const jsonResponse = await handleUpload({ + token, + request: { + url: '/api/upload?via=node', + headers: { + host: 'internal.local:3000', + 'x-forwarded-host': 'uploads.example.com', + 'x-forwarded-proto': 'https', + }, + } as unknown as IncomingMessage, + body: { + type: 'blob.generate-client-token', + payload: { + pathname: 'newfile.txt', + multipart: false, + clientPayload: null, + }, + }, + onBeforeGenerateToken: async () => { + return Promise.resolve({ + addRandomSuffix: false, + }); + }, + onUploadCompleted: async () => { + await Promise.resolve(); + }, + }); + + const decodedPayload = getPayloadFromClientToken( + (jsonResponse.type === 'blob.generate-client-token' && + jsonResponse.clientToken) || + '', + ); + + expect(decodedPayload.onUploadCompleted?.callbackUrl).toBe( + 'https://uploads.example.com/api/upload?via=node', + ); + + process.env = originalEnv; + }); + + it('should infer http callbackUrl for localhost node requests', async () => { + const originalEnv = process.env; + process.env = { + NODE_ENV: 'test', + }; + + const token = + 'vercel_blob_rw_12345fakeStoreId_30FakeRandomCharacters12345678'; + + const jsonResponse = await handleUpload({ + token, + request: { + url: '/api/upload', + headers: { + host: 'localhost:3000', + }, + } as unknown as IncomingMessage, + body: { + type: 'blob.generate-client-token', + payload: { + pathname: 'newfile.txt', + multipart: false, + clientPayload: null, + }, + }, + onBeforeGenerateToken: async () => { + return Promise.resolve({ + addRandomSuffix: false, + }); + }, + onUploadCompleted: async () => { + await Promise.resolve(); + }, + }); + + const decodedPayload = getPayloadFromClientToken( + (jsonResponse.type === 'blob.generate-client-token' && + jsonResponse.clientToken) || + '', + ); + + expect(decodedPayload.onUploadCompleted?.callbackUrl).toBe( + 'http://localhost:3000/api/upload', + ); + + process.env = originalEnv; + }); + it('should warn when callbackUrl provided but onUploadCompleted not defined', async () => { const originalConsoleWarn = console.warn; const mockConsoleWarn = jest.fn(); diff --git a/packages/blob/src/client.ts b/packages/blob/src/client.ts index 8c7eb12f1..f89073d48 100644 --- a/packages/blob/src/client.ts +++ b/packages/blob/src/client.ts @@ -881,6 +881,11 @@ function getCallbackUrl(request: RequestType): string | undefined { return `${process.env.VERCEL_BLOB_CALLBACK_URL}${reqPath}`; } + const callbackUrlFromHeaders = getCallbackUrlFromHeaders(request, reqPath); + if (callbackUrlFromHeaders) { + return callbackUrlFromHeaders; + } + // Not hosted on Vercel and no VERCEL_BLOB_CALLBACK_URL if (process.env.VERCEL !== '1') { console.warn( @@ -910,6 +915,58 @@ function getCallbackUrl(request: RequestType): string | undefined { return undefined; } +function getCallbackUrlFromHeaders( + request: RequestType, + reqPath: string, +): string | undefined { + if (!request.headers) { + return undefined; + } + + const rawHost = hasHeadersGet(request.headers) + ? request.headers.get('x-forwarded-host') ?? + request.headers.get('host') ?? + undefined + : getFirstHeaderValue( + request.headers['x-forwarded-host'] ?? request.headers.host, + ); + + if (!rawHost) { + return undefined; + } + + const host = rawHost.split(',')[0]?.trim(); + if (!host) { + return undefined; + } + + const rawProtocol = hasHeadersGet(request.headers) + ? request.headers.get('x-forwarded-proto') ?? undefined + : getFirstHeaderValue(request.headers['x-forwarded-proto']); + + const protocol = rawProtocol?.split(',')[0]?.trim() || inferProtocol(host); + + return `${protocol}://${host}${reqPath}`; +} + +function getFirstHeaderValue( + headerValue: string | string[] | undefined, +): string | undefined { + return Array.isArray(headerValue) ? headerValue[0] : headerValue; +} + +function hasHeadersGet( + headers: IncomingMessage['headers'] | Headers | undefined, +): headers is Headers { + return typeof headers === 'object' && headers !== null && 'get' in headers; +} + +function inferProtocol(host: string): 'http' | 'https' { + return host.startsWith('localhost') || host.startsWith('127.0.0.1') + ? 'http' + : 'https'; +} + /** * @internal Helper function to safely extract pathname and query string from request URL * Handles both full URLs (http://localhost:3000/api/upload?test=1) and relative paths (/api/upload?test=1)