Skip to content

fs: .write(options) support #41666

Closed
@LiviaMedeiros

Description

@LiviaMedeiros

What is the problem this feature will solve?

In fs subsystem, there are forms of .read methods, accepting Object as argument: filehandle.read, fs.read, fs.readSync.
They allow to skip optional offset and/or length, and to use convenient reusable objects, e.g. const webpSize = {buffer: new Uint32Array(1), position: 0x4}
However, .write counterparts don't support that. So instead of using "named arguments" and reusing the same objects (e.g. await fh.write(webpSize); after modifying buffer contents), we have to write a wrapper function or something like:

await fh.write(webpSize.buffer, undefined, undefined, webpSize.position);
// or emulate single-passable-variable:
const _webpSizeA = [webpSize.buffer, ...[,,], webpSize.position];
await fh.write(..._webpSizeA);

...which doesn't feel right.

What is the feature you are proposing to solve the problem?

Adding:
filehandle.write(options) to Promises API (FileHandle class)
fs.write(fd, options, callback) to Callback API
fs.writeSync(fd, options) to Synchronous API
Skipping options would do the same as passing {} which means buffer === undefined, so making them optional is redundant.
Doing nothing or "writing 0 bytes" by default contradicts with async reading functions: they create 16KB buffer.

Rough patch for Promises API:

diff --git a/lib/internal/fs/promises.js b/lib/internal/fs/promises.js
index 6a0ce4d05c6b..8d534b0256bd 100644
--- a/lib/internal/fs/promises.js
+++ b/lib/internal/fs/promises.js
@@ -562,7 +562,18 @@ async function readv(handle, buffers, position) {
   return { bytesRead, buffers };
 }
 
-async function write(handle, buffer, offset, length, position) {
+async function write(handle, bufferOrOptions, offset, length, position) {
+  let buffer = bufferOrOptions;
+  if (!isArrayBufferView(buffer)) {
+    validateBuffer(bufferOrOptions?.buffer);
+    ({
+      buffer,
+      offset = 0,
+      length = buffer.byteLength,
+      position = null
+    } = bufferOrOptions);
+  }
+
   if (buffer?.byteLength === 0)
     return { bytesWritten: 0, buffer };

Of course, this will break current code, because if buffer isn't an ArrayBufferView, Node.js assumes filehandle.write(string[, position[, encoding]]).
Or it implements this: "If buffer is a plain object, it must have an own (not inherited) toString function property."?
Or this: "If string is not a string, or an object with an own toString function property, the promise is rejected with an error."?

So here goes the important part: current code is already broken.

Correctly handled error:

import {open} from 'fs/promises';
const fh = await open('/dev/null', 'r+');
const obj = {[Symbol.toPrimitive]: hint => 'iAmNotToString'};
fh.write(obj).then(console.log); // should not work according to docs
$ ./node -v && ./node ./tst.mjs
v18.0.0-pre
node:internal/errors:464
    ErrorCaptureStackTrace(err);
    ^

TypeError [ERR_INVALID_ARG_TYPE]: The "buffer" argument must be of type string or an instance of Buffer, TypedArray, or DataView. Received an instance of Object
    at write (node:internal/fs/promises:586:3)
    at fsCall (node:internal/fs/promises:365:18)
    at FileHandle.write (node:internal/fs/promises:190:12)
    at file:///home/livia/nodejs/node/tst.mjs:4:4 {
  code: 'ERR_INVALID_ARG_TYPE'
}

Again but with toString:

import {open} from 'fs/promises';
const fh = await open('/dev/null', 'r+');
const obj = {toString: () => 'writeMe'}; // const obj = {toString() {return 'writeMe';}};
fh.write(obj).then(console.log); // should return: { bytesWritten: 7, buffer: 'writeMe' }
$ ./node -v && ./node ./tst.mjs
v18.0.0-pre
./node[3418]: ../src/string_bytes.cc:319:static size_t node::StringBytes::Write(v8::Isolate*, char*, size_t, v8::Local<v8::Value>, node::encoding, int*): Assertion `val->IsString() == true' failed.
 1: 0x5610cba98420 node::Abort() [./node]
 2: 0x5610cba984a5  [./node]
 3: 0x5610cbb9b98e node::StringBytes::Write(v8::Isolate*, char*, unsigned long, v8::Local<v8::Value>, node::encoding, int*) [./node]
 4: 0x5610cbaa7e58  [./node]
 5: 0x5610cbcfdda4 v8::internal::FunctionCallbackArguments::Call(v8::internal::CallHandlerInfo) [./node]
 6: 0x5610cbcfe6b2  [./node]
 7: 0x5610cbcfebaf v8::internal::Builtin_HandleApiCall(int, unsigned long*, v8::internal::Isolate*) [./node]
 8: 0x5610cc635479  [./node]
Aborted

Same outcome in v16.13.1.

While ".toString() must be an own property, not inherited" rule might successfully prevent from writing random [object Object]s everywhere, it also makes stringifying objects barely usable:

// .write won't accept these:
let url = new URL('https://example.com');
let date = new Date();

// and people will be encouraged to:
url.toString = url.toString;
date.toString = Date.prototype.toString;

// but why not just:
url = url.toString();
await fh.write(date.toString());

Rather than fixing this and allowing weird and potentially dangerous implicit .toString() calls, I suggest to use it as opportunity to change behaviour for object to "named arguments", because it won't break too much.

Thank you.

What alternatives have you considered?

Additional segregation between methods for buffers and strings, e.g. by introducing .writeString, .writeStringSync
Considerable, but devastatingly breaking change.

Metadata

Metadata

Assignees

No one assigned

    Labels

    feature requestIssues that request new features to be added to Node.js.fsIssues and PRs related to the fs subsystem / file system.

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions