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"
},
...
}
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 () => {}
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')
}
}
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))
}
}
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()
}
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))
}
}
})
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)
})
On both architectures it will produce the exact same console output:
isomorphic code rocks => NfxQJL4a58eszCB64Fi0DRvolnEhABf9x4fVZsMTH6BF296uTdK2MYBbUJzLqHJIuUTLzAJqhzfZlAcEuCuZSQ==
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*.
Top comments (5)
...but is it right example? Because your code is enveloped with Meteor.isClient or Meteor.isServer, but all code is still fully preset on client side after build.
You're right, the import statement for the crypto is one level too high. I'll update the example.
I'm interesting, how you will update your code, because by my experience (I checked the builded package), isClient or isServer is only execution code feature, but if lines of code will be putted and compiled to the code, everything depend on, If you will put your code to the server folder or not.
Code compilation will only scan in top-level scope for require/import and add the module to the target bundle. By using closures together with isServer you can circumvent this and therefore mimoc exact Codesplitting without using server / client folders.
The example is already updated.