Repository: pyodide/pyodide
Pull request: Implement more detailed streams support
This adds a carefully designed API for controlling stdin, stdout, and stderr. It changes the default behavior to be a bit more useful, though in doing so introduces some mild backwards incompatibility. In particular:

1. By default, stdin reads directly from `process.stdin` in node (as before) and raises an error if in browser (not as before).
2. By default, stdout writes directly to `process.stdout` in node (before it called console.log) and calls console.log in browser (as before).
3. By default, stderr writes directly to `process.stderr` in node (before it called console.warn) and calls console.warn in browser (as before).
4. In all three cases, by default isatty(stdin/stdout/stderr) is true in node and false in browser (in the browser it used to be true).
5. As before, if you pass `stdin`, `stdout`, or `stderr` as arguments to `loadPyodide`, `isatty` of the corresponding stream is set to false.

The stdin function is now more flexible: we now correctly handle the case where it returns an ArrayBuffer or ArrayBufferView.

I also added 3 new functions to set streams after Pyodide is loaded which offer additional control:
* `setStdin({stdin?, error?, isatty = false})` -- Sets the stdin function. The stdin function takes no arguments and should return null, undefined, a string, or a buffer. Sets and `isatty(stdin)` to `isatty` (by default `false`). If error is true, set stdin to always raise an EIO error when it is read.
* `setStdout({raw?, batched?, isatty = false})` -- If neither raw nor batched is passed, restore default stdout behavior. If rwa is passed, the raw stdout function receives a byte which it should interpret as a utf8 character code. Sets `isatty(stdout)` to isatty (by default `false`). If batched is passed but not raw, it sets a batched stdout function. The stdout function receives a string and should do something with it. In this case it ignores isatty and sets isatty(stdout) to false.
* `setStderr({raw?, batched?, isatty = false})` -- same but with stderr.

Resolves #3112.

### Checklists

<!-- Note:
     If you think some of these steps are not necessary for your PR,
     remove those checkboxes, or mark them as checked. If you keep unchecked checkboxes,
     we will assume that your PR is not ready to be merged  -->

