diff --git a/index.js b/index.js index 8ce3afc..da95437 100644 --- a/index.js +++ b/index.js @@ -1,8 +1,11 @@ 'use strict' +var fs = require('fs') var path = require('path') var callerPath = require('caller-path') var resolvePath = require('resolve') +var hasAsyncHooks = require('has-async-hooks')() +var asyncHooks = hasAsyncHooks ? require('async_hooks') : null function attachCb (promise, cb) { if (cb) { @@ -14,16 +17,129 @@ function attachCb (promise, cb) { return promise } +var bundleMappings + +var captureBundles +var captureHooks +var activeCaptures = 0 +if (hasAsyncHooks) { + captureBundles = new Map() + + captureHooks = asyncHooks.createHook({ + init: function (asyncId, type, triggerAsyncId) { + // Inherit bundles list from the parent + if (captureBundles.has(triggerAsyncId)) { + captureBundles.set(asyncId, captureBundles.get(triggerAsyncId)) + } + }, + destroy: function (asyncId) { + captureBundles.delete(asyncId) + } + }) +} + +function capture (opts, run, cb) { + if (typeof opts === 'function') { + cb = run + run = opts + opts = {} + } + if (!opts) opts = {} + + if (!hasAsyncHooks) throw new Error('async_hooks is not available. Upgrade your Node version to 8.1.0 or higher') + if (!bundleMappings && opts.filenames !== true) { + throw new Error('Load a manifest file before using splitRequire.capture(). ' + + 'This is required to inform split-require about the bundle filenames. ' + + 'If you want the filenames for the unbundled entry points instead, do ' + + '`splitRequire.capture({ filenames: true }, run, cb)`.') + } + + if (activeCaptures === 0) captureHooks.enable() + activeCaptures++ + + var currentBundles = [] + + if (!cb) { + var promise = new Promise(function (resolve, reject) { + cb = function (err, result, bundles) { + if (err) reject(err) + else resolve({ result: result, bundles: bundles }) + } + }) + } + + // Make sure we're in a new async execution context + // This way doing two .capture() calls side by side from the same + // sync function won't interfere + // + // sr.capture(fn1) + // sr.capture(fn2) + process.nextTick(newContext) + + return promise + + function newContext () { + var asyncId = asyncHooks.executionAsyncId() + captureBundles.set(asyncId, { + list: currentBundles, + filenames: opts.filenames === true + }) + + var p = run(ondone) + if (p && p.then) p.then(function (result) { ondone(null, result) }, ondone) + + function ondone (err, result) { + captureBundles.delete(asyncId) // no memory leak + + activeCaptures-- + if (activeCaptures === 0) { + captureHooks.disable() + } + + cb(err, result, currentBundles) + } + } +} + +function loadManifest (manifest) { + if (!bundleMappings) bundleMappings = new Map() + + var mappings = JSON.parse(fs.readFileSync(manifest, 'utf8')) + var basedir = path.dirname(path.resolve(manifest)) + var publicPath = mappings.publicPath + var bundles = mappings.bundles + Object.keys(bundles).forEach(function (bundleName) { + bundles[bundleName].forEach(function (filename) { + bundleMappings.set(path.join(basedir, filename), path.join(publicPath, bundleName)) + }) + }) +} + module.exports = function load (filename, cb) { if (typeof filename === 'object' && filename._options) { return require('./plugin')(filename, cb) } + var currentBundles = hasAsyncHooks && activeCaptures > 0 + ? captureBundles.get(asyncHooks.executionAsyncId()) + : null + var basedir = path.dirname(callerPath()) var resolved = new Promise(function (resolve, reject) { resolvePath(filename, { basedir: basedir }, function (err, fullpath) { if (err) return reject(err) + // Add the path to the bundle list if it is being captured + if (currentBundles) { + if (currentBundles.filenames) { + currentBundles.list.push(fullpath) + } else { + var bundle = bundleMappings.get(fullpath) + if (!bundle) return reject(new Error('Could not find \'' + fullpath + '\' in the bundle manifest')) + currentBundles.list.push(bundle) + } + } + resolve(fullpath) }) }) @@ -31,6 +147,9 @@ module.exports = function load (filename, cb) { return attachCb(resolved.then(require), cb) } +module.exports.capture = capture +module.exports.loadManifest = loadManifest + Object.defineProperty(module.exports, 'createStream', { configurable: true, enumerable: true, diff --git a/package.json b/package.json index cb49fe2..0c9f74d 100644 --- a/package.json +++ b/package.json @@ -7,13 +7,14 @@ }, "dependencies": { "acorn-node": "^1.1.0", - "browser-pack": "^6.0.2", + "browser-pack": "6.0.4", "caller-path": "^2.0.0", "convert-source-map": "^1.5.0", "end-of-stream": "^1.4.0", "estree-is-require": "^1.0.0", "estree-walk": "^2.2.0", "flush-write-stream": "^1.0.2", + "has-async-hooks": "^1.0.0", "labeled-stream-splicer": "^2.0.0", "object-delete-value": "^1.0.0", "object-values": "^1.0.0", @@ -31,11 +32,13 @@ "factor-bundle": "^2.5.0", "has-object-spread": "^1.0.0", "mkdirp": "^0.5.1", + "proxyquire": "^2.0.1", "read-file-tree": "^1.1.0", "rimraf": "^2.6.2", "run-series": "^1.1.4", "standard": "^10.0.3", "tap-diff": "^0.1.1", + "tap-min": "^1.2.2", "tape": "^4.8.0", "tape-run": "^3.0.4", "uglify-es": "^3.3.7" @@ -50,10 +53,11 @@ "url": "git+https://github.com/goto-bus-stop/split-require.git" }, "scripts": { - "test": "npm run test:lint && npm run test:tap && npm run test:browser", + "test": "npm run test:lint && npm run test:tap && npm run test:server && npm run test:browser", "test:lint": "standard", "test:tap": "tape test/test.js | tap-diff", - "test:browser": "browserify -p [ ./plugin --out ./test/browser/static ] -r ./browser:split-require test/browser | tape-run --static ./test/browser/static" + "test:server": "tape test/capture.js | tap-min", + "test:browser": "browserify -p [ ./plugin --out ./test/browser/static ] -r ./browser:split-require test/browser | tape-run --static ./test/browser/static | tap-diff" }, "standard": { "ignore": [ diff --git a/plugin.js b/plugin.js index 01e96e6..aef97f7 100644 --- a/plugin.js +++ b/plugin.js @@ -107,6 +107,20 @@ function createSplitter (b, opts) { } return fs.createWriteStream(path.join(outputDir, bundleName)) } + var writeManifest = typeof opts.manifest === 'function' ? opts.manifest : function (manifest, cb) { + var manifestPath = null + if (typeof opts.manifest === 'string') { + manifestPath = opts.manifest + } else if (!opts.manifest) { + return cb() + } else { + var outdir = opts.out || opts.dir + if (!outdir || outdir.indexOf('%f') !== -1) return cb() // Just don't write it I guess? + manifestPath = path.join(opts.out || opts.dir, 'split-require.json') + } + + fs.writeFile(manifestPath, JSON.stringify(manifest, null, 2), cb) + } var publicPath = opts.public || './' var rows = [] @@ -239,6 +253,14 @@ function createSplitter (b, opts) { runParallel(pipelines, function (err, mappings) { if (err) return cb(err) var sri = {} + var manifest = { + publicPath: publicPath, + bundles: mappings.reduce(function (obj, x) { + obj[x.filename] = x.modules + return obj + }, {}) + } + mappings = mappings.reduce(function (obj, x) { obj[x.entry] = path.join(publicPath, x.filename) if (x.integrity) sri[x.entry] = x.integrity @@ -257,13 +279,23 @@ function createSplitter (b, opts) { self.push(row) }) - cb(null) + writeManifest(manifest, cb) }) } function createPipeline (entryId, depRows, cb) { var entry = getRow(entryId) + var currentModules = [] + var recordModules = through.obj(function (rec, enc, next) { + if (rec.file) { + currentModules.push(typeof opts.manifest === 'string' + ? path.relative(path.dirname(opts.manifest), rec.file) + : rec.file) + } + next(null, rec) + }) var pipeline = splicer.obj([ + 'record', [ recordModules ], 'pack', [ pack({ raw: true }) ], 'wrap', [] ]) @@ -295,6 +327,7 @@ function createSplitter (b, opts) { cb(null, { entry: entryId, filename: basename, + modules: currentModules, integrity: opts.sri ? sri.value : null }) } diff --git a/test/basic/app.js b/test/basic/app.js index ef203da..0fcbe29 100644 --- a/test/basic/app.js +++ b/test/basic/app.js @@ -1,6 +1,8 @@ var xyz = require('./xyz') var splitRequire = require('split-require') -splitRequire('./dynamic', function (err, exports) { - console.log(xyz(10) + exports) -}) +module.exports = function (cb) { + splitRequire('./dynamic', function (err, exports) { + cb(xyz(10) + exports) + }) +} diff --git a/test/basic/expected/bundle.js b/test/basic/expected/bundle.js index 0fa63a6..864349d 100644 --- a/test/basic/expected/bundle.js +++ b/test/basic/expected/bundle.js @@ -4,9 +4,11 @@ require("split-require").b = {"2":"bundle.2.js"}; var xyz = require('./xyz') var splitRequire = require('split-require') -splitRequire(2, function (err, exports) { - console.log(xyz(10) + exports) -}) +module.exports = function (cb) { + splitRequire(2, function (err, exports) { + cb(xyz(10) + exports) + }) +} },{"./xyz":4,"split-require":"split-require"}],4:[function(require,module,exports){ module.exports = function xyz (num) { diff --git a/test/capture.js b/test/capture.js new file mode 100644 index 0000000..035af38 --- /dev/null +++ b/test/capture.js @@ -0,0 +1,100 @@ +var test = require('tape') +var path = require('path') +var hasAsyncHooks = require('has-async-hooks')() +var mkdirp = require('mkdirp') +var browserify = require('browserify') +var proxyquire = require('proxyquire') +var sr = require('../') + +var expected = { + one: [ + path.join(__dirname, 'capture/view1.js'), + path.join(__dirname, 'capture/data1.js'), + path.join(__dirname, 'capture/data3.js') + ].sort(), + two: [ + path.join(__dirname, 'capture/view2.js'), + path.join(__dirname, 'capture/data3.js') + ].sort() +} + +test('capture', { skip: !hasAsyncHooks }, function (t) { + t.plan(200) + var app = require('./capture/app') + + // Ensure multiple concurrent renders don't interfere + // by just setting off a ton of them + for (var i = 0; i < 200; i++) { + var which = Math.random() > 0.5 ? 'one' : 'two' + queue(which) + } + + function queue (which) { + setTimeout(function () { + app(which).then(function (result) { + t.deepEqual(result.bundles.sort(), expected[which]) + }).catch(t.fail) + }, 4 + Math.floor(Math.random() * 10)) + } +}) + +test('capture sync', { skip: !hasAsyncHooks }, function (t) { + t.plan(20) + var render = require('./capture/render') + + // similar to above, but every call originates in the same + // async context + for (var i = 0; i < 20; i++) { + (function (i) { + var which = i % 2 ? 'one' : 'two' + + sr.capture({ filenames: true }, function () { + return render(which) + }).then(function (result) { + t.deepEqual(result.bundles.sort(), expected[which]) + }).catch(t.fail) + }(i)) + } +}) + +test('no capture', { skip: hasAsyncHooks }, function (t) { + t.plan(1) + t.pass('ok') +}) + +test('capture bundles', { skip: !hasAsyncHooks }, function (t) { + t.plan(4) + + var outdir = path.join(__dirname, 'capture/actual/') + var manifest = path.join(outdir, 'split-require.json') + var entry = path.join(__dirname, 'basic/app.js') + + mkdirp.sync(outdir) + + browserify(entry) + .require(path.join(__dirname, '../'), { expose: 'split-require' }) + .plugin(sr, { + dir: outdir, + manifest: manifest + }) + .bundle(function (err, bundle) { + t.ifError(err) + + ssr() + }) + + function ssr () { + sr.loadManifest(manifest) + sr.capture(function (cb) { + sr['@noCallThru'] = true // AAAAAAAAAAAAAAA + sr['@global'] = true // AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + proxyquire(entry, { 'split-require': sr })(cb.bind(null, null)) + }, ondone) + } + + function ondone (err, result, bundles) { + t.ifError(err) + t.equal(result, 146) + t.deepEqual(bundles, ['bundle.2.js']) + } +}) diff --git a/test/capture/app.js b/test/capture/app.js new file mode 100644 index 0000000..232802a --- /dev/null +++ b/test/capture/app.js @@ -0,0 +1,10 @@ +var sr = require('../../') +var render = require('./render') + +module.exports = async function app (route) { + var { bundles, result } = await sr.capture({ filenames: true }, function () { + return render(route) + }) + + return { result, bundles } +} diff --git a/test/capture/data1.js b/test/capture/data1.js new file mode 100644 index 0000000..873d1f5 --- /dev/null +++ b/test/capture/data1.js @@ -0,0 +1,16 @@ +var fs = require('fs') +var path = require('path') +var sr = require('../../') + +module.exports = function () { + return new Promise((resolve, reject )=> { + // callback somewhere + fs.readFile(path.join(__dirname, 'json.json'), function (err, d) { + if (err) reject(err) + else sr('./data3', function (err, d3) { + if (err) reject(err) + else resolve(`this is json: ${JSON.stringify(JSON.parse(d))} and this is d3: ${d3}`) + }) + }) + }) +} diff --git a/test/capture/data3.js b/test/capture/data3.js new file mode 100644 index 0000000..6887896 --- /dev/null +++ b/test/capture/data3.js @@ -0,0 +1 @@ +module.exports = 3 diff --git a/test/capture/json.json b/test/capture/json.json new file mode 100644 index 0000000..734d895 --- /dev/null +++ b/test/capture/json.json @@ -0,0 +1 @@ +{"some":"json"} diff --git a/test/capture/render.js b/test/capture/render.js new file mode 100644 index 0000000..da7d720 --- /dev/null +++ b/test/capture/render.js @@ -0,0 +1,9 @@ +var route1 = require('./route1') +var route2 = require('./route2') + +module.exports = function (route) { + if (route === 'one') { + return route1() + } + return require('util').promisify(route2)() +} diff --git a/test/capture/route1.js b/test/capture/route1.js new file mode 100644 index 0000000..1396a98 --- /dev/null +++ b/test/capture/route1.js @@ -0,0 +1,5 @@ +var sr = require('../../') + +module.exports = async function route1 () { + return (await sr('./view1'))(await sr('./data1')) +} diff --git a/test/capture/route2.js b/test/capture/route2.js new file mode 100644 index 0000000..08e7692 --- /dev/null +++ b/test/capture/route2.js @@ -0,0 +1,14 @@ +var parallel = require('run-parallel') +module.exports = function (cb) { + process.nextTick(function () { + Promise.resolve().then(function () { + parallel([ + function (cb) { require('../../')('./view2', cb) }, + function (cb) { require('../../')('./data3', cb) }, + function (cb) { setTimeout(cb, 1) } // ensure that there is an event loop yield at some point + ], function (err, xyz) { + cb(null, xyz[0](`Second route. ${xyz[1]}`)) + }) + }) + }) +} diff --git a/test/capture/view1.js b/test/capture/view1.js new file mode 100644 index 0000000..d73503f --- /dev/null +++ b/test/capture/view1.js @@ -0,0 +1,3 @@ +module.exports = async function (data) { + return `

${await data()}

` +} diff --git a/test/capture/view2.js b/test/capture/view2.js new file mode 100644 index 0000000..36f5d9d --- /dev/null +++ b/test/capture/view2.js @@ -0,0 +1,3 @@ +module.exports = function (data) { + return `

${data}

` +}