Elixir → JavaScript Porting Initiative

Ah, Type.encodeMapKey is something I overlooked. That may address some of my issues. Thanks! I’ll make some progress this week and see where it takes me. I’ll keep you in the loop @Lucassifoni.

I’d be glad if you looked (when you get the time) at the current test helpers I added in the open PR, because on one hand I don’t really like adding helpers, on the other hand I’m not for shimming Elixir modules at test time to make them conform to Hologram’s behaviour (not supporting v1 sets) just for the sake of mirroring js/elixir tests.. that would amount to testing test code IMO since this limitation does not really exist elixir-side.

In retrospect I did not choose the simplest module but it’s the one I truly need to port my app to Hologram x) .

Hologram preserves the original key : (see type.mjs)

 static map(data = []) {
    const hashTableWithMetadata = data.reduce((acc, [key, value]) => {
      acc[Type.encodeMapKey(key)] = [key, value];
      return acc;
    }, {});

    return {type: "map", data: hashTableWithMetadata};
  }

So you should be good to go by going through Type @tenkiller

2 Likes

I noticed that Type.encodeMapKey() does not support string data types. Is this intentional? I can extend it to support them in a separate PR, or would you rather I include that change in my upcoming :sets.to_list/1 PR?

For reference, here is the test I would add to type_test.mjs after extending it:

it("encodes boxed string value as map key", () => {
  const string = Type.string("Hologram");
  const result = Type.encodeMapKey(string);

  assert.equal(result, "string(Hologram)");
});

Looking at your PRs now, @Lucassifoni

That’s intentional - there’s no separate primitive “string” type in Elixir at the VM level. Elixir strings are UTF-8 encoded binaries. Type.string() is only an internal helper Hologram uses for text segments inside bitstrings, not a public string type. You can use Type.bitstring() helper for text - it will convert the text to boxed bitstring type (which includes binaries and therefore UTF-8 strings).

Quick update: I’ve gone through all the remaining TODO Erlang functions and reviewed them. With far fewer left now, this was much more manageable!

I’ve pushed some functions to phase 2 after analyzing which Elixir stdlib functions depend on them. These deferred functions are primarily used by process-related Elixir stdlib functions, which aren’t targets for phase 1. Most of the deferred functions were ETS-related, commonly used by modules like PartitionSupervisor and Registry.

This is your last chance to contribute – only 40 functions left to port for phase 1!

Thanks for all your contributions so far!

6 Likes

@bartblast Concerning os.type/0, I know you said you’d like it to mimic Erlang’s behavior and detect the OS platform and family via the navigator.userAgentData, but how should I actually test this? What’s the best strategy for stubbing the window.navigator object in os_test.mjs?

1 Like

@tenkiller For navigator, just swap out globalThis.navigator with a mock object in your tests:

describe("type/0", () => {
  let originalNavigator;

  beforeEach(() => {
    originalNavigator = globalThis.navigator;
  });

  afterEach(() => {
    globalThis.navigator = originalNavigator;
  });

  it("detects macOS via userAgentData", () => {
    globalThis.navigator = {
      userAgentData: {platform: "macOS"},
    };
    // test returns {:unix, :darwin}
  });

  it("detects Windows via userAgentData", () => {
    globalThis.navigator = {
      userAgentData: {platform: "Windows"},
    };
    // test returns {:win32, :nt}
  });

  it("detects Linux via userAgentData", () => {
    globalThis.navigator = {
      userAgentData: {platform: "Linux"},
    };
    // test returns {:unix, :linux}
  });

  it("falls back to platform when userAgentData unavailable", () => {
    globalThis.navigator = {
      userAgentData: undefined,
      platform: "MacIntel",
    };
    // test returns {:unix, :darwin}
  });

  it("falls back to userAgent when platform unavailable", () => {
    globalThis.navigator = {
      userAgentData: undefined,
      platform: "",
      userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64)...",
    };
    // test returns {:win32, :nt}
  });
});

