Skip to content

Commit

Permalink
Merge pull request #25 from gilmoreorless/performance-fixes
Browse files Browse the repository at this point in the history
Performance fixes
  • Loading branch information
gilmoreorless authored Apr 11, 2020
2 parents d9362c6 + ed6d5fa commit d0d1054
Show file tree
Hide file tree
Showing 6 changed files with 368 additions and 102 deletions.
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@
"prebuild:example": "rm -rf example/dist/*",
"build:example": "cd example && for cfg in config/webpack.config.*.js; do webpack --config $cfg; done && gzip --keep --force dist/*.js",
"lint": "eslint example src test",
"test": "mocha --require intelli-espower-loader test/*.test.js"
"test": "mocha --require intelli-espower-loader test/*.test.js",
"test:performance": "node test/performance.js"
},
"dependencies": {
"find-cache-dir": "^3.0.0",
Expand Down
68 changes: 52 additions & 16 deletions src/helpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,7 @@ if (!RegExp.escape) {

/**
* A rough equivalent of Array.prototype.flatMap, which is Node >= 11 only.
* This isn't a spec-compliant polyfill, just a small helper for my
* specific use cases.
* This isn't a spec-compliant polyfill, just a small helper for my specific use cases.
*/
function flatMap(arr, mapper) {
if (typeof arr.flatMap === 'function') {
Expand All @@ -37,43 +36,78 @@ function flatMap(arr, mapper) {
return ret;
}

/**
* Get all unique values in an array or string.
* unique([1, 2, 3, 1, 5, 2, 4]) -> [1, 2, 3, 5, 4]
* unique('this is a string') -> ['t', 'h', 'i', 's', ' ', 'a', 'r', 'n', 'g']
*/
function unique(items) {
if (!Array.isArray(items) && typeof items !== 'string') {
return [];
}
return Array.from(new Set(items));
}

/**
* Create regexps for matching zone names.
* Returns an array of regexps matching the values of `matchZones`:
* - createZoneMatchers(string) => [RegExpToMatchString]
* - createZoneMatchers(RegExp) => [RegExp]
* - createZoneMatchers([RegExp, RegExp, ...]) => [RegExp, RegExp, ...]
* - createZoneMatchers([string, string, ...]) => [RegExpMatchingAllStrings]
* Returns an array of regexps matching the values of `matchZones` or `matchCountries`:
* - createMatchers(undefined) => [/.?/]
* - createMatchers(string) => [RegExpToMatchString]
* - createMatchers(RegExp) => [RegExp]
* - createMatchers([RegExp, RegExp, ...]) => [RegExp, RegExp, ...]
* - createMatchers([string, string, ...]) => [RegExpMatchingAllStrings]
*/
function createZoneMatchers(matchZones) {
function createMatchers(matchItems) {
if (!matchItems) {
// For invalid input, return a RegExp that matches anything
return [/.?/];
}
const exactRegExp = (pattern) => new RegExp('^(?:' + pattern + ')$');
const arrayRegExp = (arr) => exactRegExp(
arr.map(value =>
RegExp.escape(value.toString())
).join('|')
);

if (matchZones instanceof RegExp) {
return [matchZones];
if (matchItems instanceof RegExp) {
return [matchItems];
}
if (Array.isArray(matchZones)) {
const hasRegExp = matchZones.find(mz => mz instanceof RegExp);
if (Array.isArray(matchItems)) {
const hasRegExp = matchItems.some(mz => mz instanceof RegExp);
// Quick shortcut — combine array of strings into a single regexp
if (!hasRegExp) {
return [arrayRegExp(matchZones)];
return [arrayRegExp(matchItems)];
}
// Find all string values and combine them
let ret = [];
let strings = [];
matchZones.forEach(mz => {
matchItems.forEach(mz => {
(mz instanceof RegExp ? ret : strings).push(mz);
});
if (strings.length) {
ret.push(arrayRegExp(strings));
}
return ret;
}
return [exactRegExp(RegExp.escape(matchZones.toString()))];
return [exactRegExp(RegExp.escape(matchItems.toString()))];
}

/**
* Return `true` if `item` matches any of the RegExps in an array of matchers.
* If optional `extraMatchers` array is provided, `item` must match BOTH sets of matchers.
* If either array is empty, it's counted as matching everything.
*/
function anyMatch(item, regExpMatchers, extraMatchers) {
if (extraMatchers !== undefined) {
return (
anyMatch(item, regExpMatchers) &&
anyMatch(item, extraMatchers)
);
}
if (!regExpMatchers || !regExpMatchers.length) {
return true;
}
return regExpMatchers.some(matcher => matcher.test(item));
}

function cacheKey(tzdata, config) {
Expand Down Expand Up @@ -124,8 +158,10 @@ function cacheFile(tzdata, config, cacheDirPath) {

module.exports = {
pluginName,
createZoneMatchers,
flatMap,
unique,
createMatchers,
anyMatch,
cacheDir,
cacheFile,
};
161 changes: 85 additions & 76 deletions src/index.js
Original file line number Diff line number Diff line change
@@ -1,107 +1,113 @@
const fs = require('fs');
const path = require('path');
const webpack = require('webpack');
const { createZoneMatchers, cacheFile, flatMap } = require('./helpers');
const { unique, createMatchers, anyMatch, cacheFile, flatMap } = require('./helpers');

function filterData(tzdata, config, file) {
function filterData(tzdata, config) {
const moment = require('moment-timezone/moment-timezone-utils');
const momentHasCountries = Boolean(tzdata.countries); // moment-timezone >= 0.5.28
const { matchZones, matchCountries, startYear, endYear } = config;
const hasMatchCountries = matchCountries != null;
const hasMatchZones = matchZones != null;

let matchers = createZoneMatchers(matchZones);
let countryCodeMatchers = [/./];
let countryZoneMatchers = [/./];

if (matchCountries) {
// TODO: Rename createZoneMatchers as it's more generic than that
countryCodeMatchers = createZoneMatchers(matchCountries);
const countryCodes = tzdata.countries
.map(country => country.split('|'))
.filter(country =>
countryCodeMatchers.find(matcher => matcher.test(country[0]))
);
const countryZones = flatMap(countryCodes, country =>
country[1].split(' ').filter(zone =>
matchers.find(matcher => matcher.test(zone))
)
);
countryZoneMatchers = createZoneMatchers(countryZones);
let { version, zones } = tzdata;

// Unpack necessary data.
// "America/Anchorage|US/Alaska" -> ["America/Anchorage", "US/Alaska"]
let links = tzdata.links.map(link => link.split('|'));

// Map country data to the required format for filterLinkPack, as moment-timezone
// doesn't yet provide an unpack() equivalent for countries.
// "LI|Europe/Zurich Europe/Vaduz" -> { name: "LI", zones: ["Europe/Zurich", "Europe/Vaduz"] }
let countries = momentHasCountries
? tzdata.countries.map(country => {
const [name, zonesStr] = country.split('|');
const zones = zonesStr.split(' ');
return { name, zones };
})
: [];

let zoneMatchers = createMatchers(matchZones);
let countryCodeMatchers = null;
let countryZoneMatchers = null;

// Get zones associated with countries that meet `matchCountries` filter
if (hasMatchCountries) {
countryCodeMatchers = createMatchers(matchCountries);
const matchingCountries = countries.filter(country => anyMatch(country.name, countryCodeMatchers));
const countryZones = unique(flatMap(matchingCountries, country =>
hasMatchZones
? country.zones.filter(zone => anyMatch(zone, zoneMatchers))
: country.zones
));
countryZoneMatchers = createMatchers(countryZones);
}

// Find all links that match anything in the matcher list.
// TODO: Optimise - shortcut when initial matchZones/matchCountries args are empty
const newLinksData = tzdata.links
.map(link => link.split('|'))
.filter(link =>
matchers.find(matcher => matcher.test(link[1])) &&
countryZoneMatchers.find(matcher => matcher.test(link[1]))
);
if (hasMatchCountries || hasMatchZones) {
// Find all links that match anything in the matcher list.
links = links.filter(link => anyMatch(link[1], zoneMatchers, countryZoneMatchers));

// If links exist, add the links’ destination zones to the matcher list.
if (newLinksData.length) {
// TODO: De-duplicate the list of link sources before passing to createZoneMatchers
let linkMatchers = createZoneMatchers(
newLinksData.map(link => link[0])
);
matchers = matchers.concat(linkMatchers);
// If links exist, add the links’ destination zones to the matcher list.
if (links.length) {
// De-duplicate the link sources.
const linkMatchers = createMatchers(unique(links.map(link => link[0])));
zoneMatchers = zoneMatchers.concat(linkMatchers);
}

// Find all zones that match anything in the matcher list (including link destinations).
zones = zones.filter(zone => {
const [zoneName] = zone.split('|');
return anyMatch(zoneName, zoneMatchers, countryZoneMatchers);
});
}

// Find all zones that match anything in the matcher list (including link destinations).
const newZonesData = tzdata.zones
.filter(zone => {
const zoneName = zone.split('|')[0];
return (
// TODO: Clean up all these repetitive .find() matcher tests
matchers.find(matcher => matcher.test(zoneName)) &&
countryZoneMatchers.find(matcher => matcher.test(zoneName))
);
})
.map(moment.tz.unpack);
// Unpack all relevant zones and built a reference Map for link normalisation.
const zoneMap = new Map();
zones = zones.map(zone => {
const unpacked = moment.tz.unpack(zone);
zoneMap.set(unpacked.name, unpacked);
return unpacked;
});

// Normalise links to become full copies of their destination zones.
// This helps to avoid bugs when links end up pointing to other links, as detailed at
// https://github.com/gilmoreorless/moment-timezone-data-webpack-plugin/pull/6
newLinksData.forEach(link => {
const newEntry = { ...newZonesData.find(z => z.name === link[0]) };
newEntry.name = link[1];
newZonesData.push(newEntry);
links.forEach(link => {
const linkClone = {
...zoneMap.get(link[0]),
name: link[1],
};
zones.push(linkClone);
});

// Find all countries that contain the matching zones.
let newCountryData = [];
if (momentHasCountries) {
newCountryData = tzdata.countries
.map(country => {
const [name, zonesStr] = country.split('|');
const zones = zonesStr.split(' ');
const matchingZones = zones.filter(zone =>
matchers.find(matcher => matcher.test(zone))
);
// Manually map country data to the required format for filterLinkPack, as moment-timezone
// doesn't yet provide an unpack() equivalent for countries.
return {
name,
zones: matchingZones
};
})
.filter(country =>
country.zones.length > 0 &&
countryCodeMatchers.find(matcher => matcher.test(country.name))
);
if (hasMatchZones) {
countries.forEach(country => {
country.zones = country.zones.filter(zone => anyMatch(zone, zoneMatchers));
});
}
// Reduce the country data to only include countries that...
countries = countries.filter(country =>
// ...contain zones meeting `matchZones` filter and...
country.zones.length > 0 &&
// ...also meet `matchCountries` filter if provided.
anyMatch(country.name, countryCodeMatchers)
);
}

// Finally, run the whole lot through moment-timezone’s inbuilt packing method.
const filteredData = moment.tz.filterLinkPack(
{
version: tzdata.version,
zones: newZonesData,
version,
zones,
links: [], // Deliberately empty to ensure correct link data is generated from the zone data.
countries: newCountryData,
countries,
},
startYear,
endYear
);
fs.writeFileSync(file.path, JSON.stringify(filteredData, null, 2));
return filteredData;
}

function throwInvalid(message) {
Expand Down Expand Up @@ -167,8 +173,8 @@ function MomentTimezoneDataPlugin(options = {}) {

const startYear = options.startYear || -Infinity;
const endYear = options.endYear || Infinity;
const matchZones = options.matchZones || /./;
const matchCountries = options.matchCountries;
const matchZones = options.matchZones || null;
const matchCountries = options.matchCountries || null;
const cacheDir = options.cacheDir || null;

return new webpack.NormalModuleReplacementPlugin(
Expand All @@ -180,7 +186,8 @@ function MomentTimezoneDataPlugin(options = {}) {
const file = cacheFile(tzdata, config, cacheDir);
if (!file.exists) {
try {
filterData(tzdata, config, file);
const filteredData = filterData(tzdata, config, file);
fs.writeFileSync(file.path, JSON.stringify(filteredData, null, 2));
} catch (err) {
console.warn(err); // eslint-disable-line no-console
return; // Don't rewrite the request
Expand All @@ -193,3 +200,5 @@ function MomentTimezoneDataPlugin(options = {}) {
}

module.exports = MomentTimezoneDataPlugin;
// Exported for testing purposes only
module.exports.filterData = filterData;
Loading

0 comments on commit d0d1054

Please sign in to comment.