Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 0 additions & 91 deletions .eslintrc

This file was deleted.

3 changes: 2 additions & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ jobs:

strategy:
matrix:
node-version: [18.x, 20.x, 22.x, 24.x]
node-version: [22.13.x, 24.x]

steps:
- uses: actions/checkout@v6
Expand All @@ -23,4 +23,5 @@ jobs:
with:
node-version: ${{ matrix.node-version }}
- run: npm i
- run: npm run lint
- run: npm test
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ jobs:
with:
node-version: 24
- name: Install dependencies
run: npm i
run: npm install
- name: Release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
Expand Down
8 changes: 0 additions & 8 deletions .travis.yml

This file was deleted.

6 changes: 3 additions & 3 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
FROM mhart/alpine-node:8
FROM node:24-alpine

MAINTAINER Dmitry Shirokov <deadrunk@gmail.com>
LABEL maintainer="Dmitry Shirokov <deadrunk@gmail.com>"

ADD package.json /tmp/package.json

RUN cd /tmp && \
npm install --production && \
npm install --omit=dev && \
mkdir -p /opt/npm-proxy-cache && \
cp -a /tmp/node_modules /opt/npm-proxy-cache && \
mkdir -p /opt/npm-proxy-cache/cache
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ npm --proxy http://npm-proxy-cache:8080 --https-proxy http://npm-proxy-cache:808

## Limitations

- Works only with node `6` and above.
- Requires Node.js `22.13` or newer.


----
Expand Down
104 changes: 82 additions & 22 deletions bin/npm-proxy-cache
Original file line number Diff line number Diff line change
@@ -1,27 +1,87 @@
#!/usr/bin/env node

var fs = require('fs'),
program = require('commander');
const fs = require('fs');
const { parseArgs } = require('node:util');