For the implementation, the recommended fallback order is:

  1. navigator.userAgentData.platform - modern API with clean values (“macOS”, “Windows”, “Linux”)
  2. navigator.platform - legacy but widely supported (“MacIntel”, “Win32”, “Linux x86_64”)
  3. navigator.userAgent - last resort, requires string parsing

The userAgentData API isn’t supported in Firefox or Safari, so the platform fallback will handle those browsers. You may find that parsing userAgent is rarely needed in practice.

UserAgent parsing is rather complicated if we rolled our own. Do you have a library recommendation, like ua-parser.js perhaps? Also, testing consistency with the JS implementation in os_test.exs is not clear to me either.

@tenkiller Using an external lib like ua-parser.js would be overkill IMO, especially when that package is ~12kb minzipped. We’re only detecting basic OS families here, not parsing detailed browser/device info.

:os.type/0 returns just a basic system type - in practice it’s one of these (though could you verify the complete list by checking the Erlang/OTP docs?):

Unix family:

  • {:unix, :darwin} - macOS
  • {:unix, :linux} - Linux
  • {:unix, :freebsd} - FreeBSD
  • {:unix, :openbsd} - OpenBSD
  • {:unix, :netbsd} - NetBSD
  • {:unix, :sunos} - Solaris/SunOS
  • {:unix, :aix} - IBM AIX

Windows family:

  • {:win32, :nt} - Windows NT-based systems (includes Windows XP, Vista, 7, 8, 10, 11, and Server editions)

We just need some simple heuristics for the userAgent string - probably looking for specific words/patterns would be enough. Check out the patterns from the Elixir ua_parser lib for ideas: ua_parser/priv/patterns.yml at main · beam-community/ua_parser · GitHub

For example, you could check if the userAgent contains “Mac”, “Win”, “Linux”, etc. and map those to the appropriate tuples. Should be pretty straightforward!

Re: consistency testing - it’s mostly impossible in this case since the Elixir version runs on the server (returning the server’s OS) while the JS version runs in the browser (returning the client’s OS).

So for this one:

  • Put your JS tests inside a “client-only behaviour” describe block in os_test.mjs and test various OS detections thoroughly there
  • In os_test.exs, just add a sanity test that calls the actual Erlang :os.type/0 function and asserts the returned value is one of the tuples we support on the client-side

Closing the loop on the :os.type/0 discussion for future reference (this reasoning applies to similar cases as well):

We decided to hardcode {:unix, :web} for the client-side implementation. This aligns with our approach in other modules like :filename, where we unify path handling and treat the Hologram client platform as Unix-based.

The reasoning is twofold:

  1. :os.type/0 in Erlang/Elixir is meant to return the OS of the machine running the BEAM VM - but on the client side, our code runs in a browser sandbox, not directly on the user’s OS. Returning the actual user’s OS would be semantically incorrect and could break code that relies on :os.type/0 for platform-specific behavior (like path handling).

  2. Web APIs provide a unified, platform-independent abstraction - the browser hides OS differences from JavaScript code. Since web conventions align with Unix (forward slashes for paths/URLs), choosing {:unix, :web} is a pragmatic decision that simplifies our implementation (e.g., consistent path handling in :filename and similar modules).

For use cases where you actually need the user’s platform information (analytics, file downloaders offering platform-specific binaries, UI adaptations, etc.), we’ll expose this through a dedicated Hologram module that clearly indicates it’s about the end user’s environment rather than the execution platform.

3 Likes

Update on time-related functions

While reviewing the PRs for Erlang time functions (:os.system_time, :erlang.monotonic_time, :erlang.system_time, :erlang.time_offset, :erlang.convert_time_unit, :erlang.localtime), I realized they’re more interconnected than I initially thought.

In Erlang’s time system: Erlang System Time = Erlang Monotonic Time + Time Offset

This means these functions should delegate to each other rather than each independently calling JS APIs. I’ve documented a unified porting strategy to ensure consistency:

Erlang Time Functions Porting Strategy

Happy to discuss if you have questions or alternative suggestions!