Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve resource caching #973

Merged
merged 21 commits into from
Oct 28, 2019
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
2 changes: 1 addition & 1 deletion @plotly/webpack-dash-dynamic-import/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@plotly/webpack-dash-dynamic-import",
"version": "1.0.0",
"version": "1.1.0",
"description": "Webpack Plugin for Dynamic Import in Dash",
"repository": {
"type": "git",
Expand Down
63 changes: 53 additions & 10 deletions @plotly/webpack-dash-dynamic-import/src/index.js
Original file line number Diff line number Diff line change
@@ -1,29 +1,72 @@
const resolveImportSource = `\
const fs = require('fs');

function getFingerprint() {
const package = fs.readFileSync('./package.json');
const packageJson = JSON.parse(package);

const timestamp = Math.round(Date.now() / 1000);
const version = packageJson.version.replace(/[.]/g, '_');

return `"v${version}m${timestamp}"`;
}

const resolveImportSource = () => `\
const getCurrentScript = function() {
let script = document.currentScript;
if (!script) {
/* Shim for IE11 and below */
/* Do not take into account async scripts and inline scripts */
const scripts = Array.from(document.getElementsByTagName('script')).filter(function(s) { return !s.async && !s.text && !s.textContent; });
script = scripts.slice(-1)[0];
}

return script;
};

const isLocalScript = function(script) {
return /\/_dash-components-suite\//.test(script.src);
};

Object.defineProperty(__webpack_require__, 'p', {
get: (function () {
let script = document.currentScript;
if (!script) {
/* Shim for IE11 and below */
/* Do not take into account async scripts and inline scripts */
const scripts = Array.from(document.getElementsByTagName('script')).filter(function(s) { return !s.async && !s.text && !s.textContent; });
script = scripts.slice(-1)[0];
}
let script = getCurrentScript();

var url = script.src.split('/').slice(0, -1).join('/') + '/';

return function() {
return url;
};
})()
});`
});

const __jsonpScriptSrc__ = jsonpScriptSrc;
jsonpScriptSrc = function(chunkId) {
let script = getCurrentScript();
let isLocal = isLocalScript(script);

let src = __jsonpScriptSrc__(chunkId);

if(!isLocal) {
return src;
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't fingerprint if using an external resource!

}

const srcFragments = src.split('/');
const fileFragments = srcFragments.slice(-1)[0].split('.');

fileFragments.splice(1, 0, ${getFingerprint()});
srcFragments.splice(-1, 1, fileFragments.join('.'))

return srcFragments.join('/');
};
`

class WebpackDashDynamicImport {
apply(compiler) {
compiler.hooks.compilation.tap('WebpackDashDynamicImport', compilation => {
compilation.mainTemplate.hooks.requireExtensions.tap('WebpackDashDynamicImport > RequireExtensions', (source, chunk, hash) => {
return [
source,
resolveImportSource
resolveImportSource()
]
});
});
Expand Down
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,11 @@ This project adheres to [Semantic Versioning](http://semver.org/).
## Unreleased
### Added
- [#964](https://github.com/plotly/dash/pull/964) Adds support for preventing
updates in clientside functions.
updates in clientside functions.
- Reject all updates with `throw window.dash_clientside.PreventUpdate;`
- Reject a single output by returning `window.dash_clientside.no_update`
- [#899](https://github.com/plotly/dash/pull/899) Add support for async dependencies and components
- [#973](https://github.com/plotly/dash/pull/973) Adds support for resource caching and adds a fallback caching mechanism through etag

## [1.4.1] - 2019-10-17
### Fixed
Expand Down
34 changes: 29 additions & 5 deletions dash/dash.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
import dash_renderer

from .dependencies import Input, Output, State
from .fingerprint import build_fingerprint, check_fingerprint
from .resources import Scripts, Css
from .development.base_component import Component, ComponentRegistry
from . import exceptions
Expand Down Expand Up @@ -541,12 +542,14 @@ def _relative_url_path(relative_package_path="", namespace=""):

modified = int(os.stat(module_path).st_mtime)

return "{}_dash-component-suites/{}/{}?v={}&m={}".format(
return "{}_dash-component-suites/{}/{}".format(
self.config.requests_pathname_prefix,
namespace,
relative_package_path,
importlib.import_module(namespace).__version__,
modified,
build_fingerprint(
relative_package_path,
importlib.import_module(namespace).__version__,
modified,
),
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isolated the fingerprinting logic with its inverse in a easily testable file / function

)

srcs = []
Expand Down Expand Up @@ -676,6 +679,10 @@ def _generate_meta_html(self):

# Serve the JS bundles for each package
def serve_component_suites(self, package_name, path_in_package_dist):
path_in_package_dist, has_fingerprint = check_fingerprint(
path_in_package_dist
)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the resource was requested with the correct pattern (from dash filling the html page or from async resources override) - use cache-control, otherwise use Etag


if package_name not in self.registered_paths:
raise exceptions.DependencyException(
"Error loading dependency.\n"
Expand Down Expand Up @@ -711,11 +718,28 @@ def serve_component_suites(self, package_name, path_in_package_dist):
package.__path__,
)

return flask.Response(
response = flask.Response(
pkgutil.get_data(package_name, path_in_package_dist),
mimetype=mimetype,
)

if has_fingerprint:
# Fingerprinted resources are good forever (1 year)
# No need for ETag as the fingerprint changes with each build
response.cache_control.max_age = 31536000 # 1 year
else:
# Non-fingerprinted resources are given an ETag that
# will be used / check on future requests
response.add_etag()
tag = response.get_etag()[0]

request_etag = flask.request.headers.get('If-None-Match')

if '"{}"'.format(tag) == request_etag:
response = flask.Response(None, status=304)

return response

def index(self, *args, **kwargs): # pylint: disable=unused-argument
scripts = self._generate_scripts_html()
css = self._generate_css_dist_html()
Expand Down
31 changes: 31 additions & 0 deletions dash/fingerprint.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import re

build_regex = re.compile(r'^(?P<filename>[\w@-]+)(?P<extension>.*)$')

check_regex = re.compile(
r'^(?P<filename>.*)[.]v[\w-]+m[0-9a-fA-F]+(?P<extension>(?:(?:(?<![.])[.])?[\w])+)$'
)


def build_fingerprint(path, version, hash_value):
res = build_regex.match(path)

return '{}.v{}m{}{}'.format(
res.group('filename'),
str(version).replace('.', '_'),
hash_value,
res.group('extension'),
)


def check_fingerprint(path):
# Check if the resource has a fingerprint
res = check_regex.match(path)

# Resolve real resource name from fingerprinted resource path
return (
res.group('filename') + res.group('extension')
if res is not None
else path,
res is not None,
)
54 changes: 54 additions & 0 deletions tests/unit/test_fingerprint.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@

from dash.fingerprint import build_fingerprint, check_fingerprint

version = 1
hash_value = 1

valid_resources = [
{'path': 'react@16.8.6.min.js', 'fingerprint': 'react@16.v1m1.8.6.min.js'},
{'path': 'react@16.8.6.min.js', 'fingerprint': 'react@16.v1_1_1m1234567890abcdef.8.6.min.js', 'version': '1.1.1', 'hash': '1234567890abcdef' },
{'path': 'react@16.8.6.min.js', 'fingerprint': 'react@16.v1_1_1-alpha_1m1234567890abcdef.8.6.min.js', 'version': '1.1.1-alpha.1', 'hash': '1234567890abcdef' },
{'path': 'dash.plotly.js', 'fingerprint': 'dash.v1m1.plotly.js'},
{'path': 'dash.plotly.j_s', 'fingerprint': 'dash.v1m1.plotly.j_s'},
{'path': 'dash.plotly.css', 'fingerprint': 'dash.v1m1.plotly.css'},
{'path': 'dash.plotly.xxx.yyy.zzz', 'fingerprint': 'dash.v1m1.plotly.xxx.yyy.zzz'}
]

valid_fingerprints = [
'react@16.v1_1_2m1571771240.8.6.min.js',
'dash.plotly.v1_1_1m1234567890.js',
'dash.plotly.v1_1_1m1234567890.j_s',
'dash.plotly.v1_1_1m1234567890.css',
'dash.plotly.v1_1_1m1234567890.xxx.yyy.zzz',
'dash.plotly.v1_1_1-alpha1m1234567890.js',
'dash.plotly.v1_1_1-alpha_3m1234567890.js',
'dash.plotly.v1_1_1m1234567890123.js',
'dash.plotly.v1_1_1m4bc3.js'
]

invalid_fingerprints = [
'dash.plotly.v1_1_1m1234567890..js',
'dash.plotly.v1_1_1m1234567890.',
'dash.plotly.v1_1_1m1234567890..',
'dash.plotly.v1_1_1m1234567890.js.',
'dash.plotly.v1_1_1m1234567890.j-s'
]

def test_fingerprint():
for resource in valid_resources:
# The fingerprint matches expectations
fingerprint = build_fingerprint(resource.get('path'), resource.get('version', version), resource.get('hash', hash_value))
assert fingerprint == resource.get('fingerprint')

(original_path, has_fingerprint) = check_fingerprint(fingerprint)
# The inverse operation returns that the fingerprint was valid and the original path
assert has_fingerprint
assert original_path == resource.get('path')

for resource in valid_fingerprints:
(_, has_fingerprint) = check_fingerprint(resource)
assert has_fingerprint

for resource in invalid_fingerprints:
(_, has_fingerprint) = check_fingerprint(resource)
assert not has_fingerprint
10 changes: 5 additions & 5 deletions tests/unit/test_resources.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ def test_external(mocker):
mocker.patch("dash_core_components._js_dist")
mocker.patch("dash_html_components._js_dist")
dcc._js_dist = _monkey_patched_js_dist # noqa: W0212
dcc.__version__ = 1
dcc.__version__ = "1.0.0"

app = dash.Dash(
__name__, assets_folder="tests/assets", assets_ignore="load_after.+.js"
Expand Down Expand Up @@ -66,7 +66,7 @@ def test_internal(mocker):
mocker.patch("dash_core_components._js_dist")
mocker.patch("dash_html_components._js_dist")
dcc._js_dist = _monkey_patched_js_dist # noqa: W0212,
dcc.__version__ = 1
dcc.__version__ = "1.0.0"

app = dash.Dash(
__name__, assets_folder="tests/assets", assets_ignore="load_after.+.js"
Expand All @@ -83,10 +83,10 @@ def test_internal(mocker):

assert resource == [
"/_dash-component-suites/"
"dash_core_components/external_javascript.js?v=1&m=1",
"dash_core_components/external_javascript.v1_0_0m1.js",
"/_dash-component-suites/"
"dash_core_components/external_css.css?v=1&m=1",
"/_dash-component-suites/" "dash_core_components/fake_dcc.js?v=1&m=1",
"dash_core_components/external_css.v1_0_0m1.css",
"/_dash-component-suites/" "dash_core_components/fake_dcc.v1_0_0m1.js",
]

assert (
Expand Down