Skip to content

feat: accept in-memory upload inputs - #733

Merged
TalLevAmi merged 7 commits into
cloudinary:masterfrom
TalLevAmi:codex/upload-binary-inputs
Oct 6, 2026
Merged

TalLevAmi merged 7 commits into
cloudinary:masterfrom
TalLevAmi:codex/upload-binary-inputs

Conversation

@TalLevAmi

@TalLevAmi TalLevAmi commented Mar 8, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • allow uploader.upload() and unsigned_upload() to accept in-memory binary inputs
  • send Buffer, Uint8Array, ArrayBuffer, and Blob data directly in the multipart body instead of treating them like file paths
  • update the TypeScript declarations and uploader specs for the new supported input types

Testing

  • npm run dtslint
  • ./node_modules/.bin/eslint lib/uploader.js test/integration/api/uploader/uploader_spec.js
  • mocked local runtime validation covering Buffer, Uint8Array, ArrayBuffer, Blob, and raw buffer uploads without hitting fs.createReadStream

Notes

  • README documents the in-memory inputs and the filename option (raw uploads take their extension from it)

…-inputs

Conflict in types/cloudinary_ts_spec.ts: keep the new upload() type tests from this branch.
Use the "UploadStream | Promise<UploadApiResponse>" order from master for upload_large.
In promise mode, call_api reports a failure to the callback and also rejects.
The Blob branch then called the callback again from its .catch.
Now the Blob branch calls the callback only for a failure before call_api returns.
Examples of such a failure are a Blob read error or a synchronous throw.
Add a unit test that sends a mocked HTTP 500 response.
@cloudinary-pkoniu

Copy link
Copy Markdown
Contributor

Code Review

🐞 Bugs (5) 🧩 Compatibility (1) 🧹 Maintainability (2) ⚡ Performance (1)

Every finding below was reproduced with a standalone Node.js script against this branch (d277990, consumed via npm link, Node 24.13.0). Wire-level checks used a local HTTPS server that captures the exact multipart request; #3 and #4 were also confirmed against a live staging cloud. Baseline comparisons use the released cloudinary@2.11.0.


Action required

1. Blob uploads read options after an await ≡ Correctness
Description
For Blob inputs, build_upload_params(options) and api_url run only after getBlobArrayBuffer()
resolves. Any change the caller makes to the same options object after calling upload() leaks
into the request. Buffer, Uint8Array and path inputs build params synchronously and are unaffected,
so behaviour now differs by input type.
Code

lib/uploader.js[R61-R67]

