The official Node.js docs (v25.2.1) say that process.platform can be 'aix' | 'darwin' | 'freebsd' | 'linux' | 'openbsd' | 'sunos' | 'win32' and process.arch can be 'arm' | 'arm64' | 'ia32' | 'loong64' | 'mips' | 'mipsel' | 'ppc64' | 'riscv64' | 's390' | 's390x' | 'x64'. That's not quite true. This is my chronicle as I explore where the values come from.

TL;DR:

  • node:process platform & node:os platform(): "netbsd", "os390", "win32", "darwin", "sunos", "freebsd", "openbsd", "linux", "android", "aix", "os400", "ios", "openharmony", "emscripten", "wasm", "wasi", "cloudabi"
  • node:process arch & node:os arch(): "s390x", "arm64", "arm", "ia32", "mipsel", "mips", "mips64el", "ppc64", "x64", "riscv64", "riscv32", "loong64"
  • node:os machine(): x86_64, ia64, i386, i486, i586, i686, mips, alpha, powerpc, sh, arm, unknown on Windows; ppc64 or any uname -m value on Unix.

node:process platform & node:os platform()

The Node.js process object is defined across some C++ files as a global object. The node:process module just reexports it with all the appropriate named properties. This means there's nothing to see in lib/process.js

lib/process.js
'use strict';

// Re-export process as a built-in module
module.exports = process;

Instead, we have to jump to the src/node_*.cc files to see anything that defines process.* properties & methods. Don't worry about where and how this v8 process object gets set as a global variable; that's not important right now.

src/node_process_object.cc
// process.platform
READONLY_STRING_PROPERTY(process, "platform", per_process::metadata.platform);

What's this per_process::metadata struct? Where is it defined? It must be some kind of singleton/constant since it's being used to access .platform as a property. Sure enough, it is!

src/node_metadata.h
// Per-process global
namespace per_process {
extern Metadata metadata;
}
src/node_metadata.cc
namespace per_process {
Metadata metadata;
}

node::Metadata is a class that has a single constructor that sets .arch and .platform (std::string-s) to some #define-ed macro string literals.

src/node_metadata.h
class Metadata {
 public:
  Metadata();
  Metadata(Metadata&) = delete;
  Metadata(Metadata&&) = delete;
  Metadata operator=(Metadata&) = delete;
  Metadata operator=(Metadata&&) = delete;

  struct Versions {
    // -- ✂️ SNIP --
  }

  Versions versions;
  const Release release;
  const std::string arch;
  const std::string platform;
};
src/node_metadata.cc
Metadata::Metadata() : arch(NODE_ARCH), platform(NODE_PLATFORM) {}

So far we know that node.platform is set to whatever value NODE_PLATFORM is set to. Where is that set? It's set by the GYP build system configuration.

{
  'target_name': '<(node_core_target_name)',
  'type': 'executable',

  'defines': [
    'NODE_ARCH="<(target_arch)"',
    'NODE_PLATFORM="<(OS)"', # 👈
    'NODE_WANT_INTERNALS=1',
  ],

  # -- ✂️ SNIP --
}

What's this OS variable? Where does it come from? It is a GYP predefined variable. I don't really know if you're supposed to manually set it or if it's inferred from your host platform.

  • OS: The name of the operating system that the generator produces output for. Common values for values for OS are:

    • 'linux'
    • 'mac'
    • 'win'

    But other values may be encountered and this list should not be considered exhaustive.

GYP input format reference docs

There are other files that appear to overwrite the value of NODE_PLATFORM to other values that are more specialized than just <(OS).

