Write isomorphic code in MeteorJs

jankapunkt

Jan Küster

Posted on June 30, 2023

Write isomorphic code in MeteorJs

MeteorJs (short "Meteor") is a fullstack JavaScript framework with isomorphic capabilities: you can write code once and use it on the server and client similar.

This is a great advantage in shipping code faster but could also leak server-code to the client or bloat the client bundle.

Fortunately Meteor's bundling tools allow for exact code splitting: a module will only get bundled for a certain architecture realm (server, client) if it's imported/required within that realm.

How to determine, if a module will get bundled?

The realms are defined by the entry point modules. Check your package.json for the following entry:

{
  ...
  "meteor": {
    "mainModule": {
      "client": "client/main.js",
      "server": "server/main.js"
    },
    "testModule": "tests/main.js"
  },
  ...
}
Enter fullscreen mode Exit fullscreen mode

Every import, starting from these entry points will cause the bundler to add the imported module to the server or client bundle or both. One exception, though is using dynamic imports but we won't cover that today.

Splitting isomorphic code

First of all, we should talk about the term "isomorphic". The term itself is somewhat imprecise and is sometimes used synonymous with "universal JavaScript". However, our goal is not always a universal module, since we deal with different dependencies in each environment. You will see this in the upcoming example.

If we talk about code structure then we may use the term "isomorphic" to describe code, that has the same signature and represents the (mostly) same behavior in all environments.

Let's take a look at a simple example:

export const SHA512 = {}

/**
 * Creates a new SHA512 hash from a given input
 * @param {string} input
 * @returns {Promise.<String>}
 */
SHA512.create = async () => {}
Enter fullscreen mode Exit fullscreen mode

This module has the same signature and general behavior on the server and the client. It creates a SHA512 hash from a given input. Now, let's implement this code on the server using the NodeJs-builtin crypto module first:

...

if (Meteor.isServer) {
  SHA512.create = async input => {
    import crypto from 'crypto'
    return  crypto
      .createHash('sha512')
      .update(input, 'utf8')
      .digest('base64')
  }
}
Enter fullscreen mode Exit fullscreen mode

For the client we use the Web Crypto API instead. By doing so we avoid any expensive importing or stubbing of the crypto module.

...

if (Meteor.isClient) {
  SHA512.create = async input => {
    const encoder = new TextEncoder()
    const data = encoder.encode(input)
    const hash = await window.crypto.subtle.digest({ name: 'SHA-512' }, data)
    const buffer = new Uint8Array(hash)
    return window.btoa(String.fromCharCode.apply(String, buffer))
  }
}
Enter fullscreen mode Exit fullscreen mode

Improve readability

The examples above are minimal and readability drastically declines if larger modules get involved. Therefore, we need to write an abstraction that helps to improve readability.

Fortunately, this is as simple as it can get:

import { Meteor } from 'meteor/meteor'

export const isomorphic = ({ client, server }) => {
  if (Meteor.isClient && client) return client()
  if (Meteor.isServer && server) return server()
}
Enter fullscreen mode Exit fullscreen mode

With this wrapper we can now define a single variable or property and immediately assign it's value, based on a function that only gets executed, if the bundler runs for the specific architecture realm. Let's apply this wrapper to our SHA512 module.

export const SHA512 = {}

/**
 * Creates a new SHA512 hash from a given input
 * @method
 * @async
 * @param {string} input
 * @returns {Promise.<string>}
 */
SHA512.create = isomorphic({
  server () {
    return async input => {
      import crypto from 'crypto'
      return crypto
        .createHash('sha512')
        .update(input, 'utf8')
        .digest('base64')
    }
  },
  client () {
    return async input => {
      const encoder = new TextEncoder()
      const data = encoder.encode(input)
      const hash = await window.crypto.subtle.digest({ name: 'SHA-512' }, data)
      const buffer = new Uint8Array(hash)

      return window.btoa(String.fromCharCode.apply(String, buffer))
    }
  }
})
Enter fullscreen mode Exit fullscreen mode

Run the code

Now let's run the code on the server and the client. You can use the following code snipped and place it in your entry points (usually 'server/main.js' and 'client/main.js'):

import { SHA512 } from '../imports/SHA512'

Meteor.startup(async () => {
  const input = 'isomorphic code rocks'
  const output = await SHA512.create(input)
  console.debug(input, '=>', output)
})
Enter fullscreen mode Exit fullscreen mode

On both architectures it will produce the exact same console output:

isomorphic code rocks => NfxQJL4a58eszCB64Fi0DRvolnEhABf9x4fVZsMTH6BF296uTdK2MYBbUJzLqHJIuUTLzAJqhzfZlAcEuCuZSQ==
Enter fullscreen mode Exit fullscreen mode

If you are in doubt, whether this really uses the defined parts in server and client I suggest you to run your debuggers on the server and the client and inspect the running code. You will see that in each architecture there is only the architecture-specific code running.

Hooray! You wrote an isomorphic module with architecture specific implementation! 🎉 🎉 🎉


About me

I regularly publish articles here on dev.to about Meteor and JavaScript.

You can also find (and contact) me on GitHub, Twitter and LinkedIn.

If you like what you are reading and want to support me, you can sponsor me on GitHub or send me a tip via PayPal.

Keep up with the latest development on Meteor by visiting their blog* and if you are the same into Meteor like I am and want to show it to the world, you should check out the Meteor merch store*.

💖 💪 🙅 🚩
jankapunkt
Jan Küster

Posted on June 30, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related