├── .gitignore
├── Makefile
├── README.md
├── package.json
├── preact.config.js
├── scripts
└── deploy.js
├── src
├── assets
│ ├── favicon.ico
│ └── icons
│ │ ├── android-chrome-192x192.png
│ │ ├── android-chrome-512x512.png
│ │ ├── apple-touch-icon.png
│ │ ├── favicon-16x16.png
│ │ ├── favicon-32x32.png
│ │ └── mstile-150x150.png
├── components
│ ├── app.js
│ └── header
│ │ ├── index.js
│ │ └── style.css
├── index.js
├── manifest.json
├── prerender-urls.json
├── routes
│ ├── 404
│ │ ├── index.js
│ │ └── style.css
│ ├── home
│ │ ├── index.js
│ │ └── style.css
│ └── profile
│ │ ├── index.js
│ │ └── style.css
├── store.js
└── style
│ └── index.css
├── webpack.config.worker.js
└── worker
├── index.js
└── ssr-render.js
/.gitignore:
--------------------------------------------------------------------------------
1 | dist
2 | .env*
3 |
4 | # Logs
5 | logs
6 | *.log
7 | npm-debug.log*
8 | yarn-debug.log*
9 | yarn-error.log*
10 |
11 | # Runtime data
12 | pids
13 | *.pid
14 | *.seed
15 | *.pid.lock
16 |
17 | # Directory for instrumented libs generated by jscoverage/JSCover
18 | lib-cov
19 |
20 | # Coverage directory used by tools like istanbul
21 | coverage
22 |
23 | # nyc test coverage
24 | .nyc_output
25 |
26 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
27 | .grunt
28 |
29 | # Bower dependency directory (https://bower.io/)
30 | bower_components
31 |
32 | # node-waf configuration
33 | .lock-wscript
34 |
35 | # Compiled binary addons (http://nodejs.org/api/addons.html)
36 | build/Release
37 |
38 | # Dependency directories
39 | node_modules/
40 | jspm_packages/
41 |
42 | # Typescript v1 declaration files
43 | typings/
44 |
45 | # Optional npm cache directory
46 | .npm
47 |
48 | # Optional eslint cache
49 | .eslintcache
50 |
51 | # Optional REPL history
52 | .node_repl_history
53 |
54 | # Output of 'npm pack'
55 | *.tgz
56 |
57 | # Yarn Integrity file
58 | .yarn-integrity
59 |
60 | # dotenv environment variables file
61 | .env
62 |
63 | .DS_Store
64 |
65 | # lock files
66 | yarn.lock
67 | package-lock.json
68 | build
69 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | # source the environment variables file
2 | include .env
3 |
4 | prepare-for-cf:
5 | npm run build
6 |
7 | deploy-to-cf:
8 | npm run build-worker && make upload-worker
9 |
10 | cf: prepare-for-cf deploy-to-cf
11 |
12 | upload-worker:
13 | curl -X PUT "https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/workers/script" \
14 | -H "X-Auth-Email:${ACCOUNT_EMAIL}" \
15 | -H "X-Auth-Key:${ACCOUNT_AUTH_KEY}" \
16 | -H "Content-Type:application/javascript" \
17 | --data-binary "@./dist/worker.js"
18 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | [](https://www.webpagetest.org/lighthouse.php?test=181128_9K_916ea978dd63605aa04610aa64ec8f30&run=3)
2 |
3 | ### Cloudflare worker Preact Progressive Web App
4 |
5 | This example app deploys the awesome Progressive Web App created by [`preact-cli` ](https://github.com/developit/preact-cli) to a Cloudflare worker. It also implements dynamic server side rendering using a Redux store. It uses the default Material Design `preact-cli` template, with the addition of Redux.
6 |
7 | The app is **interactive in 1.2 seconds on mobile 3G** as tested at webpagetest.org using Chrome on a Motorola G (gen 4) tested from Dulles, Virginia on a 1.6 Mbps 3G connection with 300ms of latency. [Check out the results here.](https://www.webpagetest.org/lighthouse.php?test=181128_9K_916ea978dd63605aa04610aa64ec8f30&run=3)
8 |
9 | View the demo at: https://growthcloud.io
10 |
11 | ### The app features
12 |
13 | - **Interactive in 1.2 seconds on mobile 3G**
14 | - Cost only $5.00 per month to run and that includes a free Cloudflare SSL certificate and 5,000,000 requests
15 | - **Server push** of static assets
16 | - Runtime **server side rendering** with Redux data store and client hydration of the server state
17 | - **Streaming responses** with Cloudflare (head is sent before starting SSR). [View the Cloudflare streaming docs](https://developers.cloudflare.com/workers/recipes/streaming-responses/)
18 | - Embedded critical `js` and `css` assets right in the worker provide consistent ultra-low latency serving of static assets. Even when the worker is 'cold' and time to first byte is slower, the server push of `js` and `css` is pretty consistent because they are coming out of the now running worker (with server push). We've noticed 40-50ms times for our bundles (on desktop) regardless if the worker was hot or cold.
19 | - Proxy non-critical assets (like icons) to an s3 bucket
20 | - All the great stuff of an **app created with `preact-cli@3.0.0-next.14`**
21 | - 100/100 Lighthouse score ([as audited in production on Cloudflare worker](https://www.webpagetest.org/lighthouse.php?test=181128_9K_916ea978dd63605aa04610aa64ec8f30&run=3))
22 | - Code splitting
23 | - Service Worker and **offline functionality**
24 | - You can install it on the home screen of your mobile device (tested on Android/Chrome)
25 |
26 | ### Further work & community
27 |
28 | Feel free to open an issue with any comments, questions, or feedback! This is just a proof of concept to see that it could really be done. A lot still needs to be done to clean it up! If you're interested in collaborating on this, open an issue and let us know!
29 |
30 | ### Basic guide to running this
31 |
32 | If you want to deploy your own PWA onto a Cloudflare worker, here are some basic instructions (also as a reminder to myself).
33 |
34 | #### Setting up a worker & DNS
35 |
36 | [Worker documentation](https://developers.cloudflare.com/workers/).
37 |
38 | You will need to point your nameservers at Cloudflare's DNS. It seems this has to be the root domain, it's a shame we cannot delegate a subdomain to Cloudflare DNS. Once that is done it seems that you also need to create some root record for your domain within the Cloudflare DNS. I just pointed a CNAME at our S3 bucket. Maybe this is not needed, but it seemed to be.
39 |
40 | You then need to enable workers for your domain. It costs $5.00 per month and that covers the first 5,000,000 requests.
41 |
42 | #### Deploying the worker
43 |
44 | It's pretty easy to use `curl` to deploy the worker and it's very fast. Also it seems most of the time the live worker is updated almost immediately. Although sometimes it can take a little time to see the new version (very rarely more than 30 seconds).
45 |
46 | There is a simple `Makefile` in this repo with a couple commands to build and deploy. If you want to use it you'll need to get your Cloudflare keys and add them to a `.env` file. [The Cloudflare docs for deploying and finding your keys](https://developers.cloudflare.com/workers/api/).
47 |
48 | ```
49 | # save this to a .env file
50 | ACCOUNT_AUTH_KEY=
51 | ZONE_ID=
52 | ACCOUNT_EMAIL=
53 |
54 | STATIC_ASSETS_BASE_URL=we-use-an-s3-bucket-here
55 | ```
56 |
57 | After that you can run `make cf` to build and deploy everything. Have a look at the `Makefile` if you want to see the commands.
58 |
59 | #### Redux & SSR
60 |
61 | We add redux in the `index.js` file of the default app created by `preact-cli`. For server side rendering we accept two props `reduxStateFromServer` and `url`.
62 |
63 | The default `index.js`
64 |
65 | ```js
66 | import "./style";
67 | import App from "./components/app";
68 |
69 | export default App;
70 | ```
71 |
72 | Adding Redux to [`index.js`](src/index.js). Note that `Provider` comes from `preact-redux`.
73 |
74 | ```js
75 | import "./style";
76 | import App from "./components/app";
77 | import store from "./store";
78 | import { Provider } from "preact-redux";
79 |
80 | export default ({ reduxStateFromServer, url }) => {
81 | const initialState =
82 | typeof window !== "undefined"
83 | ? // we are on the client let's rehydrate the state from the server, if available
84 | window.__PRELOADED_STATE__ || {
85 | // if all goes well, we should never see this state
86 | name: "Default name from client side"
87 | }
88 | : // we are on the server
89 | reduxStateFromServer;
90 | return (
91 |
92 |
93 |
94 | );
95 | };
96 | ```
97 |
98 | Then for server side rendering we import the ssr bundle created by `preact-cli` and pass it the needed props. [See the whole file here.](worker/ssr-render.js)
99 |
100 | ```js
101 | import { h } from "preact"; // eslint-disable-line no-unused-vars
102 | import render from "preact-render-to-string";
103 | import App from "../build/ssr-build/ssr-bundle";
104 |
105 | // ... //
106 | const serverGatheredState = await (() =>
107 | Promise.resolve({ name: "State rehydrated from the server" }))();
108 |
109 | const html = render(
110 |
111 | );
112 | // ... //
113 | ```
114 |
115 | #### Creating `preact.config.js`
116 |
117 | We utilize `preact.config.js` to modify the default `webpack` config inside the `preact-cli`. We use the `on-build-webpack` plugin to create the files needed for our worker so that we can embed and utilize server push for critical `js` and `css` assets.
118 |
119 | #### Non-critical assets (like icons)
120 |
121 | We serve non-critical assets from an s3 bucket. This url can be set in the `.env` file as `STATIC_ASSETS_BASE_URL` and it will be included into the worker with `dotenv-webpack`. See [`webpack.config.worker.js`](webpack.config.worker.js). We've used s3, but it doesn't need to be s3 specifically.
122 |
123 | #### Multiple route caching in our Service Worker
124 |
125 | This feels like a bit of a hack and a deeper customization of the service worker would be more appropriate, but we haven't done that yet.
126 |
127 | In any case to get the Service Worker to cache multiple routes, we updated the `preact-cli` build command in `package.json` and added a `prerender-urls.json` file.
128 |
129 | The `prerender-urls.json` file.
130 |
131 | ```js
132 | [{ url: "/" }, { url: "/profile" }];
133 | ```
134 |
135 | Update `package.json`
136 |
137 | ```js
138 | {
139 | "build": "preact build --prerender --prerenderUrls src/prerender-urls.json",
140 | }
141 | ```
142 |
143 | Note that this generates files at `build/index.html` and `build/profile/index.html`. We never use these generated files and when the Service Worker requests them we return a runtime generated response using our redux store and SSR. In this way we can provide a unique offline experience for each user depending on the data they load.
144 |
145 | To complete this temporary hack we also create a little mapping in our worker to get SSR to return the correct pages.
146 |
147 | ```js
148 | const simpleRouterHack = (pathname, ssr) => {
149 | return pathname === "/index.html" || pathname === "/"
150 | ? "/"
151 | : pathname === "/profile" ||
152 | pathname === "/profile/" ||
153 | pathname === "/profile/index.html"
154 | ? "/profile"
155 | : // a route like /profile/:username/extra-field should be a 404
156 | pathname.includes("/profile") && !pathname.split("/")[3]
157 | ? // if this is for ssr then we give the full pathname
158 | ssr
159 | ? pathname
160 | : // otherwise it's to pull the currect assets out of our
161 | // push-manifest and we give just /profile
162 | "/profile"
163 | : "/404";
164 | };
165 | ```
166 |
167 | #### Double rendering of async components
168 |
169 | We had some issues with seeing double rendering of async components and it seems to come from calling setState() when the component first renders. There is an open issue here: https://github.com/developit/preact-cli/issues/677
170 |
171 | ### Areas we still investigating
172 |
173 | - Routes like `/profile` vs `/profile/` and `/profile/:user` flicker from the home route to the correct route because the service worker doesn't understand them. The `/profile/` does not flicker. Would be nice to make these work without the flickering.
174 | - We'd like to further clean up the `preact.config.js` plugin to reduce data manipulation in the function. For example `const mainBundleName = Object.keys(Assets).filter( key => key.includes("bundle") && key.includes("js") )[0];` should be done in the plugin. Also this could be made as a separate plugin and more easily installed.
175 | - Routing / 404 pages / serving assets is just a hack at the moment and would need a more robust solution for a real app of any complexity. Also all 404 routes flicker with the homepage if it is not a first time request or a hard refresh (service worker can't seem to tell it should be a 404).
176 | - Would be nice to make a local dev version of the worker so that we could test locally. At least enough to test the app.
177 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "private": true,
3 | "name": "materialdemo",
4 | "version": "0.0.0",
5 | "license": "MIT",
6 | "scripts": {
7 | "start":
8 | "if-env NODE_ENV=production && npm run -s serve || npm run -s dev",
9 | "build":
10 | "preact build --prerender --prerenderUrls src/prerender-urls.json",
11 | "dev": "preact watch",
12 | "lint": "eslint src",
13 | "build-worker": "webpack --config ./webpack.config.worker.js",
14 | "deploy": "node ./scripts/deploy.js",
15 | "new": "preact create simple simple"
16 | },
17 | "browserslist": ["> 1%", "IE >= 9", "last 5 versions"],
18 | "eslintConfig": {
19 | "extends": "eslint-config-synacor",
20 | "rules": {
21 | "no-unused-vars": "warn",
22 | "react/sort-comp": "off",
23 | "lines-around-comment": "off",
24 | "react/prefer-stateless-function": "off"
25 | }
26 | },
27 | "eslintIgnore": ["build/*"],
28 | "devDependencies": {
29 | "dotenv": "^6.1.0",
30 | "dotenv-webpack": "^1.5.7",
31 | "eslint": "^4.5.0",
32 | "eslint-config-synacor": "^1.1.0",
33 | "if-env": "^1.0.0",
34 | "on-build-webpack": "^0.1.0",
35 | "preact-cli": "^3.0.0-next.14",
36 | "webpack-cli": "^3.1.2"
37 | },
38 | "dependencies": {
39 | "preact": "^8.2.1",
40 | "preact-compat": "^3.17.0",
41 | "preact-material-components": "^1.4.3",
42 | "preact-redux": "^2.0.3",
43 | "preact-router": "^2.5.5",
44 | "redux": "^4.0.1"
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/preact.config.js:
--------------------------------------------------------------------------------
1 | var WebpackOnBuildPlugin = require("on-build-webpack");
2 | var fs = require("fs");
3 |
4 | // rawLoader taken from https://github.com/webpack-contrib/raw-loader/blob/master/index.js
5 | const rawLoader = source =>
6 | JSON.stringify(source)
7 | .replace(/\u2028/g, "\\u2028")
8 | .replace(/\u2029/g, "\\u2029");
9 |
10 | export default (config, env, helpers) => {
11 | config.plugins.push(
12 | new WebpackOnBuildPlugin(function(stats) {
13 | // this is the client data
14 | if (env.ssr === false) {
15 | // read the service worker because it's not in the output list
16 | const serviceWorker = fs.readFileSync("./build/sw.js", "utf8");
17 |
18 | // get all the assets that webpack just compiled for the client
19 | const Assets = stats.compilation.assets;
20 |
21 | // create one file that can be used to import sources so webpack will include in Cloudflare worker bundle
22 | const requireableSources =
23 | Object.keys(Assets)
24 | .filter(fileName => {
25 | const ext = fileName.split(".").pop();
26 | return (
27 | (ext === "js" ||
28 | ext === "css" ||
29 | ext === "json") &&
30 | // we don't want to serve the push-manifest.json to the public
31 | !fileName.includes("push-manifest.json")
32 | );
33 | })
34 | .map(fileName => {
35 | return {
36 | fileName,
37 | source: rawLoader(
38 | // here we pull out the webpack compiled source for ever client side asset
39 | Assets[fileName].source().toString("utf8") // manifest.json is buffer
40 | )
41 | };
42 | })
43 | .reduce((acc, resource) => {
44 | return `${acc},"/${resource.fileName}": ${
45 | resource.source
46 | }`;
47 | }, `module.exports={"/sw.js": ${rawLoader(serviceWorker)}`) +
48 | "}";
49 |
50 | // write out to a file we can use to serve the assets directly from our Cloudflare worker
51 | fs.writeFileSync(
52 | "./build/ssr-build/client-assets.js",
53 | requireableSources
54 | );
55 | }
56 | })
57 | );
58 | };
59 |
--------------------------------------------------------------------------------
/scripts/deploy.js:
--------------------------------------------------------------------------------
1 | // adapted from https://github.com/cloudflare/workers-react-example/blob/master/scripts/deploy.js
2 | require("dotenv").config({ path: ".env" });
3 | const fs = require("fs");
4 | const util = require("util");
5 | const fetch = require("node-fetch");
6 | const readFile = util.promisify(fs.readFile);
7 |
8 | async function deploy(script) {
9 | let resp = await fetch(
10 | `https://api.cloudflare.com/client/v4/zones/${
11 | process.env.ZONE_ID
12 | }/workers/script`,
13 | {
14 | method: "PUT",
15 | headers: {
16 | "cache-control": "no-cache",
17 | "content-type": "application/javascript",
18 | "X-Auth-Email": process.env.ACCOUNT_EMAIL,
19 | "X-Auth-Key": process.env.ACCOUNT_AUTH_KEY
20 | },
21 | body: script
22 | }
23 | );
24 | let data = await resp.json();
25 | return data;
26 | }
27 |
28 | readFile("dist/worker.js", "utf8").then(data => {
29 | deploy(data).then(d => {
30 | if (d.success) {
31 | console.log("Worker uploaded");
32 | } else {
33 | console.log(d.errors);
34 | }
35 | });
36 | });
37 |
--------------------------------------------------------------------------------
/src/assets/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DigitalOptimizationGroup/cloudflare-worker-preact-pwa/e13ea09201053bafb6da21615311f2ee9c1eae57/src/assets/favicon.ico
--------------------------------------------------------------------------------
/src/assets/icons/android-chrome-192x192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DigitalOptimizationGroup/cloudflare-worker-preact-pwa/e13ea09201053bafb6da21615311f2ee9c1eae57/src/assets/icons/android-chrome-192x192.png
--------------------------------------------------------------------------------
/src/assets/icons/android-chrome-512x512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DigitalOptimizationGroup/cloudflare-worker-preact-pwa/e13ea09201053bafb6da21615311f2ee9c1eae57/src/assets/icons/android-chrome-512x512.png
--------------------------------------------------------------------------------
/src/assets/icons/apple-touch-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DigitalOptimizationGroup/cloudflare-worker-preact-pwa/e13ea09201053bafb6da21615311f2ee9c1eae57/src/assets/icons/apple-touch-icon.png
--------------------------------------------------------------------------------
/src/assets/icons/favicon-16x16.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DigitalOptimizationGroup/cloudflare-worker-preact-pwa/e13ea09201053bafb6da21615311f2ee9c1eae57/src/assets/icons/favicon-16x16.png
--------------------------------------------------------------------------------
/src/assets/icons/favicon-32x32.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DigitalOptimizationGroup/cloudflare-worker-preact-pwa/e13ea09201053bafb6da21615311f2ee9c1eae57/src/assets/icons/favicon-32x32.png
--------------------------------------------------------------------------------
/src/assets/icons/mstile-150x150.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DigitalOptimizationGroup/cloudflare-worker-preact-pwa/e13ea09201053bafb6da21615311f2ee9c1eae57/src/assets/icons/mstile-150x150.png
--------------------------------------------------------------------------------
/src/components/app.js:
--------------------------------------------------------------------------------
1 | import { h, Component } from "preact";
2 | import { Router } from "preact-router";
3 |
4 | import Header from "./header";
5 | import Home from "../routes/home";
6 | import Profile from "../routes/profile";
7 | import NotFound from "../routes/404";
8 |
9 | export default class App extends Component {
10 | handleRoute = e => {
11 | // awaiting this issue: https://github.com/developit/preact-cli/issues/677
12 | // wrapping in setTimeout is a temporary solution
13 | setTimeout(
14 | () =>
15 | this.setState({
16 | currentUrl: this.props.url || e.url
17 | }),
18 | 0
19 | );
20 | };
21 |
22 | render(props) {
23 | return (
24 |