var numeric = function (v) {
return parseInt(v, 10);
const packageInfo = JSON.parse(fs.readFileSync(__dirname + '/../package.json', 'utf8'));
const help = `Usage: npm-proxy-cache [options]

Options:
-V, --version Output the version number
-h, --host <name> Hostname (default: "localhost")
-p, --port <number> Port (default: 8080)
-t, --ttl <seconds> Cache lifetime in seconds (default: 1800)
-s, --storage <path> Storage path
-x, --proxy <address> HTTP proxy to use
-e, --expired Use expired cache when the registry is unavailable
-f, --friendly-names Use actual file names instead of cache hashes
-v, --verbose Enable verbose logging
-n, --metadata-excluded Exclude metadata requests from caching
-l, --log-path <path> Log path
-m, --internal-port <port> Port for the internal HTTPS MITM server
--help Display help
`;

let values;
try {
({ values } = parseArgs({
options: {
version: { type: 'boolean', short: 'V' },
host: { type: 'string', short: 'h', default: 'localhost' },
port: { type: 'string', short: 'p', default: '8080' },
ttl: { type: 'string', short: 't', default: '1800' },
storage: { type: 'string', short: 's', default: __dirname + '/../cache' },
proxy: { type: 'string', short: 'x' },
expired: { type: 'boolean', short: 'e' },
'friendly-names': { type: 'boolean', short: 'f' },
verbose: { type: 'boolean', short: 'v' },
'metadata-excluded': { type: 'boolean', short: 'n' },
'log-path': { type: 'string', short: 'l' },
'internal-port': { type: 'string', short: 'm' },
help: { type: 'boolean' }
},
strict: true,
allowPositionals: false
}));
} catch (err) {
console.error(err.message);
console.error('Run npm-proxy-cache --help for usage.');
process.exit(1);
}

if (values.help) {
process.stdout.write(help);
process.exit(0);
}

if (values.version) {
console.log(packageInfo.version);
process.exit(0);
}

const opts = {
host: values.host,
port: integer(values.port, 'port'),
ttl: integer(values.ttl, 'ttl'),
storage: values.storage,
proxy: values.proxy,
expired: values.expired,
friendlyNames: values['friendly-names'],
verbose: values.verbose,
metadataExcluded: values['metadata-excluded'],
logPath: values['log-path'],
internalPort: values['internal-port'] === undefined
? undefined
: integer(values['internal-port'], 'internal-port')
};

program
.version(JSON.parse(fs.readFileSync(__dirname + '/../package.json', 'utf8')).version)

.option('-h, --host [name]', 'Hostname [localhost]', 'localhost')
.option('-p, --port [number]', 'An integer argument [8080]', numeric, 8080)
.option('-t, --ttl [seconds]', 'Cache lifetime in seconds [1800]', numeric, 1800)
.option('-s, --storage [path]', 'Storage path', __dirname + '/../cache')
.option('-x, --proxy [address]', 'HTTP proxy to be used, e.g. http://user:pass@example.com:8888/')
.option('-e, --expired', 'Use expired cache when npm registry unavailable')
.option('-f, --friendly-names', 'Use actual file names instead of hashes in the cache')
.option('-v, --verbose', 'Verbose mode')
.option('-n, --metadata-excluded', 'Exclude metadata requests from caching')
.option('-l, --log-path [path]', 'Log path')
.option('-m, --internal-port [port]',
'HTTPs port to use for internal proxying "MITM" server (mandatory on Windows systems)')
.parse(process.argv);

require('../lib/proxy').powerup(program);
require('../lib/proxy').powerup(opts);

function integer(value, name) {
const number = Number(value);
if (!/^\d+$/.test(value) || !Number.isSafeInteger(number)) {
console.error('Option --' + name + ' must be a non-negative integer.');
process.exit(1);
}
return number;
}
24 changes: 24 additions & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
'use strict';

const js = require('@eslint/js');
const globals = require('globals');

module.exports = [
{
ignores: ['cache/**', 'node_modules/**']
},
js.configs.recommended,
{
files: ['**/*.js', 'bin/npm-proxy-cache'],
languageOptions: {
ecmaVersion: 'latest',
sourceType: 'commonjs',
globals: Object.assign({}, globals.node, globals.mocha)
},
rules: {
'no-unused-vars': ['error', { args: 'none' }],
'quotes': ['error', 'single', { avoidEscape: true }],
'semi': ['error', 'always']
}
}
];
61 changes: 33 additions & 28 deletions lib/cache.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,12 @@ const path = require('path');
const fs = require('fs');
const os = require('os');
const crypto = require('crypto');
const mkdirp = require('mkdirp');
const mv = require('mv');
const { pipeline } = require('stream');

function Cache(opts) {
this.opts = opts || {};
this.opts.ttl = (opts.ttl || 1800) * 1000;
this.opts.friendlyNames = opts.friendlyNames;
this.opts.path = opts.path || path.join(__dirname, '/../cache');
this.opts.ttl = (this.opts.ttl || 1800) * 1000;
this.opts.path = this.opts.path || path.join(__dirname, '/../cache');

this.locks = {};
const nop = function() {};
Expand All @@ -32,13 +30,9 @@ function Cache(opts) {
this.meta = function(key, cb) {
const self = this;
const fullpath = this.getPath(key).full;
const stat = this.stat(fullpath);

if (stat.status === Cache.NOT_FOUND || stat.status === Cache.EXPIRED)
return cb(null, stat);

if (!this.locks[key])
return cb(null, stat);
return cb(null, this.stat(fullpath));

// wait until lock releases
// generally when file is locked means that process is writing to file right now
Expand All @@ -54,13 +48,7 @@ function Cache(opts) {

this.read = function(key) {
const pathInfo = this.getPath(key);
const file = fs.createReadStream(pathInfo.full);

file.on('finish', function() {
file.close(nop);
});

return file;
return fs.createReadStream(pathInfo.full);
};


Expand All @@ -72,22 +60,24 @@ function Cache(opts) {
// Create a lock
locks[key] = true;

mkdirp.sync(pathInfo.dir, 511); // 511 is decimal equvivalent of 0777
fs.mkdirSync(pathInfo.dir, { recursive: true, mode: 0o777 });

// On top of locking mechanism, doing write to a temp location, and
// when it's finish moving the data file to its final destination.
const tmpPath = path.join(os.tmpdir(), pathInfo.file + '-' + Math.round(Math.random() * 1e9).toString(36));
const tmpPath = path.join(os.tmpdir(), pathInfo.file + '-' + crypto.randomUUID());

const writeStream = fs.createWriteStream(tmpPath);
readStream.pipe(writeStream);

writeStream.on('finish', function() {
writeStream.close(nop);

// Release the lock, move data file to final destination.
delete (locks[key]);
mv(tmpPath, pathInfo.full, function(err) {
if (err) { return cb(err); }
pipeline(readStream, writeStream, function(err) {
if (err) {
delete (locks[key]);
fs.rm(tmpPath, { force: true }, nop);
return cb(err);
}

move(tmpPath, pathInfo.full, function(err) {
delete (locks[key]);
if (err)
return cb(err);
self.meta(key, cb);
});
});
Expand Down Expand Up @@ -135,6 +125,21 @@ function Cache(opts) {

}

function move(source, destination, cb) {
fs.rename(source, destination, function(err) {
if (!err)
return cb();
if (err.code !== 'EXDEV')
return cb(err);

fs.copyFile(source, destination, function(err) {
if (err)
return cb(err);
fs.unlink(source, cb);
});
});
}

Cache.NOT_FOUND = 0;
Cache.EXPIRED = 2;
Cache.FRESH = 4;
Expand Down
Loading