node.gypi
[ 'OS=="win"', {
  'defines!': [
    'NODE_PLATFORM="win"',
  ],
  'defines': [
    'FD_SETSIZE=1024',
    # we need to use node's preferred "win32" rather than gyp's preferred "win"
    'NODE_PLATFORM="win32"', # 👈
    '_UNICODE=1',
  ],
  # -- ✂️ SNIP --
]
node.gypi
[ 'OS=="mac"', {
  # linking Corefoundation is needed since certain macOS debugging tools
  # like Instruments require it for some features. Security is needed for
  # --use-system-ca.
  'libraries': [ '-framework CoreFoundation -framework Security' ],
  'defines!': [
    'NODE_PLATFORM="mac"',
  ],
  'defines': [
    # we need to use node's preferred "darwin" rather than gyp's preferred "mac"
    'NODE_PLATFORM="darwin"', # 👈
  ],
}],
node.gypi
[ 'OS=="solaris"', {
  'libraries': [
    '-lkstat',
    '-lumem',
  ],
  'defines!': [
    'NODE_PLATFORM="solaris"',
  ],
  'defines': [
    # we need to use node's preferred "sunos"
    # rather than gyp's preferred "solaris"
    'NODE_PLATFORM="sunos"', # 👈
  ],
}],
common.gypi
['OS == "zos"', {
  'defines': [
    '_XOPEN_SOURCE_EXTENDED',
    '_XOPEN_SOURCE=600',
    '_UNIX03_THREADS',
    '_UNIX03_WITHDRAWN',
    '_UNIX03_SOURCE',
    '_OPEN_SYS_SOCK_IPV6',
    '_OPEN_SYS_FILE_EXT=1',
    '_POSIX_SOURCE',
    '_OPEN_SYS',
    '_OPEN_SYS_IF_EXT',
    '_OPEN_SYS_SOCK_IPV6',
    '_OPEN_MSGQ_EXT',
    '_LARGE_TIME_API',
    '_ALL_SOURCE',
    '_AE_BIMODAL=1',
    '__IBMCPP_TR1__',
    'NODE_PLATFORM="os390"', # 👈
    'PATH_MAX=1024',
    '_ENHANCED_ASCII_EXT=0xFFFFFFFF',
    '_Export=extern',
    '__static_assert=static_assert',
  ],
  # -- ✂️ SNIP --
]

But what are the other possible values for this OS variable? We have to take a look at how GYP initializes that variable with a default value. It seems like all code paths call gyp.common.GetFlavor(params) somehow to get an initial OS name and then sometimes do custom configuration and initialization based on the return value (if win, if linux, if mac, etc.).

nodejs/gyp-next: pylib/gyp/generator/compile_commands_json.py
default_variables.setdefault("OS", gyp.common.GetFlavor(params))

The GetFlavor() function delegates to GetFlavorByPlatform()...

nodejs/gyp-next: pylib/gyp/common.py
def GetFlavor(params):
    if "flavor" in params:
        return params["flavor"]

    defines = GetCompilerPredefines()
    if "__EMSCRIPTEN__" in defines:
        return "emscripten"
    if "__wasm__" in defines:
        return "wasi" if "__wasi__" in defines else "wasm"

    return GetFlavorByPlatform()

...which is where the real if sys.platform == "..." magic happens.

nodejs/gyp-next: pylib/gyp/common.py
def GetFlavorByPlatform():
    """Returns |params.flavor| if it's set, the system's default flavor else."""
    flavors = {
        "cygwin": "win",
        "win32": "win",
        "darwin": "mac",
    }

    if sys.platform in flavors:
        return flavors[sys.platform]
    if sys.platform.startswith("sunos"):
        return "solaris"
    if sys.platform.startswith(("dragonfly", "freebsd")):
        return "freebsd"
    if sys.platform.startswith("openbsd"):
        return "openbsd"
    if sys.platform.startswith("netbsd"):
        return "netbsd"
    if sys.platform.startswith("aix"):
        return "aix"
    if sys.platform.startswith(("os390", "zos")):
        return "zos"
    if sys.platform == "os400":
        return "os400"

    return "linux"

All told, the returned flavor string can be "emscripten", "wasi", "wasm", "win", "mac", "solaris", "freebsd", "openbsd", "netbsd", "aix", "zos", "os400", "linux", or any other custom name set by params["flavor"].

So where does the Node.js build system set -DOS=<something>? Node.js doesn't use GYP directly, instead the official build procedure is to run ./configure and make -j4 (on Unix & macOS). The ./configure script is a /bin/sh script that reexecutes itself as a Python script and then imports ./configure.py if the Python version is satisfactory. That configure.py file is where the magic happens.

configure.py
valid_os = ('win', 'mac', 'solaris', 'freebsd', 'openbsd', 'linux',
            'android', 'aix', 'cloudabi', 'os400', 'ios', 'openharmony')
configure.py
parser.add_argument('--dest-os',
    action='store',
    dest='dest_os',
    choices=valid_os,
    help=f"operating system to build for ({', '.join(valid_os)})")
configure.py
# determine the "flavor" (operating system) we're building for,
# leveraging gyp's GetFlavor function
flavor_params = {}
if options.dest_os:
  flavor_params['flavor'] = options.dest_os
flavor = GetFlavor(flavor_params)

Yes, it's the same gyp.common.GetFlavor() function

This means that the OS when set manually (for cross compiling or whatever) must be one of 'win', 'mac', 'solaris', 'freebsd', 'openbsd', 'linux', 'android', 'aix', 'cloudabi', 'os400', 'ios', or 'openharmony'. But if it's not set at all (if options.dest_os is falsey) then it can be any of the possible return values from GetFlavor() with the knowledge that params["flavor"] is not set. GetCompilerPredefines() may still return __EMSCRIPTEN__, __wasm__, or __wasi__ though so we can't rule those out as impossible values. That means the total combined list is:

  • "emscripten"
  • "wasi"
  • "wasm"
  • "netbsd"
  • "zos"
  • "win"
  • "mac"
  • "solaris"
  • "freebsd"
  • "openbsd"
  • "linux"
  • "android"
  • "aix"
  • "cloudabi"
  • "os400"
  • "ios"
  • "openharmony"

But some of these values, remember, have special mappings to Node.js NODE_PLATFORM values.

  • zos becomes os390
  • solaris becomes sunos
  • mac becomes darwin
  • win becomes win32

All together here's a list of all the possible Node.js NODE_PLATFORM (and by extension process.platform & friends) values with some usage numbers from a quick GitHub search:

Note that a few of these values are extremely rare, but they are sort of possible depending on how you interpret "possible" in this situation.

node:process arch & node:os arch()

The first few layers of platform.arch are quite similar to node.platform; it's just sourced from a different macro NODE_ARCH, which is set in a different way.

src/node_process_object.cc
// process.arch
READONLY_STRING_PROPERTY(process, "arch", per_process::metadata.arch);

And we know from the above process.platform investigation that per_process::metadata.arch is set to NODE_ARCH.

src/node_metadata.cc
Metadata::Metadata() : arch(NODE_ARCH), platform(NODE_PLATFORM) {}

So where is this NODE_ARCH macro defined?

node.gyp
{
 'target_name': '<(node_core_target_name)',
 'type': 'executable',

 'defines': [
   'NODE_ARCH="<(target_arch)"', # 👈
   'NODE_PLATFORM="<(OS)"',
   'NODE_WANT_INTERNALS=1',
 ],
 # -- ✂️ SNIP --
}

There's a target_arch variable defined somewhere. There's no monkey business with custom GYP-to-Node.js arch name conversions like there was with NODE_PLATFORM.

configure.py
host_arch = host_arch_win() if os.name == 'nt' else host_arch_cc()
target_arch = options.dest_cpu or host_arch
# ia32 is preferred by the build tools (GYP) over x86 even if we prefer the latter
# the Makefile resets this to x86 afterward
if target_arch == 'x86':
  target_arch = 'ia32'
# x86_64 is common across linuxes, allow it as an alias for x64
if target_arch == 'x86_64':
  target_arch = 'x64'
o['variables']['host_arch'] = host_arch
o['variables']['target_arch'] = target_arch # 👈

First, let's look at host_arch to see what possible values it could be.

configure.py
def host_arch_win():
  """Host architecture check using environ vars (better way to do this?)"""

  observed_arch = os.environ.get('PROCESSOR_ARCHITECTURE', 'AMD64')
  arch = os.environ.get('PROCESSOR_ARCHITEW6432', observed_arch)

  matchup = {
    'AMD64'  : 'x64',
    'arm'    : 'arm',
    'mips'   : 'mips',
    'ARM64'  : 'arm64'
  }

  return matchup.get(arch, 'x64')
configure.py
def host_arch_cc():
  """Host architecture check using the CC command."""

  if sys.platform.startswith('zos'):
    return 's390x'
  k = cc_macros(os.environ.get('CC_host'))

  matchup = {
    '__aarch64__' : 'arm64',
    '__arm__'     : 'arm',
    '__i386__'    : 'ia32',
    '__MIPSEL__'  : 'mipsel',
    '__mips__'    : 'mips',
    '__PPC64__'   : 'ppc64',
    '__PPC__'     : 'ppc64',
    '__x86_64__'  : 'x64',
    '__s390x__'   : 's390x',
    '__riscv'     : 'riscv',
    '__loongarch64': 'loong64',
  }

  rtn = 'ia32' # default

  for key, value in matchup.items():
    if k.get(key, 0) and k[key] != '0':
      rtn = value
      break

  if rtn == 'mipsel' and '_LP64' in k:
    rtn = 'mips64el'

  if rtn == 'riscv':
    if k['__riscv_xlen'] == '64':
      rtn = 'riscv64'
    else:
      rtn = 'riscv32'

  return rtn

cc_macros() returns all predefined C compiler macros as a dict

host_arch_win() possible values: x64, arm, mips, arm64
host_arch_cc() possible values: s390x, arm64, arm, ia32, mipsel, mips, mips64el, ppc64, x64, riscv64, riscv32, loong64

Now let's look at what options are valid options.dest_cpu --dest-cpu values.

configure.py
valid_arch = ('arm', 'arm64', 'ia32', 'mips', 'mipsel', 'mips64el',
              'ppc64', 'x64', 'x86', 'x86_64', 's390x', 'riscv64', 'loong64')
configure.py
parser.add_argument('--dest-cpu',
    action='store',
    dest='dest_cpu',
    choices=valid_arch,
    help=f"CPU architecture to build for ({', '.join(valid_arch)})")

But x86 gets mapped to ia32 and x86_64 gets mapped to x64. Here's the logic flow. Note that some values that aren't allowed in --dest-cpu can sneak in if --dest-cpu isn't set and the arch is inferred from the environment.

host_arch = host_arch_win() if os.name == 'nt' else host_arch_cc()
# TYPE: "s390x" | "arm64" | "arm" | "ia32" | "mipsel" | "mips" | "mips64el" | "ppc64" | "x64" | "riscv64" | "riscv32" | "loong64"

target_arch = options.dest_cpu or host_arch
# TYPE: "x86" | "x86_64" | "s390x" | "arm64" | "arm" | "ia32" | "mipsel" | "mips" | "mips64el" | "ppc64" | "x64" | "riscv64" | "riscv32" | "loong64"

# ia32 is preferred by the build tools (GYP) over x86 even if we prefer the latter
# the Makefile resets this to x86 afterward
if target_arch == 'x86':
  target_arch = 'ia32'
# target_arch TYPE: "x86_64" | "s390x" | "arm64" | "arm" | "ia32" | "mipsel" | "mips" | "mips64el" | "ppc64" | "x64" | "riscv64" | "riscv32" | "loong64"

# x86_64 is common across linuxes, allow it as an alias for x64
if target_arch == 'x86_64':
  target_arch = 'x64'
# target_arch TYPE: "s390x" | "arm64" | "arm" | "ia32" | "mipsel" | "mips" | "mips64el" | "ppc64" | "x64" | "riscv64" | "riscv32" | "loong64"

o['variables']['host_arch'] = host_arch

# FINAL TYPE: "s390x" | "arm64" | "arm" | "ia32" | "mipsel" | "mips" | "mips64el" | "ppc64" | "x64" | "riscv64" | "riscv32" | "loong64"
o['variables']['target_arch'] = target_arch

So the final possible values for process.arch are the same as those possible for NODE_ARCH which are: "s390x", "arm64", "arm", "ia32", "mipsel", "mips", "mips64el", "ppc64", "x64", "riscv64", "riscv32", "loong64".

node:os machine()

os.machine() is a wrapper that returns the ...[3] property of the return value of internalBinding("os").getOSInformation().

lib/os.js
module.exports = {
  // -- ✂️ SNIP --
  machine: getMachine,
};
lib/os.js
const getMachine = () => machine;
lib/os.js
const {
  0: type,
  1: version,
  2: release,
  3: machine,
} = _getOSInformation();
lib/os.js
const {
  getAvailableParallelism,
  getCPUs,
  getFreeMem,
  getHomeDirectory: _getHomeDirectory,
  getHostname: _getHostname,
  getInterfaceAddresses: _getInterfaceAddresses,
  getLoadAvg,
  getPriority: _getPriority,
  getOSInformation: _getOSInformation, // 👈
  getTotalMem,
  getUserInfo,
  getUptime: _getUptime,
  isBigEndian,
  setPriority: _setPriority,
} = internalBinding('os');

That getOSInformation() C++ function sets ...[3] to info.machine which is set by uv_os_name().

src/node_os.cc
static void GetOSInformation(const FunctionCallbackInfo<Value>& args) {
  Environment* env = Environment::GetCurrent(args);
  uv_utsname_t info;
  int err = uv_os_uname(&info);

  if (err != 0) {
    CHECK_GE(args.Length(), 1);
    USE(env->CollectUVExceptionInfo(
        args[args.Length() - 1], err, "uv_os_uname"));
    return;
  }

  // [sysname, version, release, machine]
  Local<Value> osInformation[4];
  if (String::NewFromUtf8(env->isolate(), info.sysname)
          .ToLocal(&osInformation[0]) &&
      String::NewFromUtf8(env->isolate(), info.version)
          .ToLocal(&osInformation[1]) &&
      String::NewFromUtf8(env->isolate(), info.release)
          .ToLocal(&osInformation[2]) &&
      String::NewFromUtf8(env->isolate(), info.machine) // 👈
          .ToLocal(&osInformation[3])) {
    args.GetReturnValue().Set(
        Array::New(env->isolate(), osInformation, arraysize(osInformation)));
  }
}

uv_os_name() has two different implementations: one for Unix and one for Windows. The Unix implementation uses uname() .machine.

libuv/libuv: src/unix/core.c
int uv_os_uname(uv_utsname_t* buffer) {
  struct utsname buf;
  int r;

  if (buffer == NULL)
    return UV_EINVAL;

  if (uname(&buf) == -1) {
    r = UV__ERR(errno);
    goto error;
  }

  // -- ✂️ SNIP --

#if defined(_AIX) || defined(__PASE__)
  r = uv__strscpy(buffer->machine, "ppc64", sizeof(buffer->machine));
#else
  r = uv__strscpy(buffer->machine, buf.machine, sizeof(buffer->machine));
#endif

  if (r == UV_E2BIG)
    goto error;

  return 0;

error:
  buffer->sysname[0] = '\0';
  buffer->release[0] = '\0';
  buffer->version[0] = '\0';
  buffer->machine[0] = '\0';
  return r;
}

So the possible values are whatever uname() .machine can be plus ppc64 if it's AIX or __PASE__ (whatever that is). But what are the possible values of uname() .machine? That's a good question... for another time. TODO: Investigate the possible values of uname -m

Back to uv_os_name(). The Windows implementation sets the .machine property to a variety of static strings depending on some conditions.

libuv/libuv: src/win/util.c
int uv_os_uname(uv_utsname_t* buffer) {
  /* Implementation loosely based on
     https://github.com/gagern/gnulib/blob/master/lib/uname.c */
  OSVERSIONINFOW os_info;
  SYSTEM_INFO system_info;
  HKEY registry_key;
  WCHAR product_name_w[256];
  DWORD product_name_w_size;
  size_t version_size;
  int processor_level;
  int r;

  // -- ✂️ SNIP --

  /* Populate the machine field. */
  GetSystemInfo(&system_info);

  switch (system_info.wProcessorArchitecture) {
    case PROCESSOR_ARCHITECTURE_AMD64:
      uv__strscpy(buffer->machine, "x86_64", sizeof(buffer->machine));
      break;
    case PROCESSOR_ARCHITECTURE_IA64:
      uv__strscpy(buffer->machine, "ia64", sizeof(buffer->machine));
      break;
    case PROCESSOR_ARCHITECTURE_INTEL:
      uv__strscpy(buffer->machine, "i386", sizeof(buffer->machine));

      if (system_info.wProcessorLevel > 3) {
        processor_level = system_info.wProcessorLevel < 6 ?
                          system_info.wProcessorLevel : 6;
        buffer->machine[1] = '0' + processor_level;
      }

      break;
    case PROCESSOR_ARCHITECTURE_IA32_ON_WIN64:
      uv__strscpy(buffer->machine, "i686", sizeof(buffer->machine));
      break;
    case PROCESSOR_ARCHITECTURE_MIPS:
      uv__strscpy(buffer->machine, "mips", sizeof(buffer->machine));
      break;
    case PROCESSOR_ARCHITECTURE_ALPHA:
    case PROCESSOR_ARCHITECTURE_ALPHA64:
      uv__strscpy(buffer->machine, "alpha", sizeof(buffer->machine));
      break;
    case PROCESSOR_ARCHITECTURE_PPC:
      uv__strscpy(buffer->machine, "powerpc", sizeof(buffer->machine));
      break;
    case PROCESSOR_ARCHITECTURE_SHX:
      uv__strscpy(buffer->machine, "sh", sizeof(buffer->machine));
      break;
    case PROCESSOR_ARCHITECTURE_ARM:
      uv__strscpy(buffer->machine, "arm", sizeof(buffer->machine));
      break;
    default:
      uv__strscpy(buffer->machine, "unknown", sizeof(buffer->machine));
      break;
  }

  return 0;

error:
  buffer->sysname[0] = '\0';
  buffer->release[0] = '\0';
  buffer->version[0] = '\0';
  buffer->machine[0] = '\0';
  return r;
}

All possible values are: x86_64, ia64, i386, i486, i586, i686, mips, alpha, powerpc, sh, arm, unknown.

So the possible values are well-known on Windows (yay), but very varied on Unix systems (not yay). This post is already too long; I'm not going to investigate all uname -m possible values, too. Maybe another time.