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

Multiple servers and CDN support #17

Open
wants to merge 19 commits into
base: master
Choose a base branch
from
Open
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
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
/.classpath
/.project
/tmp
/dist
50 changes: 50 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
Press plugin for playframework - Multiple servers and CDN support
=================================================================

This is a fork of the press plugin for the Play! framework, which can be found here -
https://github.com/dirkmc/press

This fork comes to address two issues - using press on a web farm with multiple
servers, and handling files that are cached on a CDN.

Motivation
----------

The original version of press relies on the local web server cache for returning the
compressed file. It generates a hash for the file on the HTML, and then looks for
that hash on the cache for resolving it to the compressed file.
This poses a problem when working in a multiple server environment - one server may
generate the HTML, but another one can get the request for the file and fail since
it doesn't have the hash in the local cache yet.

The second issue is routing the files through a CDN. We want the CDN to automatically
re-fetch updated files without the need to manually refresh them on the CDN.

Features
--------

### Multiple Servers
* Press supports a new operation mode - serverFarm. To enable it add in the conf file:
`press.serverFarm=true` .
When this mode is set, Press won't use a hash in the requests for the compressed files.
Instead it will generate a file name consisting of the combined files and their time.
Since this file name can be long, if it's too long it will break it to several files.
* The combined files should maintain a certain order, otherwise it can cause Javascript errors.
To do so, in serverFarm mode you have to specify the order of the combined files. Do it
by using the new "pos" parameter on the press.script tag:
`#{press.script '/my.js', pos:1 /}`

### CDN Support
* To route all press file requests through a CDN add to the conf file:
`press.contentHostingDomain=http://my.cdn.host` .
* Press supports a new "cache buster" mode that will add the timestamp of the file as a request parameter. To enable it
add in the conf file: `press.cacheBuster=true`
* Cache buster has two modes. In the default mode, press will add the timestamp of the file to all requests
generated with the single-script tag (The combined scripts already include the file time).
That will make the CDN re-fetch the file if the file time is different.
* Some CDN providers (like Amazon CloudFront) doesn't support query strings. To handle this there is a seconds cache buster mode that doesn't use query string. Insead it will append a press suffix to the filename, and press will capture these kind of requests and will return the original file. To enable this mode add in the conf file: `press.cacheBusterInQueryString=false`
* If you need to route other non-js or css files (like images) through the CDN, you can use the new "versioned-file" tag. This will render a CDN versioned file name instead of the original one. Example:
`<link rel="stylesheet" href="#{press.versioned-file '/public/mystyle.css'/}" type="text/css" media="screen" />`
will output:
`<link rel="stylesheet" href="/public/mystyle.css.15432453.press" type="text/css" media="screen" />`

141 changes: 135 additions & 6 deletions app/controllers/press/Press.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,46 @@
import java.io.IOException;
import java.io.InputStream;
import java.io.UnsupportedEncodingException;
import java.util.Date;
import java.util.Locale;

import org.joda.time.DateTime;
import org.joda.time.format.DateTimeFormat;
import org.joda.time.format.DateTimeFormatter;

import play.Play;
import play.exceptions.UnexpectedException;
import play.libs.MimeTypes;
import play.mvc.Controller;
import play.mvc.Http;
import play.utils.Utils;
import play.vfs.VirtualFile;
import press.CSSCompressor;
import press.CachingStrategy;
import press.JSCompressor;
import press.Plugin;
import press.PluginConfig;
import press.io.CompressedFile;
import press.io.FileIO;