- [x] Add a [CHANGELOG](https://github.com/pyodide/pyodide/blob/main/docs/project/changelog.md) entry
- [x] Add / update tests
- [x] Add new / update outdated documentation
- [x] Add section to Usage docs


Problem statement:
Stdout not ending in newline doesn't trigger stdout callback until newline is printed
## 🐛 Bug

When the Python code prints to stdout some text not ending with a new line, the stdout callback configured in loadPyodide() isn't called.

### To Reproduce

```js
let py = await loadPyodide({
        stdout: (data) => {
            console.log(`Got stdout data: ${data}`);
        }
});


py.runPython(`print("Hello world!", end="")`); // this doesn't call the stdout callback
py.runPython(`print("\n")`); // until this happens
```

### Expected behavior

This should call the stdout event as soon as text is put. Once for putting `Hello world!`, then once for putting new line. Some devs may prefer it only called on newline prints, but I need to use the output. Could be an configurable option? After all, this would probably be a breaking change in some projects.

### Environment

- Pyodide Version: `0.21.2` loaded in browser with JSDelivr, with type interfaces over npm (not included in built website)
- Browser version: Chrome 105.0.5195.127
- Any other relevant information: Using Typescript and Webpack (with pyodide set as an external to load via JSDelivr)

### Additional context

There may be other functions that write to stdout like this, but print() is the most prominent example.

*Cheers!*

Hints from issue discussion:
Thanks for the report!

The reason is that the default `stdout` and `stderr` is `console.log` and `console.warn` and these cannot print partial lines. So the emscripten helper functionality for this is written with the assumption that the streams will similarly only want complete lines. Another issue that comes up is declaring whether `isatty` should be True or False, which it should be possible to declare separately. Currently `isatty` defaults to `True` but becomes `False` if you pass a custom handler. It should really default to `False` since `console.log` can't act like a tty. If the user passes their own handler, they should get to declare if it should be atty or not.

I am hoping to include a fix to this in Pyodide v0.22.0. 

The following code is a work around that you could use in the meantime. It's written for Node but with some minor adaptation could be used in the browser:
https://github.com/pyodide/pyodide/blob/main/tools/python#L94
@sbc100

Commit message: Update changelog

Patch to review:
diff --git a/docs/project/changelog.md b/docs/project/changelog.md
index 30ca898a8d5..bb5573b4c20 100644
--- a/docs/project/changelog.md
+++ b/docs/project/changelog.md
@@ -98,6 +98,13 @@ substitutions:
   a new option `checkIntegrity`. If set to False, integrity check for Python Packages
   will be disabled.
 
+- {{ Enhancement }} Added APIs {any}`setStdin <pyodide.setStdin>`,
+  {any}`setStdout <pyodide.setStdout>`, {any}`setStderr <pyodide.setStderr>`
+  (and plus several more) for changing the stream handlers after loading
+  Pyodide. Also added more careful control over whether `isatty` returns true or
+  false on stdin, stdout, and stderr.
+  {pr}`3268`
+
 - {{ Fix }} Fix undefined symbol error when loading shared library
   {pr}`3193`
 
diff --git a/src/js/api.ts b/src/js/api.ts
index d723deeb49c..18d9dfe621a 100644
--- a/src/js/api.ts
+++ b/src/js/api.ts
@@ -10,6 +10,17 @@ import { loadBinaryFile } from "./compat";
 import version from "./version";
 export { loadPackage, loadedPackages, isPyProxy };
 import "./error_handling.gen.js";
+import {
+  setDefaultStdin,
+  setStdinError,
+  setStdin,
+  setDefaultStdout,
+  setStdout,
+  setRawStdout,
+  setDefaultStderr,
+  setStderr,
+  setRawStderr,
+} from "./streams";
 
 API.loadBinaryFile = loadBinaryFile;
 
@@ -506,6 +517,15 @@ export type PyodideInterface = {
   registerComlink: typeof registerComlink;
   PythonError: typeof PythonError;
   PyBuffer: typeof PyBuffer;
+  setDefaultStdin: typeof setDefaultStdin;
+  setStdin: typeof setStdin;
+  setStdinError: typeof setStdinError;
+  setDefaultStdout: typeof setDefaultStdout;
+  setStdout: typeof setStdout;
+  setRawStdout: typeof setRawStdout;
+  setDefaultStderr: typeof setDefaultStderr;
+  setStderr: typeof setStderr;
+  setRawStderr: typeof setRawStderr;
 };
 
 /**
@@ -572,6 +592,15 @@ API.makePublicAPI = function (): PyodideInterface {
     PyBuffer,
     _module: Module,
     _api: API,
+    setDefaultStdin,
+    setStdin,
+    setStdinError,
+    setDefaultStdout,
+    setStdout,
+    setRawStdout,
+    setDefaultStderr,
+    setStderr,
+    setRawStderr,
   };
 
   API.public_api = namespace;
diff --git a/src/js/module.ts b/src/js/module.ts
index 5bc27236598..21b5158fa70 100644
--- a/src/js/module.ts
+++ b/src/js/module.ts
@@ -1,4 +1,35 @@
 /** @private */
+
+type FSNode = any;
+type FSStream = any;
+
+export interface FS {
+  unlink: (path: string) => void;
+  mkdirTree: (path: string, mode?: number) => void;
+  chdir: (path: string) => void;
+  symlink: (target: string, src: string) => FSNode;
+  createDevice: (
+    parent: string,
+    name: string,
+    input?: (() => number | null) | null,
+    output?: ((code: number) => void) | null,
+  ) => FSNode;
+  closeStream: (fd: number) => void;
+  open: (path: string, flags: string | number, mode?: number) => FSStream;
+  makedev: (major: number, minor: number) => number;
+  mkdev: (path: string, dev: number) => FSNode;
+  filesystems: any;
+  stat: (path: string, dontFollow?: boolean) => any;
+  readdir: (node: FSNode) => string[];
+  isDir: (mode: number) => boolean;
+  lookupPath: (path: string) => FSNode;
+  isFile: (mode: number) => boolean;
+  writeFile: (path: string, contents: any, o?: { canOwn?: boolean }) => void;
+  chmod: (path: string, mode: number) => void;
+  utime: (path: string, atime: number, mtime: number) => void;
+  rmdir: (path: string) => void;
+}
+
 export interface Module {
   noImageDecoding: boolean;
   noAudioDecoding: boolean;
@@ -8,8 +39,9 @@ export interface Module {
   print: (a: string) => void;
   printErr: (a: string) => void;
   ENV: { [key: string]: string };
-  FS: any;
   PATH: any;
+  TTY: any;
+  FS: FS;
 }
 
 /**
@@ -30,81 +62,6 @@ export function createModule(): any {
   return Module;
 }
 
-/**
- *
- * @param stdin
- * @param stdout
- * @param stderr
- * @private
- */
-export function setStandardStreams(
-  Module: Module,
-  stdin?: () => string,
-  stdout?: (a: string) => void,
-  stderr?: (a: string) => void,
-) {
-  // For stdout and stderr, emscripten provides convenient wrappers that save us the trouble of converting the bytes into a string
-  if (stdout) {
-    Module.print = stdout;
-  }
-
-  if (stderr) {
-    Module.printErr = stderr;
-  }
-
-  // For stdin, we have to deal with the low level API ourselves
-  if (stdin) {
-    Module.preRun.push(function () {
-      Module.FS.init(createStdinWrapper(stdin), null, null);
-    });
-  }
-}
-
-function createStdinWrapper(stdin: () => string) {
-  // When called, it asks the user for one whole line of input (stdin)
-  // Then, it passes the individual bytes of the input to emscripten, one after another.
-  // And finally, it terminates it with null.
-  const encoder = new TextEncoder();
-  let input = new Uint8Array(0);
-  let inputIndex = -1; // -1 means that we just returned null
-  function stdinWrapper() {
-    try {
-      if (inputIndex === -1) {
-        let text = stdin();
-        if (text === undefined || text === null) {
-          return null;
-        }
-        if (typeof text !== "string") {
-          throw new TypeError(
-            `Expected stdin to return string, null, or undefined, got type ${typeof text}.`,
-          );
-        }
-        if (!text.endsWith("\n")) {
-          text += "\n";
-        }
-        input = encoder.encode(text);
-        inputIndex = 0;
-      }
-
-      if (inputIndex < input.length) {
-        let character = input[inputIndex];
-        inputIndex++;
-        return character;
-      } else {
-        inputIndex = -1;
-        return null;
-      }
-    } catch (e) {
-      // emscripten will catch this and set an IOError which is unhelpful for
-      // debugging.
-      console.error("Error thrown in stdin:");
-      console.error(e);
-      throw e;
-    }
-  }
-  return stdinWrapper;
-}
-
 /**
  * Make the home directory inside the virtual file system,
  * then change the working directory to it.
diff --git a/src/js/pyodide.ts b/src/js/pyodide.ts
index e8c3d9ba4e4..1064a11b391 100644
--- a/src/js/pyodide.ts
+++ b/src/js/pyodide.ts
@@ -10,7 +10,7 @@ import {
   resolvePath,
 } from "./compat";
 
-import { createModule, setStandardStreams, setHomeDirectory } from "./module";
+import { createModule, setHomeDirectory } from "./module";
 import { initializeNativeFS } from "./nativefs";
 import version from "./version";
 
@@ -242,8 +242,8 @@ export async function loadPyodide(
      * The home directory which Pyodide will use inside virtual file system. Default: "/home/pyodide"
      */
     homedir?: string;
-
-    /** Load the full Python standard library.
+    /**
+     * Load the full Python standard library.
      * Setting this to false excludes unvendored modules from the standard library.
      * Default: false
      */
@@ -290,6 +290,8 @@ export async function loadPyodide(
   );
 
   const Module = createModule();
+  Module.print = config.stdout;
+  Module.printErr = config.stderr;
   Module.preRun.push(() => {
     for (const mount of config._node_mounts) {
       Module.FS.mkdirTree(mount);
@@ -301,7 +303,6 @@ export async function loadPyodide(
   const API: any = { config };
   Module.API = API;
 
-  setStandardStreams(Module, config.stdin, config.stdout, config.stderr);
   setHomeDirectory(Module, config.homedir);
 
   const moduleLoaded = new Promise((r) => (Module.postRun = r));
@@ -338,14 +339,13 @@ If you updated the Pyodide version, make sure you also updated the 'indexURL' pa
 `,
     );
   }
-
-  initializeNativeFS(Module);
-
   // Disable further loading of Emscripten file_packager stuff.
   Module.locateFile = (path: string) => {
     throw new Error("Didn't expect to load any more file_packager files!");
   };
 
+  initializeNativeFS(Module);
+
   const pyodide_py_tar = await pyodide_py_tar_promise;
   unpackPyodidePy(Module, pyodide_py_tar);
   Module._pyodide_init();
@@ -372,5 +372,6 @@ If you updated the Pyodide version, make sure you also updated the 'indexURL' pa
     await pyodide.loadPackage(API._pyodide._importhook.UNVENDORED_STDLIBS);
   }
   pyodide.runPython("print('Python initialization complete')");
+  API.initializeStreams(config.stdin, config.stdout, config.stderr);
   return pyodide;
 }
diff --git a/src/js/streams.ts b/src/js/streams.ts
new file mode 100644
index 00000000000..a07863be7cf
--- /dev/null
+++ b/src/js/streams.ts
@@ -0,0 +1,385 @@
+import { IN_NODE } from "./compat.js";
+import type { Module } from "./module";
+
+declare var API: any;
+declare var Module: Module;
+
+declare var FS: typeof Module.FS;
+declare var TTY: any;
+
+// The type of the function we need to produce to read from stdin
+// (Either directly from a device when isatty is false or as part of a tty when
+// isatty is true)
+type GetCharType = () => null | number;
+
+// The type of the function we expect the user to give us. make_get_char takes
+// one of these and turns it into a GetCharType function for us.
+type InFuncType = () =>
+  | null
+  | undefined
+  | string
+  | ArrayBuffer
+  | ArrayBufferView;
+
+// To define the output behavior of a tty we need to define put_char and fsync.
+// fsync flushes the stream.
+//
+// If isatty is false, we ignore fsync and use put_char.bind to fill in a dummy
+// value for the tty argument. We don't ever use the tty argument.
+type PutCharType = {
+  put_char: (tty: void, val: number) => void;
+  fsync: (tty: void) => void;
+};
+
+// A tty needs both a GetChar function and a PutChar pair.
+type TtyOps = {
+  get_char: GetCharType;
+} & PutCharType;
+
+/**
+ * We call refreshStreams at the end of every update method, but refreshStreams
+ * won't work until initializeStreams is called. So when INITIALIZED is false,
+ * refreshStreams is a no-op.
+ * @private
+ */
+let INITIALIZED = false;
+
+// These can't be used until they are initialized by initializeStreams.
+const ttyout_ops = {} as TtyOps;
+const ttyerr_ops = {} as TtyOps;
+const isattys = {} as {
+  stdin: boolean;
+  stdout: boolean;
+  stderr: boolean;
+};
+
+function refreshStreams() {
+  if (!INITIALIZED) {
+    return;
+  }
+  FS.unlink("/dev/stdin");
+  FS.unlink("/dev/stdout");
+  FS.unlink("/dev/stderr");
+  if (isattys.stdin) {
+    FS.symlink("/dev/tty", "/dev/stdin");
+  } else {
+    FS.createDevice("/dev", "stdin", ttyout_ops.get_char);
+  }
+  if (isattys.stdout) {
+    FS.symlink("/dev/tty", "/dev/stdout");
+  } else {
+    FS.createDevice(
+      "/dev",
+      "stdout",
+      null,
+      ttyout_ops.put_char.bind(undefined, undefined),
+    );
+  }
+  if (isattys.stderr) {
+    FS.symlink("/dev/tty", "/dev/stderr");
+  } else {
+    FS.createDevice(
+      "/dev",
+      "stderr",
+      null,
+      ttyerr_ops.put_char.bind(undefined, undefined),
+    );
+  }
+
+  // Refresh std streams so they use our new versions
+  FS.closeStream(0 /* stdin */);
+  FS.closeStream(1 /* stdout */);
+  FS.closeStream(2 /* stderr */);
+  FS.open("/dev/stdin", 0 /* write only */);
+  FS.open("/dev/stdout", 1 /* read only */);
+  FS.open("/dev/stderr", 1 /* read only */);
+}
+
+/**
+ * This is called at the end of loadPyodide to set up the streams. If
+ * loadPyodide has been given stdin, stdout, stderr arguments they are provided
+ * here. Otherwise, we set the default behaviors. This also fills in the global
+ * state in this file.
+ * @param stdin
+ * @param stdout
+ * @param stderr
+ * @private
+ */
+API.initializeStreams = function (
+  stdin?: InFuncType,
+  stdout?: (a: string) => void,
+  stderr?: (a: string) => void,
+) {
+  let stdin_isatty = false;
+  if (stdin) {
+    setStdin(stdin, stdin_isatty);
+  } else {
+    setDefaultStdin();
+  }
+  if (stdout) {
+    setStdout(stdout);
+  } else {
+    setDefaultStdout();
+  }
+
+  if (stderr) {
+    setStderr(stderr);
+  } else {
+    setDefaultStderr();
+  }
+  const ttyout_dev = FS.makedev(5, 0);
+  const ttyerr_dev = FS.makedev(6, 0);
+  TTY.register(ttyout_dev, ttyout_ops);
+  TTY.register(ttyerr_dev, ttyerr_ops);
+  INITIALIZED = true;
+  refreshStreams();
+};
+
+/**
+ * Sets the default stdin. If in node, stdin will read from `process.stdin`
+ * and isatty(stdin) will be set to tty.isatty(process.stdin.fd).
+ * If in a browser, this calls setStdinError.
+ */
+export function setDefaultStdin() {
+  if (IN_NODE) {
+    const BUFSIZE = 256;
+    const buf = Buffer.alloc(BUFSIZE);
+    const fs = require("fs");
+    const tty = require("tty");
+    const stdin = function () {
+      let bytesRead;
+      try {
+        bytesRead = fs.readSync(process.stdin.fd, buf, 0, BUFSIZE, -1);
+      } catch (e) {
+        // Platform differences: on Windows, reading EOF throws an exception,
+        // but on other OSes, reading EOF returns 0. Uniformize behavior by
+        // catching the EOF exception and returning 0.
+        if ((e as Error).toString().includes("EOF")) {
+          bytesRead = 0;
+        } else {
+          throw e;
+        }
+      }
+      if (bytesRead === 0) {
+        return null;
+      }
+      return buf.subarray(0, bytesRead);
+    };
+    setStdin(stdin, tty.isatty(process.stdin.fd));
+  } else {
+    setStdinError();
+  }
+}
+
+/**
+ * Sets isatty(stdin) to false and makes reading from stdin always set an EIO
+ * error.
+ */
+export function setStdinError() {
+  isattys.stdin = false;
+  const get_char = () => {
+    throw 0;
+  };
+  ttyout_ops.get_char = get_char;
+  ttyerr_ops.get_char = get_char;
+  refreshStreams();
+}
+
+/**
+ * Sets a stdin function. This function will be called whenever stdin is read.
+ * Also sets isatty(stdin) to the value of the isatty argument (default false).
+ *
+ * The stdin function is called with zero arguments. It should return one of:
+ * - `null` or `undefined`: these are interpreted as EOF
+ * - a string
+ * - an ArrayBuffer or an ArrayBufferView with BYTES_PER_ELEMENT === 1
+ *
+ * If a string is returned, a new line is appended if one is not present and the
+ * resulting string is turned into a Uint8Array using TextEncoder.
+ *
+ * Returning a buffer is more efficient and allows returning partial lines of
+ * text.
+ *
+ */
+export function setStdin(stdin: InFuncType, isatty: boolean = false) {
+  isattys.stdin = isatty;
+  const get_char = make_get_char(stdin);
+  ttyout_ops.get_char = get_char;
+  ttyerr_ops.get_char = get_char;
+  refreshStreams();
+}
+
+/**
+ * If in node, sets stdout to write directly to process.stdout and sets isatty(stdout)
+ * to tty.isatty(process.stdout.fd).
+ * If in a browser, sets stdout to write to console.log and sets isatty(stdout) to false.
+ */
+export function setDefaultStdout() {
+  if (IN_NODE) {
+    const tty = require("tty");
+    const rawstdout = (x: number) => process.stdout.write(Buffer.from([x]));
+    const isatty = tty.isatty(process.stdout.fd);
+    setRawStdout(rawstdout, isatty);
+  } else {
+    setStdout((x) => console.log(x));
+  }
+}
+
+/**
+ * Sets writes to stdout to call `stdout(line)` whenever a complete line is
+ * written or stdout is flushed. In the former case, the received line will end
+ * with a newline, in the latter case it will not.
+ *
+ * isatty(stdout) is set to false (this API buffers stdout so it is impossible
+ * to make a tty with it).
+ */
+export function setStdout(stdout: (a: string) => void) {
+  isattys.stdout = false;
+  Object.assign(ttyout_ops, make_batched_put_char(stdout));
+  refreshStreams();
+}
+
+/**
+ * Sets stdout to call `stdout(character_code)` whenever a character is written
+ * to stdout.
+ * Also sets isatty(stdout) to the value of the isatty argument (default false).
+ */
+export function setRawStdout(
+  rawstdout: (a: number) => void,
+  isatty: boolean = false,
+) {
+  isattys.stdout = isatty;
+  Object.assign(ttyout_ops, make_unbatched_put_char(rawstdout));
+  refreshStreams();
+}
+
+/**
+ * If in node, sets stderr to write directly to process.stderr and sets isatty(stderr)
+ * to tty.isatty(process.stderr.fd).
+ * If in a browser, sets stderr to write to console.warn and sets isatty(stderr) to false.
+ */
+export function setDefaultStderr() {
+  if (IN_NODE) {
+    const tty = require("tty");
+    const rawstderr = (x: number) => process.stderr.write(Buffer.from([x]));
+    const isatty = tty.isatty(process.stderr.fd);
+    setRawStderr(rawstderr, isatty);
+  } else {
+    setStderr((x) => console.warn(x));
+  }
+}
+
+/**
+ * Sets writes to stderr to call `stderr(line)` whenever a complete line is
+ * written or stderr is flushed. In the former case, the received line will end
+ * with a newline, in the latter case it will not.
+ *
+ * isatty(stderr) is set to false (this API buffers stderr so it is impossible
+ * to make a tty with it).
+ */
+export function setStderr(stderr: (a: string) => void) {
+  isattys.stderr = false;
+  Object.assign(ttyerr_ops, make_batched_put_char(stderr));
+  refreshStreams();
+}
+
+/**
+ * Sets stderr to call `stderr(character_code)` whenever a character is written
+ * to stderr.
+ * Also sets isatty(stderr) to the value of the isatty argument (default false).
+ */
+export function setRawStderr(
+  rawstderr: (a: number) => void,
+  isatty: boolean = false,
+) {
+  isattys.stderr = isatty;
+  Object.assign(ttyerr_ops, make_unbatched_put_char(rawstderr));
+  refreshStreams();
+}
+
+const textencoder = new TextEncoder();
+const textdecoder = new TextDecoder();
+
+function make_get_char(infunc: InFuncType): GetCharType {
+  let index = 0;
+  let buf: Uint8Array = new Uint8Array(0);
+  // get_char has 3 particular return values:
+  // a.) the next character represented as an integer
+  // b.) undefined to signal that no data is currently available
+  // c.) null to signal an EOF
+  return function get_char() {
+    try {
+      if (index >= buf.length) {
+        let input = infunc();
+        if (input === undefined || input === null) {
+          return null;
+        }
+        if (typeof input === "string") {
+          if (!input.endsWith("\n")) {
+            input += "\n";
+          }
+          buf = textencoder.encode(input);
+        } else if (ArrayBuffer.isView(input)) {
+          if ((input as any).BYTES_PER_ELEMENT !== 1) {
+            throw new Error("Expected BYTES_PER_ELEMENT to be 1");
+          }
+          buf = input as Uint8Array;
+        } else if (
+          Object.prototype.toString.call(input) === "[object ArrayBuffer]"
+        ) {
+          buf = new Uint8Array(input);
+        } else {
+          throw new Error(
+            "Expected result to be undefined, null, string, array buffer, or array buffer view",
+          );
+        }
+        if (buf.length === 0) {
+          return null;
+        }
+        index = 0;
+      }
+      return buf[index++];
+    } catch (e) {
+      // emscripten will catch this and set an IOError which is unhelpful for
+      // debugging.
+      console.error("Error thrown in stdin:");
+      console.error(e);
+      throw e;
+    }
+  };
+}
+
+function make_unbatched_put_char(out: (a: number) => void): PutCharType {
+  return {
+    put_char(tty: any, val: any) {
+      out(val);
+    },
+    fsync() {},
+  };
+}
+
+function make_batched_put_char(out: (a: string) => void): PutCharType {
+  let output: number[] = [];
+  return {
+    // get_char has 3 particular return values:
+    // a.) the next character represented as an integer
+    // b.) undefined to signal that no data is currently available
+    // c.) null to signal an EOF,
+    put_char(tty: any, val: number) {
+      if (val === null || val === 10 /* charCode('\n') */) {
+        out(textdecoder.decode(new Uint8Array(output)));
+        output = [];
+      } else {
+        if (val !== 0) {
+          output.push(val); // val == 0 would cut text output off in the middle.
+        }
+      }
+    },
+    fsync(tty: any) {
+      if (output && output.length > 0) {
+        out(textdecoder.decode(new Uint8Array(output)));
+        output = [];
+      }
+    },
+  };
+}
diff --git a/src/tests/test_pyodide.py b/src/tests/test_pyodide.py
index 5d8ba13d12c..a83b831fca4 100644
--- a/src/tests/test_pyodide.py
+++ b/src/tests/test_pyodide.py
@@ -1067,7 +1067,7 @@ def test_restore_error(selenium):
 
 @pytest.mark.skip_refcount_check
 @pytest.mark.skip_pyproxy_check
-def test_custom_stdin_stdout(selenium_standalone_noload):
+def test_custom_stdin_stdout(selenium_standalone_noload, runtime):
     selenium = selenium_standalone_noload
     strings = [
         "hello world",
@@ -1119,11 +1119,11 @@ def test_custom_stdin_stdout(selenium_standalone_noload):
     assert (
         selenium.run_js(
             f"""
-        return pyodide.runPython(`
-            [input() for x in range({len(outstrings)})]
-            # ... test more stuff
-        `).toJs();
-        """
+            return pyodide.runPython(`
+                [input() for x in range({len(outstrings)})]
+                # ... test more stuff
+            `).toJs();
+            """
         )
         == outstrings
     )
@@ -1144,6 +1144,97 @@ def test_custom_stdin_stdout(selenium_standalone_noload):
     ]
     stderrstrings = _strip_assertions_stderr(stderrstrings)
     assert stderrstrings == ["something to stderr"]
+    IN_NODE = runtime == "node"
+    selenium.run_js(
+        f"""
+        pyodide.runPython(`
+            import sys
+            assert not sys.stdin.isatty()
+            assert not sys.stdout.isatty()
+            assert not sys.stderr.isatty()
+        `);
+        pyodide.setDefaultStdin();
+        pyodide.setDefaultStdout();
+        pyodide.setDefaultStderr();
+        pyodide.runPython(`
+            import sys
+            assert sys.stdin.isatty() is {IN_NODE}
+            assert sys.stdout.isatty() is {IN_NODE}
+            assert sys.stderr.isatty() is {IN_NODE}
+        `);
+        """
+    )
+
+
+def test_custom_stdin_stdout2(selenium):
+    result = selenium.run_js(
+        """
+        function stdin(){
+            return "hello there!\\nThis is a several\\nline\\nstring";
+        }
+        pyodide.setStdin(stdin);
+        pyodide.runPython(`
+            import sys
+            assert sys.stdin.read(1) == "h"
+            assert not sys.stdin.isatty()
+        `);
+        pyodide.setStdin(stdin, false);
+        pyodide.runPython(`
+            import sys
+            assert sys.stdin.read(1) == "e"
+        `);
+        pyodide.setDefaultStdout();
+        pyodide.runPython(`
+            assert sys.stdin.read(1) == "l"
+            assert not sys.stdin.isatty()
+        `);
+        pyodide.setStdin(stdin, true);
+        pyodide.runPython(`
+            assert sys.stdin.read(1) == "l"
+            assert sys.stdin.isatty()
+        `);
+
+        let stdout_codes = [];
+        function rawstdout(code) {
+            stdout_codes.push(code);
+        }
+        pyodide.setRawStdout(rawstdout);
+        pyodide.runPython(`
+            print("hello")
+            assert sys.stdin.read(1) == "o"
+            assert not sys.stdout.isatty()
+            assert sys.stdin.isatty()
+        `);
+        pyodide.setRawStdout(rawstdout, false);
+        pyodide.runPython(`
+            print("2hello again")
+            assert sys.stdin.read(1) == " "
+            assert not sys.stdout.isatty()
+            assert sys.stdin.isatty()
+        `);
+        pyodide.setRawStdout(rawstdout, true);
+        pyodide.runPython(`
+            print("3hello")
+            assert sys.stdin.read(1) == "t"
+            assert sys.stdout.isatty()
+            assert sys.stdin.isatty()
+        `);
+        pyodide.runPython(`
+            print("partial line", end="")
+        `);
+        let result1 = new TextDecoder().decode(new Uint8Array(stdout_codes));
+        pyodide.runPython(`
+            sys.stdout.flush()
+        `);
+        let result2 = new TextDecoder().decode(new Uint8Array(stdout_codes));
+        pyodide.setDefaultStdin();
+        pyodide.setDefaultStdout();
+        pyodide.setDefaultStderr();
+        return [result1, result2];
+        """
+    )
+    assert result[0] == "hello\n2hello again\n3hello\n"
+    assert result[1] == "hello\n2hello again\n3hello\npartial line"
 
 
 def test_home_directory(selenium_standalone_noload):
diff --git a/tools/python b/tools/python
index 71258209a08..ec039745acd 100755
--- a/tools/python
+++ b/tools/python
@@ -49,74 +49,6 @@ exec node "$ARGS" "$0" "$@"
 const { loadPyodide } = require("../dist/pyodide");
 const fs = require("fs");
 
-/**
- * The default stderr/stdout do not handle newline or flush correctly, and stdin
- * is also strange. Make a tty that connects Emscripten stdstreams to node
- * stdstreams. We will make one tty for stdout and one for stderr, the
- * `outstream` argument controls which one we make.
- *
- * Note that setting Module.stdin / Module.stdout / Module.stderr does not work
- * because these cause `isatty(stdstream)` to return false. We want `isatty` to
- * be true. (IMO this is an Emscripten issue, maybe someday we can fix it.)
- */
-function make_tty_ops(outstream) {
-    const BUFSIZE = 256;
-    const buf = Buffer.alloc(BUFSIZE);
-    let index = 0;
-    let numbytes = 0;
-    return {
-        // get_char has 3 particular return values:
-        // a.) the next character represented as an integer
-        // b.) undefined to signal that no data is currently available
-        // c.) null to signal an EOF
-        get_char(tty) {
-            if (index >= numbytes) {
-                // read synchronously at most BUFSIZE bytes from file descriptor 0 (stdin)
-                const bytesRead = fs.readSync(0, buf, 0, BUFSIZE, -1);
-                if (bytesRead === 0) {
-                    return null;
-                }
-                index = 0;
-                numbytes = bytesRead;
-            }
-            return buf[index++];
-        },
-        put_char(tty, val) {
-            outstream.write(Buffer.from([val]));
-        },
-        flush(tty) {},
-        fsync() {},
-    };
-}
-
-/**
- * Fix standard streams by replacing them with ttys that work better via make_tty_ops.
- */
-function setupStreams(FS, TTY) {
-    // Create and register devices
-    let mytty = FS.makedev(FS.createDevice.major++, 0);
-    let myttyerr = FS.makedev(FS.createDevice.major++, 0);
-    TTY.register(mytty, make_tty_ops(process.stdout));
-    TTY.register(myttyerr, make_tty_ops(process.stderr));
-    // Attach devices to files
-    FS.mkdev("/dev/mytty", mytty);
-    FS.mkdev("/dev/myttyerr", myttyerr);
-    // Replace /dev/stdxx with our custom devices
-    FS.unlink("/dev/stdin");
-    FS.unlink("/dev/stdout");
-    FS.unlink("/dev/stderr");
-    FS.symlink("/dev/mytty", "/dev/stdin");
-    FS.symlink("/dev/mytty", "/dev/stdout");
-    FS.symlink("/dev/myttyerr", "/dev/stderr");
-    // Refresh std streams so they use our new versions
-    FS.closeStream(0 /* stdin */);
-    FS.closeStream(1 /* stdout */);
-    FS.closeStream(2 /* stderr */);
-    FS.open("/dev/stdin", 0 /* write only */);
-    FS.open("/dev/stdout", 1 /* read only */);
-    FS.open("/dev/stderr", 1 /* read only */);
-}
-
 /**
  * Determine which native top level directories to mount into the Emscripten
  * file system.
@@ -143,7 +75,7 @@ async function main() {
             _node_mounts,
             homedir: process.cwd(),
             // Strip out standard messages written to stdout and stderr while loading
-            // After Pyodide is loaded we will replace stdstreams with setupStreams.
+            // After Pyodide is loaded we will restore default behavior.
             stdout(e) {
                 if (e.trim() === "Python initialization complete") {
                     return;
@@ -160,7 +92,7 @@ async function main() {
                     return;
                 }
                 console.warn(e);
-            },
+            }
         });
     } catch (e) {
         if (e.constructor.name !== "ExitStatus") {
@@ -170,10 +102,10 @@ async function main() {
         // arguments that is invalid in some way, we will exit here.
         process.exit(e.status);
     }
-    const FS = py.FS;
-    setupStreams(FS, py._module.TTY);
+    py.setDefaultStdout();
+    py.setDefaultStderr();
     let sideGlobals = py.runPython("{}");
-    globalThis.handleExit = function handleExit(code) {
+    function handleExit(code) {
         if (code === undefined) {
             code = 0;
         }
@@ -185,6 +117,7 @@ async function main() {
         // scheduled will segfault.
         process.exit(code);
     };
+    sideGlobals.set("handleExit", handleExit);
 
     py.runPython(
         `
@@ -216,7 +149,6 @@ async function main() {
         loop = asyncio.get_event_loop()
         # Make sure we don't run _no_in_progress_handler before we finish _run_main.
         loop._in_progress += 1
-        from js import handleExit
         loop._no_in_progress_handler = handleExit
         loop._system_exit_handler = handleExit
         loop._keyboard_interrupt_handler = lambda: handleExit(130)


Code review comments:
diff_hunk: @@ -0,0 +1,385 @@
+import { IN_NODE } from "./compat.js";
+import type { Module } from "./module";
+
+declare var API: any;
+declare var Module: Module;
+
+declare var FS: typeof Module.FS;
+declare var TTY: any;
+
+// The type of the function we need to produce to read from stdin
+// (Either directly from a device when isatty is false or as part of a tty when
+// isatty is true)
+type GetCharType = () => null | number;
+
+// The type of the function we expect the user to give us. make_get_char takes
+// one of these and turns it into a GetCharType function for us.
+type InFuncType = () =>
+  | null
+  | undefined
+  | string
+  | ArrayBuffer
+  | ArrayBufferView;
+
+// To define the output behavior of a tty we need to define put_char and fsync.
+// fsync flushes the stream.
+//
+// If isatty is false, we ignore fsync and use put_char.bind to fill in a dummy
+// value for the tty argument. We don't ever use the tty argument.
+type PutCharType = {
+  put_char: (tty: void, val: number) => void;
+  fsync: (tty: void) => void;
+};
+
+// A tty needs both a GetChar function and a PutChar pair.
+type TtyOps = {
+  get_char: GetCharType;
+} & PutCharType;
+
+/**
+ * We call refreshStreams at the end of every update method, but refreshStreams
+ * won't work until initializeStreams is called. So when INITIALIZED is false,
+ * refreshStreams is a no-op.
+ * @private
+ */
+let INITIALIZED = false;
+
+// These can't be used until they are initialized by initializeStreams.
+const ttyout_ops = {} as TtyOps;
+const ttyerr_ops = {} as TtyOps;
+const isattys = {} as {
+  stdin: boolean;
+  stdout: boolean;
+  stderr: boolean;
+};
+
+function refreshStreams() {
+  if (!INITIALIZED) {
+    return;
+  }
+  FS.unlink("/dev/stdin");
+  FS.unlink("/dev/stdout");
+  FS.unlink("/dev/stderr");
+  if (isattys.stdin) {
+    FS.symlink("/dev/tty", "/dev/stdin");
+  } else {
+    FS.createDevice("/dev", "stdin", ttyout_ops.get_char);
+  }
+  if (isattys.stdout) {
+    FS.symlink("/dev/tty", "/dev/stdout");
+  } else {
+    FS.createDevice(
+      "/dev",
+      "stdout",
+      null,
+      ttyout_ops.put_char.bind(undefined, undefined),
+    );
+  }
+  if (isattys.stderr) {
+    FS.symlink("/dev/tty", "/dev/stderr");
+  } else {
+    FS.createDevice(
+      "/dev",
+      "stderr",
+      null,
+      ttyerr_ops.put_char.bind(undefined, undefined),
+    );
+  }
+
+  // Refresh std streams so they use our new versions
+  FS.closeStream(0 /* stdin */);
+  FS.closeStream(1 /* stdout */);
+  FS.closeStream(2 /* stderr */);
+  FS.open("/dev/stdin", 0 /* write only */);
+  FS.open("/dev/stdout", 1 /* read only */);
+  FS.open("/dev/stderr", 1 /* read only */);
+}
+
+/**
+ * This is called at the end of loadPyodide to set up the streams. If
+ * loadPyodide has been given stdin, stdout, stderr arguments they are provided
+ * here. Otherwise, we set the default behaviors. This also fills in the global
+ * state in this file.
+ * @param stdin
+ * @param stdout
+ * @param stderr
+ * @private
+ */
+API.initializeStreams = function (
+  stdin?: InFuncType,
+  stdout?: (a: string) => void,
+  stderr?: (a: string) => void,
+) {
+  let stdin_isatty = false;
+  if (stdin) {
+    setStdin(stdin, stdin_isatty);
+  } else {
+    setDefaultStdin();
+  }
+  if (stdout) {
+    setStdout(stdout);
+  } else {
+    setDefaultStdout();
+  }
+
+  if (stderr) {
+    setStderr(stderr);
+  } else {
+    setDefaultStderr();
+  }
+  const ttyout_dev = FS.makedev(5, 0);
+  const ttyerr_dev = FS.makedev(6, 0);
+  TTY.register(ttyout_dev, ttyout_ops);
+  TTY.register(ttyerr_dev, ttyerr_ops);
+  INITIALIZED = true;
+  refreshStreams();
+};
+
+/**
+ * Sets the default stdin. If in node, stdin will read from `process.stdin`
+ * and isatty(stdin) will be set to tty.isatty(process.stdin.fd).
+ * If in a browser, this calls setStdinError.
+ */
+export function setDefaultStdin() {
+  if (IN_NODE) {
+    const BUFSIZE = 256;
+    const buf = Buffer.alloc(BUFSIZE);
+    const fs = require("fs");
+    const tty = require("tty");
+    const stdin = function () {
+      let bytesRead;
+      try {
+        bytesRead = fs.readSync(process.stdin.fd, buf, 0, BUFSIZE, -1);
+      } catch (e) {
+        // Platform differences: on Windows, reading EOF throws an exception,
+        // but on other OSes, reading EOF returns 0. Uniformize behavior by
+        // catching the EOF exception and returning 0.
+        if ((e as Error).toString().includes("EOF")) {
+          bytesRead = 0;
+        } else {
+          throw e;
+        }
+      }
+      if (bytesRead === 0) {
+        return null;
+      }
+      return buf.subarray(0, bytesRead);
+    };
+    setStdin(stdin, tty.isatty(process.stdin.fd));
+  } else {
+    setStdinError();
+  }
+}
+
+/**
+ * Sets isatty(stdin) to false and makes reading from stdin always set an EIO
+ * error.
+ */
+export function setStdinError() {
+  isattys.stdin = false;
+  const get_char = () => {
+    throw 0;
+  };
+  ttyout_ops.get_char = get_char;
+  ttyerr_ops.get_char = get_char;
+  refreshStreams();
+}
+
+/**
+ * Sets a stdin function. This function will be called whenever stdin is read.
+ * Also sets isatty(stdin) to the value of the isatty argument (default false).
+ *
+ * The stdin function is called with zero arguments. It should return one of:
+ * - `null` or `undefined`: these are interpreted as EOF
+ * - a string
+ * - an ArrayBuffer or an ArrayBufferView with BYTES_PER_ELEMENT === 1
+ *
+ * If a string is returned, a new line is appended if one is not present and the
+ * resulting string is turned into a Uint8Array using TextEncoder.
+ *
+ * Returning a buffer is more efficient and allows returning partial lines of
+ * text.
+ *
+ */
+export function setStdin(stdin: InFuncType, isatty: boolean = false) {
+  isattys.stdin = isatty;
+  const get_char = make_get_char(stdin);
+  ttyout_ops.get_char = get_char;
+  ttyerr_ops.get_char = get_char;
+  refreshStreams();
+}
+
+/**
+ * If in node, sets stdout to write directly to process.stdout and sets isatty(stdout)
+ * to tty.isatty(process.stdout.fd).
+ * If in a browser, sets stdout to write to console.log and sets isatty(stdout) to false.
+ */
+export function setDefaultStdout() {
+  if (IN_NODE) {
+    const tty = require("tty");
+    const rawstdout = (x: number) => process.stdout.write(Buffer.from([x]));
+    const isatty = tty.isatty(process.stdout.fd);
+    setRawStdout(rawstdout, isatty);
+  } else {
+    setStdout((x) => console.log(x));
+  }
+}
+
+/**
+ * Sets writes to stdout to call `stdout(line)` whenever a complete line is
+ * written or stdout is flushed. In the former case, the received line will end
+ * with a newline, in the latter case it will not.
+ *
+ * isatty(stdout) is set to false (this API buffers stdout so it is impossible
+ * to make a tty with it).
+ */
+export function setStdout(stdout: (a: string) => void) {
+  isattys.stdout = false;
+  Object.assign(ttyout_ops, make_batched_put_char(stdout));
+  refreshStreams();
+}
+
+/**
+ * Sets stdout to call `stdout(character_code)` whenever a character is written
+ * to stdout.
+ * Also sets isatty(stdout) to the value of the isatty argument (default false).
+ */
+export function setRawStdout(
+  rawstdout: (a: number) => void,
+  isatty: boolean = false,
+) {
+  isattys.stdout = isatty;
+  Object.assign(ttyout_ops, make_unbatched_put_char(rawstdout));
+  refreshStreams();
+}
+
+/**
+ * If in node, sets stderr to write directly to process.stderr and sets isatty(stderr)
+ * to tty.isatty(process.stderr.fd).
+ * If in a browser, sets stderr to write to console.warn and sets isatty(stderr) to false.
+ */
+export function setDefaultStderr() {
+  if (IN_NODE) {
+    const tty = require("tty");
+    const rawstderr = (x: number) => process.stderr.write(Buffer.from([x]));
+    const isatty = tty.isatty(process.stderr.fd);
+    setRawStderr(rawstderr, isatty);
+  } else {
+    setStderr((x) => console.warn(x));
+  }
+}
+
+/**
+ * Sets writes to stderr to call `stderr(line)` whenever a complete line is
+ * written or stderr is flushed. In the former case, the received line will end
+ * with a newline, in the latter case it will not.
+ *
+ * isatty(stderr) is set to false (this API buffers stderr so it is impossible
+ * to make a tty with it).
+ */
+export function setStderr(stderr: (a: string) => void) {
+  isattys.stderr = false;
+  Object.assign(ttyerr_ops, make_batched_put_char(stderr));
+  refreshStreams();
+}
+
+/**
+ * Sets stderr to call `stderr(character_code)` whenever a character is written
+ * to stderr.
+ * Also sets isatty(stderr) to the value of the isatty argument (default false).
+ */
+export function setRawStderr(
+  rawstderr: (a: number) => void,
+  isatty: boolean = false,
+) {
+  isattys.stderr = isatty;
+  Object.assign(ttyerr_ops, make_unbatched_put_char(rawstderr));
+  refreshStreams();
+}
+
+const textencoder = new TextEncoder();
+const textdecoder = new TextDecoder();
+
+function make_get_char(infunc: InFuncType): GetCharType {
path: src/js/streams.ts
text: @user1:
Is there a reason you named these functions in snake case?

@author:
Well Emscripten named `get_char` and `put_char` in snake case so I figured it made sense to stick with the snake case. Since we define and use functions across three different languages following many different conventions I don't place a particularly high value on consistency. But I could change them to camel case if you prefer.

diff_hunk: @@ -0,0 +1,385 @@
+import { IN_NODE } from "./compat.js";
+import type { Module } from "./module";
+
+declare var API: any;
+declare var Module: Module;
+
+declare var FS: typeof Module.FS;
+declare var TTY: any;
+
+// The type of the function we need to produce to read from stdin
+// (Either directly from a device when isatty is false or as part of a tty when
+// isatty is true)
+type GetCharType = () => null | number;
+
+// The type of the function we expect the user to give us. make_get_char takes
+// one of these and turns it into a GetCharType function for us.
+type InFuncType = () =>
+  | null
+  | undefined
+  | string
+  | ArrayBuffer
+  | ArrayBufferView;
+
+// To define the output behavior of a tty we need to define put_char and fsync.
+// fsync flushes the stream.
+//
+// If isatty is false, we ignore fsync and use put_char.bind to fill in a dummy
+// value for the tty argument. We don't ever use the tty argument.
+type PutCharType = {
+  put_char: (tty: void, val: number) => void;
+  fsync: (tty: void) => void;
+};
+
+// A tty needs both a GetChar function and a PutChar pair.
+type TtyOps = {
+  get_char: GetCharType;
+} & PutCharType;
+
+/**
+ * We call refreshStreams at the end of every update method, but refreshStreams
+ * won't work until initializeStreams is called. So when INITIALIZED is false,
+ * refreshStreams is a no-op.
+ * @private
+ */
+let INITIALIZED = false;
+
+// These can't be used until they are initialized by initializeStreams.
+const ttyout_ops = {} as TtyOps;
+const ttyerr_ops = {} as TtyOps;
+const isattys = {} as {
+  stdin: boolean;
+  stdout: boolean;
+  stderr: boolean;
+};
+
+function refreshStreams() {
+  if (!INITIALIZED) {
+    return;
+  }
+  FS.unlink("/dev/stdin");
+  FS.unlink("/dev/stdout");
+  FS.unlink("/dev/stderr");
+  if (isattys.stdin) {
+    FS.symlink("/dev/tty", "/dev/stdin");
+  } else {
+    FS.createDevice("/dev", "stdin", ttyout_ops.get_char);
+  }
+  if (isattys.stdout) {
+    FS.symlink("/dev/tty", "/dev/stdout");
+  } else {
+    FS.createDevice(
+      "/dev",
+      "stdout",
+      null,
+      ttyout_ops.put_char.bind(undefined, undefined),
+    );
+  }
+  if (isattys.stderr) {
+    FS.symlink("/dev/tty", "/dev/stderr");
+  } else {
+    FS.createDevice(
+      "/dev",
+      "stderr",
+      null,
+      ttyerr_ops.put_char.bind(undefined, undefined),
+    );
+  }
+
+  // Refresh std streams so they use our new versions
+  FS.closeStream(0 /* stdin */);
+  FS.closeStream(1 /* stdout */);
+  FS.closeStream(2 /* stderr */);
+  FS.open("/dev/stdin", 0 /* write only */);
+  FS.open("/dev/stdout", 1 /* read only */);
+  FS.open("/dev/stderr", 1 /* read only */);
+}
+
+/**
+ * This is called at the end of loadPyodide to set up the streams. If
+ * loadPyodide has been given stdin, stdout, stderr arguments they are provided
+ * here. Otherwise, we set the default behaviors. This also fills in the global
+ * state in this file.
+ * @param stdin
+ * @param stdout
+ * @param stderr
+ * @private
+ */
+API.initializeStreams = function (
+  stdin?: InFuncType,
+  stdout?: (a: string) => void,
+  stderr?: (a: string) => void,
+) {
+  let stdin_isatty = false;
+  if (stdin) {
+    setStdin(stdin, stdin_isatty);
+  } else {
+    setDefaultStdin();
+  }
+  if (stdout) {
+    setStdout(stdout);
+  } else {
+    setDefaultStdout();
+  }
+
+  if (stderr) {
+    setStderr(stderr);
+  } else {
+    setDefaultStderr();
+  }
+  const ttyout_dev = FS.makedev(5, 0);
+  const ttyerr_dev = FS.makedev(6, 0);
+  TTY.register(ttyout_dev, ttyout_ops);
+  TTY.register(ttyerr_dev, ttyerr_ops);
+  INITIALIZED = true;
+  refreshStreams();
+};
+
+/**
+ * Sets the default stdin. If in node, stdin will read from `process.stdin`
+ * and isatty(stdin) will be set to tty.isatty(process.stdin.fd).
+ * If in a browser, this calls setStdinError.
+ */
+export function setDefaultStdin() {
+  if (IN_NODE) {
+    const BUFSIZE = 256;
+    const buf = Buffer.alloc(BUFSIZE);
+    const fs = require("fs");
+    const tty = require("tty");
+    const stdin = function () {
+      let bytesRead;
+      try {
+        bytesRead = fs.readSync(process.stdin.fd, buf, 0, BUFSIZE, -1);
+      } catch (e) {
+        // Platform differences: on Windows, reading EOF throws an exception,
+        // but on other OSes, reading EOF returns 0. Uniformize behavior by
+        // catching the EOF exception and returning 0.
+        if ((e as Error).toString().includes("EOF")) {
+          bytesRead = 0;
+        } else {
+          throw e;
+        }
+      }
+      if (bytesRead === 0) {
+        return null;
+      }
+      return buf.subarray(0, bytesRead);
+    };
+    setStdin(stdin, tty.isatty(process.stdin.fd));
+  } else {
+    setStdinError();
+  }
+}
+
+/**
+ * Sets isatty(stdin) to false and makes reading from stdin always set an EIO
+ * error.
+ */
+export function setStdinError() {
+  isattys.stdin = false;
+  const get_char = () => {
+    throw 0;
+  };
+  ttyout_ops.get_char = get_char;
+  ttyerr_ops.get_char = get_char;
+  refreshStreams();
+}
+
+/**
+ * Sets a stdin function. This function will be called whenever stdin is read.
+ * Also sets isatty(stdin) to the value of the isatty argument (default false).
+ *
+ * The stdin function is called with zero arguments. It should return one of:
+ * - `null` or `undefined`: these are interpreted as EOF
+ * - a string
+ * - an ArrayBuffer or an ArrayBufferView with BYTES_PER_ELEMENT === 1
+ *
+ * If a string is returned, a new line is appended if one is not present and the
+ * resulting string is turned into a Uint8Array using TextEncoder.
+ *
+ * Returning a buffer is more efficient and allows returning partial lines of
+ * text.
+ *
+ */
+export function setStdin(stdin: InFuncType, isatty: boolean = false) {
+  isattys.stdin = isatty;
+  const get_char = make_get_char(stdin);
+  ttyout_ops.get_char = get_char;
+  ttyerr_ops.get_char = get_char;
+  refreshStreams();
+}
+
+/**
+ * If in node, sets stdout to write directly to process.stdout and sets isatty(stdout)
+ * to tty.isatty(process.stdout.fd).
+ * If in a browser, sets stdout to write to console.log and sets isatty(stdout) to false.
+ */
+export function setDefaultStdout() {
+  if (IN_NODE) {
+    const tty = require("tty");
+    const rawstdout = (x: number) => process.stdout.write(Buffer.from([x]));
+    const isatty = tty.isatty(process.stdout.fd);
+    setRawStdout(rawstdout, isatty);
+  } else {
+    setStdout((x) => console.log(x));
+  }
+}
+
+/**
+ * Sets writes to stdout to call `stdout(line)` whenever a complete line is
+ * written or stdout is flushed. In the former case, the received line will end
+ * with a newline, in the latter case it will not.
+ *
+ * isatty(stdout) is set to false (this API buffers stdout so it is impossible
+ * to make a tty with it).
+ */
+export function setStdout(stdout: (a: string) => void) {
+  isattys.stdout = false;
+  Object.assign(ttyout_ops, make_batched_put_char(stdout));
+  refreshStreams();
+}
+
+/**
+ * Sets stdout to call `stdout(character_code)` whenever a character is written
+ * to stdout.
+ * Also sets isatty(stdout) to the value of the isatty argument (default false).
+ */
+export function setRawStdout(
+  rawstdout: (a: number) => void,
+  isatty: boolean = false,
+) {
+  isattys.stdout = isatty;
+  Object.assign(ttyout_ops, make_unbatched_put_char(rawstdout));
+  refreshStreams();
+}
+
+/**
+ * If in node, sets stderr to write directly to process.stderr and sets isatty(stderr)
+ * to tty.isatty(process.stderr.fd).
+ * If in a browser, sets stderr to write to console.warn and sets isatty(stderr) to false.
+ */
+export function setDefaultStderr() {
+  if (IN_NODE) {
+    const tty = require("tty");
+    const rawstderr = (x: number) => process.stderr.write(Buffer.from([x]));
+    const isatty = tty.isatty(process.stderr.fd);
+    setRawStderr(rawstderr, isatty);
+  } else {
+    setStderr((x) => console.warn(x));
+  }
+}
+
+/**
+ * Sets writes to stderr to call `stderr(line)` whenever a complete line is
+ * written or stderr is flushed. In the former case, the received line will end
+ * with a newline, in the latter case it will not.
+ *
+ * isatty(stderr) is set to false (this API buffers stderr so it is impossible
+ * to make a tty with it).
+ */
+export function setStderr(stderr: (a: string) => void) {
+  isattys.stderr = false;
+  Object.assign(ttyerr_ops, make_batched_put_char(stderr));
+  refreshStreams();
+}
+
+/**
+ * Sets stderr to call `stderr(character_code)` whenever a character is written
+ * to stderr.
+ * Also sets isatty(stderr) to the value of the isatty argument (default false).
+ */
+export function setRawStderr(
+  rawstderr: (a: number) => void,
+  isatty: boolean = false,
+) {
+  isattys.stderr = isatty;
+  Object.assign(ttyerr_ops, make_unbatched_put_char(rawstderr));
+  refreshStreams();
+}
+
+const textencoder = new TextEncoder();
+const textdecoder = new TextDecoder();
+
+function make_get_char(infunc: InFuncType): GetCharType {
+  let index = 0;
+  let buf: Uint8Array = new Uint8Array(0);
+  // get_char has 3 particular return values:
+  // a.) the next character represented as an integer
+  // b.) undefined to signal that no data is currently available
+  // c.) null to signal an EOF
+  return function get_char() {
+    try {
+      if (index >= buf.length) {
+        let input = infunc();
+        if (input === undefined || input === null) {
+          return null;
+        }
+        if (typeof input === "string") {
+          if (!input.endsWith("\n")) {
+            input += "\n";
+          }
+          buf = textencoder.encode(input);
+        } else if (ArrayBuffer.isView(input)) {
+          if ((input as any).BYTES_PER_ELEMENT !== 1) {
+            throw new Error("Expected BYTES_PER_ELEMENT to be 1");
+          }
+          buf = input as Uint8Array;
+        } else if (
+          Object.prototype.toString.call(input) === "[object ArrayBuffer]"
+        ) {
+          buf = new Uint8Array(input);
+        } else {
+          throw new Error(
+            "Expected result to be undefined, null, string, array buffer, or array buffer view",
+          );
+        }
+        if (buf.length === 0) {
+          return null;
+        }
+        index = 0;
+      }
+      return buf[index++];
+    } catch (e) {
+      // emscripten will catch this and set an IOError which is unhelpful for
+      // debugging.
+      console.error("Error thrown in stdin:");
+      console.error(e);
+      throw e;
+    }
+  };
+}
+
+function make_unbatched_put_char(out: (a: number) => void): PutCharType {
+  return {
+    put_char(tty: any, val: any) {
path: src/js/streams.ts
text: @user1:
```suggestion
    put_char(tty: any, val: number) {
```