Implement server-side GWT permutation selection

Rather than downloading and evaluating a large $module.nocache.js
page into the browser, perform the module evaulation on the server
side using the HttpServletRequest's headers.

In the case of Gerrit Code Review, which only has the 6 standard
browser permutations, the $module.nocache.js file is 5179 bytes.

Performing server side selection and inlining the result into the
host page completely removes this HTTP request, its local disk
cache space, and the network transfer costs.

The server side selected host page, including a single theme
stylesheet reference, is about 519 bytes larger than the same
page using a <script src="$module.nocache.js"> reference.

Change-Id: I9ae530fe7a3b32f14f29440736644e9e32d28f0c
Signed-off-by: Shawn O. Pearce <sop@google.com>
diff --git a/src/main/java/com/google/gwtexpui/linker/ServerPlannedIFrameLinker.gwt.xml b/src/main/java/com/google/gwtexpui/linker/ServerPlannedIFrameLinker.gwt.xml
new file mode 100644
index 0000000..a6978ab
--- /dev/null
+++ b/src/main/java/com/google/gwtexpui/linker/ServerPlannedIFrameLinker.gwt.xml
@@ -0,0 +1,19 @@
+<!--
+ Copyright (C) 2009 The Android Open Source Project
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http:..www.apache.org.licenses.LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+-->
+<module>
+  <define-linker name='serverplanned' class='com.google.gwtexpui.linker.rebind.ServerPlannedIFrameLinker'/>
+  <add-linker name='serverplanned'/>
+</module>
diff --git a/src/main/java/com/google/gwtexpui/linker/rebind/ServerPlannedIFrameLinker.java b/src/main/java/com/google/gwtexpui/linker/rebind/ServerPlannedIFrameLinker.java
new file mode 100644
index 0000000..6fd8f21
--- /dev/null
+++ b/src/main/java/com/google/gwtexpui/linker/rebind/ServerPlannedIFrameLinker.java
@@ -0,0 +1,68 @@
+// Copyright (C) 2009 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gwtexpui.linker.rebind;
+
+import com.google.gwt.core.ext.LinkerContext;
+import com.google.gwt.core.ext.TreeLogger;
+import com.google.gwt.core.ext.UnableToCompleteException;
+import com.google.gwt.core.ext.linker.AbstractLinker;
+import com.google.gwt.core.ext.linker.ArtifactSet;
+import com.google.gwt.core.ext.linker.CompilationResult;
+import com.google.gwt.core.ext.linker.LinkerOrder;
+import com.google.gwt.core.ext.linker.SelectionProperty;
+import com.google.gwt.core.ext.linker.StylesheetReference;
+
+import java.util.Map;
+import java.util.SortedMap;
+
+/** Saves data normally used by the {@code nocache.js} file. */
+@LinkerOrder(LinkerOrder.Order.POST)
+public class ServerPlannedIFrameLinker extends AbstractLinker {
+  @Override
+  public String getDescription() {
+    return "ServerPlannedIFrameLinker";
+  }
+
+  @SuppressWarnings("unchecked")
+  @Override
+  public ArtifactSet link(final TreeLogger logger, final LinkerContext context,
+      final ArtifactSet artifacts) throws UnableToCompleteException {
+    ArtifactSet toReturn = new ArtifactSet(artifacts);
+
+    StringBuilder table = new StringBuilder();
+    for (StylesheetReference r : artifacts.find(StylesheetReference.class)) {
+      table.append("css ");
+      table.append(r.getSrc());
+      table.append("\n");
+    }
+
+    for (CompilationResult r : artifacts.find(CompilationResult.class)) {
+      table.append(r.getStrongName() + "\n");
+      for (SortedMap<SelectionProperty, String> p : r.getPropertyMap()) {
+        for (Map.Entry<SelectionProperty, String> e : p.entrySet()) {
+          table.append("  ");
+          table.append(e.getKey().getName());
+          table.append("=");
+          table.append(e.getValue());
+          table.append('\n');
+        }
+      }
+      table.append("\n");
+    }
+
+    toReturn.add(emitString(logger, table.toString(), "permutations"));
+    return toReturn;
+  }
+}
diff --git a/src/main/java/com/google/gwtexpui/linker/server/ClientSideRule.java b/src/main/java/com/google/gwtexpui/linker/server/ClientSideRule.java
new file mode 100644
index 0000000..89da529
--- /dev/null
+++ b/src/main/java/com/google/gwtexpui/linker/server/ClientSideRule.java
@@ -0,0 +1,36 @@
+// Copyright (C) 2009 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gwtexpui.linker.server;
+
+import javax.servlet.http.HttpServletRequest;
+
+/** A rule that must execute on the client, as we don't know how to compute it. */
+final class ClientSideRule implements Rule {
+  private final String name;
+
+  ClientSideRule(String name) {
+    this.name = name;
+  }
+
+  @Override
+  public String getName() {
+    return name;
+  }
+
+  @Override
+  public String select(HttpServletRequest req) {
+    return null;
+  }
+}
diff --git a/src/main/java/com/google/gwtexpui/linker/server/Permutation.java b/src/main/java/com/google/gwtexpui/linker/server/Permutation.java
new file mode 100644
index 0000000..b319db1
--- /dev/null
+++ b/src/main/java/com/google/gwtexpui/linker/server/Permutation.java
@@ -0,0 +1,160 @@
+// Copyright (C) 2009 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gwtexpui.linker.server;
+
+import org.w3c.dom.Document;
+import org.w3c.dom.Element;
+
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.util.Arrays;
+
+/** A single permutation of the compiled GWT application. */
+public class Permutation {
+  private final PermutationSelector selector;
+  private final String cacheHTML;
+  private final String[] values;
+
+  Permutation(PermutationSelector sel, String cacheHTML, String[] values) {
+    this.selector = sel;
+    this.cacheHTML = cacheHTML;
+    this.values = values;
+  }
+
+  boolean matches(String[] r) {
+    return Arrays.equals(values, r);
+  }
+
+  /**
+   * Append GWT bootstrap for this permutation onto the end of the body.
+   * <p>
+   * The GWT bootstrap for this particular permutation is appended onto the end
+   * of the {@code body} element of the passed host page.
+   * <p>
+   * To keep the bootstrap code small and simple, not all GWT features are
+   * actually supported. The {@code gwt:property}, {@code gwt:onPropertyErrorFn}
+   * and {@code gwt:onLoadErrorFn} meta tags are ignored and not handled.
+   * <p>
+   * Load order may differ from the standard GWT {@code nocache.js}. The browser
+   * is asked to load the iframe immediately, rather than after the body has
+   * finished loading.
+   *
+   * @param dom host page HTML document.
+   */
+  public void inject(Document dom) {
+    String moduleName = selector.getModuleName();
+    String moduleFunc = moduleName;
+
+    StringBuilder s = new StringBuilder();
+    s.append("\n");
+    s.append("function " + moduleFunc + "(){");
+    s.append("var s,l,t");
+    s.append(",w=window");
+    s.append(",d=document");
+    s.append(",n='" + moduleName + "'");
+    s.append(",f=d.createElement('iframe')");
+    s.append(";");
+
+    // Callback to execute the module once both s and l are true.
+    //
+    s.append("function m(){");
+    s.append("if(s&&l){");
+    // Base path needs to be absolute. There isn't an easy way to do this
+    // other than forcing an image to load and then pulling the URL back.
+    //
+    s.append("var b,i=d.createElement('img');");
+    s.append("i.src=n+'/clear.cache.gif';");
+    s.append("b=i.src;");
+    s.append("b=b.substring(0,b.lastIndexOf('/')+1);");
+    s.append(moduleFunc + "=null;"); // allow us to GC
+    s.append("f.contentWindow.gwtOnLoad(undefined,n,b);");
+    s.append("}");
+    s.append("}");
+
+    // Set s true when the module script has finished loading. The
+    // exact name here is known to the IFrameLinker and is called by
+    // the code in the iframe.
+    //
+    s.append(moduleFunc + ".onScriptLoad=function(){");
+    s.append("s=1;m();");
+    s.append("};");
+
+    // Set l true when the browser has finished processing the iframe
+    // tag, and everything else on the page.
+    //
+    s.append(moduleFunc + ".r=function(){");
+    s.append("l=1;m();");
+    s.append("};");
+
+    // Prevents mixed mode security in IE6/7.
+    s.append("f.src=\"javascript:''\";");
+    s.append("f.id=n;");
+    s.append("f.style.cssText"
+        + "='position:absolute;width:0;height:0;border:none';");
+    s.append("f.tabIndex=-1;");
+    s.append("d.body.appendChild(f);");
+
+    // The src has to be set after the iframe is attached to the DOM to avoid
+    // refresh quirks in Safari. We have to use the location.replace trick to
+    // avoid FF2 refresh quirks.
+    //
+    s.append("f.contentWindow.location.replace(n+'/" + cacheHTML + "');");
+
+    // defer attribute here is to workaround IE running immediately.
+    //
+    s.append("d.write('<script defer=\"defer\">" //
+        + moduleFunc + ".r()</'+'script>');");
+    s.append("}");
+    s.append(moduleFunc + "();");
+    s.append("\n//");
+
+    final Element html = dom.getDocumentElement();
+    final Element head = (Element) html.getElementsByTagName("head").item(0);
+    final Element body = (Element) html.getElementsByTagName("body").item(0);
+
+    for (String css : selector.getCSS()) {
+      if (isRelativeURL(css)) {
+        css = moduleName + '/' + css;
+      }
+
+      final Element link = dom.createElement("link");
+      link.setAttribute("rel", "stylesheet");
+      link.setAttribute("href", css);
+      head.appendChild(link);
+    }
+
+    final Element script = dom.createElement("script");
+    script.setAttribute("type", "text/javascript");
+    script.setAttribute("language", "javascript");
+    script.appendChild(dom.createComment(s.toString()));
+    body.appendChild(script);
+  }
+
+  private static boolean isRelativeURL(String src) {
+    if (src.startsWith("/")) {
+      return false;
+    }
+
+    try {
+      // If it parses as a URL, assume it is not relative.
+      //
+      new URL(src);
+      return false;
+    } catch (MalformedURLException e) {
+    }
+
+    return true;
+  }
+}
diff --git a/src/main/java/com/google/gwtexpui/linker/server/PermutationSelector.java b/src/main/java/com/google/gwtexpui/linker/server/PermutationSelector.java
new file mode 100644
index 0000000..d3e5ae3
--- /dev/null
+++ b/src/main/java/com/google/gwtexpui/linker/server/PermutationSelector.java
@@ -0,0 +1,205 @@
+// Copyright (C) 2009 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gwtexpui.linker.server;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import javax.servlet.ServletContext;
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServletRequest;
+
+/**
+ * Selects a permutation based on the HTTP request.
+ * <p>
+ * To use this class the application's GWT module must include our linker by
+ * inheriting our module:
+ *
+ * <pre>
+ *   &lt;inherits name='com.google.gwtexpui.linker.ServerPlannedIFrameLinker'/&gt;
+ * </pre>
+ */
+public class PermutationSelector {
+  private final String moduleName;
+  private final Map<String, Rule> rulesByName;
+  private final List<Rule> ruleOrder;
+  private final List<Permutation> permutations;
+  private final List<String> css;
+
+  /**
+   * Create an empty selector for a module.
+   * <p>
+   * {@link UserAgentRule} rule is automatically registered. Additional custom
+   * selector rules may be registered before {@link #init(ServletContext)} is
+   * called to finish the selector setup.
+   *
+   * @param moduleName the name of the module within the context.
+   */
+  public PermutationSelector(final String moduleName) {
+    this.moduleName = moduleName;
+
+    this.rulesByName = new HashMap<String, Rule>();
+    this.ruleOrder = new ArrayList<Rule>();
+    this.permutations = new ArrayList<Permutation>();
+    this.css = new ArrayList<String>();
+
+    register(new UserAgentRule());
+  }
+
+  private void notInitialized() {
+    if (!ruleOrder.isEmpty()) {
+      throw new IllegalStateException("Already initialized");
+    }
+  }
+
+  /**
+   * Register a property selection rule.
+   *
+   * @param r the rule implementation.
+   */
+  public void register(Rule r) {
+    notInitialized();
+    rulesByName.put(r.getName(), r);
+  }
+
+  /**
+   * Initialize the selector by reading the module's {@code permutations} file.
+   *
+   * @param ctx context to load the module data from.
+   * @throws ServletException
+   * @throws IOException
+   */
+  public void init(ServletContext ctx) throws ServletException, IOException {
+    notInitialized();
+
+    final String tableName = "/" + moduleName + "/permutations";
+    final InputStream in = ctx.getResourceAsStream(tableName);
+    if (in == null) {
+      throw new ServletException("No " + tableName + " in context");
+    }
+    try {
+      BufferedReader r = new BufferedReader(new InputStreamReader(in, "UTF-8"));
+      for (;;) {
+        final String strongName = r.readLine();
+        if (strongName == null) {
+          break;
+        }
+
+        if (strongName.startsWith("css ")) {
+          css.add(strongName.substring("css ".length()));
+          continue;
+        }
+
+        Map<String, String> selections = new LinkedHashMap<String, String>();
+        for (;;) {
+          String permutation = r.readLine();
+          if (permutation == null || permutation.isEmpty()) {
+            break;
+          }
+
+          int eq = permutation.indexOf('=');
+          if (eq < 0) {
+            throw new ServletException(tableName + " has malformed content");
+          }
+
+          String k = permutation.substring(0, eq).trim();
+          String v = permutation.substring(eq + 1);
+
+          Rule rule = get(k);
+          if (!ruleOrder.contains(rule)) {
+            ruleOrder.add(rule);
+          }
+
+          if (selections.put(k, v) != null) {
+            throw new ServletException("Table " + tableName
+                + " has multiple values for " + k + " within permutation "
+                + strongName);
+          }
+        }
+
+        String cacheHtml = strongName + ".cache.html";
+        String[] values = new String[ruleOrder.size()];
+        for (int i = 0; i < values.length; i++) {
+          values[i] = selections.get(ruleOrder.get(i).getName());
+        }
+        permutations.add(new Permutation(this, cacheHtml, values));
+      }
+    } finally {
+      in.close();
+    }
+  }
+
+  private Rule get(final String name) {
+    Rule r = rulesByName.get(name);
+    if (r == null) {
+      r = new ClientSideRule(name);
+      register(r);
+    }
+    return r;
+  }
+
+  /** @return name of the module (within the application context). */
+  public String getModuleName() {
+    return moduleName;
+  }
+
+  /** @return all possible permutations */
+  public List<Permutation> getPermutations() {
+    return Collections.unmodifiableList(permutations);
+  }
+
+  /**
+   * Select the permutation that best matches the browser request.
+   *
+   * @param req current request.
+   * @return the selected permutation; null if no permutation can be fit to the
+   *         request and the standard {@code nocache.js} loader must be used.
+   */
+  public Permutation select(HttpServletRequest req) {
+    final String[] values = new String[ruleOrder.size()];
+    for (int i = 0; i < values.length; i++) {
+      final String value = ruleOrder.get(i).select(req);
+      if (value == null) {
+        // If the rule returned null it doesn't know how to compute
+        // the value for this HTTP request. Since we can't do that
+        // defer to JavaScript by not picking a permutation.
+        //
+        return null;
+      }
+      values[i] = value;
+    }
+
+    for (Permutation p : permutations) {
+      if (p.matches(values)) {
+        return p;
+      }
+    }
+
+    return null;
+  }
+
+  Collection<String> getCSS() {
+    return css;
+  }
+}
diff --git a/src/main/java/com/google/gwtexpui/linker/server/Rule.java b/src/main/java/com/google/gwtexpui/linker/server/Rule.java
new file mode 100644
index 0000000..76b9b51
--- /dev/null
+++ b/src/main/java/com/google/gwtexpui/linker/server/Rule.java
@@ -0,0 +1,40 @@
+// Copyright (C) 2009 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gwtexpui.linker.server;
+
+import javax.servlet.http.HttpServletRequest;
+
+/** A selection rule for a permutation property. */
+public interface Rule {
+  /** @return the property name, for example {@code "user.agent"}. */
+  public String getName();
+
+  /**
+   * Compute the value for this property, given the current request.
+   * <p>
+   * This rule method must compute the proper permutation value, matching what
+   * the GWT module XML files use for this property. The rule may use any state
+   * available in the current servlet request.
+   * <p>
+   * If this method returns {@code null} server side selection will be aborted
+   * and selection for all properties will be handled on the client side by the
+   * {@code nocache.js} file.
+   *
+   * @param req the request
+   * @return the value for the property; null if the value cannot be determined
+   *         on the server side.
+   */
+  public String select(HttpServletRequest req);
+}
diff --git a/src/main/java/com/google/gwtexpui/linker/server/UserAgentRule.java b/src/main/java/com/google/gwtexpui/linker/server/UserAgentRule.java
new file mode 100644
index 0000000..f57db87
--- /dev/null
+++ b/src/main/java/com/google/gwtexpui/linker/server/UserAgentRule.java
@@ -0,0 +1,87 @@
+// Copyright (C) 2009 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gwtexpui.linker.server;
+
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import static java.util.regex.Pattern.compile;
+
+import javax.servlet.http.HttpServletRequest;
+
+/**
+ * Selects the value for the {@code user.agent} property.
+ * <p>
+ * Examines the {@code User-Agent} HTTP request header, and tries to match it to
+ * known {@code user.agent} values.
+ * <p>
+ * Ported from JavaScript in {@code com.google.gwt.user.UserAgent.gwt.xml}.
+ */
+public class UserAgentRule implements Rule {
+  private static final Pattern msie = compile(".*msie ([0-9]+)\\.([0-9]+).*");
+  private static final Pattern gecko = compile(".*rv:([0-9]+)\\.([0-9]+).*");
+
+  public String getName() {
+    return "user.agent";
+  }
+
+  @Override
+  public String select(HttpServletRequest req) {
+    String ua = req.getHeader("User-Agent");
+    if (ua == null) {
+      return null;
+    }
+
+    ua = ua.toLowerCase();
+
+    if (ua.indexOf("opera") != -1) {
+      return "opera";
+
+    } else if (ua.indexOf("webkit") != -1) {
+      return "safari";
+
+    } else if (ua.indexOf("msie") != -1) {
+      // GWT 2.0 uses document.documentMode here, which we can't do
+      // on the server side.
+
+      Matcher m = msie.matcher(ua);
+      if (m.matches() && m.groupCount() == 2) {
+        int v = makeVersion(m);
+        if (v >= 8000) {
+          return "ie8";
+        }
+        if (v >= 6000) {
+          return "ie6";
+        }
+      }
+      return null;
+
+    } else if (ua.indexOf("gecko") != -1) {
+      Matcher m = gecko.matcher(ua);
+      if (m.matches() && m.groupCount() == 2) {
+        if (makeVersion(m) >= 1008) {
+          return "gecko1_8";
+        }
+      }
+      return "gecko";
+    }
+
+    return null;
+  }
+
+  private int makeVersion(Matcher result) {
+    return (Integer.parseInt(result.group(1)) * 1000)
+        + Integer.parseInt(result.group(2));
+  }
+}