public class Press extends Controller {
public static final DateTimeFormatter httpDateTimeFormatter = DateTimeFormat.forPattern("EEE, dd MMM yyyy HH:mm:ss 'GMT'");
public static final DateTimeFormatter httpDateTimeFormatter = DateTimeFormat.forPattern("EEE, dd MMM yyyy HH:mm:ss 'GMT'").withLocale(Locale.US);

static boolean eTag_ = Play.configuration.getProperty("http.useETag", "true").equalsIgnoreCase("true");

public static void handleVersionedFile(String path) {
if (null == path) {
notFound();
return;
}
// we need to remove the filetime suffix
int pos = path.lastIndexOf('.');
if (pos > -1){
path = path.substring(0, pos);
}
VirtualFile file = FileIO.getVirtualFile(path);
renderFile(file, path);
}

public static void getCompressedJS(String key) {
key = FileIO.unescape(key);
CompressedFile compressedFile = JSCompressor.getCompressedFile(key);
Expand All @@ -44,11 +68,113 @@ public static void getSingleCompressedCSS(String key) {
renderCompressedFile(compressedFile, "CSS");
}

private static void renderFile(VirtualFile file, String fileName) {
if (file == null || !file.exists()) {
//renderBadResponse(fileName);
notFound();
return;
}

String mimeType = MimeTypes.getContentType(fileName);
// check for last modified
long l = file.lastModified();
final String etag = "\"" + l + "-" + file.hashCode() + "\"";

if (l > 0){
// if the file is not modified, return 304
if (!request.isModified(etag, l)){
if (request.method.equalsIgnoreCase("GET")) {
response.status = Http.StatusCode.NOT_MODIFIED;
if (eTag_) {
response.setHeader("Etag", etag);
//response.setHeader("Content-Type", mimeType);
}
}
return;
}
}

InputStream inputStream = file.inputstream();

// This seems to be buggy, so instead of passing the file length we
// reset the input stream and allow play to manually copy the bytes from
// the input stream to the response
// renderBinary(inputStream, compressedFile.name(),
// compressedFile.length());

try {
if(inputStream.markSupported()) {
inputStream.reset();
}
} catch (IOException e) {
throw new UnexpectedException(e);
}

// special handling for sass - let the plugin a change to handle this
boolean raw = Play.pluginCollection.serveStatic(file, request, response);

// If the caching strategy is always, the timestamp is not part of the key. If
// we let the browser cache, then the browser will keep holding old copies, even after
// changing the files at the server and restarting the server, since the key will
// stay the same.
// If the caching strategy is never, we also don't want to cache at the browser, for
// obvious reasons.
// If the caching strategy is Change, then the modified timestamp is a part of the key,
// so if the file changes, the key in the html file will be modified, and the browser will
// request a new version. Each version can therefore be cached indefinitely.
if(PluginConfig.cache.equals(CachingStrategy.Change)) {
response.setHeader("Cache-Control", "max-age=" + 31536000); // A year
response.setHeader("Expires", httpDateTimeFormatter.print(new DateTime().plusYears(1)));
response.setHeader("Last-Modified", Utils.getHttpDateFormatter().format(new Date(l + 1000)));
if (!raw)
response.setHeader("Content-Type", mimeType);
if (eTag_) {
response.setHeader("Etag", etag);
}
}
if (raw) {
try {
inputStream.close();
} catch (IOException e) {
throw new UnexpectedException(e);
}
}
else{
renderBinary(inputStream, file.getName(), true);
}
}

private static void renderCompressedFile(CompressedFile compressedFile, String type) {
if (compressedFile == null) {
renderBadResponse(type);
//renderBadResponse(type);
notFound();
return;
}

String contentType = "application/javascript; charset=utf-8";
if(type.equals("CSS")) {
contentType = "text/css; charset=utf-8";
}

// check for last modified
long l = compressedFile.lastModified();
final String etag = "\"" + l + "-" + compressedFile.originalHashCode() + "\"";

if (l > 0){
// if the file is not modified, return 304
if (!request.isModified(etag, l)){
if (request.method.equalsIgnoreCase("GET")) {
response.status = Http.StatusCode.NOT_MODIFIED;
if (eTag_) {
response.setHeader("Etag", etag);
response.setHeader("Content-Type", contentType);

}
}
return;
}
}

InputStream inputStream = compressedFile.inputStream();

// This seems to be buggy, so instead of passing the file length we
Expand Down Expand Up @@ -77,10 +203,13 @@ private static void renderCompressedFile(CompressedFile compressedFile, String t
if(PluginConfig.cache.equals(CachingStrategy.Change)) {
response.setHeader("Cache-Control", "max-age=" + 31536000); // A year
response.setHeader("Expires", httpDateTimeFormatter.print(new DateTime().plusYears(1)));
response.setHeader("Last-Modified", Utils.getHttpDateFormatter().format(new Date(l + 1000)));
response.setHeader("Content-Type", contentType);
if (eTag_) {
response.setHeader("Etag", etag);
}
}

renderBinary(inputStream, compressedFile.name());

renderBinary(inputStream, compressedFile.name(), true);
}

public static void clearJSCache() {
Expand Down
2 changes: 1 addition & 1 deletion app/press/CSSCompressor.java
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ public CSSCompressor() {
}

public String compressedSingleFileUrl(String fileName) {
return compressedSingleFileUrl(cssFileCompressor, fileName);
return compressedSingleFileUrl(cssFileCompressor, fileName, null);
}

public static CompressedFile getCompressedFile(String key) {
Expand Down
Loading