Skip to content

fix(ext/node): support encoding option in fs.readdir; don't double-call glob callback#33501

Merged
bartlomieju merged 1 commit into
denoland:mainfrom
nathanwhitbot:fix/node-compat-iter31
Apr 26, 2026
Merged

fix(ext/node): support encoding option in fs.readdir; don't double-call glob callback#33501
bartlomieju merged 1 commit into
denoland:mainfrom
nathanwhitbot:fix/node-compat-iter31

Conversation

@nathanwhitbot

Copy link
Copy Markdown
Contributor

Two unrelated node:fs alignments.

fs.readdir / fs.readdirSync

  • Accept a string options argument as shorthand for { encoding } (matches getOptions() in Node's lib/internal/fs/utils.js). Previously the string form was dropped on the floor, so fs.readdir(dir, 'hex', cb) returned plain UTF-8 names.
  • Validate encodings via Buffer.isEncoding.
  • Re-encode filenames through Buffer.from(name, 'utf8').toString(encoding) instead of TextDecoder(encoding) (TextDecoder doesn't support hex/base64/base64url/buffer). Adds a 'buffer' branch that returns Buffer instances.

fs.glob

Dispatch via Promise.then(onFulfilled, onRejected) rather than awaiting inside a try/catch. Previously, if the user-supplied callback threw on success, the catch would invoke it again with the thrown error — causing a "called more than expected" failure. Matches Node's lib/fs.js glob().

Tests enabled

  • parallel/test-fs-buffer.js
  • parallel/test-fs-glob-throw.mjs

Verified no regressions across the configured test-fs-readdir-* and test-fs-glob* tests.

…ll glob callback

Two unrelated fs alignments:

- fs.readdir / fs.readdirSync: accept a string `options` argument as
  shorthand for `{ encoding }` (matches getOptions() in Node's
  lib/internal/fs/utils.js), validate encodings via Buffer.isEncoding,
  and route filename re-encoding through Buffer.toString(encoding) so
  encodings like "hex", "base64", and "buffer" actually work. Previously
  TextDecoder was used and silently produced wrong output for non-text
  encodings, and the string-as-encoding form was dropped on the floor.
- fs.glob: dispatch via Promise.then(onFulfilled, onRejected) instead of
  awaiting inside a try/catch. Previously, if the user-supplied callback
  threw on success, the catch would invoke it again with the thrown
  error - causing a "called more than expected" failure. Matches Node's
  lib/fs.js glob().

Enables parallel/test-fs-buffer.js and parallel/test-fs-glob-throw.mjs
in the node_compat suite.

@nathanwhitbot nathanwhitbot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed against Node source. Both changes are faithful:

fs.glob (_fs_glob.ts): exact match to Node lib/fs.js:3210-3214PromisePrototypeThen(ArrayFromAsync(...), (res) => callback(null, res), callback). The Promise.then(onFulfilled, onRejected) shape is the textbook fix for the "callback throws → catch re-invokes it" double-call: the onRejected slot only fires for promise rejection, not for synchronous throws inside onFulfilled. Test test-fs-glob-throw.mjs directly exercises that path.

fs.readdir / readdirSync (_fs_readdir.ts):

  • normalizeOptions: matches Node lib/internal/fs/utils.js:317-326 getOptions() — string options promoted to { encoding: <string> }.
  • validateEncoding: matches Node lib/internal/fs/utils.js:330-331 + assertEncoding — skip on undefined and on 'buffer', else require Buffer.isEncoding. Note: 'buffer' is intentionally not in Buffer.isEncoding's set, so this avoids a spurious throw.
  • New decode(): Buffer.from(str, 'utf8').toString(encoding) correctly mirrors Node's binding-level encoding (the source filename is UTF-8 from Deno's readdir; converting to a Buffer then re-stringifying is equivalent to what Node's StringBytes::Encode does after libuv hands back UTF-8 bytes). The 'buffer' branch returns a Buffer instance, matching Node's Buffer-encoding output.
  • Order swap: decode() now applied to the final relative path (after relative()) — matches Node's handleFilePaths which encodes after path resolution. ✓

Minor: the ERR_INVALID_OPT_VALUE_ENCODING thrown as a plain Error (rather than Node's ERR_INVALID_ARG_VALUE) is a pre-existing inconsistency this PR preserves — non-blocking.

Both new test entries are alphabetically placed correctly. LGTM.

@fibibot fibibot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM. Two clear, small fixes against concrete failure modes:

glob double-call: switching from try { await ... callback(null, res) } catch { callback(err) } to Array.fromAsync(...).then(res => callback(null, res), callback) is the right shape. In the old form, if the user-supplied callback threw inside the success path, the catch would re-fire it with the throw — test-fs-glob-throw.mjs exercises exactly this. With .then(onFulfilled, onRejected), a throw from onFulfilled becomes an unhandled rejection rather than re-entering onRejected, matching Node's lib/fs.js glob().

fs.readdir encoding: the bare-string options form (fs.readdir(dir, 'hex', cb)) was getting silently dropped because the old code did typeof optionsOrCallback === "object" and a string fell to null. The new normalizeOptions mirrors getOptions() in Node's lib/internal/fs/utils.js. Re-encoding via Buffer.from(name, 'utf8').toString(encoding) instead of TextDecoder is the right call — TextDecoder doesn't support hex/base64/base64url/buffer, which is why those encodings previously round-tripped to plain UTF-8. Subtle but important: moving decode(...) to after the relative(path, join(current, name)) step in the recursive branch correctly applies the encoding to the full relative path, not just the trailing filename — that's what Node does and the old code got it wrong.

One inline note about the type signatures.

path: string | Buffer | URL,
options?: { withFileTypes?: false; encoding?: string },
options?: { withFileTypes?: false; encoding?: string } | string,
): string[];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Type signatures don't reflect the new 'buffer' encoding path. readdirSync(path, 'buffer') now actually returns Buffer[] at runtime (via decode(...) returning a Buffer), but the matching overload picks the string[] signature here. Same on the async readdir. Worth either adding a Buffer[] overload (Node's @types/node has Buffer[] for BufferEncodingOption) or narrowing the encoding param so a literal 'buffer' resolves to a Buffer[] return — otherwise a TS user with readdirSync(p, 'buffer') will get inferred string[] and .toString('hex') won't be available without a cast.

@bartlomieju
bartlomieju merged commit f47b48b into denoland:main Apr 26, 2026
112 checks passed
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.

4 participants