Write library for any ECMAScript environment
“It would be nice if my library can work across different JavaScript environments…” you thought.
Here is a guideline about how to achieve this goal. I’ll list the most general principles first then explain them in detail and finally give you a recommended pattern to develop this kind of library.
To make the discussion easier, we will call the ability to run on any ECMAScript environment as having zero host dependency.
When we say run on any ECMAScript environment, we mean: users of the library can run this library on a modern ECMAScript environment (at least ES2015) that supports the ECMAScript module with proper setups (e.g. provide IO functions) without modifying the library code.
Restrictions:
Do not use anything that is not defined in the ECMAScript specification directly. (e.g. Web or Node APIs).
Do not use module specifier (
"./file.js"
inimport x from "./file.js"
) to refer to any module.Be aware of language features (
eval
,Date.now()
,import()
,Math.random()
, etc…) are disabled in some special environments.
If you’re strictly following the rules described above, you’ll find that you’re almost not able to develop anything. But this article is going to introduce a comfortable paradigm for developing libraries with zero host dependency.
I’ll use an imaginary library called use-delayed-effect
as an example and migrates this library step by step in this article. At the end of this article, this library will have zero host dependency.
1 | // constant.js |
Host defined globals
What thing I can’t use?
Did you know that setTimeout
, console.log
, and many things you are familiar with are not part of ECMAScript specification but belong to the host (Node, Web, …)? This means if an ECMAScript environment chooses not to implement it, it is still not violating the specification.
For example, you cannot use setTimeout
in a Worklet.
Here is the list of things you can use safely. (Updated at 2021/02/14)
Values defined in the specification
Infinity NaN undefined globalThis
Namespaces defined in the specification
JSON Math Reflect
Functions defined in the specification
eval(don't use it)isFinite(use Number.isFinite)isNaN(use Number.isNaN) parseFloat parseInt decodeURI decodeURIComponent encodeURI encodeURIComponent
Classes defined in the specification
AggregateError Boolean BigInt Date Error EvalError FinalizationRegistryFunction(don't use it to construct new functions dynamically). Map Number Object Promise Proxy RangeError ReferenceError RegExp Set String Symbol SyntaxError TypeError URIError WeakMap WeakRef WeakSet
Array/TypedArray related classes defined in the specification
Array ArrayBuffer BigInt64Array BigUint64Array DataView Float32Array Float64Array Int8Array Int16Array Int32Array Uint8Array Uint8ClampedArray Uint16Array Uint32Array
The most simple way to find out if you can use a thing is to run it in the engine262 (an ECMAScript engine written in ECMAScript).
⚠ Note engine262 provides a host definition of
console
, which are not in the language.
Another way is to ask yourself, is it related to IO? If the answer is yes, it is a host defined global.
But I need it!
You can receive those functionalities from the outside world.
A “sys” object
TypeScript compiler uses this manner:
1 | export const sys = { base64encode: null }; |
Let’s use the “sys object” way to refactor our library. We will add a strange-environment-entry.js
for an imaginary environment that does not have setTimeout
but has Timer.createTimer
.
1 | // constant.js |
“Factory pattern”
1 | export function createMyLib({ base64encode, base64decode }) { |
Let’s use the “factory pattern” way to refactor our library. We will add a strange-environment-entry.js
for an imaginary environment that does not have setTimeout
but has Timer.createTimer
.
1 | // constant.js |
Now our library is much more friendly if there is no setTimeout
but some other timer functions.
Import specifiers
1 | import sth from "path"; |
This string is what we called as import specifiers. Actually, in the language specification, it is just a meanless string. The meaning of this string is defined by the host too. Let’s check out what import paths we have used in our library.
1 | import {} from "react"; |
We already know that react
import will not work on the browser (without an import map). And for a normal web server, ./core
is wrong too. We should use ./core.js
.
Now we refactor our lib in the following way:
1 | // core.js |
Is that safe now? If you’re not extremely cautious, the answer is yes. Most of the runtime supports relative path import with the correct extension. That’s means import './core.js'
is safe enough in practice. But not theoretically. The support of relative path import is not enforced by the specification. There is even no concept of a file in the spec.
If you’re extremely cautious, there’re two solutions:
Use an ES module bundler
Use rollup to bundle the core of your library and create entry files for each environment (Node, Web, Deno, …) you want to make out-of-box support.
If we choose to refactor our library in this way, here is the result:
./dist/core.js created by rollup
1 | // index.js for Node.JS |
No path specifier
In this way, we treat all relative imports the same way as external dependencies.
1 | // core.js |
As you can notice we’re not using any import in constant.js
and core.js
. Keep your core logics export-only is very annoying, I suggest using a bundler if the project is big.
About TypeScript and Deno
If your library is written in TypeScript and you want to ship TypeScript directly to Deno users, it’s not easy.
Deno follows the same module resolution strategy as Web browsers, which means you must add .ts
extension.
1 | import {} from "./core.ts"; |
An import path cannot end with a ‘.ts’ extension. Consider importing ‘./core.js’ instead.(2691)
Unfortunately, the TypeScript compiler will complain about the error above, and it emitting ECMAScript files are keeping the .ts extension.
Although there is some hacky way (e.g. using a transformer) to solve this problem, I suggest using rollup to bundle files and use rollup-plugin-dts to bundle type definitions into one single file, finally, add a triple-slash comment at top of the output file.
1 | /// <reference types="./output.d.ts" /> |
Here is an example of the output JS file that links to the type definition.
⚠ The Deno module resolution strategy applies for
.d.ts
too so please make sure your.d.ts
is an export-only file or it will not be able to share with normal TypeScript code.
Other notes
-
Error.prototype.stack
is not standard. -
import.meta
is in the ES standard butimport.meta.url
is not. -
eval
,new Function()
, etc are not available under a strict Content Security Policy. - dynamic import() does not work in Service Worker
SES (Secure ECMAScript) and XS
- No
Math.random()
- No
Date.now()
-
new Date()
will throw a TypeError -
Date(...)
will throw a TypeError - No
RegExp
static methods - All builtin function/classes are frozen and not extensible
End of the tour
Now our library has zero host dependency. If someone wants to use this library in a special environment, they can import the core file and create their instance without patching your library.
1 | // some unusual env |
You don’t have to follow all recommendations dogmatically. You can use Math.random()
without worrying about compatibility with SES. Aware of those platform exists and make smart decisions, you are the library author after all. If you choose to not support some environments, that’s not a fault. But if you do want to support any possible ES environment, this article can help.
Ad time
There are some libraries I designed with zero host dependency in mind.
- async-call-rpc: A JSON RPC server and client.
- react-refresh-typescript
- ttypescript-browser-like-import-transformer
Write library for any ECMAScript environment
https://blog.jackworks.dev/2021/Write-library-for-any-ECMAScript-environment/