In the next section, you'll be adding server-side code living in the same codebase as Hyperapp client-side code.
You've been using Snowpack to inspect all production code for imports and translating appropriate node_modules
to web_modules
.
It leads to 2 problems:
- you install the same dependency twice: in the
node_modules
and theweb_modules
- you have to exclude server code from the translation
To address those problems, you'll a Snowpack feature called webDependencies.
Change your package.json to:
{
"type": "module",
"scripts": {
"snowpack": "snowpack install --exclude '**/*' --dest=src/web_modules",
"postinstall": "npm run snowpack",
"format": "prettier --write '**/!(web_modules)/*.js'",
"test": "mocha test/*.test.js"
},
"dependencies": {
"hyperapp-render": "3.2.0"
},
"webDependencies": {
"@hyperapp/events": "0.0.4",
"htm": "3.0.4",
"hyperapp": "2.0.4",
"hyperapp-fx": "2.0.0-beta.1",
"page": "1.11.6"
},
"devDependencies": {
"mocha": "7.1.2",
"prettier": "2.0.5",
"snowpack": "2.0.0-beta.20"
}
}
After this change:
- server-side dependencies come from
dependencies
. I already added a hyperapp-render dependency you'll be using in the next section. - client-side dependencies come from
webDependencies
. I moved all previous dependencies here.
Also, exclude all code from Snowpack import analysis (--exclude '**/*'
). In our new workflow, all client-side dependencies have to be listed explicitly in webDependencies
.
Test the install:
npm i
With your new workflow, Snowpack uses its CDN to download web_modules
.
So far, you've been using Hyperapp to serialize Virtual DOM nodes into real DOM nodes.
However, you can also translate Hyperapp Virtual DOM to HTML string with a library called hyperapp-render.
Create Server.js in a root directory of your project (one level above src):
import render from "hyperapp-render";
import { state, view } from "./src/Posts.js";
const html = render.renderToString(view(state));
console.log(html);
Make sure your src/Posts.js exports the main view function and the initial state. hyperapp-render should serialize your view and state into the HTML string.
Run it in Node.js:
node Server.js
You've just seen how to turn your Hyperapp views and state into HTML. You can serve this HTML from any HTTP server.
Install a popular and minimal Node.js Web application server framework express:
"dependencies": {
"hyperapp-render": "3.2.0",
"express": "4.17.1"
},
npm i
Expose your Hyperapp posts as a resource with HTML representation. Update Server.js:
import render from "hyperapp-render";
import express from "express";
import { state, view } from "./src/Posts.js";
const app = express();
app.get("/", (req, res) => {
const html = render.renderToString(view(state));
res.send(html);
});
app.listen(3000, () => {
console.log("Listening on 3000");
});
Run it:
node Server.js
It should return unstyled HTML with a form and empty list of posts.
In this section, you'll add a list of posts to your HTML representation.
Server and client code have different data fetching patterns.
Client-side code usually fetches data in the background.
For example, LoadLatestPosts
effect starts when you open a browser, and fetches data in the background.
When the data arrives, Hyperapp re-renders the view with newly fetched response data.
The server-side code has to wait for the data first and only then render the response. So this "wait before render" behavior is a major difference.
In this book, you won't be trying to create universal data fetching effects shared between a client and a server. In a real app, you will probably load your data directly from a database.
We'll use axios
to show you how to do it without complicating the setup.
"dependencies": {
"axios": "0.19.2",
"express": "4.17.1",
"hyperapp-render": "3.2.0"
},
npm i
import render from "hyperapp-render";
import express from "express";
import {state, view, SetPosts} from "./src/Posts.js";
import axios from "axios";
const app = express();
app.get("/", async (req, res) => {
const response = await axios.get("https://hyperapp-api.herokuapp.com/api/post");
const posts = response.data;
const stateWithPosts = SetPosts(state, posts);
const html = render.renderToString(view(stateWithPosts));
res.send(html);
});
app.listen(3000, () => {
console.log("Listening on 3000");
});
Make sure to export the SetPosts
action. Notice that you're using it the same way in your server-side code.
Open your app in a browser. You should see the rendered list of posts.
To reiterate what you've just learned: Hyperapp can be used as a server-side template engine with hyperapp-render.
Hydration is a fancy name for taking control over server-side rendered content on the client-side. For large applications, server-side rendering improves the time to visible content. However, hydration always performs worse than server-side rendering alone or client side-rendering alone when you care about time to interactivity. With hydration, you pay the rendering tax twice (HTML rendering and JS rendering/hydrating). You often have to make a tradeoff of getting visible content or interactivity faster. But, since Hyperapp is really small, you may not even notice the hydration penalty.
Move src/index.html content into Server.js and put it into a template string:
const htmlTemplate = (content) => /* HTML */ `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>HyperPosts</title>
<link rel="stylesheet" href="https://andybrewer.github.io/mvp/mvp.css" />
<script type="module" src="Start.js"></script>
</head>
<body>
<main>
<div id="app">${content}</div>
</main>
</body>
</html>
`;
You'll be replacing content
placeholder with dynamically rendered content.
Note: /* HTML */
comment is for prettier to auto-format the string as HTML.
Update Server.js:
import {layout} from "./src/Layout.js";
app.get("/", async (req, res) => {
const response = await axios.get(
"https://hyperapp-api.herokuapp.com/api/post"
);
const posts = response.data;
const stateWithPosts = SetPosts(state, posts);
const content = render.renderToString(layout(view)(stateWithPosts));
res.send(htmlTemplate(content));
});
app.use(express.static("src"));
What it does:
Wraps your view
in a layout
to get the full page.
Passes a server generated content into your HTML template function.
Serves static files from the src directory.
Puts the static handler after GET "/" handler, so src/index.html
has lower precedence than our root resource.
Run it:
node Server.js
Test your posts page at http://localhost:3000 with JS enabled and disabled. (You can do that in your browser Dev Tools)
Implement server-side rendering and hydration for the login page. Test your app with JS enabled and disabled.
Solution
import { state as loginState, view as loginView } from "./src/Login.js";
app.get("/login", async (req, res) => {
const content = render.renderToString(layout(loginView)(loginState));
res.send(htmlTemplate(content));
});
You can expand on this exercise. Some ideas for further experiments:
- serialize state and pass it from server to client to avoid double fetching the initial list of posts
- build shared routing abstraction for the client and the server