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:
Ben Newman
2017-06-29 13:00:24 -04:00
parent 576641658c
commit fb73388ce3
11 changed files with 345 additions and 136 deletions

View File

@@ -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.

View 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);
}
}

View 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));
}

View File

@@ -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) {

View 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;
});
}
);

View File

@@ -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);
}
});

View File

@@ -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);
}
);

View 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;
}

View 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;
});
};

View File

@@ -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,

View File

@@ -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");