Porter
Porter is a consolidated browser module solution which provides a module system for web browsers that is both CommonJS and ES Modules compatible.
Here are the features that make Porter different from (if not better than) other module solutions:
- Both synchronous and asynchronous module loading are supported.
import
is transformed with either Babel or TypeScript. import()
is not fully supported yet but there's an equivalent require.async(specifier, mod => {})
provided. - Implemented with the concept
Module
(file) and Package
(directory with package.json and files) built-in. - Fast enough module resolution and transpilation that makes the
watch => bundle
loop unnecessary. With Porter the middleware, .css
and .js
requests are intercepted (and processed if changed) correspondingly.
Setup
This document is mainly about Porter the middleware. To learn about Porter CLI, please visit the corresponding folder.
Porter the middleware is compatible with Koa (both major versions) and Express:
const Koa = require('koa')
const Porter = require('@cara/porter')
const app = new Koa()
const porter = new Porter()
app.use(porter.async())
app.use(porter.func())
Modules
With the default setup, browser modules at ./components
folder is now accessible with /path/to/file.js
or /${pkg.name}/${pkg.version}/path/to/file.js
. Take demo-cli for example, the file structure shall resemble that of below:
➜ demo-cli git:(master) tree -L 2
.
├── components
│ ├── app.css
│ └── app.js
├── node_modules
│ ├── @cara
│ │ └── porter
│ ├── jquery
│ └── prismjs
├── package.json
└── public
└── index.html
In ./public/index.html
, we can now add CSS and JavaScript entries:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>An Porter Demo</title>
<link rel="stylesheet" type="text/css" href="/app.css">
</head>
<body>
<h1>A Porter Demo</h1>
<script src="/app.js?main"></script>
</body>
</html>
The extra ?main
querystring might seem a bit confusing at first glance. It tells the porter middleware to bundle loader when /app.js?main
is accessed. The equivalent <script>
entry of above is:
<script src="/loader.js" data-main="app.js"></script>
Both <script>
s work as the JavaScript entry of current page. In ./components/app.js
, there are the good old require
and exports
:
import $ from 'jquery';
import * as React from 'react';
import util from './util';
In CSS entry, there's @import
:
@import "prismjs/themes/prism.css";
@import "./base.css";
Options
https://www.yuque.com/porterhq/porter/fitqkz
Deployment
It is possible (and also recommended) to disable Porter in production, as long as the assets are compiled with porter.compileAll()
. To compile assets of the project, simply call porter.compileAll({ entries })
:
const porter = new Porter()
porter.compileAll({
entries: ['app.js', 'app.css']
})
.then(() => console.log('done')
.catch(err => console.error(err.stack))
Porter will compile entries and their dependencies, bundle them together afterwards. How the modules are bundled is a simple yet complicated question. Here's the default bundling strategy:
- Entries are bundled separately, e.g.
entries: ['app.js', 'app2.js']
are compiled into two different bundles. - Dependencies are bundled per package with internal modules put together, e.g. jQuery gets compiled as
jquery/3.3.1/dist/jquery.js
. - Dependencies with multiple entries gets bundled per package as well, e.g. lodash methods will be compiled as
lodash/4.17.10/~bundle-36bdcd6d.js
.
Assume the root package is:
{
"name": "@cara/demo-cli",
"version": "2.0.0"
}
and the content of ./components/app.js
is:
'use strict'
const $ = require('jquery')
const throttle = require('lodash/throttle')
const camelize = require('lodash/camelize')
const util = require('./util')
After porter.compileAll({ entries: ['app.js'] })
, the files in ./public
should be:
public
├── app.${contenthash}.js
├── app.${contenthash}.js.map
├── jquery
│ └── 3.3.1
│ └── dist
| ├── jquery.${contenthash}.js
| └── jquery.${contenthash}.js.map
└── lodash
└── 4.17.10
├── ~bundle.${contenthash}.js
└── ~bundle.${contenthash}.js.map
For different kinds of projects, different strategies shall be employed. We can tell Porter to bundle dependencies at certain scope with porter.compileEntry()
:
porter.compileEntry('app.js', { package: true })
porter.compileEntry('app.js', { all: true })
Behind the Scene
Let's start with app.js
, which might seem a bit confusing at the first glance. It is added to the page directly:
<script src="/app.js?main"></script>
And suddenly you can write app.js
as Node.js Modules or ES Modules right away:
import mobx from 'mobx'
const React = require('react')
How can browser know where to import
MobX or require
React when executing app.js
?
Loader
The secret is, entries that has main
in the querystring (e.g. app.js?main
) will be prepended with two things before the the actual app.js
when it's served with Porter:
- Loader
- Package lock
You can import app.js
explicitly if you prefer:
<script src="/loader.js"></script>
<script>porter.import('app')</script>
<script src="/loader.js" data-main="app"></script>
Both way works. To make app.js
consumable by the Loader, it will be wrapped into Common Module Declaration format on the fly:
define(id, deps, function(require, exports, module) {
});
id
is deducted from the file path.dependencies
is parsed from the factory code with js-tokens.factory
(the anonymouse function) body is left untouched or transformed with babel depending on whether .babelrc
exists or not.
If ES Module is preferred, you'll need two things:
- Put a
.babelrc
file under your components directory. - Install the presets or plugins configured in said
.babelrc
.
Back to the Loader, after the wrapped app.js
is fetched, it won't execute right away. The dependencies need to be resolved first. For relative dependencies (e.g. dependencies within the same package), it's easy to just resolve them against module.id
. For external dependencies (in this case, react and mobx), node_modules
are looked.
The parsed dependencies is in two trees, one for modules (file by file), one for packages (folder by folder). When the entry module (e.g. app.js
) is accessed, a package lock is generated and prepended before the module to make sure the correct module path is used.
Take heredoc's (simplified) node_modules for example:
➜ heredoc git:(master) ✗ tree node_modules -I "mocha|standard"
node_modules
└── should
├── index.js
├── node_modules
│ └── should-type
│ ├── index.js
│ └── package.json
└── package.json
It will be flattened into:
{
"should": {
"6.0.3": {
"main": "./lib/should.js",
"dependencies": {
"should-type": "0.0.4"
}
}
},
"should-type": {
"0.0.4": {}
}
}
Loader Config
Besides package lock, there're several basic loader settings (which are all configurable while new Porter()
):
property | description |
---|
baseUrl | root path of the browser modules, e.g. https://staticfile.org/ |
map | module mappings that may interfere module resolution |
package | metadata of the root package, e.g. { name, version, main, entries } |
preload | a syntax sugar for quick loading certain files before entry |
In development phase, Porter configs the loader with following settings:
{
baseUrl: '/',
package: { }
}
Wrap It Up
So here is app.js?main
expanded:
;(function() { })
Object.assign(porter.lock, )
define(id, dependencies, function(require, exports, module) { })
porter.import('app')
Here's the actual interaction between browser and Porter:
StyleSheets
The stylesheets part is much easier since Porter processes CSS @import
s at the first place. Take following app.css
for example:
@import "cropper/dist/cropper.css";
@import "common.css"
body {
padding: 50px;
}
When browser requests app.css
:
postcss-import
processes all of the @import
s;autoprefixer
transforms the bundle;
Porter then responses with the processed CSS (which has all @import
s replaced with actual file contents).