Serialize live running code to Javascript
Introduction
Most bundlers/transpilers convert Javascript code as text to some other form of Javascript code, also in text form.
Livepack is different - it takes a NodeJS app and produces Javascript from the live code as it's running. Essentially it's a serializer.
The difference from other serializers like serialize-javascript is that Livepack can handle function scopes and closures, so it's capable of serializing entire applications.
If you pack this with Livepack:
const externalVar = 123;
function foo() { return externalVar; }
...you get (externalVar => function foo() {return externalVar;})(123)
. The var in upper scope is captured. This works even with complex nested functions, currying etc.
What's included in the output?
Everything.
Whatever values are referenced in functions you serialize is included in the output. This includes code and objects that came from packages in node_modules
.
The entire app is output as a single .js
file, with no dependencies.
Why is this a good thing?
Faster startup
Many apps do time-consuming "bootstrapping" at startup (reading files from the filesystem, HTTP requests, loading data from a database).
With Livepack, you can run this "bootstrap" code, and then snapshot the state of the app at that point in time.
When you run the built app, the bootstrapping work is already done - it was done at build time instead of run time. Combined with the entire app's code being in a single file, an app built with Livepack should be faster to start.
Tree-shaking by default
Livepack doesn't perform tree-shaking exactly, but works more like a garbage collector - any value which is not referenced in the the objects/functions you serialize is omitted from the build.
This is more effective than tree-shaking as it doesn't rely on static analysis of the code, which can miss opportunities to discard unreferenced values.
Value-level code splitting
Most bundlers code split at the level of files. Livepack splits code at a far more granular level - individual Objects or Functions. This makes for smaller bundles. More details
Mix build-time and run-time code
Any values which are calculated before serialization are included in the build pre-calculated. ONE_GB = 1024 * 1024 * 1024;
is serialized as the result, 1073741824
. This is a trivial example, but any calculation of arbitrary complexity can be performed at build time.
Dynamic builds
This is the most compelling advantage, but the hardest to explain.
Javascript is a dynamic language, but our current build tools are static.
Livepack runs the code you give it and outputs the result. So your app can build itself. Data is code, and code is data - there's no difference between the two.
- Read from the filesystem, and create Express routes pointing to each
.js
file (build your own NextJS) - Read from a database table and create React components for each row in the table
- Pull data from an API and customize your app accordingly
Design principles
Livepack has an emphasis on correctness. It will not always output the most compact code, but the output should perfectly reproduce the input (including function names, property descriptors etc, which tools like Babel often do not faithfully translate).
Beware ye!
Livepack is a new and experimental project. It works for NodeJS server-side code, but there are some major gaps for bundling client-side code.
Please see what's missing for more information.
The intention is to overcome the current limitations in future, and make Livepack work fully for client-side code. For now it's a proof of concept of a different approach - dynamic bundling - and what patterns it makes possible. Please see section on code spitting for examples.
Usage
Installation
npm install -D livepack
Requires NodeJS v18 or later.
CLI
npx livepack <input path(s)..> -o <output dir>
Inputs should be the entry point(s) of the app to be packed.
npx livepack src/index.js -o build
npx livepack src/index.js src/another_entry.js -o build
Entry points must export a function which will be executed when the built app is run.
This is unlike bundlers like Webpack and Rollup. You must export a function including the code you want to run when the built app is launched. Top-level code will be executed during build, not at runtime.
module.exports = function() {
console.log('hello!');
};
or (see esm
option below):
export default function() {
console.log('hello!');
}
Resulting output:
console.log('hello!');
Promises
If your app needs to do some async work before serializing, export a Promise.
module.exports = (async () => {
// Do async stuff
const obj = await Promise.resolve({x: 1, y: 2});
// Return a function which will be executed at runtime
return function() {
console.log(obj.x * obj.y);
};
})();
Options
Option | Usage | Default |
---|---|---|
--output / -o
|
Output directory (required) | |
--format / -f
|
Output format - either esm or cjs
|
esm |
--ext |
JS file extension | js |
--map-ext |
Source map files extension | map |
--esm |
Enable if codebase being serialized contains ECMAScript modules (import x from 'x' ) |
Disabled |
--jsx |
Enable if codebase being serialized contains JSX | Disabled |
--minify / -m
|
Minify output | Disabled |
--mangle / --no-mangle
|
Mangle (shorten) var names | Follows minify
|
--comments / --no-comments
|
Remove comments from source | Follows minify
|
--entry-chunk-name |
Template for entry point chunk names (more info) | [name] |
--split-chunk-name |
Template for split chunk names (more info) | [name].[hash] |
--common-chunk-name |
Template for common chunk names (more info) | common.[hash] |
--source-maps / -s
|
Output source maps. --source-maps inline for inline source maps. |
Disabled |
--no-exec |
Output a file which exports the input rather than executes it. | Exec enabled |
--stats |
Output stats file. Provide filename or true for livepack-stats.json . |
Disabled |
--no-cache |
Disable instrumentation cache | Cache enabled |
Config file
You can set options in a livepack.config.json
file rather than on command line. Config file can be in .json
or .js
format, in root dir of the app. If .js
, must be CommonJS.
// livepack.config.json
{
"input": "src/index.js",
"output": "build",
"format": "esm",
"ext": "js",
"mapExt": "map",
"esm": true,
"jsx": true,
"minify": true,
"mangle": true,
"comments": false,
"entryChunkName": "[name]",
"splitChunkName": "[name].[hash]",
"commonChunkName": "common.[hash]",
"sourceMaps": true,
"exec": true,
"stats": false,
"cache": true
}
input
can be:
- File path - absolute or relative to current directory
- Array of file paths - outputs will be named same as the inputs
- Object mapping output names to input paths
input: "src/index.js"
input: ["src/index.js", "src/other.js"]
input: {"index": "src/index.js", "another": "src/other.js"}
Then run Livepack with:
npx livepack
All of the above options are optional except input
and output
.
Programmatic API
There are two parts to the programmatic API.
- Require hook
-
serialize()
/serializeEntries()
functions
Require hook
Livepack instruments the code as it runs, by patching NodeJS's require()
function. This instrumentation is what allows Livepack to capture the value of variables in closures.
Your app must have an entry point which registers the require hook, and then require()
s the app itself.
// index.js
require('livepack/register');
module.exports = require('./app.js');
// app.js
module.exports = function() {
// App code here...
};
You must register the require hook before any other require()
calls. The input file should be just be an entry point which require()
s the app and exports it. Code in the entry point file will not be instrumented and so cannot be serialized.
The entry point file must require()
the app, not import
it. The app can be written in CommonJS or ESM (use esm
option).
Require hook options
You can provide options to the require hook:
require('livepack/register')( {
// Options...
} )
Option | Type | Usage | Default |
---|---|---|---|
esm |
boolean |
Set to true if codebase being serialized contains ECMAScript modules (import x from 'x' ) |
false |
jsx |
boolean |
Set to true if codebase being serialized contains JSX |
false |
cache |
boolean |
If true , instrumentation cache is used to speed up Livepack |
true |
These options correspond to CLI options, but sometimes named slightly differently.
Serialization
Use the serialize()
or serializeEntries()
functions to serialize. serializeEntries()
is used if you have multiple entry points.
const { serialize } = require('livepack');
const js = serialize( { x: 1 } );
// js = '{x:1}'
const { serializeEntries } = require('livepack');
const files = serializeEntries( {
index: { x: 1 },
other: { y: 2 }
} );
// files = [
// { type: 'entry', name: 'index', filename: 'index.js', content: '{x:1}' },
// { type: 'entry', name: 'other', filename: 'other.js', content: '{y:1}' }
// ]
or ESM:
import { serialize } from 'livepack';
const js = serialize( { x: 1 } );
// js = '{x:1}'
Options
serialize()
and serializeEntries()
can be passed options as 2nd argument.
serialize( {x: 1}, {
// Options...
} );
Option | Type | Usage | Default |
---|---|---|---|
format |
string |
Output format. Valid options are js , cjs or esm (see below). |
'js' |
ext |
string |
JS file extension | 'js' |
mapExt |
string |
Source maps file extension | 'map' |
exec |
boolean |
Set to true to treat input as a function which should be executed when the code runs (as with CLI). Only for cjs or esm format. |
false |
minify |
boolean |
Minify output | true |
mangle |
boolean |
Mangle (shorten) variable names | options.minify |
comments |
boolean |
Include comments in output | !options.minify |
files |
boolean |
true to output array of files (see below) |
false for serialize() ,true for serializeEntries()
|
strictEnv |
boolean |
true if environment code will execute in is strict mode (only relevant for js format) |
false for js or cjs format, true for esm
|
entryChunkName |
string |
Template for entry point chunk names (more info) | '[name]' |
splitChunkName |
string |
Template for split chunk names (more info) | '[name].[hash]' |
commonChunkName |
string |
Template for common chunk names (more info) | 'common.[hash]' |
sourceMaps |
boolean or 'inline'
|
Create source maps. 'inline' adds source maps inline, true in separate .map files.If true , files option must also be true . |
false |
outputDir |
string |
Path to dir code would be output to. If provided, source maps will use relative paths (relative to outputDir ). |
undefined |
All these options (except files
, outputDir
and strictEnv
) correspond to CLI options of the same names. Unlike the CLI, in the programmatic API exec
and files
options default to false
and minify
to true
.
Output formats
-
js
(default) - output an expression which can be inserted into code e.g.function() {}
-
cjs
- output a CommonJS module e.g.module.exports = function() {}
-
esm
- output an ESM module e.g.export default function() {}
Files
If the files
option is set, the return value of serialize()
will be an array of file objects, each with type
, name
, filename
and content
properties.
Use this if you want source maps in a separate file.
serialize(
{x: 1},
{files: true, format: 'esm', sourceMaps: true}
)
outputs:
[
{
type: 'entry',
name: 'index',
filename: 'index.js',
content: 'export default{x:1}\n//# sourceMappingURL=index.js.map'
},
{
type: 'source map',
name: null,
filename: 'index.js.map',
content: '{"version":3,"sources":[],"names":[],"mappings":""}'
}
]
Code splitting
Code splitting works differently in Livepack from other bundlers.
Livepack pays no attention to what files code originates in, and splits the output at the level of values, rather than at the level of files.
This produces an optimal split of the app, where each entry point only includes exactly the code it needs, and nothing more. It's more efficient than Webpack or Rollup's file-level code splitting.
Any values shared between entry points are placed in common chunks. By default, these are named common.XXXXXXXX.js
, where XXXXXXXX
is a hash of the file's content.
For example, if your input is:
// src/entry1.js
const { double, timesTen } = require('./shared.js');
module.exports = () => double( timesTen(10) );
// src/entry2.js
const { triple, timesTen } = require('./shared.js');
module.exports = () => triple( timesTen(20) );
// src/shared.js
module.exports = {
double: function double(n) { return n * 2; },
triple: function triple(n) { return n * 3; },
timesTen: function timesTen(n) { return n * 10; }
};
Livepack will bundle this as:
// build/entry1.js
import timesTen from "./common.6N2RIGAZ.js";
export default ((double,timesTen)=>()=>double(timesTen(10)))(function double(n){return n*2},timesTen)
// build/entry2.js
import timesTen from "./common.6N2RIGAZ.js";
export default ((triple,timesTen)=>()=>triple(timesTen(20)))(function triple(n){return n*3},timesTen)
// build/common.6N2RIGAZ.js
export default function timesTen(n){return n*10}
src/shared.js
has been split up. timesTen
is used by both entry points, so has been placed in a common chunk. But double
and triple
are inlined into the entry points that use them, since they're not shared. So each entry point doesn't need to import any code it doesn't use.
You can customize how code is split to optimize caching (see below).
You may notice this output could be shorter - values are injected into a closure, when they could be accessed directly from outer scope. This is a current shortcoming of Livepack. It will be improved in a future release.
import()
No The other big difference from other bundlers is that Livepack doesn't at present support import()
. It does, however, provide another mechanism to achieve the same goal.
Multiple entry points
If you have multiple entry points, use serializeEntries()
or provide multiple input files to the CLI.
split()
You can customize how code is split with split()
. Split will cause the value provided to be placed in a separate file and other files will import it.
This can be advantageous for caching - you may want to split off parts of your app which change infrequently.
const { split } = require('livepack');
const obj = { iAmABigObjectWhichChangesInfrequently: true, x: 123 };
split( obj );
module.exports = function getX() { return obj.x; };
Bundled output:
// index.js
import obj from "./split.V4ULTFDU.js";
export default(obj=>function getX(){return obj.x;})(obj)
// split.V4ULTFDU.js
export default {iAmABigObjectWhichChangesInfrequently:true,x:123}
If you want to specify the name of split point files, pass name as 2nd argument to split()
.
// Split off in a file called `my-big-object.XXXXXXXX.js`
split( obj, 'my-big-object' );
splitAsync()
splitAsync()
is Livepack's version of import()
.
splitAsync()
takes a value and returns an import function. Just like import()
, this import function returns a Promise of a module object. The .default
property of the module object is the value splitAsync()
was called with.
When Livepack serializes an import function, it puts the value into a separate file and outputs () => import('./split.XXXXXXXX.js')
.
Example input:
const { splitAsync } = require('livepack');
const importDouble = splitAsync(
function double(n) { return n * 2; }
);
module.exports = async function quadruple(n) {
const double = (await importDouble()).default;
return double( double(n) );
};
Output:
// index.js
export default(importDouble=>(
async function quadruple(n){
const double=(await importDouble()).default;
return double(double(n))
}
)(
()=>import("./split.LKCG7RVO.js")
)
// split.LKCG7RVO.js
export default function double(n){return n*2}
There's a few things to notice here:
-
double
has been split into a separate file -
importDouble
is output as a dynamic import()=>import("./split.LKCG7RVO.js")
-
double
didn't need to be defined in a separate file to be split off
All looks very weird? Maybe. But it does open up some patterns which usually aren't possible.
For example, you can dynamically create functions to be async imported.
Example with React
splitAsync()
works well with React.lazy()
:
const people = [
{ firstName: 'Harrison', lastName: 'Ford', lotsMoreData: { /* ... */ } },
{ firstName: 'Marlon', lastName: 'Brando', lotsMoreData: { /* ... */ } },
{ firstName: 'Peewee', lastName: 'Herman', lotsMoreData: { /* ... */ } }
];
const lazyComponents = people.map(
person => React.lazy(
splitAsync(
() => <PersonPage person={person} />
)
)
);
NB See here for a full runnable example expanding on this.
lazyComponents
is an array of lazy-loaded components. Each will be output in a separate file, with the data for each individual bundled in. people
isn't accessed from inside the function being split off, so it won't be included in the bundles, only each individual person
object will be included in the file for that person.
Where it gets really interesting is that data passed in to each lazy component isn't limited to just data - it can also include functions.
So you could, for example, provide customized components for each person. e.g. include a Google Maps component only for the pages where you know the person's address, by adding a .MapComponent
property to some of the person
objects. Only pages where a map is needed would include the code for displaying a map.
More broadly, components can be created at build time however you like - create code according to data. It's far more flexible than the usual model.
Customizing chunk names
There are 3 options for customizing chunk names:
-
entryChunkName
- entry point chunks -
splitChunkName
- split chunks (split()
orsplitAsync()
) -
commonChunkName
- common chunks (code in common between entry points)
For each you can use placeholders [name]
or [hash]
within the name. e.g.:
-
entryChunkName: '[name].[hash]'
will add hashes to the end of all entry point chunks. -
commonChunkName: 'shared/[hash]'
will place all common chunks in a subfoldershared
.
commonChunkName
must include [hash]
. splitChunkName
must include [hash]
if any split points are not named.
These options should not include the file extension. Use ext
option if you want to alter file extensions from the default .js
.
If you include [hash]
in entryChunkName
, you may need to consult the files
object returned by serialize()
/ serializeEntries()
to get the eventual filenames of the entry points. If using the CLI, you can use the --stats
option to output a stats file including this information.
What's missing
This is a new and experimental project. There are some major gaps at present.
JS features
Livepack can serialize pretty much all Javascript Functions and Objects. However, the following cannot yet be serialized:
- Promises
- Proxies
- Error objects
- WeakRefs + FinalizationRegistrys
- Class properties defined inline (e.g.
class X { x = 1; }
) - Private class methods + properties
- TypedArrays which share an underlying buffer
NB Applications can use any of these within functions, just that instances of these classes can't be serialized.
- Supported:
export default Promise;
- Supported:
const P = Promise; export default function() { return P; };
- Supported:
export default function() { return Promise.resolve(); };
- Unsupported:
export default Promise.resolve();
(Promise instance serialized directly) - Unsupported:
const p = Promise.resolve(); export default function f() { return p; };
(Promise instance in outer scope of exported function)
with (...) {...}
is also not supported where it alters the scope of a function being serialized.
Browser code
This works in part. You can, for example, build a simple React app with Livepack.
However, there are outstanding problems, which mean that Livepack is presently really only suitable for NodeJS server-side code.
- Code size is not typically great (optimizations are possible which will tackle this in future)
- Tree-shaking doesn't work yet for ESM named exports (tree-shaking CommonJS works fine)
- Difficulties with use of browser globals e.g.
window
- No understanding of the
browser
field inpackage.json
, which some packages like Axios use to provide different code on client and server
Tests
Use npm test
to run the tests. Use npm run cover
to check coverage.
Changelog
See changelog.md
Issues
If you discover a bug, please raise an issue on Github. https://github.com/overlookmotel/livepack/issues
Contribution
Pull requests are very welcome. Please:
- ensure all tests pass before submitting PR
- add tests for new features
- document new functionality/API additions in README
- do not add an entry to Changelog (Changelog is created when cutting releases)