mirror of
https://github.com/meteor/meteor.git
synced 2026-01-11 00:28:02 -05:00
Make server-render API more flexible and isomorph-ish.
Render callbacks can now inject HTML content into multiple different
elements, and may also append content to the <head> or <body> elements, on
both the client and the server.
This new API was inspired by trying to use the styled-components npm
package on the server, which involves not only rendering and injecting
static HTML somewhere in the <body>, but also appending the resulting
<style> tag(s) into the <head>:
import { onPageLoad } from "meteor/server-render";
import { renderToString } from "react-dom/server";
import { ServerStyleSheet } from "styled-components";
onPageLoad(sink => {
const sheet = new ServerStyleSheet();
const html = renderToString(sheet.collectStyles(
<App location={sink.request.url} />
));
sink.renderIntoElementById("app", html);
sink.appendToHead(sheet.getStyleTags());
});
Note that the server-render package now exports an onPageLoad function,
rather than the old renderIntoElementById function. The functionality of
renderIntoElementById is now exposed by the {Client,Server}Sink API.
I say the client-side version of this API is 'isomorphish' to the
server-side version, because ClientSink methods can accept DOM nodes in
addition to raw HTML strings, whereas DOM nodes don't really make sense on
the server.
This commit is contained in:
@@ -3,35 +3,107 @@
|
||||
***
|
||||
|
||||
This package implements generic support for server-side rendering in
|
||||
Meteor apps, by providing a mechanism for injecting strings of HTML into
|
||||
static HTML in the body of HTTP responses.
|
||||
Meteor apps, by providing a mechanism for injecting fragments of HTML into
|
||||
the `<head>` and/or `<body>` of the application's initial HTML response.
|
||||
|
||||
### Usage
|
||||
|
||||
This package exports a function named `renderIntoElementById` which takes
|
||||
an HTML `id` string and a callback function.
|
||||
This package exports a function named `onPageLoad` which takes a callback
|
||||
function that will be called at page load (on the client) or whenever a
|
||||
new request happens (on the server).
|
||||
|
||||
The callback should return a string of HTML, or a `Promise<string>` if it
|
||||
needs to do any asynchronous rendering work.
|
||||
The callback receives a `sink` object, which is an instance of either
|
||||
`ClientSink` or `ServerSink` depending on the environment. Both types of
|
||||
`sink` have the same methods, though the server version accepts only HTML
|
||||
strings as content, whereas the client version also accepts DOM nodes.
|
||||
|
||||
If an element with the given `id` exists in the initial HTTP response body,
|
||||
the final result of the callback will be injected into that element as
|
||||
part of the initial HTTP response.
|
||||
|
||||
The callback receives the current `request` object as a parameter, so it can
|
||||
render according to per-request information like `request.url`.
|
||||
|
||||
The final result of the callback will be ignored if it is anything other
|
||||
than a string, or if there is no element with the given `id` in the body of
|
||||
the current response.
|
||||
|
||||
Registering multiple callbacks for the same `id` is not well defined, so
|
||||
this function just returns any previously registered callback, in case the
|
||||
new callback needs to do something with it.
|
||||
|
||||
Because the `renderIntoElementById` function is not automatically imported
|
||||
into other packages, you must import it explicitly:
|
||||
The current interface of `{Client,Server}Sink` objects is as follows:
|
||||
|
||||
```js
|
||||
import { renderIntoElementById } from "meteor/server-render";
|
||||
class Sink {
|
||||
// Appends content to the <head>.
|
||||
appendToHead(content)
|
||||
|
||||
// Appends content to the <body>.
|
||||
appendToBody(content)
|
||||
|
||||
// Appends content to the identified element.
|
||||
appendToElementById(id, content)
|
||||
|
||||
// Replaces the content of the identified element.
|
||||
renderIntoElementById(id, content)
|
||||
}
|
||||
```
|
||||
|
||||
The `sink` object may also expose additional properties depending on the
|
||||
environment. For example, on the server, `sink.request` provides access to
|
||||
the current `request` object, and `sink.arch` identifies the target
|
||||
architecture of the pending HTTP response (e.g. "web.browser").
|
||||
|
||||
Here is a basic example of `onPageLoad` usage on the server:
|
||||
|
||||
```js
|
||||
import React from "react";
|
||||
import { renderToString } from "react-dom/server";
|
||||
import { onPageLoad } from "meteor/server-render";
|
||||
import App from "/imports/Server.js";
|
||||
|
||||
onPageLoad(sink => {
|
||||
sink.renderIntoElementById("app", renderToString(
|
||||
<App location={sink.request.url} />
|
||||
));
|
||||
});
|
||||
```
|
||||
|
||||
Likewise on the client:
|
||||
|
||||
```js
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
import { onPageLoad } from "meteor/server-render";
|
||||
|
||||
onPageLoad(async sink => {
|
||||
const App = (await import("/imports/Client.js")).default;
|
||||
ReactDOM.render(
|
||||
<App />,
|
||||
document.getElementById("app")
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
Note that the `onPageLoad` callback function is allowed to return a
|
||||
`Promise` if it needs to do any asynchronous work, and thus may be
|
||||
implemented by an `async` function (as in the client case above).
|
||||
|
||||
Note also that the client example does not end up calling any methods of
|
||||
the `sink` object, because `ReactDOM.render` has its own similar API. In
|
||||
fact, you are not even required to use the `onPageLoad` API on the client,
|
||||
if you have your own ideas about how the client should do its rendering.
|
||||
|
||||
Here is a more complicated example of `onPageLoad` usage on the server,
|
||||
involving the [`styled-components`](https://www.styled-components.com/docs/advanced#server-side-rendering) npm package:
|
||||
|
||||
```js
|
||||
import React from "react";
|
||||
import { onPageLoad } from "meteor/server-render";
|
||||
import { renderToString } from "react-dom/server";
|
||||
import { ServerStyleSheet } from "styled-components"
|
||||
import App from "/imports/Server";
|
||||
|
||||
onPageLoad(sink => {
|
||||
const sheet = new ServerStyleSheet();
|
||||
const html = renderToString(sheet.collectStyles(
|
||||
<App location={sink.request.url} />
|
||||
));
|
||||
|
||||
sink.renderIntoElementById("app", html);
|
||||
sink.appendToHead(sheet.getStyleTags());
|
||||
});
|
||||
```
|
||||
|
||||
In this example, the callback not only renders the `<App />` element into
|
||||
the element with `id="app"`, but also appends any `<style>` tag(s)
|
||||
generated during rendering to the `<head>` of the response document.
|
||||
|
||||
Although these examples have all involved React, the `onPageLoad` API is
|
||||
designed to be generically useful for any kind of server-side rendering.
|
||||
|
||||
43
packages/server-render/client-sink.js
Normal file
43
packages/server-render/client-sink.js
Normal file
@@ -0,0 +1,43 @@
|
||||
const doc = document;
|
||||
const head = doc.getElementsByTagName("head")[0];
|
||||
const body = doc.body;
|
||||
|
||||
export class ClientSink {
|
||||
appendToHead(nodeOrHtml) {
|
||||
appendContent(head, nodeOrHtml);
|
||||
}
|
||||
|
||||
appendToBody(nodeOrHtml) {
|
||||
appendContent(body, nodeOrHtml);
|
||||
}
|
||||
|
||||
appendToElementById(id, nodeOrHtml) {
|
||||
appendContent(doc.getElementById(id), nodeOrHtml);
|
||||
}
|
||||
|
||||
renderIntoElementById(id, nodeOrHtml) {
|
||||
const element = doc.getElementById(id);
|
||||
while (element.lastChild) {
|
||||
element.removeChild(element.lastChild);
|
||||
}
|
||||
appendContent(element, nodeOrHtml);
|
||||
}
|
||||
}
|
||||
|
||||
function appendContent(destination, nodeOrHtml) {
|
||||
if (typeof nodeOrHtml === "string") {
|
||||
// Make a shallow clone of the destination node to ensure the new
|
||||
// children can legally be appended to it.
|
||||
const container = destination.cloneNode(false);
|
||||
// Parse the HTML into the container, allowing for multiple children.
|
||||
container.innerHTML = nodeOrHtml;
|
||||
// Transplant the children to the destination.
|
||||
while (container.firstChild) {
|
||||
destination.appendChild(container.firstChild);
|
||||
}
|
||||
} else if (Array.isArray(nodeOrHtml)) {
|
||||
nodeOrHtml.forEach(elem => appendContent(destination, elem));
|
||||
} else {
|
||||
destination.appendChild(nodeOrHtml);
|
||||
}
|
||||
}
|
||||
9
packages/server-render/client.js
Normal file
9
packages/server-render/client.js
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Meteor } from "meteor/meteor";
|
||||
import { ClientSink } from "./client-sink.js";
|
||||
|
||||
let promise = new Promise(Meteor.startup);
|
||||
let sink = new ClientSink();
|
||||
|
||||
export function onPageLoad(callback) {
|
||||
promise = promise.then(() => callback(sink));
|
||||
}
|
||||
@@ -13,7 +13,8 @@ Npm.depends({
|
||||
Package.onUse(function(api) {
|
||||
api.use("ecmascript");
|
||||
api.use("webapp");
|
||||
api.mainModule("server-render.js", "server");
|
||||
api.mainModule("client.js", "client");
|
||||
api.mainModule("server.js", "server");
|
||||
});
|
||||
|
||||
Package.onTest(function(api) {
|
||||
|
||||
70
packages/server-render/server-register.js
Normal file
70
packages/server-render/server-register.js
Normal file
@@ -0,0 +1,70 @@
|
||||
import { WebAppInternals } from "meteor/webapp";
|
||||
import { SAXParser } from "parse5";
|
||||
import MagicString from "magic-string";
|
||||
import { ServerSink } from "./server-sink.js";
|
||||
import { onPageLoad } from "./server.js";
|
||||
|
||||
WebAppInternals.registerBoilerplateDataCallback(
|
||||
"meteor/server-render",
|
||||
(request, data, arch) => {
|
||||
const sink = new ServerSink(request, arch);
|
||||
|
||||
return onPageLoad.chain(
|
||||
callback => callback(sink, request)
|
||||
).then(() => {
|
||||
if (! sink.maybeMadeChanges) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let reallyMadeChanges = false;
|
||||
|
||||
function rewrite(property) {
|
||||
const html = data[property];
|
||||
if (typeof html !== "string") {
|
||||
return;
|
||||
}
|
||||
|
||||
const magic = new MagicString(html);
|
||||
const parser = new SAXParser({
|
||||
locationInfo: true
|
||||
});
|
||||
|
||||
parser.on("startTag", (name, attrs, selfClosing, loc) => {
|
||||
attrs.some(attr => {
|
||||
if (attr.name === "id") {
|
||||
const html = sink.htmlById[attr.value];
|
||||
if (html) {
|
||||
magic.appendRight(loc.endOffset, html);
|
||||
reallyMadeChanges = true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
parser.write(html);
|
||||
|
||||
data[property] = magic.toString();
|
||||
}
|
||||
|
||||
if (sink.head) {
|
||||
data.dynamicHead = (data.dynamicHead || "") + sink.head;
|
||||
reallyMadeChanges = true;
|
||||
}
|
||||
|
||||
if (Object.keys(sink.htmlById).length > 0) {
|
||||
// We don't currently allow injecting HTML into the <head> except
|
||||
// by calling sink.appendHead(html).
|
||||
rewrite("body");
|
||||
rewrite("dynamicBody");
|
||||
}
|
||||
|
||||
if (sink.body) {
|
||||
data.dynamicBody = (data.dynamicBody || "") + sink.body;
|
||||
reallyMadeChanges = true;
|
||||
}
|
||||
|
||||
return reallyMadeChanges;
|
||||
});
|
||||
}
|
||||
);
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Tinytest } from "meteor/tinytest";
|
||||
import { WebAppInternals } from "meteor/webapp";
|
||||
import { renderIntoElementById } from "meteor/server-render";
|
||||
import { onPageLoad } from "meteor/server-render";
|
||||
import { parse } from "parse5";
|
||||
|
||||
const skeleton = `
|
||||
@@ -22,26 +22,29 @@ Tinytest.add('server-render - boilerplate', function (test) {
|
||||
// Use the underlying abstraction to set the static HTML skeleton.
|
||||
WebAppInternals.registerBoilerplateDataCallback(
|
||||
"meteor/server-render",
|
||||
(data, request, arch) => {
|
||||
(request, data, arch) => {
|
||||
if (request.isServerRenderTest) {
|
||||
test.equal(arch, "web.browser");
|
||||
test.equal(request.url, "/server-render/test");
|
||||
data.body = skeleton;
|
||||
}
|
||||
return realCallback.call(this, data, request, arch);
|
||||
return realCallback.call(this, request, data, arch);
|
||||
}
|
||||
);
|
||||
|
||||
renderIntoElementById("container-1", request => {
|
||||
return "<oyez/>";
|
||||
const callback1 = onPageLoad(sink => {
|
||||
sink.renderIntoElementById("container-1", "<oyez/>");
|
||||
});
|
||||
|
||||
// This callback is async, and that's fine because
|
||||
// WebAppInternals.getBoilerplate is able to yield. Internally the
|
||||
// webapp package uses a function called getBoilerplateAsync, so the
|
||||
// Fiber power-tools need not be involved in typical requests.
|
||||
renderIntoElementById("container-2", async request => {
|
||||
return (await "oy") + (await "ez");
|
||||
const callback2 = onPageLoad(async sink => {
|
||||
sink.renderIntoElementById(
|
||||
"container-2",
|
||||
(await "oy") + (await "ez")
|
||||
);
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -95,7 +98,7 @@ Tinytest.add('server-render - boilerplate', function (test) {
|
||||
realCallback
|
||||
);
|
||||
|
||||
renderIntoElementById("container-1", null);
|
||||
renderIntoElementById("container-2", null);
|
||||
onPageLoad.remove(callback1);
|
||||
onPageLoad.remove(callback2);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
import { WebAppInternals } from "meteor/webapp";
|
||||
import { SAXParser } from "parse5";
|
||||
import MagicString from "magic-string";
|
||||
|
||||
const callbacksById = Object.create(null);
|
||||
|
||||
// Register a callback function that returns a string of HTML (or a
|
||||
// Promise<string> if asynchronous work needs to be done). If an element
|
||||
// with the given id exists in the initial HTTP response body, the result
|
||||
// of the callback will be injected into that element as part of the
|
||||
// initial response. The callback receives the current request object as a
|
||||
// parameter, so it can render according to per-request information like
|
||||
// request.url. The final result of the callback will be ignored if it is
|
||||
// anything other than a string, or if there is no element with the given
|
||||
// id in the body of the current response. Registering multiple callbacks
|
||||
// for the same id is not well defined, so this function just returns any
|
||||
// previously registered callback, in case the new callback needs to do
|
||||
// something with it.
|
||||
export function renderIntoElementById(id, callback) {
|
||||
const previous = callbacksById[id];
|
||||
callbacksById[id] = callback;
|
||||
return previous;
|
||||
}
|
||||
|
||||
WebAppInternals.registerBoilerplateDataCallback(
|
||||
"meteor/server-render",
|
||||
(data, request, arch) => {
|
||||
let madeChanges = false;
|
||||
|
||||
function rewrite(property) {
|
||||
const html = data[property];
|
||||
if (typeof html !== "string") {
|
||||
return;
|
||||
}
|
||||
|
||||
let promise = Promise.resolve();
|
||||
const magic = new MagicString(html);
|
||||
const parser = new SAXParser({
|
||||
locationInfo: true,
|
||||
});
|
||||
|
||||
parser.on("startTag", (name, attrs, selfClosing, loc) => {
|
||||
attrs.some(attr => {
|
||||
if (attr.name === "id") {
|
||||
// TODO Ignore this id if it appears later?
|
||||
const callback = callbacksById[attr.value];
|
||||
if (typeof callback === "function") {
|
||||
promise = promise
|
||||
.then(() => callback(request))
|
||||
.then(rendered => {
|
||||
if (typeof rendered === "string") {
|
||||
magic.appendRight(loc.endOffset, rendered);
|
||||
madeChanges = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
parser.write(html);
|
||||
|
||||
return promise.then(
|
||||
() => data[property] = magic.toString()
|
||||
);
|
||||
}
|
||||
|
||||
return Promise.all([
|
||||
rewrite("body"),
|
||||
rewrite("dynamicBody"),
|
||||
]).then(() => madeChanges);
|
||||
}
|
||||
);
|
||||
50
packages/server-render/server-sink.js
Normal file
50
packages/server-render/server-sink.js
Normal file
@@ -0,0 +1,50 @@
|
||||
export class ServerSink {
|
||||
constructor(request, arch) {
|
||||
this.request = request;
|
||||
this.arch = arch;
|
||||
this.head = "";
|
||||
this.body = "";
|
||||
this.htmlById = Object.create(null);
|
||||
this.maybeMadeChanges = false;
|
||||
}
|
||||
|
||||
appendToHead(html) {
|
||||
if (appendContent(this, "head", html)) {
|
||||
this.maybeMadeChanges = true;
|
||||
}
|
||||
}
|
||||
|
||||
appendToBody(html) {
|
||||
if (appendContent(this, "body", html)) {
|
||||
this.maybeMadeChanges = true;
|
||||
}
|
||||
}
|
||||
|
||||
appendToElementById(id, html) {
|
||||
if (appendContent(this.htmlById, id, html)) {
|
||||
this.maybeMadeChanges = true;
|
||||
}
|
||||
}
|
||||
|
||||
renderIntoElementById(id, html) {
|
||||
this.htmlById[id] = "";
|
||||
this.appendToElementById(id, html);
|
||||
}
|
||||
}
|
||||
|
||||
function appendContent(object, property, content) {
|
||||
let madeChanges = false;
|
||||
|
||||
if (Array.isArray(content)) {
|
||||
content.forEach(elem => {
|
||||
if (appendContent(object, property, elem)) {
|
||||
madeChanges = true;
|
||||
}
|
||||
});
|
||||
} else if ((content = content && content.toString("utf8"))) {
|
||||
object[property] = (object[property] || "") + content;
|
||||
madeChanges = true;
|
||||
}
|
||||
|
||||
return madeChanges;
|
||||
}
|
||||
32
packages/server-render/server.js
Normal file
32
packages/server-render/server.js
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Meteor } from "meteor/meteor";
|
||||
import "./server-register.js";
|
||||
|
||||
const startupPromise = new Promise(Meteor.startup);
|
||||
const pageLoadCallbacks = new Set;
|
||||
|
||||
export function onPageLoad(callback) {
|
||||
if (typeof callback === "function") {
|
||||
pageLoadCallbacks.add(callback);
|
||||
}
|
||||
|
||||
// Return the callback so that it can be more easily removed later.
|
||||
return callback;
|
||||
}
|
||||
|
||||
onPageLoad.remove = function (callback) {
|
||||
pageLoadCallbacks.delete(callback);
|
||||
};
|
||||
|
||||
onPageLoad.clear = function () {
|
||||
pageLoadCallbacks.clear();
|
||||
};
|
||||
|
||||
onPageLoad.chain = function (handler) {
|
||||
return startupPromise.then(() => {
|
||||
let promise = Promise.resolve();
|
||||
pageLoadCallbacks.forEach(callback => {
|
||||
promise = promise.then(() => handler(callback));
|
||||
});
|
||||
return promise;
|
||||
});
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
import assert from "assert";
|
||||
import { readFile } from "fs";
|
||||
import { createServer } from "http";
|
||||
import {
|
||||
@@ -238,7 +239,7 @@ WebApp._timeoutAdjustmentRequestCallback = function (req, res) {
|
||||
var boilerplateByArch = {};
|
||||
|
||||
// Register a callback function that can selectively modify boilerplate
|
||||
// data given arguments (data, request, arch). The key should be a unique
|
||||
// data given arguments (request, data, arch). The key should be a unique
|
||||
// identifier, to prevent accumulating duplicate callbacks from the same
|
||||
// call site over time. Callbacks will be called in the order they were
|
||||
// registered. A callback should return false if it did not make any
|
||||
@@ -247,21 +248,17 @@ var boilerplateByArch = {};
|
||||
const boilerplateDataCallbacks = Object.create(null);
|
||||
WebAppInternals.registerBoilerplateDataCallback = function (key, callback) {
|
||||
const previousCallback = boilerplateDataCallbacks[key];
|
||||
if (previousCallback) {
|
||||
// Delete any existing callback first, so the new callback is always a
|
||||
// new property, and thus will come last in Object.keys order.
|
||||
delete boilerplateDataCallbacks[key];
|
||||
}
|
||||
|
||||
if (typeof callback === "function") {
|
||||
boilerplateDataCallbacks[key] = callback;
|
||||
} else if (callback !== null) {
|
||||
throw new Error("Bad callback: " + callback);
|
||||
} else {
|
||||
assert.strictEqual(callback, null);
|
||||
delete boilerplateDataCallbacks[key];
|
||||
}
|
||||
|
||||
// Return the previous callback in case the new callback needs to call
|
||||
// it; for example, when the new callback is a wrapper for the old.
|
||||
return previousCallback;
|
||||
return previousCallback || null;
|
||||
};
|
||||
|
||||
// Given a request (as returned from `categorizeRequest`), return the
|
||||
@@ -278,30 +275,38 @@ function getBoilerplate(request, arch) {
|
||||
return getBoilerplateAsync(request, arch).await();
|
||||
}
|
||||
|
||||
async function getBoilerplateAsync(request, arch) {
|
||||
function getBoilerplateAsync(request, arch) {
|
||||
const boilerplate = boilerplateByArch[arch];
|
||||
const data = Object.assign({}, boilerplate.baseData, {
|
||||
htmlAttributes: getHtmlAttributes(request),
|
||||
}, _.pick(request, "dynamicHead", "dynamicBody"));
|
||||
|
||||
let madeChanges = false;
|
||||
let promise = Promise.resolve();
|
||||
|
||||
for (const key of Object.keys(boilerplateDataCallbacks)) {
|
||||
const callback = boilerplateDataCallbacks[key];
|
||||
const result = await callback(data, request, arch);
|
||||
// Callbacks should return false if they did not make any changes.
|
||||
if (result !== false) {
|
||||
madeChanges = true;
|
||||
Object.keys(boilerplateDataCallbacks).forEach(key => {
|
||||
promise = promise.then(() => {
|
||||
const callback = boilerplateDataCallbacks[key];
|
||||
return callback(request, data, arch);
|
||||
}).then(result => {
|
||||
// Callbacks should return false if they did not make any changes.
|
||||
if (result !== false) {
|
||||
madeChanges = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return promise.then(() => {
|
||||
const useMemoized = ! (
|
||||
data.dynamicHead ||
|
||||
data.dynamicBody ||
|
||||
madeChanges
|
||||
);
|
||||
|
||||
if (! useMemoized) {
|
||||
return boilerplate.toHTML(data);
|
||||
}
|
||||
}
|
||||
|
||||
const useMemoized = ! (
|
||||
request.dynamicHead ||
|
||||
request.dynamicBody ||
|
||||
madeChanges
|
||||
);
|
||||
|
||||
if (useMemoized) {
|
||||
// The only thing that changes from request to request (unless extra
|
||||
// content is added to the head or body, or boilerplateDataCallbacks
|
||||
// modified the data) are the HTML attributes (used by, eg, appcache)
|
||||
@@ -318,9 +323,7 @@ async function getBoilerplateAsync(request, arch) {
|
||||
}
|
||||
|
||||
return memoizedBoilerplate[memHash];
|
||||
}
|
||||
|
||||
return boilerplate.toHTML(data);
|
||||
});
|
||||
}
|
||||
|
||||
WebAppInternals.generateBoilerplateInstance = function (arch,
|
||||
|
||||
@@ -160,7 +160,7 @@ Tinytest.add("webapp - WebAppInternals.registerBoilerplateDataCallback", functio
|
||||
const key = "from webapp_tests.js";
|
||||
let callCount = 0;
|
||||
|
||||
function callback(data, request, arch) {
|
||||
function callback(request, data, arch) {
|
||||
test.equal(arch, "web.browser");
|
||||
test.equal(request.url, "http://example.com");
|
||||
test.equal(data.dynamicHead, "so dynamic");
|
||||
|
||||
Reference in New Issue
Block a user