xp-forge / frontend

Web frontends
1 stars 1 forks source link

Asset fingerprinting #15

Closed thekid closed 3 years ago

thekid commented 3 years ago

Static resources such as JavaScript, CSS, images, etc.:

image

See https://webhint.io/docs/user-guide/hints/hint-http-cache/

thekid commented 3 years ago

How this might work:

The place where this hash value is stored could also contain other global values, e.g. a navigation lookup table

thekid commented 3 years ago

Most other solutions use a so-called asset manifest, see e.g. https://webpack.js.org/concepts/manifest/ and https://webpack.js.org/guides/caching/

{
  "main.css": "/static/main.225eedd.css",
  "main.js": "/static/main.5f31c89.js",
  "icons.svg": "/static/icons.3a909e9.svg",
  "brand-icons.svg": "/static/brand-icons.5f363d3.svg"
}

In EmberJS, this is called an "asset map", see See https://codeburst.io/ember-js-lazy-assets-fingerprinting-loading-static-dynamic-assets-on-demand-f09cd7568155

{
  "assets": {
    "assets/images/blue-theme-logo.png": "assets/images/blue-theme-logo-40cfba464935ff80190c3507e84d94b5.png",
    "assets/bootstrap.css": "assets/bootstrap-c87a4c2c79156714058688911e8c1493.css",
    "assets/bootstrap.js": "assets/bootstrap-a6614093a8f614bdac2df7cf89676516.js",
  }
}

Here's a version for ruby on rails: https://github.com/eliotsykes/asset_fingerprint. It actually stores the file as [name].css and only has the fingerprints on the "front" side, rewriting via

rewrite "^(.+)-fp-[0-9a-z]{32}(.*)" $1$2 last;

For newer versions, this is now in https://guides.rubyonrails.org/asset_pipeline.html


See also:

thekid commented 3 years ago

If we pass the fingerprint as context variable then it would be easily accessible from within the templates:

new Frontend(
  new HandlersIn('com.example.skills.web'),
  new Handlebars($this->environment->path('src/main/handlebars')),
  ['assets' => '/assets/'.$fingerprint]
);

The base parameter which is currently passed to the context as "base" would be remodeled to be a map holding a global context; each key/value pair being passed in the context. This is implementable in a BC-break free way.

thekid commented 3 years ago

The place where this hash value is stored could also contain other global values

We could generate a config file for this, e.g. frontend.ini, which would hold these values.

fingerprint=40cfba464935ff80190c3507e84d94b5

Web applications can access these via Environment::properties(), giving us:

new Frontend(
  new HandlersIn('com.example.skills.web'),
  new Handlebars($this->environment->path('src/main/handlebars')),
  $this->environment->properties('frontend')->readSection(null)
);

The bundler would then use -f src/main/etc/frontend.ini to find the location, and rewrite this file replacing the fingerprint key, the web application would be started with -c src/main/etc to point it to the same location.

thekid commented 3 years ago

The downside of inventing our own approach is that if we switch from using our own bundler to webpack, we would also need to switch the application's implementation. Here's an alternative idea:

new Frontend(
  new HandlersIn('com.example.skills.web'),
  new Handlebars($this->environment->path('src/main/handlebars')),
  ['manifest' => new AssetManifest($this->environment->path('manifest.json'))]
);

Our bundler would use -m manifest.json to generate this file, mapping asset names to files including the fingerprint:

{
  "vendor.js": "vendor.1234567890.js",
  "vendor.css": "vendor.0987654321.css"
}

Note: Webpack puts this file in the same directory as the bundled .css and .js files, and includes publicPath inside the values.


The AssetsManifest class is mostly straight-forward:

<?php namespace web\frontend;

use text\json\{Json, Input, FileInput};

class AssetsManifest {
  public $assets;

  /** @param text.json.Input|io.Path|io.File|string */
  public function __construct($arg) {
    $this->assets= Json::read($arg instanceof Input ? $arg : new FileInput($arg));
  }
}
thekid commented 3 years ago

Now caching can be done by something along the lines of:

$assets= new AssetsFrom($this->environment->path('src/main/webapp'))->with(function($file) {
  if (preg_match(AssetsManifest::WITH_FINGERPRINT, $file->filename)) {
    yield 'Cache-Control' => 'max-age=31536000, immutable';
  } else {
    yield 'Cache-Control' => 'max-age=2419200, must-revalidate';
  }
});

However, this is quite some boilerplate we might want to reduce by including it in a default implementation somewhere.

thekid commented 3 years ago

However, this is quite some boilerplate we might want to reduce

First iteration:

$manifest= new AssetsManifest($this->environment->path('src/main/webapp/static/manifest.json'));
$assets= new AssetsFrom($this->environment->path('src/main/webapp'))->with(fn($file) => [
  'Cache-Control' => $manifest->immutable($file) ?? 'max-age=2419200, must-revalidate'
]);

This could be incorporated into the cache control API and become something like:

$manifest= new AssetsManifest($this->environment->path('src/main/webapp/static/manifest.json'));
$assets= new AssetsFrom($this->environment->path('src/main/webapp'))->with(
  CacheControl::immutable($manifest)->default('max-age=2419200, must-revalidate')
);