├── .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 | [![Lighthouse score: 100/100](https://lighthouse-badge.appspot.com/?score=100)](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 |
25 |
26 | 27 | 28 | 29 | 30 | 31 | 32 |
33 | ); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/components/header/index.js: -------------------------------------------------------------------------------- 1 | import { h, Component } from "preact"; 2 | import { route } from "preact-router"; 3 | import TopAppBar from "preact-material-components/TopAppBar"; 4 | import Drawer from "preact-material-components/Drawer"; 5 | import List from "preact-material-components/List"; 6 | import Dialog from "preact-material-components/Dialog"; 7 | import Switch from "preact-material-components/Switch"; 8 | import "preact-material-components/Switch/style.css"; 9 | import "preact-material-components/Dialog/style.css"; 10 | import "preact-material-components/Drawer/style.css"; 11 | import "preact-material-components/List/style.css"; 12 | import "preact-material-components/TopAppBar/style.css"; 13 | 14 | export default class Header extends Component { 15 | closeDrawer() { 16 | this.drawer.MDComponent.open = false; 17 | this.state = { 18 | darkThemeEnabled: false 19 | }; 20 | } 21 | 22 | openDrawer = () => (this.drawer.MDComponent.open = true); 23 | 24 | openSettings = () => this.dialog.MDComponent.show(); 25 | 26 | drawerRef = drawer => (this.drawer = drawer); 27 | dialogRef = dialog => (this.dialog = dialog); 28 | 29 | linkTo = path => () => { 30 | route(path); 31 | this.closeDrawer(); 32 | }; 33 | 34 | goHome = this.linkTo("/"); 35 | goToMyProfile = this.linkTo("/profile/"); 36 | 37 | toggleDarkTheme = () => { 38 | this.setState( 39 | { 40 | darkThemeEnabled: !this.state.darkThemeEnabled 41 | }, 42 | () => { 43 | if (this.state.darkThemeEnabled) { 44 | document.body.classList.add("mdc-theme--dark"); 45 | } else { 46 | document.body.classList.remove("mdc-theme--dark"); 47 | } 48 | } 49 | ); 50 | }; 51 | 52 | render(props) { 53 | return ( 54 |
55 | 56 | 57 | 58 | 59 | 66 | 67 | 68 | 69 | 70 | Preact app 71 | 72 | 77 | 78 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 98 | 99 | 105 | 106 | 107 | 108 | 109 | Home 110 | 111 | 115 | 116 | 122 | 123 | 124 | 125 | 126 | Profile 127 | 128 | 129 | 130 | 131 | Settings 132 | 133 |
134 | Enable dark theme{" "} 135 | 136 |
137 |
138 | 139 | OK 140 | 141 |
142 |
143 | ); 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /src/components/header/style.css: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DigitalOptimizationGroup/cloudflare-worker-preact-pwa/e13ea09201053bafb6da21615311f2ee9c1eae57/src/components/header/style.css -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import "./style"; 2 | import App from "./components/app"; 3 | 4 | import store from "./store"; 5 | import { Provider } from "preact-redux"; 6 | 7 | export default ({ reduxStateFromServer, url }) => { 8 | const initialState = 9 | typeof window !== "undefined" 10 | ? // we are on the client let's rehydrate the state from the server, if available 11 | window.__PRELOADED_STATE__ || { 12 | name: "Default name from client side" 13 | } 14 | : // we are on the server 15 | reduxStateFromServer; 16 | return ( 17 | 18 | 19 | 20 | ); 21 | }; 22 | -------------------------------------------------------------------------------- /src/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "materialDemo", 3 | "short_name": "materialDemo", 4 | "start_url": "/", 5 | "display": "standalone", 6 | "orientation": "portrait", 7 | "background_color": "#fff", 8 | "theme_color": "#673ab8", 9 | "icons": [ 10 | { 11 | "src": "/assets/icons/android-chrome-192x192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "/assets/icons/android-chrome-512x512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ] 21 | } -------------------------------------------------------------------------------- /src/prerender-urls.json: -------------------------------------------------------------------------------- 1 | [{ "url": "/" }, { "url": "/profile" }] 2 | -------------------------------------------------------------------------------- /src/routes/404/index.js: -------------------------------------------------------------------------------- 1 | import { h, Component } from 'preact'; 2 | import Card from 'preact-material-components/Card'; 3 | import 'preact-material-components/Card/style.css'; 4 | import style from './style'; 5 | 6 | export default class NotFound extends Component { 7 | render() { 8 | return ( 9 |
10 | 11 |
12 |

404! Page not found.

13 |
14 |
15 | Looks like the page you are trying to access, doesn't exist. 16 |
17 |
18 |
19 | ); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/routes/404/style.css: -------------------------------------------------------------------------------- 1 | .home { 2 | padding: 20px; 3 | min-height: 100%; 4 | width: 100%; 5 | } 6 | 7 | .cardHeader { 8 | padding: 16px; 9 | } 10 | 11 | .cardBody { 12 | padding: 16px; 13 | } 14 | -------------------------------------------------------------------------------- /src/routes/home/index.js: -------------------------------------------------------------------------------- 1 | import { h, Component } from "preact"; 2 | import Card from "preact-material-components/Card"; 3 | import "preact-material-components/Card/style.css"; 4 | import "preact-material-components/Button/style.css"; 5 | import style from "./style"; 6 | import { connect } from "preact-redux"; 7 | 8 | class Home extends Component { 9 | render({ name }) { 10 | return ( 11 |
12 |

Home route

13 | 14 |
15 |

Home card

16 |
17 | Welcome to home route, {name}! 18 |
19 |
20 |
21 | Progressive Web App - Try it offline! Bootstraped with 22 | preact-cli! 23 |
24 | 25 | OKAY 26 | 27 |
28 |
29 | ); 30 | } 31 | } 32 | 33 | export default connect( 34 | state => { 35 | return { 36 | name: state.name 37 | }; 38 | }, 39 | {} 40 | )(Home); 41 | -------------------------------------------------------------------------------- /src/routes/home/style.css: -------------------------------------------------------------------------------- 1 | .home { 2 | padding: 20px; 3 | min-height: 100%; 4 | width: 100%; 5 | } 6 | 7 | .cardHeader { 8 | padding: 16px; 9 | } 10 | 11 | .cardBody { 12 | padding: 16px; 13 | } 14 | -------------------------------------------------------------------------------- /src/routes/profile/index.js: -------------------------------------------------------------------------------- 1 | import { h, Component } from 'preact'; 2 | import Button from 'preact-material-components/Button'; 3 | import 'preact-material-components/Button/style.css'; 4 | import style from './style'; 5 | 6 | export default class Profile extends Component { 7 | state = { 8 | time: Date.now(), 9 | count: 10 10 | }; 11 | 12 | // gets called when this route is navigated to 13 | componentDidMount() { 14 | // start a timer for the clock: 15 | this.timer = setInterval(this.updateTime, 1000); 16 | } 17 | 18 | // gets called just before navigating away from the route 19 | componentWillUnmount() { 20 | clearInterval(this.timer); 21 | } 22 | 23 | // update the current time 24 | updateTime = () => { 25 | this.setState({ time: Date.now() }); 26 | }; 27 | 28 | increment = () => { 29 | this.setState({ count: this.state.count+1 }); 30 | }; 31 | 32 | // Note: `user` comes from the URL, courtesy of our router 33 | render({ user }, { time, count }) { 34 | return ( 35 |
36 |

Profile: {user}

37 |

This is the user profile for a user named { user }.

38 | 39 |
Current time: {new Date(time).toLocaleString()}
40 | 41 |

42 | 43 | {' '} 44 | Clicked {count} times. 45 |

46 |
47 | ); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/routes/profile/style.css: -------------------------------------------------------------------------------- 1 | .profile { 2 | padding: 20px; 3 | min-height: 100%; 4 | width: 100%; 5 | } 6 | -------------------------------------------------------------------------------- /src/store.js: -------------------------------------------------------------------------------- 1 | import { createStore } from "redux"; 2 | 3 | export default defaultState => { 4 | return createStore((state = {}, action) => { 5 | return state; 6 | }, defaultState); 7 | }; 8 | -------------------------------------------------------------------------------- /src/style/index.css: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | height: 100%; 4 | width: 100%; 5 | padding: 0; 6 | margin: 0; 7 | background: #fafafa; 8 | font-family: "Helvetica Neue", arial, sans-serif; 9 | font-weight: 400; 10 | color: #444; 11 | -webkit-font-smoothing: antialiased; 12 | -moz-osx-font-smoothing: grayscale; 13 | } 14 | 15 | /* 16 | @font-face { 17 | font-family: 'Material Icons'; 18 | font-style: normal; 19 | font-weight: 400; 20 | src: url(https://fonts.gstatic.com/s/materialicons/v29/2fcrYFNaTjcS6g4U3t-Y5UEw0lE80llgEseQY3FEmqw.woff2) format('woff2'); 21 | } 22 | 23 | .material-icons { 24 | font-family: 'Material Icons'; 25 | font-weight: normal; 26 | font-style: normal; 27 | font-size: 24px; 28 | line-height: 1; 29 | letter-spacing: normal; 30 | text-transform: none; 31 | display: inline-block; 32 | white-space: nowrap; 33 | word-wrap: normal; 34 | direction: ltr; 35 | -webkit-font-feature-settings: 'liga'; 36 | -webkit-font-smoothing: antialiased; 37 | } 38 | fallback */ 39 | 40 | * { 41 | box-sizing: border-box; 42 | } 43 | 44 | #app { 45 | height: 100%; 46 | } 47 | 48 | .mdc-theme--dark { 49 | background-color: #333; 50 | color: #fff; 51 | } 52 | 53 | .mdc-theme--dark .mdc-card { 54 | color: #444; 55 | } 56 | 57 | #app .page { 58 | padding-top: 56px; 59 | } 60 | -------------------------------------------------------------------------------- /webpack.config.worker.js: -------------------------------------------------------------------------------- 1 | const Dotenv = require("dotenv-webpack"); 2 | 3 | module.exports = { 4 | entry: { worker: "./worker" }, 5 | target: "webworker", 6 | module: { 7 | rules: [ 8 | { 9 | test: /\.js$/, 10 | exclude: /(node_modules)/, 11 | use: { 12 | loader: "babel-loader", 13 | options: { 14 | presets: [ 15 | [ 16 | "@babel/preset-env", 17 | { 18 | targets: { chrome: "70" } 19 | } 20 | ] 21 | ], 22 | plugins: [ 23 | [ 24 | "@babel/plugin-transform-react-jsx", 25 | { pragma: "h" } 26 | ] 27 | ] 28 | } 29 | } 30 | } 31 | ] 32 | }, 33 | plugins: [ 34 | new Dotenv({ 35 | path: ".env" 36 | }) 37 | ] 38 | }; 39 | -------------------------------------------------------------------------------- /worker/index.js: -------------------------------------------------------------------------------- 1 | import ssrRender from "./ssr-render"; 2 | import Assets from "../build/ssr-build/client-assets"; 3 | import PushManifest from "../build/push-manifest.json"; 4 | 5 | addEventListener("fetch", event => { 6 | event.respondWith(handleRequest(event)); 7 | }); 8 | 9 | const simpleRouterHack = (pathname, ssr) => { 10 | return pathname === "/index.html" || pathname === "/" 11 | ? "/" 12 | : pathname === "/profile" || 13 | pathname === "/profile/" || 14 | pathname === "/profile/index.html" 15 | ? "/profile" 16 | : // a route like /profile/:username/extra-field should be a 404 17 | pathname.includes("/profile") && !pathname.split("/")[3] 18 | ? // if this is for ssr then we give the full pathname 19 | ssr 20 | ? pathname 21 | : // otherwise it's to pull the currect assets out of our push-manifest and we give just /profile 22 | "/profile" 23 | : "/404"; 24 | }; 25 | 26 | // would be super interesting to try streaming && see if it makes a difference on the low end devices 27 | async function handleRequest(event) { 28 | const request = event.request; 29 | const { pathname } = new URL(request.url); 30 | 31 | if ( 32 | pathname.includes(".js") || 33 | pathname.includes(".css") || 34 | pathname.includes(".json") || 35 | pathname.includes("favicon.ico") || 36 | pathname.includes("assets") 37 | ) { 38 | if (pathname.includes("/assets") || pathname.includes("favicon.ico")) { 39 | // fetch from s3 40 | return fetch( 41 | `${process.env.STATIC_ASSETS_BASE_URL}${pathname}`, 42 | request 43 | ); 44 | } else if (Assets[pathname]) { 45 | // fetch from this worker for ultra-low latency, even on "cold starts" the bundles 46 | // come really fast because the worker is already awake from the request 47 | // coming from S3, if they are not in the Cloudflare cache it could be 800-1,000ms vs 50ms 48 | return new Response(Assets[pathname], { 49 | status: 200, 50 | headers: new Headers({ 51 | "Content-Type": pathname.includes(".js") 52 | ? "application/javascript" 53 | : "text/css", 54 | "Cache-Control": pathname.includes("sw.js") 55 | ? // don't cache the service worker 56 | "private, no-cache" 57 | : // cache static assets 58 | "public, max-age=31536000" 59 | }) 60 | }); 61 | } else { 62 | return new Response("404 - Resource not found.", { 63 | status: 404, 64 | headers: new Headers({ 65 | "Content-Type": "text/plain", 66 | "Cache-Control": "public, no-cache" 67 | }) 68 | }); 69 | } 70 | } else if (pathname === "/robots.txt") { 71 | return new Response(`User-agent: * Disallow:`, { 72 | status: 200, 73 | headers: new Headers({ 74 | "Content-Type": "text/plain" 75 | }) 76 | }); 77 | } else { 78 | // this generates the path we need to pull out the URL for our push assets 79 | const manifestUrl = simpleRouterHack(pathname); 80 | 81 | // get the assets that are needed on this route or a 404 82 | const routeManifest = PushManifest[manifestUrl] 83 | ? // if we have a route, let's use that 84 | { manifest: PushManifest[manifestUrl], status: 200 } 85 | : // if we don't have a route, we use the 404 js & css & return our custom 404 page 86 | { manifest: PushManifest["/404"], status: 404 }; 87 | 88 | let { readable, writable } = new TransformStream(); 89 | 90 | let writer = writable.getWriter(); 91 | 92 | var encoder = new TextEncoder(); 93 | 94 | const pageHead = ` 95 | 96 | 97 | 98 | Digital Optimization Group - Cloudflare SSR Demo 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | ${Object.keys(routeManifest.manifest) 107 | .filter( 108 | resource => 109 | routeManifest.manifest[resource].type === "style" 110 | ) 111 | .map( 112 | bundleName => 113 | // add css links for bundles relevant to this route 114 | `` 115 | ) 116 | .join("")} 117 | `; 118 | 119 | // write & stream the page head pretty much immediately and then move on to SSR the page 120 | writer.write(encoder.encode(pageHead)); 121 | 122 | // generate the requestUrl needed by our preact-router 123 | const requestUrl = simpleRouterHack(pathname, true); 124 | 125 | // note as per the Cloudflare documentation we do NOT await this function, even though it is async 126 | ssrRender(requestUrl, writer, encoder); 127 | 128 | return new Response(readable, { 129 | // serve 200 or 404 130 | status: routeManifest.status, 131 | headers: new Headers({ 132 | "Content-Type": "text/html", 133 | Link: Object.keys(routeManifest.manifest) 134 | .map( 135 | resource => 136 | // we set the headers here so Cloudflare will server push all the assets that this page will request 137 | `<${"/" + resource}>; rel=preload; as=${ 138 | routeManifest.manifest[resource].type 139 | }` 140 | ) 141 | .join(", ") 142 | }) 143 | }); 144 | } 145 | } 146 | 147 | // if you want to embed the css in the route 148 | // ${CssBundles.filter( 149 | // bundleName => 150 | // Object.keys(routeManifest).indexOf(bundleName) > -1 151 | // ) 152 | // .map( 153 | // // should check the push-manifest to see what we need to load on the given route 154 | // bundleName => 155 | // `` 156 | // ) 157 | // .join("")} 158 | -------------------------------------------------------------------------------- /worker/ssr-render.js: -------------------------------------------------------------------------------- 1 | import { h } from "preact"; // eslint-disable-line no-unused-vars 2 | import render from "preact-render-to-string"; 3 | import App from "../build/ssr-build/ssr-bundle"; 4 | import Assets from "../build/ssr-build/client-assets"; 5 | 6 | const pollyfillBundleName = Object.keys(Assets).filter( 7 | key => key.includes("polyfills") && key.includes("js") 8 | )[0]; 9 | 10 | const mainBundleName = Object.keys(Assets).filter( 11 | key => key.includes("bundle") && key.includes("js") 12 | )[0]; 13 | 14 | // can't seem to escape the <\/script> in window.fetch||document.write(' 24 | 25 | 33 | 34 | 35 | `; 36 | } 37 | 38 | export default async (pathname, writer, encoder) => { 39 | // mock a network request, this is where we could load any needed async data to populate the 40 | // server side redux store 41 | const serverGatheredState = await (() => 42 | Promise.resolve({ name: "State rehydrated from the server" }))(); 43 | 44 | const html = render( 45 | 46 | ); 47 | 48 | // stream the body of the page with our SSR results 49 | writer.write(encoder.encode(renderFullPage(html, serverGatheredState))); 50 | 51 | // await the close of the streaming response 52 | await write.close(); 53 | }; 54 | --------------------------------------------------------------------------------