+    let uploadSourcePromise = Promise.resolve().then(() => getBlobArrayBuffer(file)).then((arrayBuffer) => {
+      let result = call_upload_api(toUploadSource(Buffer.from(arrayBuffer), options, {
+        contentType: file.type,
+        filename: file.name
+      }), callback, options);
Evidence
const opts = {};
for (const id of ['a', 'b', 'c']) {
 opts.public_id = `${kind}_${id}`;
 pending.push(cloudinary.uploader.upload(kind === 'Blob' ? new Blob([id]) : Buffer.from(id), opts));
}
Buffer public_ids sent: Buffer_a, Buffer_b, Buffer_c | file bodies: a, b, c
Blob   public_ids sent: Blob_c, Blob_c, Blob_c       | file bodies: a, b, c

All three Blob uploads are sent with the last public_id, so they overwrite the same asset.

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
In `upload()`, the Blob branch defers `call_upload_api` (and therefore `build_upload_params(options)` / `utils.api_url`) until after the Blob has been read asynchronously. Mutations to the caller's options object made after `upload()` returns leak into the request.

## Fix Focus Areas
- lib/uploader.js[54-90]
- lib/uploader.js[92-97]

## Recommended Fix
Snapshot everything derived from `options` synchronously before the first await: shallow-copy `options` at entry, or compute params and the upload URL up front and pass them into the deferred send. Add a unit test that mutates `options.public_id` right after calling `upload(blob, options)` and asserts that the request carries the original value.

2. Blobs from other implementations fall through to fs.createReadStream ≡ Correctness
Description
isBlob() uses instanceof Blob against the current global. File/Blob objects from formdata-node,
fetch-blob (node-fetch v2/v3) or another realm fail that check. A native Node Blob also fails it
while jsdom-global has replaced global.Blob. These inputs fall through to the path branch, and
upload() throws ERR_INVALID_ARG_TYPE synchronously instead of returning a promise.
Code

lib/uploader.js[R107-R109]

+function isBlob(file) {
+  return typeof Blob !== "undefined" && file instanceof Blob;
+}
Evidence
native Blob (control)   instanceof Blob=true  -> resolved
formdata-node File      instanceof Blob=false -> THREW ERR_INVALID_ARG_TYPE The "path" argument must be of type string. Received an instance of File
formdata-node Blob      instanceof Blob=false -> THREW ERR_INVALID_ARG_TYPE ... Received an instance of _Blob
fetch-blob Blob         instanceof Blob=false -> THREW ERR_INVALID_ARG_TYPE ... Received an instance of Blob

# native require('buffer').Blob while jsdom-global is active
global.Blob === buffer.Blob: false | nodeBlob instanceof Blob: false
THREW ERR_INVALID_ARG_TYPE

The throw happens synchronously in upload(), not as a rejected promise.

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`isBlob()` relies on `instanceof Blob`, so spec-compliant Blob/File objects from polyfills (formdata-node, fetch-blob) or another realm are not detected and end up in `fs.createReadStream`, which throws synchronously.

## Fix Focus Areas
- lib/uploader.js[107-109]

## Recommended Fix
Detect Blobs by shape: a non-null object with `typeof arrayBuffer === 'function'`, `typeof size === 'number'` and `typeof type === 'string'` (optionally also checking `Symbol.toStringTag` of 'Blob' or 'File'). Add unit tests that use a formdata-node `File` and a fetch-blob `Blob`.

3. Filename written to the multipart header unescaped and as latin1 ≡ Correctness ⛨ Input handling
Description
File.name now flows into Content-Disposition: ...; filename="..." with no escaping, and the header
is encoded with Buffer.from(..., 'binary'), which keeps only the low byte of each UTF-16 unit.
Quotes end the filename parameter early, CR/LF injects extra part headers, and non-Latin-1 names are
garbled. The encoding problem already exists in 2.11.0 for string paths and upload_stream with
options.filename. This PR widens it to File.name, which in server routes such as formData.get('file')
is end-user controlled.
Code

lib/uploader.js[R650-R651]

+    let { filename, contentType } = getFileUploadOptions(file, options);
+    file_header = Buffer.from(encodeFilePart(boundary, contentType, 'file', filename), 'binary');
Evidence

Captured header part (local HTTPS capture server):

File.name "zdjęcie \"1\".png" -> filename="zdj\u0019cie "1".png"          (ę -> 0x19; quote unescaped)
File.name "日本.png"           -> filename="å,.png"                       (bytes e5 2c instead of e6 97 a5 e6 9c ac)
File.name 'a.png"\r\nX-Injected: yes\r\nfoo: "'
 -> Content-Disposition: form-data; name="file"; filename="a.png"
    X-Injected: yes
    foo: ""

What the live API stored (use_filename: true):

File.name public_id original_filename
zdjęcie.png zdj_cie_… "zdj\u0019cie"
日本.png random (use_filename lost) (missing)
say "hi".png say_… "say "
name with CRLF + injected Content-Type: text/plain x_… "x" (still stored as png)
control: string path zdjęcie.png (2.11.0 code path) zdj_cie_… "zdj\u0019cie"

In 2.11.0, upload(path) with a non-Latin-1 name and upload_stream({filename: 'a"b\r\nX-Injected: 1'}) produce the same broken headers.

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The multipart file part header interpolates the filename raw and is encoded with 'binary' (latin1). File.name, which is often user-supplied, can therefore contain quotes or CR/LF that break or inject header lines, and any non-Latin-1 characters are truncated to one byte.

## Fix Focus Areas
- lib/uploader.js[650-651]
- lib/uploader.js[743-762]
- lib/uploader.js[774-782]

## Recommended Fix
Follow the HTML multipart/form-data rules that browsers use: replace `"` with `%22`, CR with `%0D` and LF with `%0A` in the filename, and encode the header as UTF-8 instead of 'binary'. Apply this to all inputs (paths, streams, Blob/File). Add tests for a quote, CRLF, a Polish name and a CJK name, and assert `original_filename` in an integration test.


Remediation recommended

4. Byte inputs upload as "file", so raw assets lose their extension ≡ Correctness
Description
Buffer, Uint8Array and ArrayBuffer inputs (and a Blob without a name) default the multipart filename to the
bare string "file". Cloudinary derives the raw asset extension and the use_filename public_id
from it, so raw uploads get no extension and every asset reports original_filename: "file". A
workaround exists (options.filename), but it is not typed or documented.
Code

lib/uploader.js[R160-R166]

+function toUploadSource(file, options = {}, extra = {}) {
+  return {
+    data: Buffer.isBuffer(file) ? file : Buffer.from(file),
+    filename: options.filename || extra.filename || "file",
Evidence

Live API:

Buffer, resource_type raw, use_filename -> public_id "file_es6iiq"        original_filename "file"
Buffer, resource_type raw               -> public_id "wyjpsl7tbvsxnshvqn6y" (no extension)
control: path report.csv, raw, use_filename -> public_id "report_quirkp.csv" original_filename "report"
Buffer image                            -> original_filename "file"

Captured request: filename="file" for Buffer, Uint8Array and ArrayBuffer. With options.filename: 'report.csv' the header carries report.csv, and the value is not added to the signed params.

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
In-memory uploads default the multipart filename to "file". Raw assets lose their extension, and original_filename/use_filename are meaningless. The `options.filename` override works but is undiscoverable.

## Fix Focus Areas
- lib/uploader.js[160-166]
- types/index.d.ts (UploadApiOptions)

## Recommended Fix
Add `filename?: string` to UploadApiOptions with a doc comment, and document it in the README next to the new Buffer/Blob examples. Optionally, warn or document that `resource_type: 'raw'` byte uploads need a filename to keep their extension. Make the raw-buffer integration test assert on `public_id`/`original_filename`, not only on `resource_type`.

5. jsdom-only fallback shipped in production code 🧹 Maintainability
Description
getBlobBuffer() scans the Blob's own symbol properties for jsdom's private _buffer field. It exists
only because uploader_spec loads jsdom-global, whose jsdom@15 Blob replaces global.Blob and has
no arrayBuffer(). The FileReader branch after it cannot run: FileReader is undefined in Node and
under the repo's jsdom-global setup.
Code

lib/uploader.js[R111-R150]

+function getBlobBuffer(file) {
+  ...
+  for (let symbol of Object.getOwnPropertySymbols(file)) {
+    let implementation = file[symbol];
+    if (implementation && Buffer.isBuffer(implementation._buffer)) {
+      return implementation._buffer;
Evidence
# repo devDependencies: jsdom 15.2.1 / jsdom-global 3.0.2
global.Blob replaced by jsdom? true | blob.arrayBuffer: undefined
own symbols: [ 'Symbol(impl)' ] -> keys [ '_buffer', 'type' ]
FileReader defined (plain Node 24 / under jsdom-global): false / false

# upload(new Blob(['jsdom-bytes'])) under jsdom-global
this branch:                       uploaded body = "jsdom-bytes"
same code, getBlobBuffer removed:  FAILED -> Blob upload requires Blob.arrayBuffer() or FileReader support
Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`getBlobBuffer` reads jsdom's private implementation object to support the integration test environment, and the `FileReader` fallback is unreachable in Node. Production behaviour should not depend on jsdom internals.

## Fix Focus Areas
- lib/uploader.js[111-150]
- test/integration/api/uploader/uploader_spec.js[55-62]
- test/integration/api/uploader/uploader_spec.js[133-143]

## Recommended Fix
Remove `getBlobBuffer` and the `FileReader` branch, and simply call `file.arrayBuffer()`. In the spec, construct the Blob with Node's own `require('buffer').Blob` (or keep the native `global.Blob` the way `URL` is already preserved) so the test exercises the real runtime Blob.

6. Error shape and timing depend on the input type ≡ Correctness
Description
When reading a Blob fails, the promise rejects with a bare Error, while an fs read failure rejects with
{error}. Synchronous config errors such as a missing cloud_name are thrown synchronously for string
and Buffer inputs but become an async rejection plus a callback call for Blob inputs.
Code

lib/uploader.js[R68-R75]

+    }).catch((error) => {
+      if (!apiCalled) {
+        callback({
+          error
+        });
+      }
+      throw error;
+    });
Evidence
missing file path (fs error)       REJECTED with plain object keys=[error]
Blob whose arrayBuffer() rejects   REJECTED with Error instance (e.error=undefined)
string path, no cloud_name         THREW SYNC "Must supply cloud_name"   callback calls=0
Buffer, no cloud_name              THREW SYNC "Must supply cloud_name"   callback calls=0
Blob, no cloud_name                REJECTED   "Must supply cloud_name"   callback calls=1
Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The Blob branch rejects with the raw Error (`throw error`), while other read failures reject with `{ error }`. Config errors are thrown synchronously for non-Blob inputs but delivered asynchronously for Blob inputs.

## Fix Focus Areas
- lib/uploader.js[54-90]

## Recommended Fix
Reject with `{ error }` to match the fs read-failure path. Resolve config and params synchronously before the async Blob read (this also fixes the options-mutation finding), so config errors surface the same way for every input type. Add unit tests for both cases.


Informational

7. Whole payload is copied into memory ⚡ Performance
Description
Buffer.from(uint8Array) copies the full payload, where a zero-copy view is possible. Blobs, including
file-backed ones from fs.openAsBlob, are fully loaded with arrayBuffer() instead of being streamed.
Code

lib/uploader.js[R162]

+    data: Buffer.isBuffer(file) ? file : Buffer.from(file),
Evidence
Uint8Array 200MB: arrayBuffers grew by 200MB synchronously
 control: Buffer.from(u8.buffer, u8.byteOffset, u8.byteLength) grew by 0MB (zero-copy view: true)
fs.openAsBlob 200MB file: peak extra arrayBuffers during upload ≈ 200MB
Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
In-memory uploads double peak memory: Uint8Array is copied, and Blobs are read fully into an ArrayBuffer.

## Fix Focus Areas
- lib/uploader.js[160-166]
- lib/uploader.js[61-67]

## Recommended Fix
For Uint8Array, use `Buffer.from(u8.buffer, u8.byteOffset, u8.byteLength)`. For Blobs, consider piping `Readable.fromWeb(blob.stream())` into the existing upload stream path instead of materialising an ArrayBuffer.

8. UploadFile type breaks on @types/node < 18 without the DOM lib 🧩 Compatibility
Description
The new UploadFile type references the global Blob. TypeScript consumers on @types/node 14 or 16
without lib: ["dom"] get TS2304, where 2.11.0 compiled. Consumers on @types/node 18 or later are unaffected.
Code

types/index.d.ts[R1288]

+            type UploadFile = string | Buffer | Uint8Array | ArrayBuffer | Blob;
Evidence

tsc --strict --lib es2020 --types node --skipLibCheck false, consumer calling cloudinary.v2.uploader.upload('a.png'):

this PR  @types/node@14.18.63  error TS2304: Cannot find name 'Blob'.  (types/index.d.ts(1288,76))
2.11.0   @types/node@14.18.63  OK
this PR  @types/node@16.18.126 error TS2304: Cannot find name 'Blob'.
2.11.0   @types/node@16.18.126 OK
this PR  @types/node@18.19.130 OK
this PR  @types/node@22.20.5   OK
Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`UploadFile` references the global `Blob`, which older @types/node versions do not declare without the DOM lib.

## Fix Focus Areas
- types/index.d.ts[1288]

## Recommended Fix
Either accept this and document @types/node >= 18 as the minimum for TS users, or replace `Blob` with a structural type such as `{ arrayBuffer(): Promise<ArrayBuffer>; type: string; size: number; name?: string }`. The structural type also matches the shape-based runtime detection suggested for the Blob-detection finding.

9. Redundant branches in the new helpers 🧹 Maintainability
Description
Buffer.isBuffer in isUploadData is subsumed by isUint8Array (a Buffer is a Uint8Array). The typeof
Uint8Array/ArrayBuffer !== "undefined" guards are dead on Node >= 9, the declared engine. The typeof Blob
guard is still needed. The FileReader branch is unreachable (see finding 5). Note: the apiCalled flag is
needed as written; a two-argument .then() would stop routing synchronous call_api errors to the callback.
Code

lib/uploader.js[R99-R158]

+function isUploadData(file) {
+  return Buffer.isBuffer(file) || isUint8Array(file) || isArrayBuffer(file);
+}
Evidence
Buffer.from('x') instanceof Uint8Array: true
typeof FileReader (Node v24.13.0): undefined
Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The new type-detection helpers carry checks that can never change the result on supported Node versions.

## Fix Focus Areas
- lib/uploader.js[99-158]

## Recommended Fix
Simplify to `isUploadData = f => f instanceof Uint8Array || f instanceof ArrayBuffer`, and drop the dead `typeof` guards and the FileReader branch. Keep the `typeof Blob` guard (or switch to shape-based detection).


Checked and dismissed: "Buffer paths are now uploaded as content". In 2.11.0, upload(Buffer.from(path)) already threw ERR_INVALID_ARG_TYPE (in basename()), so no existing caller regresses.

🤖 Generated with Claude Code. Findings were verified with local repro scripts and a live staging API before posting.

Apply the review findings on in-memory upload inputs:

- Make the upload source and call call_api at once, so that a later
  change to options does not change the upload. Read the Blob body in
  post(), with the same error handling as the fs path. A read failure
  now rejects with { error }, and config errors are thrown at once for
  all input types.
- Find a Blob by its shape, so that polyfill Blobs and Blobs from other
  realms also work.
- Escape quotes, CR and LF in the multipart filename and encode the
  file header as UTF-8.
- Add the filename option to UploadApiOptions and the README.
- Remove the jsdom-only Blob fallback and the FileReader branch. Use
  the Node Blob in the integration spec.
- Send a Uint8Array as a zero-copy view. Stream a Blob with
  Readable.fromWeb, and read it with arrayBuffer() if that fails.
- Use a structural UploadBlob type in place of the global Blob.
Node versions before 15.7 (and 14.18) have no Blob in the buffer module.
Skip the integration test on these versions, as the File test does.
Skip only the tests that need a native Blob. On Node before 15.7, the
tests with a Blob-like object now test the arrayBuffer() fallback.
Do not use assert.rejects, because Node 9 does not have it.
@TalLevAmi
TalLevAmi merged commit 46ae5c7 into cloudinary:master Oct 6, 2026
10 checks passed
@TalLevAmi
TalLevAmi deleted the codex/upload-binary-inputs branch October 6, 2026 12:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants