@cara/porter
Advanced tools
Comparing version 4.2.14 to 4.3.0
@@ -159,12 +159,13 @@ /* eslint-env browser */ | ||
if (!context) throw new Error('context module of ' + uri + ' not found'); | ||
// execute context module factory for the first time to grab the imports | ||
context.execute(); | ||
// prepare the imports of wasm module | ||
var imports = {}; | ||
imports['./' + contextId.split('/').pop()] = context.exports; | ||
// loader.js might be required to run in legacy browser hence async/await not used | ||
fetch(new URL(uri, location.origin)) | ||
.then(function onResponse(module) { | ||
// execute context module factory for the first time to grab the imports | ||
// FIXME: context might not be ready to execute if the packet weren't bundled | ||
context.execute(); | ||
// prepare the imports of wasm module | ||
var imports = {}; | ||
imports['./' + contextId.split('/').pop()] = context.exports; | ||
return loadWasm(module, imports); | ||
@@ -171,0 +172,0 @@ }) |
{ | ||
"name": "@cara/porter", | ||
"description": "A middleware for web modules", | ||
"version": "4.2.14", | ||
"version": "4.3.0", | ||
"main": "src/porter.js", | ||
@@ -46,3 +46,3 @@ "repository": { | ||
"license": "BSD-3-Clause", | ||
"gitHead": "168e9b96c1cfbd00ec482ba20c99efc034d57b2c" | ||
"gitHead": "69a3fbeb6532552ad13c915a325329463e94a966" | ||
} |
310
Readme.md
@@ -7,13 +7,15 @@ # Porter | ||
Porter is a **consolidated browser module solution** which provides a module system for web browsers that is both CommonJS and [ES Modules](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/import) compatible. | ||
[中文版](./Readme.zh-CN.md) | ||
Here are the features that make Porter different from (if not better than) other module solutions: | ||
Porter is a **consolidated browser module solution** which provides a module system for web browsers that is both CommonJS and [ES Modules](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/import) compatible, with following features supported: | ||
1. 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. | ||
2. Implemented with the concept `Module` (file) and `Package` (directory with package.json and files) built-in. | ||
3. 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. | ||
1. Both synchronous and asynchronous module loading are supported, which means `require()`, `require.async()`, `import`, or `import()` can be used at will to request modules, dynamically imported modules will be bundled separately. | ||
2. Bundle at package level, or bundle everything, it's completely up to you. | ||
3. Fast enough module resolution and transpilation, with reasonable levels of cache that makes production builds more effecient. | ||
It is recommended to first start with our [starter](https://porterhq.github.io/porter/starter) documentation or the thorough [user guides](https://porterhq.github.io/porter/basics). | ||
## Setup | ||
> This document is mainly about Porter the middleware. To learn about Porter CLI, please visit the [corresponding folder](https://github.com/porterhq/porter/packages/porter-cli). | ||
> This document is mostly about Porter the middleware. To learn about Porter CLI, please visit the [corresponding folder](https://github.com/porterhq/porter/tree/master/packages/porter-cli) or the [Porter Documentation](https://porterhq.github.io/porter). | ||
@@ -36,3 +38,3 @@ Porter the middleware is compatible with Koa (both major versions) and Express: | ||
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](https://github.com/porterhq/porter/packages/demo-cli) for example, the file structure shall resemble that of below: | ||
With the default setup, browser modules at `./components` folder is now accessible with `/path/to/file.js`. Take [demo-cli](https://github.com/porterhq/porter/tree/master/packages/demo-cli) for example, the file structure shall resemble that of below: | ||
@@ -62,3 +64,3 @@ ```bash | ||
<meta charset="utf-8"> | ||
<title>An Porter Demo</title> | ||
<title>A Porter Demo</title> | ||
<!-- CSS entry --> | ||
@@ -75,10 +77,16 @@ <link rel="stylesheet" type="text/css" href="/app.css"> | ||
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: | ||
> The extra `?main` parameter in the JavaScript entry query is added for historical reasons. It tells the porter middleware to include loader.js when bundling app.js, which isn't necessary if loader.js is included explicitly: | ||
> | ||
> ```html | ||
> <!-- entry format 1 --> | ||
> <script src="/loader.js" data-main="app.js"></script> | ||
> <!-- entry format 2 --> | ||
> <script src="/loader.js"></script> | ||
> <script>porter.import('app')</script> | ||
> ``` | ||
> | ||
> Both formats are no longer recommended, please use `<script src="/app.js?main"></script>` directly. | ||
```html | ||
<script src="/loader.js" data-main="app.js"></script> | ||
``` | ||
In JavaScript entry, all kinds of imports are supported: | ||
Both `<script>`s work as the JavaScript entry of current page. In `./components/app.js`, there are the good old `require` and `exports`: | ||
```js | ||
@@ -88,2 +96,11 @@ import $ from 'jquery'; // => ./node_modules/jquery/dist/jquery.js | ||
import util from './util'; // => ./components/util.js or ./components/util/index.js | ||
// <link rel="stylesheet" type="text/css" href="/app.js"> is still needed though | ||
import './foo.css'; | ||
// will fetch the wasm file, instantiate it, and return the exports | ||
import wasm from './foo.wasm'; | ||
// will bundle and fetch worker.js separately | ||
import Worker from 'worker-loader!./worker.js'; | ||
``` | ||
@@ -100,4 +117,83 @@ | ||
<https://www.yuque.com/porterhq/porter/fitqkz> | ||
In a nutshell, here is the list of porter options: | ||
```javascript | ||
const path = require('path'); | ||
const Porter = require('@cara/porter'); | ||
const porter = new Porter({ | ||
// project root, defaults to `process.cwd()` | ||
root: process.cwd(), | ||
// paths of browser modules, or components, defaults to `'components'` | ||
paths: 'components', | ||
// output settings | ||
output: { | ||
// path of the compile output, defaults to `'public'` | ||
path: 'public', | ||
}, | ||
// cache settings | ||
cache: { | ||
// path of the cache store, defaults to `output.path` | ||
path: '.porter-cache', | ||
// cache identifier to shortcut cache invalidation | ||
identifier({ packet }) { | ||
return JSON.stringify([ | ||
require('@cara/porter/package.json').version, | ||
packet.transpiler, | ||
packet.transpilerVersion, | ||
packet.transpilerOpts, | ||
]); | ||
}, | ||
}, | ||
// preload common dependencies, defaults to `[]` | ||
preload: [ 'preload', '@babel/runtime' ], | ||
// the module resolution behaviour | ||
resolve: { | ||
// an alias at project level to simplify import specifier, such as | ||
// import util from '@/util'; // => components/util/index.js | ||
alias: { | ||
'@': path.join(process.cwd(), 'components'), | ||
}, | ||
// supported extensions | ||
extensions: [ '*', '.js', '.jsx', '.ts', '.tsx', '.css' ], | ||
// transform big libraries that support partial import by conventions | ||
import: [ | ||
{ libraryName: 'antd', style: 'css' }, | ||
{ libraryName: 'lodash', | ||
libraryDirectory: '', | ||
camel2DashComponentName: false }, | ||
], | ||
}, | ||
// transpile settings | ||
transpile: { | ||
// turn on transpilation on certain dependencies, defaults to `[]` | ||
include: [ 'antd' ], | ||
}, | ||
// bundle settings | ||
bundle: { | ||
// excluded dependencies will be bundled separately, defaults to `[]` | ||
exclude: [ 'antd' ], | ||
}, | ||
// source settings | ||
source: { | ||
// serve the source file if it's development mode, defaults to `false` | ||
serve: process.env.NODE_ENV !== 'production', | ||
// the `sourceRoot` in the generated source map, defaults to `'/'` | ||
root: 'localhost:3000', | ||
}, | ||
}); | ||
``` | ||
## Deployment | ||
@@ -108,9 +204,9 @@ | ||
```js | ||
const porter = new Porter() | ||
const porter = new Porter({ | ||
output: { path: 'dist' }, | ||
}); | ||
porter.compileAll({ | ||
await porter.compileAll({ | ||
entries: ['app.js', 'app.css'] | ||
}) | ||
.then(() => console.log('done') | ||
.catch(err => console.error(err.stack)) | ||
}); | ||
``` | ||
@@ -121,28 +217,16 @@ | ||
- 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`. | ||
- Dependencies are bundled per package with internal modules put together, e.g. jQuery gets compiled as `jquery/3.3.1/dist/jquery.4f8208b0.js`. | ||
- Dependencies with multiple entries gets bundled per package as well, e.g. lodash methods will be compiled as `lodash/4.17.10/lodash.36bdcd6d.js`. | ||
Assume the root package is: | ||
Take following app.js for example: | ||
```json | ||
{ | ||
"name": "@cara/demo-cli", | ||
"version": "2.0.0" | ||
} | ||
``` | ||
and the content of `./components/app.js` is: | ||
```js | ||
'use strict' | ||
const $ = require('jquery') | ||
const throttle = require('lodash/throttle') | ||
const camelize = require('lodash/camelize') | ||
const util = require('./util') | ||
import $ from 'jquery'; | ||
import throttle from 'lodash/throttle'; | ||
import camelize from 'lodash/camelize'; | ||
import util from './util'; | ||
// code | ||
``` | ||
After `porter.compileAll({ entries: ['app.js'] })`, the files in `./public` should be: | ||
When `porter.compileAll({ entries: ['app.js'] })` is done, the output files should be: | ||
@@ -173,147 +257,1 @@ ```bash | ||
``` | ||
## 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: | ||
```html | ||
<script src="/app.js?main"></script> | ||
``` | ||
And suddenly you can write `app.js` as Node.js Modules or ES Modules right away: | ||
```js | ||
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: | ||
1. Loader | ||
2. Package lock | ||
You can import `app.js` explicitly if you prefer: | ||
```html | ||
<script src="/loader.js"></script> | ||
<script>porter.import('app')</script> | ||
<!-- or with shortcut --> | ||
<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: | ||
```js | ||
define(id, deps, function(require, exports, module) { | ||
// actual main.js content | ||
}); | ||
``` | ||
- `id` is deducted from the file path. | ||
- `dependencies` is parsed from the factory code with [js-tokens](https://github.com/lydell/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: | ||
1. Put a `.babelrc` file under your components directory. | ||
2. 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: | ||
```bash | ||
➜ 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: | ||
```js | ||
{ | ||
"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: | ||
```js | ||
{ | ||
baseUrl: '/', | ||
package: { /* generated from package.json of the project */ } | ||
} | ||
``` | ||
### Wrap It Up | ||
So here is `app.js?main` expanded: | ||
```js | ||
// GET /loader.js returns both Loader and Loader Config. | ||
;(function() { /* Loader */ }) | ||
Object.assign(porter.lock, /* package lock */) | ||
// The module definition and the import kick off. | ||
define(id, dependencies, function(require, exports, module) { /* app.js */ }) | ||
porter.import('app') | ||
``` | ||
Here's the actual interaction between browser and Porter: | ||
![](https://cdn.yuque.com/__puml/76189ffa06e35b64edd55c3e9423734d.svg) | ||
### StyleSheets | ||
The stylesheets part is much easier since Porter processes CSS `@import`s at the first place. Take following `app.css` for example: | ||
```css | ||
@import "cropper/dist/cropper.css"; | ||
@import "common.css" | ||
body { | ||
padding: 50px; | ||
} | ||
``` | ||
![](https://cdn.yuque.com/__puml/5c1a7b8ae1312893829aaf4f357cdadd.svg) | ||
When browser requests `app.css`: | ||
1. `postcss-import` processes all of the `@import`s; | ||
2. `autoprefixer` transforms the bundle; | ||
Porter then responses with the processed CSS (which has all `@import`s replaced with actual file contents). |
'use strict'; | ||
const glob = require('glob'); | ||
// https://github.com/junosuarez/heredoc/blob/master/index.js | ||
@@ -20,10 +22,15 @@ const stripPattern = /^[ \t]*(?=[^\s]+)/mg; | ||
/** | ||
* @typedef { import("@babel/core").NodePath } NodePath | ||
* @param {Object} options | ||
* @param { import("@babel/types")} options.types | ||
* @param { import("@babel/template")} options.template | ||
* @returns {Object} | ||
*/ | ||
module.exports = function({ types: t, template }) { | ||
let globIndex = 0; | ||
module.exports = function({ types: t }) { | ||
const visitor = { | ||
/** | ||
* Remove `require('heredoc')` | ||
* @param {NodePath} path | ||
* @param {import("@babel/core").NodePath} path | ||
* @param {import('@babel/core').PluginPass} state | ||
*/ | ||
@@ -41,3 +48,4 @@ VariableDeclaration(path) { | ||
* Transform `heredoc(function() {/* text ...})` to text. | ||
* @param {NodePath} path | ||
* @param {import("@babel/core").NodePath} path | ||
* @param {import('@babel/core').PluginPass} state | ||
*/ | ||
@@ -55,10 +63,44 @@ CallExpression(path) { | ||
/** | ||
* Transform `import.meta` to `__module.meta` | ||
* @param {NodePath} path | ||
* Transform `import.meta.url` to `__module.meta.url` | ||
* Transform `import.meta.glob(pattern, options)` like vite https://vitejs.dev/guide/features.html#glob-import | ||
* @param {import("@babel/core").NodePath} path | ||
* @param {import('@babel/core').PluginPass} state | ||
*/ | ||
MetaProperty(path) { | ||
const { node } = path; | ||
if (node.meta && node.meta.name === 'import' && | ||
node.property.name === 'meta') { | ||
path.replaceWithSourceString('__module.meta'); | ||
MetaProperty(path, state) { | ||
if (t.isMemberExpression(path.parent) && path.parent.property.name === 'url') { | ||
path.replaceWith(t.memberExpression(t.identifier('__module'), t.identifier('meta'))); | ||
} | ||
if (t.isCallExpression(path.parentPath.parent) && path.parent.property.name === 'glob') { | ||
const node = path.parentPath.parent; | ||
if (node.arguments.length === 0) { | ||
throw new Error('import.meta.glob must have at least one argument'); | ||
} | ||
const [pattern, options = {}] = node.arguments; | ||
if (!t.isStringLiteral(pattern)) { | ||
throw new Error('import.meta.glob first argument must be a string literal'); | ||
} | ||
const opts = { cwd: require('path').dirname(state.filename) }; | ||
for (const prop of options.properties || []) opts[prop.key.name] = prop.value.value; | ||
const files = glob.sync(pattern.value, opts); | ||
const callExpression = path.find(p => p.isCallExpression()); | ||
if (opts.eager) { | ||
const properties = []; | ||
const buildImport = template('import * as %%local%% from %%source%%;', { sourceType: 'module' }); | ||
const statement = callExpression.getStatementParent(); | ||
for (let i = 0; i < files.length; i++) { | ||
const file = files[i]; | ||
const local = `__glob_${globIndex++}_${i}`; | ||
statement.insertBefore(buildImport({ local: t.identifier(local), source: t.stringLiteral(file) })); | ||
properties.push(t.objectProperty(t.stringLiteral(file), t.identifier(local))); | ||
} | ||
callExpression.replaceWith(t.objectExpression(properties)); | ||
} else { | ||
const properties = []; | ||
const buildDynamicImport = template.expression('() => import(%%source%%)', { sourceType: 'module' }); | ||
for (let i = 0; i < files.length; i++) { | ||
const file = files[i]; | ||
properties.push(t.objectProperty(t.stringLiteral(file), buildDynamicImport({ source: t.stringLiteral(file) }))); | ||
} | ||
callExpression.replaceWith(t.objectExpression(properties)); | ||
} | ||
} | ||
@@ -65,0 +107,0 @@ }, |
@@ -13,8 +13,11 @@ 'use strict'; | ||
module.exports = class Cache { | ||
constructor({ path: cachePath, identifier }) { | ||
constructor({ path: cachePath, identifier, clean = false }) { | ||
this.path = cachePath; | ||
if (typeof identifier === 'function') this.identifier = identifier; | ||
this.clean = clean; | ||
} | ||
identifier({ packet }) { | ||
identifier(app) { | ||
const { packet } = app; | ||
const { uglifyOptions } = app; | ||
const rPorterDir = new RegExp(path.resolve(__dirname, '..'), 'g'); | ||
@@ -28,2 +31,3 @@ const result = JSON.stringify({ | ||
}, | ||
uglifyOptions, | ||
}); | ||
@@ -66,6 +70,6 @@ return result.replace(rPorterDir, '<porterDir>'); | ||
this.salt = this.identifier({ packet }); | ||
if (this.clean) await fs.rm(this.path, { recursive: true, force: true }); | ||
const saltPath = path.join(this.path, 'salt.cache'); | ||
const salt = await fs.readFile(saltPath, 'utf8').catch(() => ''); | ||
if (salt !== this.salt) { | ||
if (!this.clean && salt !== this.salt) { | ||
if (salt) debug('cache salt changed from %j to %j', salt, this.salt); | ||
@@ -72,0 +76,0 @@ await fs.mkdir(path.dirname(saltPath), { recursive: true }); |
@@ -208,3 +208,3 @@ 'use strict'; | ||
const obj = { | ||
babel: ['babel.config.js', '.babelrc'], | ||
babel: ['babel.config.js', 'babel.config.cjs', '.babelrc'], | ||
typescript: 'tsconfig.json', | ||
@@ -211,0 +211,0 @@ }; |
@@ -63,3 +63,3 @@ 'use strict'; | ||
const cachePath = path.resolve(root, opts.cache && opts.cache.path || output.path); | ||
const cache = new Cache({ path: cachePath }); | ||
const cache = new Cache({ ...opts.cache, path: cachePath }); | ||
@@ -208,3 +208,3 @@ const bundle = { exclude: [], ...opts.bundle }; | ||
await packet.prepare(); | ||
await cache.prepare({ packet }); | ||
await cache.prepare(this); | ||
@@ -265,2 +265,3 @@ debug('parse preload, entries, and lazyload'); | ||
async compileAll({ entries = [] }) { | ||
if (this.output.clean) await fs.rm(this.output.path, { recursive: true, force: true }); | ||
await this.ready({ minify: true }); | ||
@@ -267,0 +268,0 @@ |
138390
25
3520
249