├── README.md ├── bin ├── cli.js └── usage.txt ├── local-cache.js ├── mdns-swarm.js ├── package.json └── server.js /README.md: -------------------------------------------------------------------------------- 1 | # friendpm 2 | 3 | > Share, publish, and install node packages from your cache over the local 4 | > network. 5 | 6 | ## Install 7 | 8 | With [npm](https://npmjs.org/) installed, run 9 | 10 | ``` 11 | $ npm install --global friendpm 12 | ``` 13 | 14 | ## Usage 15 | 16 | Use the `friendpm` command just like `npm`, except the `install` and `publish` 17 | subcommands operate on your machine's cache and other friendpm users on the 18 | local network, instead of the NPM central servers. 19 | 20 | ``` 21 | friendpm i, install [-S] [-D] 22 | 23 | Works like `npm install`. Accepts a package name to install from someone on 24 | the local network, or your own cache if none are found. 25 | 26 | friendpm publish 27 | 28 | Works like `npm publish`, except your package is only published to your 29 | local cache. It can be installed immediately after by you or others on the 30 | network (if you're running `friendpm share`). 31 | 32 | friendpm share 33 | 34 | Run a tiny npm registry that other `friendpm` users can discover and use 35 | over the local network. 36 | 37 | ``` 38 | 39 | ## Caveats 40 | 41 | At version 5 `npm` changed its internal caching mechanism, making `friendpm` no 42 | longer work with it. However, your left-over npm cache from when you ran 4 or 43 | earlier (if you did) still remains and can be shared! 44 | 45 | You can downgrade to `npm@4` or earlier, *or* consider donating a patch that 46 | adds npm5 caching support! 47 | 48 | ## License 49 | 50 | ISC 51 | 52 | -------------------------------------------------------------------------------- /bin/cli.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | var fs = require('fs') 4 | var path = require('path') 5 | var spawn = require('child_process').spawn 6 | var homedir = require('os').homedir 7 | var createServer = require('../server') 8 | var args = require('minimist')(process.argv) 9 | 10 | if (args._.length === 2) { 11 | printUsage() 12 | return 13 | } 14 | 15 | var port = args.p || args.port || 9001 16 | 17 | switch (args._[2]) { 18 | case 'install': 19 | case 'i': 20 | startServerIfNeeded(port, function (err, server) { 21 | if (err) throw err 22 | 23 | var npmArgs = ['--registry', 'http://localhost:' + port] 24 | npmArgs = npmArgs.concat(args._.slice(2)) 25 | var p = spawn('npm', npmArgs, {stdio:'inherit'}) 26 | 27 | p.on('close', function (code, signal) { 28 | process.exit(0) 29 | }) 30 | }) 31 | break 32 | case 'publish': 33 | if (!isNpmrcReady()) { 34 | console.log('init\'ing..') 35 | initNpmrc() 36 | } 37 | 38 | startServerIfNeeded(port, function (err, server) { 39 | if (err) throw err 40 | 41 | var npmArgs = [ 42 | '--registry', 'http://localhost:' + port, 43 | '--cache-min=Infinity' 44 | ].concat(args._.slice(2)) 45 | var p = spawn('npm', npmArgs, {stdio:'inherit'}) 46 | 47 | p.on('close', function (code, signal) { 48 | process.exit(0) 49 | }) 50 | }) 51 | break 52 | case 'share': 53 | var server = createServer({port:port}, function (err, server) { 54 | console.log('listening on http://0.0.0.0:' + port) 55 | }) 56 | process.on('SIGINT', function () { 57 | console.log('\nUnpublishing mDNS..') 58 | server.terminate(function () { 59 | console.log('Goodbye!') 60 | process.exit() 61 | }) 62 | }) 63 | break 64 | default: 65 | printUsage() 66 | break 67 | } 68 | 69 | // Start a new friendpm server, if one is not already running. 70 | // TODO: don't enable mdns if we're creating a new one; user may not want/expect that 71 | function startServerIfNeeded (port, done) { 72 | createServer({port:port, skipPublish: true}, function (err, server) { 73 | if (err && err.code === 'EADDRINUSE') { 74 | done() 75 | } else if (!err) { 76 | done(null, server) 77 | } else { 78 | done(err) 79 | } 80 | }) 81 | } 82 | 83 | function printUsage () { 84 | require('fs').createReadStream(__dirname + '/usage.txt').pipe(process.stdout) 85 | } 86 | 87 | function isNpmrcReady () { 88 | var npmrc = fs.readFileSync(path.join(homedir(), '.npmrc')) 89 | return npmrc.indexOf('//localhost:9001/:_authToken=baz') !== -1 90 | } 91 | 92 | function initNpmrc () { 93 | fs.appendFileSync(path.join(homedir(), '.npmrc'), '//localhost:9001/:_authToken=baz') 94 | } 95 | -------------------------------------------------------------------------------- /bin/usage.txt: -------------------------------------------------------------------------------- 1 | USAGE: 2 | 3 | friendpm i, install [-S] [-D] 4 | 5 | Works like `npm install`. Accepts a package name to install from someone on 6 | the local network, or your own cache if none are found. 7 | 8 | friendpm publish 9 | 10 | Works like `npm publish`, except your package is only published to your 11 | local cache. It can be installed immediately after by you or others on the 12 | network (if you're running `friendpm share`). 13 | 14 | friendpm share 15 | 16 | Run a tiny npm registry that other `friendpm` users can discover and use 17 | over the local network. 18 | 19 | -------------------------------------------------------------------------------- /local-cache.js: -------------------------------------------------------------------------------- 1 | var fs = require('fs') 2 | var path = require('path') 3 | var debug = require('debug')('friendpm') 4 | 5 | var CACHE_DIR = process.env.npm_config_cache || path.join(require('os').homedir(), '.npm') 6 | 7 | module.exports = function (opts) { 8 | var reg = {} 9 | opts = opts || {} 10 | opts.port = opts.port || 9001 11 | 12 | reg.fetchMetadata = function (pkg, done) { 13 | // npm cache escapes 14 | pkg = pkg.replace('@', '_40') 15 | pkg = pkg.replace('/', '_252f') 16 | 17 | var cacheMeta = path.join(CACHE_DIR, 'registry.npmjs.org', pkg, '.cache.json') 18 | debug('local cache :: path', cacheMeta) 19 | if (fs.existsSync(cacheMeta)) { 20 | var data = JSON.parse(fs.readFileSync(cacheMeta, 'utf8')) 21 | fixTarballUrl(data, 'localhost:' + opts.port) 22 | done(null, JSON.stringify(data)) 23 | } else { 24 | cacheMeta = path.join(CACHE_DIR, 'localhost_' + opts.port, pkg, '.cache.json') 25 | if (fs.existsSync(cacheMeta)) { 26 | var data = JSON.parse(fs.readFileSync(cacheMeta, 'utf8')) 27 | fixTarballUrl(data, 'localhost_' + opts.port) 28 | done(null, JSON.stringify(data)) 29 | } else { 30 | done({ notFound: true }) 31 | } 32 | } 33 | } 34 | 35 | reg.getTarballReadStream = function (tarball, done) { 36 | debug('local cache :: want tarball', tarball) 37 | var version = tarball.match(/.*-(\d\.\d\.\d).tgz/)[1] 38 | var pkg = tarball.match(/(.*)-\d\.\d\.\d.tgz/)[1] 39 | debug('local cache :: version', version) 40 | debug('local cache :: pkg', pkg) 41 | 42 | var cacheTarball = path.join(CACHE_DIR, pkg, version, 'package.tgz') 43 | debug('path', cacheTarball) 44 | if (fs.existsSync(cacheTarball)) { 45 | done(null, fs.createReadStream(cacheTarball)) 46 | } else { 47 | done({ notFound: true }) 48 | } 49 | } 50 | 51 | return reg 52 | } 53 | 54 | function fixTarballUrl (data, cacheDirName) { 55 | Object.keys(data.versions).forEach(function (version) { 56 | data.versions[version].dist.tarball = data.versions[version].dist.tarball 57 | .replace('registry.npmjs.org', cacheDirName) 58 | }) 59 | } 60 | -------------------------------------------------------------------------------- /mdns-swarm.js: -------------------------------------------------------------------------------- 1 | var fs = require('fs') 2 | var path = require('path') 3 | var http = require('http') 4 | var concat = require('concat-stream') 5 | var debug = require('debug')('friendpm') 6 | var Bonjour = require('bonjour') 7 | 8 | module.exports = function (opts) { 9 | var peers = [] 10 | 11 | var bonjour = Bonjour() 12 | var bonjourBrowser = null 13 | var bonjourService = null 14 | var bonjourName = 'friendpm' + (''+Math.random()).substring(2, 8) 15 | 16 | function mdnsInit () { 17 | if (!opts.skipPublish) mdnsBroadcast() 18 | 19 | bonjourBrowser = mdnsSearch() 20 | bonjourBrowser.on('up', function (service) { 21 | if (service.name === bonjourName) return 22 | debug('bonjour :: found a friendpm peer:', service) 23 | peers.push(service) 24 | }) 25 | bonjourBrowser.on('down', function (service) { 26 | if (service.name === bonjourName) return 27 | debug('bonjour :: said goodbye to a friendpm peer:', service) 28 | peers = peers.filter(function (peer) { return peer.name !== service.name }) 29 | }) 30 | } 31 | 32 | function mdnsBroadcast () { 33 | debug('bonjour :: publishing') 34 | bonjourService = bonjour.publish({ name: bonjourName, type: 'friendpm', port: opts.port }) 35 | } 36 | 37 | function mdnsSearch (foundCb) { 38 | debug('bonjour :: searching') 39 | return bonjour.find({ type: 'friendpm' }) 40 | } 41 | 42 | mdnsInit() 43 | 44 | // 3 seconds to look for peers; be quick! 45 | var readyTime = Date.now() + 1000 * 3 46 | 47 | var reg = {} 48 | 49 | reg.fetchMetadata = function (pkg, done) { 50 | var diff = readyTime - Date.now() 51 | if (diff > 0) { 52 | console.log('waiting', diff, 'ms') 53 | return setTimeout(function () { 54 | reg.fetchMetadata(pkg, done) 55 | }, diff) 56 | } 57 | 58 | var responses = [] 59 | var pending = peers.length 60 | 61 | if (!peers.length) { 62 | return done({notFound:true}) 63 | } 64 | 65 | // Make HTTP requests to all peers (GET /:pkg) 66 | peers.forEach(function (peerInfo) { 67 | http.get({ 68 | hostname: peerInfo.addresses[0], 69 | port: peerInfo.port, 70 | path: '/' + encodeURI(pkg).replace('/', '%2f') + '?ttl=0' 71 | }, function (res) { 72 | debug('GET', pkg, res.statusCode) 73 | if (res.statusCode === 200) responses.push(res) 74 | if (--pending === 0) processResponses() 75 | }) 76 | }) 77 | 78 | function processResponses () { 79 | if (responses.length === 0) done({notFound:true}) 80 | else { 81 | // TODO: handle error 82 | responses[0].pipe(concat(function (data) { 83 | done(null, data) 84 | })) 85 | } 86 | } 87 | } 88 | 89 | reg.getTarballReadStream = function (tarball, done) { 90 | var diff = readyTime - Date.now() 91 | if (diff > 0) { 92 | console.log('waiting', diff, 'ms') 93 | return setTimeout(function () { 94 | reg.fetchMetadata(pkg, done) 95 | }, diff) 96 | } 97 | 98 | console.log('want tarball', tarball) 99 | var version = tarball.match(/.*-(\d\.\d\.\d).tgz/)[1] 100 | var pkg = tarball.match(/(.*)-\d\.\d\.\d.tgz/)[1] 101 | console.log('version', version) 102 | console.log('pkg', pkg) 103 | 104 | var responses = [] 105 | var pending = peers.length 106 | 107 | // Make HTTP requests to all peers (GET /:pkg) 108 | peers.forEach(function (peerInfo) { 109 | http.get({ 110 | hostname: peerInfo.addresses[0], 111 | port: peerInfo.port, 112 | path: '/' + tarball + '?ttl=0' 113 | }, function (res) { 114 | console.log('GET', pkg, res.statusCode) 115 | if (res.statusCode === 200) responses.push(res) 116 | if (--pending === 0) processResponses() 117 | }) 118 | }) 119 | 120 | function processResponses () { 121 | if (responses.length === 0) done({notFound:true}) 122 | else done(null, responses[0]) 123 | } 124 | } 125 | 126 | reg.close = function (done) { 127 | bonjourService.stop(done) 128 | } 129 | 130 | return reg 131 | } 132 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "friendpm", 3 | "description": "share all of the node packages on your computer's cache with your friends via LAN and p2p networks", 4 | "author": "Stephen Whitmore ", 5 | "version": "1.0.0", 6 | "bin": { 7 | "friendpm": "bin/cli.js" 8 | }, 9 | "repository": { 10 | "url": "git://github.com/noffle/friendpm.git" 11 | }, 12 | "homepage": "https://github.com/noffle/friendpm", 13 | "bugs": "https://github.com/noffle/friendpm/issues", 14 | "main": "index.js", 15 | "scripts": { 16 | "lint": "standard", 17 | "start": "node ./bin/cli.js" 18 | }, 19 | "keywords": [], 20 | "dependencies": { 21 | "body": "^5.1.0", 22 | "bonjour": "^3.5.0", 23 | "concat-stream": "^1.6.0", 24 | "debug": "^2.6.8", 25 | "minimist": "^1.2.0", 26 | "mkdirp": "^0.5.1", 27 | "once": "^1.4.0", 28 | "routes": "^2.1.0" 29 | }, 30 | "devDependencies": { 31 | "standard": "~10.0.2" 32 | }, 33 | "license": "ISC" 34 | } 35 | -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | var path = require('path') 2 | var http = require('http') 3 | var fs = require('fs') 4 | var routes = require('routes') 5 | var url = require('url') 6 | var body = require('body') 7 | var mkdirp = require('mkdirp') 8 | var once = require('once') 9 | var debug = require('debug')('friendpm') 10 | var url = require('url') 11 | 12 | var CACHE_DIR = process.env.npm_config_cache 13 | 14 | module.exports = function (opts, done) { 15 | if (typeof opts === 'function' && !done) { 16 | done = opts 17 | opts = {} 18 | } 19 | opts = opts || {} 20 | opts.port = opts.port || 9001 21 | opts.skipPublish = opts.skipPublish || false 22 | done = once(done) 23 | 24 | var router = routes() 25 | router.addRoute('/:tarball\.tgz', onTarball) 26 | router.addRoute('/:pkg/-/:tarball\.tgz', onTarball) 27 | router.addRoute('/:pkg', onPackage) 28 | router.addRoute('/-/user/org.couchdb.user\::user', onAddUser) 29 | 30 | var cache = require('./local-cache')({port:opts.port}) 31 | var swarm = require('./mdns-swarm')(opts) 32 | 33 | var server = http.createServer(function (req, res) { 34 | debug(req.method.toUpperCase() + ' ' + req.url) 35 | 36 | var dir = url.parse(req.url).pathname 37 | var match = router.match(dir) 38 | if (match) { 39 | match.fn(req, res, match) 40 | } else { 41 | res.statusCode = 404 42 | res.end() 43 | } 44 | }) 45 | 46 | server.terminate = function (done) { 47 | this.close() 48 | swarm.close(done) 49 | } 50 | 51 | server.on('error', function (err) { 52 | done(err) 53 | }) 54 | server.listen(opts.port, function () { 55 | done(null, server) 56 | }) 57 | 58 | function onPackage (req, res, match) { 59 | if (req.method === 'GET') { 60 | var q = url.parse(req.url, true) 61 | var shouldUseSwarm = Number(url.parse(req.url, true).query.ttl) !== 0 62 | 63 | var pkg = decodeURI(match.params.pkg) 64 | cache.fetchMetadata(pkg, function (err, data) { 65 | // if (shouldUseSwarm) err = {notFound:true} // TEMP 66 | if (err && err.notFound) { 67 | if (shouldUseSwarm) { 68 | swarm.fetchMetadata(pkg, function (err, data) { 69 | if (err) { 70 | res.statusCode = 404 71 | res.end() 72 | } else { 73 | res.write(data) 74 | res.statusCode = 201 75 | res.end() 76 | } 77 | }) 78 | } else { 79 | res.statusCode = 404 80 | res.end() 81 | return 82 | } 83 | } else if (err) { 84 | res.statusCode = 404 85 | res.end() 86 | } else { 87 | res.write(data) 88 | res.statusCode = 201 89 | res.end() 90 | } 91 | }) 92 | } else if (req.method === 'PUT') { 93 | debug('wants to publish', match.params.pkg) 94 | body(req, { limit: 100000000 }, function (err, data) { 95 | if (err) { 96 | debug('err', err) 97 | res.statusCode = 500 98 | res.end() 99 | return 100 | } 101 | data = JSON.parse(data) 102 | publishPackage(data, function (err) { 103 | if (err) { 104 | res.statusCode = 404 105 | res.end(JSON.stringify({error: err.toString()})) 106 | } else { 107 | res.statusCode = 201 108 | } 109 | res.end() 110 | }) 111 | }) 112 | } else { 113 | res.statusCode = 404 114 | res.end() 115 | } 116 | } 117 | 118 | function publishPackage (data, done) { 119 | var attachments = data._attachments 120 | delete data._attachments 121 | 122 | var pkg = data.name 123 | var version = data['dist-tags'].latest 124 | var dir = path.join(CACHE_DIR, pkg, version) 125 | 126 | writeAttachments(pkg, attachments, dir, function (err) { 127 | if (err) return done(err) 128 | debug('wrote tarball') 129 | 130 | debug(data) 131 | var pkgJson = JSON.stringify(data.versions[data['dist-tags'].latest]) 132 | var cacheJson = JSON.stringify(data) 133 | 134 | mkdirp.sync(path.join(dir, 'package')) 135 | fs.writeFileSync(path.join(dir, 'package', 'package.json'), pkgJson, 'utf8') 136 | debug('wrote meta') 137 | 138 | // write cache entry 139 | setTimeout(function () { 140 | var cacheDir = path.join(CACHE_DIR, 'localhost_' + opts.port, pkg) 141 | mkdirp.sync(cacheDir) 142 | fs.writeFileSync(path.join(cacheDir, '.cache.json'), cacheJson, 'utf8') 143 | debug('wrote cache meta', cacheDir) 144 | }, 1000) 145 | 146 | done() 147 | }) 148 | } 149 | 150 | function writeAttachments (pkg, attachments, dir, done) { 151 | var pending = Object.keys(attachments).length 152 | var res = [] 153 | 154 | Object.keys(attachments).forEach(function (filename) { 155 | var data = new Buffer(attachments[filename].data, 'base64') 156 | mkdirp(dir, function (err) { 157 | debug('created', dir) 158 | // TODO: handle err 159 | debug('writing package.tgz') 160 | fs.writeFileSync(path.join(dir, 'package.tgz'), data, 'utf8') 161 | debug('end write to', path.join(dir, 'package.tgz')) 162 | if (--pending === 0) return done(null) 163 | }) 164 | }) 165 | 166 | } 167 | 168 | function onAddUser (req, res, match) { 169 | debug('wants to add user') 170 | body(req, function (err, data) { 171 | res.statusCode = 201 172 | res.end() 173 | }) 174 | } 175 | 176 | function onTarball (req, res, match) { 177 | var tarball = match.params.tarball + '.tgz' 178 | debug('getting tarball', tarball) 179 | var shouldUseSwarm = Number(url.parse(req.url, true).query.ttl) !== 0 180 | 181 | cache.getTarballReadStream(tarball, function (err, stream) { 182 | if (err && err.notFound) { 183 | if (shouldUseSwarm) { 184 | swarm.getTarballReadStream(tarball, function (err, stream) { 185 | if (err) { 186 | res.statusCode = 404 187 | res.end(err.toString() + '\n') 188 | } else stream.pipe(res) 189 | }) 190 | } else { 191 | res.statusCode = 404 192 | res.end() 193 | } 194 | } else if (err) { 195 | debug('unable to get tarball', tarball, err) 196 | res.statusCode = 404 197 | res.end(err.toString() + '\n') 198 | } else { 199 | stream.pipe(res) 200 | } 201 | }) 202 | } 203 | 204 | 205 | return server 206 | } 207 | 208 | function mapHashesToMetadata (pkg, hashes) { 209 | var meta = { 210 | _id: pkg, 211 | name: pkg, 212 | versions: {} 213 | } 214 | 215 | Object.keys(hashes).forEach(function (version) { 216 | meta.versions[version] = { 217 | name: pkg, 218 | version: version, 219 | dist: { 220 | shasum: hashes[version], 221 | tarball: 'http://localhost:' + opts.port + '/' + hashes[version] + '.tgz' 222 | }, 223 | } 224 | }) 225 | 226 | // TODO: figure out actual latest version 227 | meta['dist-tags'] = { 228 | latest: Object.keys(hashes)[Object.keys(hashes).length - 1] 229 | } 230 | 231 | return meta 232 | } 233 | --------------------------------------------------------------------------------