├── CHANGELOG.md ├── examples ├── with-cms-wordpress │ ├── .cargo-ok │ ├── README.md │ ├── public │ │ └── favicon.ico │ ├── pages │ │ ├── _app.js │ │ ├── posts │ │ │ └── [slug].js │ │ └── index.js │ ├── wrangler.toml │ ├── .gitignore │ ├── package.json │ ├── lib │ │ └── wordpress.js │ ├── styles.css │ └── index.js └── with-typescript │ ├── README.md │ ├── wrangler.toml │ ├── pages │ └── index.tsx │ ├── .gitignore │ ├── package.json │ └── index.js ├── router.js ├── head.js ├── webpack.js ├── flareact.png ├── link.js ├── jsconfig.json ├── src ├── components │ ├── _app.js │ ├── AppProvider.js │ └── _document.js ├── index.js ├── client │ ├── index.js │ └── page-loader.js ├── utils.js ├── worker │ ├── worker.js │ ├── pages.js │ └── index.js ├── bin │ └── flareact.js ├── router.js └── link.js ├── babel.config.js ├── configs ├── loaders.js ├── postcss.config.js ├── utils.js ├── webpack │ ├── loaders │ │ └── flareact-client-pages-loader.js │ └── plugins │ │ └── build-manifest-plugin.js ├── babel │ ├── flareact-babel-loader.js │ └── plugins │ │ └── flareact-edge-transform.js ├── webpack.config.js ├── webpack.worker.config.js └── webpack.client.config.js ├── .github └── workflows │ ├── build.yml │ └── publish.yml ├── docs ├── fast-refresh.md ├── static-file-serving.md ├── flareact-head.md ├── custom-app-page.md ├── flareact-router.md ├── pages.md ├── typescript.md ├── built-in-css-support.md ├── custom-postcss-config.md ├── dynamic-routes.md ├── custom-webpack-config.md ├── flareact-link.md ├── api-routes.md ├── comparison-to-nextjs.md ├── deployment.md ├── index.md ├── getting-started.md └── data-fetching.md ├── .gitignore ├── README.md ├── tests ├── router.spec.js └── pages.spec.js ├── LICENSE └── package.json /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/with-cms-wordpress/.cargo-ok: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /router.js: -------------------------------------------------------------------------------- 1 | export * from "./src/router"; 2 | -------------------------------------------------------------------------------- /head.js: -------------------------------------------------------------------------------- 1 | export { Helmet as default } from "react-helmet"; 2 | -------------------------------------------------------------------------------- /examples/with-typescript/README.md: -------------------------------------------------------------------------------- 1 | # Flareact TypeScript Example 2 | -------------------------------------------------------------------------------- /webpack.js: -------------------------------------------------------------------------------- 1 | module.exports = require("./configs/webpack.worker.config"); 2 | -------------------------------------------------------------------------------- /flareact.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adaptive/flareact/canary/flareact.png -------------------------------------------------------------------------------- /link.js: -------------------------------------------------------------------------------- 1 | export * from "./src/link"; 2 | export { default } from "./src/link"; 3 | -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "esnext", 4 | "jsx": "react" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /examples/with-cms-wordpress/README.md: -------------------------------------------------------------------------------- 1 | # Flareact CMS WordPress Example 2 | 3 | Visit it at https://with-cms-wordpress.jplhomer.workers.dev/ 4 | -------------------------------------------------------------------------------- /examples/with-cms-wordpress/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adaptive/flareact/canary/examples/with-cms-wordpress/public/favicon.ico -------------------------------------------------------------------------------- /src/components/_app.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export default function App({ Component, pageProps }) { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /examples/with-cms-wordpress/pages/_app.js: -------------------------------------------------------------------------------- 1 | import "../styles.css"; 2 | 3 | export default function App({ Component, pageProps }) { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | [ 4 | "@babel/preset-env", 5 | { 6 | targets: { 7 | node: "current", 8 | }, 9 | }, 10 | ], 11 | ["@babel/preset-react"], 12 | ], 13 | }; 14 | -------------------------------------------------------------------------------- /examples/with-typescript/wrangler.toml: -------------------------------------------------------------------------------- 1 | name = "flareact-ts" 2 | type = "webpack" 3 | account_id = "" 4 | workers_dev = true 5 | route = "" 6 | zone_id = "" 7 | webpack_config = "node_modules/flareact/webpack" 8 | 9 | [site] 10 | bucket = "out" 11 | entry-point = "./" 12 | -------------------------------------------------------------------------------- /examples/with-cms-wordpress/wrangler.toml: -------------------------------------------------------------------------------- 1 | name = "with-cms-wordpress" 2 | type = "webpack" 3 | account_id = "" 4 | workers_dev = true 5 | route = "" 6 | zone_id = "" 7 | webpack_config = "node_modules/flareact/webpack" 8 | 9 | [site] 10 | bucket = "out" 11 | entry-point = "./" 12 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import { handleEvent } from "./worker/worker"; 2 | // TODO: Deprecate this, as if it's used in client pages, it imports ALL THIS STUFF. 3 | import { useRouter } from "./router"; 4 | import { PageNotFoundError } from "./worker/pages"; 5 | 6 | export { handleEvent, useRouter, PageNotFoundError }; 7 | -------------------------------------------------------------------------------- /configs/loaders.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | 3 | module.exports = function ({ dev, isServer }) { 4 | return { 5 | babel: { 6 | loader: path.join(__dirname, "babel/flareact-babel-loader.js"), 7 | options: { 8 | dev, 9 | isServer, 10 | }, 11 | }, 12 | }; 13 | }; 14 | -------------------------------------------------------------------------------- /configs/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: [ 3 | require("postcss-flexbugs-fixes"), 4 | require("postcss-preset-env")({ 5 | autoprefixer: { 6 | flexbox: "no-2009", 7 | }, 8 | stage: 3, 9 | features: { 10 | "custom-properties": false, 11 | }, 12 | }), 13 | ], 14 | }; 15 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | - pull_request 5 | - workflow_dispatch 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v2 12 | - uses: actions/setup-node@v1 13 | with: 14 | node-version: 12 15 | - run: yarn --pure-lockfile 16 | - run: yarn test 17 | -------------------------------------------------------------------------------- /examples/with-typescript/pages/index.tsx: -------------------------------------------------------------------------------- 1 | type IndexProps = { 2 | message: string; 3 | }; 4 | 5 | export async function getEdgeProps() { 6 | return { 7 | props: { 8 | message: "Hello", 9 | } as IndexProps, 10 | }; 11 | } 12 | 13 | export default function Index({ message }: IndexProps) { 14 | return ( 15 |
16 |

{message}

17 |
18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /configs/utils.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const fs = require("fs"); 3 | 4 | function fileExistsInDir(dir, file) { 5 | return fs.existsSync(path.join(dir, file)); 6 | } 7 | 8 | module.exports.fileExistsInDir = fileExistsInDir; 9 | 10 | module.exports.flareactConfig = function (dir) { 11 | const file = "flareact.config.js"; 12 | 13 | return fileExistsInDir(dir, file) ? require(path.join(dir, file)) : {}; 14 | }; 15 | -------------------------------------------------------------------------------- /docs/fast-refresh.md: -------------------------------------------------------------------------------- 1 | # Fast Refresh 2 | 3 | Flareact _kind of_ supports React Fast Refresh. Next.js has a [nice explainer on Fast Refresh](https://nextjs.org/docs/basic-features/fast-refresh) and how it works. 4 | 5 | However, you'll find quirks with Flareact's implementation. For example, it is not resilient to syntax errors - and often times, changes will not be picked up. 6 | 7 | _Sorry — I don't understand how a lot of it works._ 8 | -------------------------------------------------------------------------------- /docs/static-file-serving.md: -------------------------------------------------------------------------------- 1 | # Static File Serving 2 | 3 | When you add files to a `/public` folder in your project, Flareact will serve those assets at your site root path. 4 | 5 | For example, if you add `public/favicon.ico` locally, it will be served at `site.com/favicon.ico`. 6 | 7 | Behind the scenes, Cloudflare stores these assets in the [Workers KV](https://www.cloudflare.com/products/workers-kv/) and serves them directly on the edge. 8 | -------------------------------------------------------------------------------- /examples/with-typescript/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | /out/ 12 | 13 | # production 14 | /dist/ 15 | 16 | # misc 17 | .DS_Store 18 | *.pem 19 | 20 | # debug 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | 25 | # local env files 26 | .env 27 | 28 | /worker 29 | -------------------------------------------------------------------------------- /examples/with-cms-wordpress/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | /out/ 12 | 13 | # production 14 | /dist/ 15 | 16 | # misc 17 | .DS_Store 18 | *.pem 19 | 20 | # debug 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | 25 | # local env files 26 | .env 27 | 28 | /worker 29 | -------------------------------------------------------------------------------- /examples/with-cms-wordpress/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "flareact-template", 3 | "version": "1.0.0", 4 | "author": "Josh Larson ", 5 | "license": "MIT", 6 | "main": "index.js", 7 | "private": true, 8 | "scripts": { 9 | "dev": "flareact dev", 10 | "deploy": "flareact publish" 11 | }, 12 | "dependencies": { 13 | "flareact": "^0.7.1", 14 | "react": "^16.13.1", 15 | "react-dom": "^16.13.1" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | /build 12 | 13 | # misc 14 | .DS_Store 15 | *.pem 16 | 17 | # debug 18 | npm-debug.log* 19 | yarn-debug.log* 20 | yarn-error.log* 21 | 22 | .vscode/settings.json 23 | 24 | # examples: ignore yarn.lock as to include the latest possible version 25 | examples/*/yarn.lock 26 | -------------------------------------------------------------------------------- /examples/with-cms-wordpress/lib/wordpress.js: -------------------------------------------------------------------------------- 1 | export async function getPosts() { 2 | const endpoint = `https://wpdemo.jplhomer.org/wp-json/wp/v2/posts`; 3 | const res = await fetch(endpoint); 4 | return await res.json(); 5 | } 6 | 7 | export async function getPost(slug) { 8 | const endpoint = `https://wpdemo.jplhomer.org/wp-json/wp/v2/posts?slug=${slug}&limit=1`; 9 | const res = await fetch(endpoint); 10 | const posts = await res.json(); 11 | return posts[0]; 12 | } 13 | -------------------------------------------------------------------------------- /src/components/AppProvider.js: -------------------------------------------------------------------------------- 1 | import { useRouter } from "../router"; 2 | 3 | export default function AppProvider({ 4 | Component: initialComponent, 5 | pageProps, 6 | App, 7 | }) { 8 | const { component } = useRouter(); 9 | const Component = component.Component || initialComponent; 10 | const props = component.pageProps || pageProps; 11 | 12 | if (!Component) { 13 | return null; 14 | } 15 | 16 | return ; 17 | } 18 | -------------------------------------------------------------------------------- /docs/flareact-head.md: -------------------------------------------------------------------------------- 1 | # flareact/head 2 | 3 | You can insert custom elements into your page's `` tag by using the `flareact/head` component. 4 | 5 | This is powered by [react-helmet](https://github.com/nfl/react-helmet/): 6 | 7 | ```js 8 | import Head from "flareact/head"; 9 | 10 | export default function Index() { 11 | return ( 12 |
13 | 14 | My page title 15 | 16 |

Hello, world.

17 |
18 | ); 19 | } 20 | ``` 21 | -------------------------------------------------------------------------------- /examples/with-cms-wordpress/styles.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, 3 | Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif; 4 | } 5 | 6 | .container { 7 | margin: 3em auto; 8 | max-width: 50rem; 9 | padding: 1em; 10 | } 11 | 12 | .posts { 13 | display: grid; 14 | gap: 1em; 15 | font-size: 1.5em; 16 | } 17 | 18 | @media (min-width: 500px) { 19 | .posts { 20 | grid-template-columns: repeat(3, 1fr); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /examples/with-typescript/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "flareact-template", 3 | "version": "1.0.0", 4 | "author": "Josh Larson ", 5 | "license": "MIT", 6 | "main": "index.js", 7 | "private": true, 8 | "scripts": { 9 | "dev": "flareact dev", 10 | "build": "flareact build", 11 | "deploy": "flareact publish" 12 | }, 13 | "dependencies": { 14 | "flareact": "^0.8.0-alpha.2", 15 | "react": "^16.13.1", 16 | "react-dom": "^16.13.1" 17 | }, 18 | "devDependencies": { 19 | "@types/react": "^16.9.56" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /configs/webpack/loaders/flareact-client-pages-loader.js: -------------------------------------------------------------------------------- 1 | const loaderUtils = require("loader-utils"); 2 | 3 | module.exports = function () { 4 | const { absolutePagePath, page } = loaderUtils.getOptions(this); 5 | const stringifiedAbsolutePagePath = JSON.stringify(absolutePagePath); 6 | const stringifiedPage = JSON.stringify(page); 7 | 8 | return ` 9 | (window.__FLAREACT_PAGES = window.__FLAREACT_PAGES || []).push([ 10 | ${stringifiedPage}, 11 | function () { 12 | return require(${stringifiedAbsolutePagePath}); 13 | } 14 | ]); 15 | `; 16 | }; 17 | -------------------------------------------------------------------------------- /docs/custom-app-page.md: -------------------------------------------------------------------------------- 1 | # Custom App Page 2 | 3 | It's likely that you will want to import global stylesheets, or wrap your entire application in a layout component or [React Context](https://reactjs.org/docs/context.html) provider. 4 | 5 | To do so, you can define your own custom `App` component which Flareact will pull in to render your app. 6 | 7 | Define a `pages/_app.js` file, and be sure to return ``: 8 | 9 | ```js 10 | export default function MyApp({ Component, pageProps }) { 11 | // Your custom stuff here 12 | 13 | return ; 14 | } 15 | ``` 16 | -------------------------------------------------------------------------------- /docs/flareact-router.md: -------------------------------------------------------------------------------- 1 | # flareact/router 2 | 3 | Flareact provides access to the `router` instance using the `useRouter()` hook: 4 | 5 | ```js 6 | import { useRouter } from "flareact/router"; 7 | 8 | export default function Index() { 9 | const router = useRouter(); 10 | 11 | // Inspect the pathname 12 | router.pathname; 13 | 14 | // Inspect the asPath 15 | router.asPath; 16 | 17 | // Inspect the query and params 18 | router.query; 19 | 20 | // Navigate to the /about page 21 | router.push("/about"); 22 | 23 | // Navigate to the dynamic route /posts/[slug] using `href`, `as` 24 | router.push("/posts/[slug]", "/posts/my-first-post"); 25 | } 26 | ``` 27 | -------------------------------------------------------------------------------- /docs/pages.md: -------------------------------------------------------------------------------- 1 | # Pages 2 | 3 | In Flareact, a **page** is a React Component exported from a `.js` file in the `pages` directory. Each page is associated with a route based on its filename. 4 | 5 | **Example**: If you create `pages/about.js` that exports a React component like this, it will be accessible at `/about`: 6 | 7 | ```js 8 | export default function About() { 9 | return

About

; 10 | } 11 | ``` 12 | 13 | ## Dynamic Routes 14 | 15 | Flareact supports pages with dynamic routes. 16 | 17 | **Example**: If you create `pages/posts/[slug].js`, then it will be accessible at `posts/my-first-post`, `posts/another-update`, etc. 18 | 19 | [Learn more about Dynamic Routes](/docs/dynamic-routes) 20 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | - push 5 | 6 | jobs: 7 | build: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v2 11 | - uses: actions/setup-node@v1 12 | with: 13 | node-version: 12 14 | - run: yarn --pure-lockfile 15 | - run: yarn test 16 | 17 | publish-npm: 18 | needs: build 19 | runs-on: ubuntu-latest 20 | steps: 21 | - name: Checkout 22 | uses: actions/checkout@v2 23 | - name: Semantic Release 24 | uses: cycjimmy/semantic-release-action@v2 25 | env: 26 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 27 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 28 | -------------------------------------------------------------------------------- /docs/typescript.md: -------------------------------------------------------------------------------- 1 | # TypeScript Support 2 | 3 | Flareact supports TypeScript out of the box (**currently in `alpha`**). 4 | 5 | To get started, add `typescript` to your project: 6 | 7 | ```bash 8 | yarn add flareact@alpha typescript 9 | yarn add -D @types/react 10 | ``` 11 | 12 | Then you can write components in TypeScript: 13 | 14 | ```tsx 15 | type IndexProps = { 16 | message: string; 17 | }; 18 | 19 | export async function getEdgeProps() { 20 | return { 21 | props: { 22 | message: "Hello", 23 | } as IndexProps, 24 | }; 25 | } 26 | 27 | export default function Index({ message }: IndexProps) { 28 | return ( 29 |
30 |

{message}

31 |
32 | ); 33 | } 34 | ``` 35 | 36 | Coming soon: 37 | 38 | - Official Flareact type definitions 39 | -------------------------------------------------------------------------------- /examples/with-cms-wordpress/pages/posts/[slug].js: -------------------------------------------------------------------------------- 1 | import { getPost } from "../../lib/wordpress"; 2 | import Link from "flareact/link"; 3 | 4 | export async function getStaticProps({ params }) { 5 | const { slug } = params; 6 | 7 | const post = await getPost(slug); 8 | 9 | return { 10 | props: { 11 | post, 12 | }, 13 | // Revalidate every 8 hours 14 | revalidate: 60 * 60 * 8, 15 | }; 16 | } 17 | 18 | export default function Post({ post }) { 19 | return ( 20 |
21 |

{post.title.rendered}

22 |

23 | 24 | Home 25 | 26 |

27 |
31 |
32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /examples/with-cms-wordpress/index.js: -------------------------------------------------------------------------------- 1 | import { handleEvent } from "flareact"; 2 | 3 | /** 4 | * The DEBUG flag will do two things that help during development: 5 | * 1. we will skip caching on the edge, which makes it easier to 6 | * debug. 7 | * 2. we will return an error message on exception in your Response rather 8 | * than the default 404.html page. 9 | */ 10 | const DEBUG = false; 11 | 12 | addEventListener("fetch", (event) => { 13 | try { 14 | event.respondWith( 15 | handleEvent(event, require.context("./pages/", true, /\.js$/), DEBUG) 16 | ); 17 | } catch (e) { 18 | if (DEBUG) { 19 | return event.respondWith( 20 | new Response(e.message || e.toString(), { 21 | status: 500, 22 | }) 23 | ); 24 | } 25 | event.respondWith(new Response("Internal Error", { status: 500 })); 26 | } 27 | }); 28 | -------------------------------------------------------------------------------- /examples/with-typescript/index.js: -------------------------------------------------------------------------------- 1 | import { handleEvent } from "flareact"; 2 | 3 | /** 4 | * The DEBUG flag will do two things that help during development: 5 | * 1. we will skip caching on the edge, which makes it easier to 6 | * debug. 7 | * 2. we will return an error message on exception in your Response rather 8 | * than the default 404.html page. 9 | */ 10 | const DEBUG = false; 11 | 12 | addEventListener("fetch", (event) => { 13 | try { 14 | event.respondWith( 15 | handleEvent(event, require.context("./pages/", true, /\.(js|jsx|ts|tsx)$/), DEBUG) 16 | ); 17 | } catch (e) { 18 | if (DEBUG) { 19 | return event.respondWith( 20 | new Response(e.message || e.toString(), { 21 | status: 500, 22 | }) 23 | ); 24 | } 25 | event.respondWith(new Response("Internal Error", { status: 500 })); 26 | } 27 | }); 28 | -------------------------------------------------------------------------------- /examples/with-cms-wordpress/pages/index.js: -------------------------------------------------------------------------------- 1 | import { getPosts } from "../lib/wordpress"; 2 | import Link from "flareact/link"; 3 | 4 | export async function getEdgeProps() { 5 | const posts = await getPosts(); 6 | 7 | return { 8 | props: { 9 | posts, 10 | }, 11 | // Revalidate every 8 hours 12 | revalidate: 60 * 60 * 8, 13 | }; 14 | } 15 | 16 | export default function Index({ posts = [] }) { 17 | return ( 18 |
19 |

WordPress, Powered by Flareact

20 |
21 | {posts.map((post) => { 22 | return ( 23 |
24 | 25 | {post.title.rendered} 26 | 27 |
28 | ); 29 | })} 30 |
31 |
32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Flareact 2 | 3 | Flareact is an **edge-rendered React framework** powered by Cloudflare Workers. 4 | 5 | [![Deploy to Cloudflare Workers](https://deploy.workers.cloudflare.com/button?paid=true)](https://deploy.workers.cloudflare.com/?url=https://github.com/flareact/flareact-template&paid=true) 6 | 7 | It's inspired by [Next.js](https://nextjs.org/). 8 | 9 | That means it supports nice API patterns like: 10 | 11 | - File-based routing 12 | - Dynamic page paths 13 | - Data fetching for page props using `getEdgeProps` 14 | - Universal application rendering (server and client) 15 | 16 | However, it's brand new! So it's also a bunch of these things: 17 | 18 | - **It probably does not work on Windows** 19 | - An early prototype 20 | - Missing years worth of optimizations the Next.js team has implemented 21 | - Probably has a heavy bundle 22 | - Styles? Sort of borked right now 23 | 24 | [Check out the docs!](https://flareact.com) 25 | -------------------------------------------------------------------------------- /configs/babel/flareact-babel-loader.js: -------------------------------------------------------------------------------- 1 | module.exports = require("babel-loader").custom((babel) => { 2 | return { 3 | customOptions({ dev, isServer, ...loader }) { 4 | return { 5 | custom: { dev, isServer }, 6 | loader, 7 | }; 8 | }, 9 | 10 | config(cfg, { customOptions: { isServer, dev } }) { 11 | const filename = this.resourcePath; 12 | const isPageFile = filename.includes("pages"); 13 | 14 | let plugins = ["react-require", "@babel/plugin-transform-runtime"]; 15 | 16 | if (!isServer) { 17 | if (dev) { 18 | plugins.push(require.resolve("react-refresh/babel")); 19 | } 20 | 21 | if (isPageFile) { 22 | plugins.push(require.resolve("./plugins/flareact-edge-transform")); 23 | } 24 | } 25 | 26 | return { 27 | ...cfg.options, 28 | presets: ["@babel/preset-env", "@babel/preset-react", ...(cfg.options.presets || [])], 29 | plugins: [...plugins, ...(cfg.options.plugins || [])], 30 | }; 31 | }, 32 | }; 33 | }); 34 | -------------------------------------------------------------------------------- /tests/router.spec.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { RouterProvider, useRouter } from "../src/router"; 3 | import { render, screen } from "@testing-library/react"; 4 | 5 | it("knows basic information about a route", () => { 6 | render( 7 | 12 | 13 | 14 | ); 15 | 16 | expect(screen.getByTestId("pathname").innerHTML).toBe("/hello/[slug]"); 17 | expect(screen.getByTestId("asPath").innerHTML).toBe("/hello/world"); 18 | expect(screen.getByTestId("query").innerHTML).toBe( 19 | JSON.stringify({ slug: "world" }) 20 | ); 21 | }); 22 | 23 | function RouteTest() { 24 | const router = useRouter(); 25 | 26 | return ( 27 |
28 |

{router.pathname}

29 |

{router.asPath}

30 |

{JSON.stringify(router.query)}

31 |
32 | ); 33 | } 34 | 35 | function Noop() { 36 | return

Hi

; 37 | } 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Flareact 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /docs/built-in-css-support.md: -------------------------------------------------------------------------------- 1 | # Built-In CSS Support 2 | 3 | Flareact currently supports **standard CSS imports**: 4 | 5 | ```js 6 | import '../styles.css'; 7 | 8 | export default MyComponent() { 9 | // 10 | } 11 | ``` 12 | 13 | Your compiled stylesheet will be injected into your document. When deploying, your styles will be minimized automatically. 14 | 15 | A few warning about CSS support in Flareact: 16 | 17 | - No support yet for Sass/Less/etc 18 | - No support for scoped CSS Modules 19 | - In production, Flareact will always attempt to load a `main.css` stylesheet, even if you don't have styles defined. _lol, sorry._ 20 | 21 | ## Global Styles 22 | 23 | To import a global stylesheet in your application, create a [custom App page](/docs/custom-app-page) and import the style there. 24 | 25 | In `pages/_app.js`: 26 | 27 | ```js 28 | import "../styles.css"; 29 | 30 | export default function App({ Component, pageProps }) { 31 | return ; 32 | } 33 | ``` 34 | 35 | ## PostCSS 36 | 37 | Flareact processes all styles through [PostCSS](https://postcss.org/). 38 | 39 | [Learn more about customizing your PostCSS config](/docs/custom-postcss-config). 40 | -------------------------------------------------------------------------------- /docs/custom-postcss-config.md: -------------------------------------------------------------------------------- 1 | # Custom PostCSS Config 2 | 3 | Flareact processes your CSS using PostCSS. By default, Flareact uses the following plugins: 4 | 5 | ```js 6 | module.exports = { 7 | plugins: [ 8 | require("postcss-flexbugs-fixes"), 9 | require("postcss-preset-env")({ 10 | autoprefixer: { 11 | flexbox: "no-2009", 12 | }, 13 | stage: 3, 14 | features: { 15 | "custom-properties": false, 16 | }, 17 | }), 18 | ], 19 | }; 20 | ``` 21 | 22 | If you need to customize the PostCSS plugins for your project, you can define a local `postcss.config.js` file. 23 | 24 | **Note**: If you define a custom PostCSS config, it will completely replace the config that Flareact provides - so be sure to include everything you need to process your styles. 25 | 26 | Here's an example for using TailwindCSS: 27 | 28 | ```js 29 | module.exports = { 30 | plugins: [ 31 | require("postcss-flexbugs-fixes"), 32 | require("postcss-preset-env")({ 33 | autoprefixer: { 34 | flexbox: "no-2009", 35 | }, 36 | stage: 3, 37 | features: { 38 | "custom-properties": false, 39 | }, 40 | }), 41 | ], 42 | }; 43 | ``` 44 | -------------------------------------------------------------------------------- /src/client/index.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | import PageLoader from "./page-loader"; 4 | import { RouterProvider } from "../router"; 5 | import AppProvider from "../components/AppProvider"; 6 | 7 | const initialData = JSON.parse( 8 | document.getElementById("__FLAREACT_DATA").textContent 9 | ); 10 | 11 | window.__FLAREACT_DATA = initialData; 12 | 13 | const pagePath = initialData.page.pagePath; 14 | const pageLoader = new PageLoader(pagePath); 15 | 16 | const register = (page) => pageLoader.registerPage(page); 17 | 18 | if (window.__FLAREACT_PAGES) { 19 | window.__FLAREACT_PAGES.forEach((p) => register(p)); 20 | } 21 | 22 | window.__FLAREACT_PAGES = []; 23 | window.__FLAREACT_PAGES.push = register; 24 | 25 | async function render() { 26 | const App = await pageLoader.loadPage("/_app"); 27 | const Component = await pageLoader.loadPage(pagePath); 28 | 29 | ReactDOM.hydrate( 30 | 36 | 41 | , 42 | document.getElementById("__flareact") 43 | ); 44 | } 45 | 46 | render(); 47 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | import { DYNAMIC_PAGE } from "./worker/pages"; 2 | 3 | /** 4 | * Extract dynamic params from a slug path. For use in the router, but should eventually 5 | * be refactored to share responsibilities with the /worker/pages.js module. 6 | * 7 | * @param {string} source e.g. /articles/[slug] 8 | * @param {string} path e.g. /articles/hello-world 9 | * @returns {<[string]: string>} e.g. { slug: 'hello-world' } 10 | */ 11 | export function extractDynamicParams(source, path) { 12 | let test = source; 13 | let parts = []; 14 | let params = {}; 15 | 16 | for (const match of source.matchAll(/\[(\w+)\]/g)) { 17 | parts.push(match[1]); 18 | 19 | test = test.replace(DYNAMIC_PAGE, () => "([\\w_-]+)"); 20 | } 21 | 22 | test = new RegExp(test, "g"); 23 | 24 | const matches = path.matchAll(test); 25 | 26 | for (const match of matches) { 27 | parts.forEach((part, idx) => (params[part] = match[idx + 1])); 28 | } 29 | 30 | return params; 31 | } 32 | 33 | // This utility is based on https://github.com/zertosh/htmlescape 34 | // License: https://github.com/zertosh/htmlescape/blob/0527ca7156a524d256101bb310a9f970f63078ad/LICENSE 35 | 36 | const ESCAPE_LOOKUP = { 37 | "&": "\\u0026", 38 | ">": "\\u003e", 39 | "<": "\\u003c", 40 | "\u2028": "\\u2028", 41 | "\u2029": "\\u2029", 42 | }; 43 | 44 | const ESCAPE_REGEX = /[&><\u2028\u2029]/g; 45 | 46 | export function htmlEscapeJsonString(str) { 47 | return str.replace(ESCAPE_REGEX, (match) => ESCAPE_LOOKUP[match]); 48 | } 49 | -------------------------------------------------------------------------------- /docs/dynamic-routes.md: -------------------------------------------------------------------------------- 1 | # Dynamic Routes 2 | 3 | Often times, your app pages will need to be created dynamically. 4 | 5 | Similar to page-based routes, Flareact allows you to define **dynamic routes** using React components defined in files. 6 | 7 | To define a dynamic route, create a file with a name wrapped by square brackets `[]`, e.g. `/pages/[slug].js`. 8 | 9 | ## Dynamic Routes with `getEdgeProps` 10 | 11 | If you need to [fetch data](/docs/data-fetching) for your dynamic page component, the dynamic parts will be passed as a `params` property to your `getEdgeProps` function. 12 | 13 | **Example**: Your `/pages/posts/[slug].js` file might look like this: 14 | 15 | ```js 16 | export async function getEdgeProps({ params }) { 17 | const { slug } = params; 18 | const post = await getSomeRemotePost({ slug }); 19 | 20 | return { 21 | props: { 22 | post 23 | } 24 | } 25 | } 26 | 27 | export default function Post({ post }) { 28 | ... 29 | } 30 | ``` 31 | 32 | ## Nested Dynamic Routes 33 | 34 | You can also nest dynamic routes, e.g. 35 | 36 | ``` 37 | /pages/posts/[category]/[slug].js 38 | ``` 39 | 40 | The params passed to your `getEdgeProps` function will contain each dynamic path property: 41 | 42 | ```js 43 | { 44 | params: { 45 | category, 46 | slug, 47 | } 48 | } 49 | ``` 50 | 51 | You can also reference the query params with the `useRouter` hook in your component: 52 | 53 | ```js 54 | function Post() { 55 | const router = useRouter(); 56 | 57 | const { category, slug } = router.query; 58 | } 59 | ``` 60 | 61 | ## Resources 62 | 63 | - [Linking to Dynamic Routes](/docs/flareact-link#dynamic-routes) 64 | -------------------------------------------------------------------------------- /docs/custom-webpack-config.md: -------------------------------------------------------------------------------- 1 | # Custom Webpack Config 2 | 3 | Flareact allows you to customize the Webpack config for your worker and client builds. 4 | 5 | To modify the Webpack config, define a `flareact.config.js` file in the root of your project: 6 | 7 | ```js 8 | module.exports = { 9 | webpack: (config, { dev, isWorker, defaultLoaders, webpack }) => { 10 | // Note: we provide webpack above so you should not `require` it 11 | // Perform customizations to webpack config 12 | config.plugins.push(new webpack.IgnorePlugin(/\/__tests__\//)); 13 | 14 | // Important: return the modified config 15 | return config; 16 | }, 17 | }; 18 | ``` 19 | 20 | The `webpack` function is executed twice, once for the worker and once for the client. This allows you to distinguish between client and server configuration using the `isWorker` property. 21 | 22 | The second argument to the `webpack` function is an object with the following properties: 23 | 24 | - `dev`: Boolean - Indicates if the compilation will be done in development 25 | - `isWorker`: Boolean - It's true for worker-side compilation, and false for client-side compilation 26 | - `defaultLoaders`: Object - Default loaders used internally by Flareact: 27 | - `babel`: Object - Default babel-loader configuration 28 | 29 | Example usage of `defaultLoaders.babel`: 30 | 31 | ```js 32 | module.exports = { 33 | webpack: (config, options) => { 34 | config.module.rules.push({ 35 | test: /\.mdx/, 36 | use: [ 37 | options.defaultLoaders.babel, 38 | { 39 | loader: "@mdx-js/loader", 40 | options: {}, 41 | }, 42 | ], 43 | }); 44 | 45 | return config; 46 | }, 47 | }; 48 | ``` 49 | -------------------------------------------------------------------------------- /docs/flareact-link.md: -------------------------------------------------------------------------------- 1 | # flareact/link 2 | 3 | To perform client-side routing between different pages of your Flareact app, use the `flareact/link` component: 4 | 5 | ```js 6 | import Link from "flareact/link"; 7 | 8 | export default function Index() { 9 | return ( 10 |
11 | 12 | Go to About 13 | 14 |
15 | ); 16 | } 17 | ``` 18 | 19 | `` accepts a single child element. If you provide an `` tag, it will automatically apply the `href` at render time. 20 | 21 | ## Dynamic Routes 22 | 23 | In order to support [dynamic routes](/docs/dynamic-routes), you need to provide an extra parameter `as` to the `` component. 24 | 25 | In the following example, we want to link to `/posts/my-first-post`, which represents a dynamic page route at `/pages/posts/[slug].js`: 26 | 27 | ```js 28 | import Link from "flareact/link"; 29 | 30 | export default function Index() { 31 | return ( 32 | 33 | My First Post 34 | 35 | ); 36 | } 37 | ``` 38 | 39 | ## Prefetch 40 | 41 | By default, Flareact will detect usage of `` in your page and: 42 | 43 | - Prefetch the page and props if the link is in viewport (using `IntersectionObserver`) 44 | - Preload the page bundle on hover 45 | 46 | This happens **only in production**. You can disable this behavior by passing a `false` value to `prefetch`: 47 | 48 | ```js 49 | import Link from "flareact/link"; 50 | 51 | export default function Index() { 52 | return ( 53 |
54 | 55 | Go to About 56 | 57 |
58 | ); 59 | } 60 | ``` 61 | -------------------------------------------------------------------------------- /configs/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const MiniCssExtractPlugin = require("mini-css-extract-plugin"); 3 | const defaultLoaders = require("./loaders"); 4 | const { fileExistsInDir } = require("./utils"); 5 | 6 | module.exports = function ({ dev, isServer }) { 7 | const loaders = defaultLoaders({ dev, isServer }); 8 | 9 | const cssExtractLoader = { 10 | loader: MiniCssExtractPlugin.loader, 11 | }; 12 | 13 | const styleLoader = "style-loader"; 14 | 15 | const finalStyleLoader = () => { 16 | if (dev) { 17 | if (isServer) return cssExtractLoader; 18 | return styleLoader; 19 | } else { 20 | return cssExtractLoader; 21 | } 22 | }; 23 | 24 | return { 25 | context: process.cwd(), 26 | plugins: [new MiniCssExtractPlugin()], 27 | stats: "errors-warnings", 28 | module: { 29 | rules: [ 30 | { 31 | test: /\.js$/, 32 | exclude: /node_modules\/(?!(flareact)\/).*/, 33 | use: loaders.babel, 34 | }, 35 | { 36 | test: /\.css$/, 37 | use: isServer 38 | ? require.resolve("null-loader") 39 | : [ 40 | finalStyleLoader(), 41 | { loader: "css-loader", options: { importLoaders: 1 } }, 42 | { 43 | loader: "postcss-loader", 44 | options: { 45 | config: { 46 | path: fileExistsInDir(process.cwd(), "postcss.config.js") 47 | ? process.cwd() 48 | : path.resolve(__dirname), 49 | }, 50 | }, 51 | }, 52 | ], 53 | }, 54 | ], 55 | }, 56 | }; 57 | }; 58 | -------------------------------------------------------------------------------- /configs/webpack.worker.config.js: -------------------------------------------------------------------------------- 1 | const baseConfig = require("./webpack.config"); 2 | const CopyPlugin = require("copy-webpack-plugin"); 3 | const path = require("path"); 4 | const webpack = require("webpack"); 5 | const { flareactConfig } = require("./utils"); 6 | const defaultLoaders = require("./loaders"); 7 | const { nanoid } = require("nanoid"); 8 | const fs = require("fs"); 9 | 10 | const dev = !!process.env.WORKER_DEV; 11 | const isServer = true; 12 | const projectDir = process.cwd(); 13 | const flareact = flareactConfig(projectDir); 14 | 15 | const outPath = path.resolve(projectDir, "out"); 16 | 17 | const buildManifest = dev 18 | ? {} 19 | : fs.readFileSync( 20 | path.join(outPath, "_flareact", "static", "build-manifest.json"), 21 | "utf-8" 22 | ); 23 | 24 | module.exports = function (env, argv) { 25 | let config = { 26 | ...baseConfig({ dev, isServer }), 27 | target: "webworker", 28 | entry: path.resolve(projectDir, "./index.js"), 29 | }; 30 | 31 | config.plugins.push( 32 | new CopyPlugin([ 33 | { 34 | from: path.resolve(projectDir, "public"), 35 | to: outPath, 36 | }, 37 | ]) 38 | ); 39 | 40 | let inlineVars = { 41 | "process.env.BUILD_ID": JSON.stringify(nanoid()), 42 | }; 43 | 44 | if (dev) { 45 | inlineVars.DEV = dev; 46 | } else { 47 | inlineVars["process.env.BUILD_MANIFEST"] = buildManifest; 48 | } 49 | 50 | config.plugins.push(new webpack.DefinePlugin(inlineVars)); 51 | 52 | if (flareact.webpack) { 53 | config = flareact.webpack(config, { 54 | dev, 55 | isServer, 56 | isWorker: isServer, 57 | defaultLoaders: defaultLoaders({ dev, isServer }), 58 | webpack, 59 | }); 60 | } 61 | 62 | return config; 63 | }; 64 | -------------------------------------------------------------------------------- /docs/api-routes.md: -------------------------------------------------------------------------------- 1 | # API Routes 2 | 3 | Flareact provides a way for you to build your **API** using the existing file-based routing system. 4 | 5 | Any file inside the folder `pages/api` is mapped to `/api/*` and will be treated as an API endpoint instead of a `page`. 6 | 7 | For example, the following API route `pages/api/hello.js` returns a standard text response: 8 | 9 | ```js 10 | export default async (event) => { 11 | return new Response("Hello there"); 12 | }; 13 | ``` 14 | 15 | For an API route to work, you need to export a default function (a **request handler**) which receives a [`FetchEvent` parameter](https://developers.cloudflare.com/workers/reference/apis/fetch-event). 16 | 17 | Your API method may return a new instance of [`Response`](https://developers.cloudflare.com/workers/reference/apis/response/). This allows you to customize headers, formatting, and status code. 18 | 19 | However, Flareact will conveniently wrap your API method's response in a `Response` object if you return only a primitive: 20 | 21 | ```js 22 | export default async (event) => { 23 | return { hello: "world" }; 24 | }; 25 | 26 | // becomes: 27 | // new Response(JSON.stringify({ hello: 'world' }), { headers: { 'content-type': 'application/json' }}) 28 | 29 | export default async (event) => { 30 | return "Hello, world"; 31 | }; 32 | 33 | // becomes: 34 | // new Response('Hello, world') 35 | ``` 36 | 37 | API routes handle requests exactly like [standard Cloudflare Worker requests](https://developers.cloudflare.com/workers/about/how-it-works/), except that you **do not need to call `event.respondWith`**. 38 | 39 | **Note**: You can use `fetch` natively within `getEdgeProps` without needing to require any polyfills, because it is a first-class WebWorker API supported by Workers. 40 | -------------------------------------------------------------------------------- /docs/comparison-to-nextjs.md: -------------------------------------------------------------------------------- 1 | # Comparison to Next.js 2 | 3 | Flareact is modeled closely after [Next.js](https://nextjs.org). Here are a few key differences: 4 | 5 | - Next.js emphasizes static generation, while Flareact leverages edge-computing + caching. 6 | - Next.js has lots of optimizations built in, and it's considered more "production ready" 7 | - Lots of other things 😊 8 | 9 | ## Rendering Modes 10 | 11 | Next.js offers three distinct ways to render your pages: 12 | 13 | - **Static-Site Generation (SSG)**: Your pages are generated at deploy time with `getStaticProps`. They are optionally revalidated on a timed basis to support **incremental re-generation** of your pages (useful for e.g. pulling in your latest blog posts). 14 | - **Server-Side Rendering (SSR)**: Your pages are generated on-demand with each request with `getServerProps`. This is less common, given the powerful tool of incremental SSG above. 15 | - **Client-Side Rendering (CSR)**: If you don't need to have your data fetched as part of your initial HTML payload, you can fetch it within your component as a typical AJAX request. 16 | 17 | Flareact offers a similar approach with **Edge-Side Rendering (ESR)**: 18 | 19 | - Your pages are generated with `getEdgeProps` and cached using the [Cloudflare Worker Cache](https://developers.cloudflare.com/workers/reference/apis/cache/) by default at the edge, similar to **SSG**. 20 | - Optionally, your pages can be revalidated after a specified time, similar to **incremental SSG**. 21 | - If want, pages can also be revalidated on every single request, similar to **SSR**. 22 | - **Client-Side Rendering (CSR)**: If you don't need to have your data fetched as part of your initial HTML payload, you can fetch it within your component as a typical AJAX request. 23 | 24 | [Learn more about data fetching in Flareact](/docs/data-fetching) 25 | -------------------------------------------------------------------------------- /tests/pages.spec.js: -------------------------------------------------------------------------------- 1 | import { resolvePagePath } from "../src/worker/pages"; 2 | 3 | it("matches simple pages", () => { 4 | const path = resolvePagePath("/index", [ 5 | "./index.js", 6 | "./apples.js", 7 | "./posts/[slug].js", 8 | ]); 9 | 10 | expect(path).toBeTruthy(); 11 | expect(path.page).toBe("./index.js"); 12 | }); 13 | 14 | it("matches dynamic pages", () => { 15 | const path = resolvePagePath("/posts/hello", [ 16 | "./index.js", 17 | "./apples.js", 18 | "./posts/[slug].js", 19 | ]); 20 | 21 | expect(path).toBeTruthy(); 22 | expect(path.page).toBe("./posts/[slug].js"); 23 | expect(path.params).toEqual({ slug: "hello" }); 24 | }); 25 | 26 | it("matches dynamic page indexes", () => { 27 | const path = resolvePagePath("/posts", [ 28 | "./index.js", 29 | "./apples.js", 30 | "./posts/[slug].js", 31 | "./posts/index.js", 32 | ]); 33 | 34 | expect(path).toBeTruthy(); 35 | expect(path.page).toBe("./posts/index.js"); 36 | }); 37 | 38 | it("matches dynamic page indexes matching directory names", () => { 39 | const path = resolvePagePath("/posts", [ 40 | "./index.js", 41 | "./apples.js", 42 | "./posts/[slug].js", 43 | "./posts.js", 44 | ]); 45 | 46 | expect(path).toBeTruthy(); 47 | expect(path.page).toBe("./posts.js"); 48 | }); 49 | 50 | it("matches longer dynamic pages", () => { 51 | const path = resolvePagePath("/posts/hello-world-it-me", [ 52 | "./index.js", 53 | "./apples.js", 54 | "./posts/[slug].js", 55 | ]); 56 | 57 | expect(path).toBeTruthy(); 58 | expect(path.page).toBe("./posts/[slug].js"); 59 | expect(path.params).toEqual({ slug: "hello-world-it-me" }); 60 | }); 61 | 62 | it("matches multiple dynamic pages", () => { 63 | const path = resolvePagePath("/posts/travel/hello-world-it-me", [ 64 | "./index.js", 65 | "./apples.js", 66 | "./posts/[category]/[slug].js", 67 | ]); 68 | 69 | expect(path).toBeTruthy(); 70 | expect(path.page).toBe("./posts/[category]/[slug].js"); 71 | expect(path.params).toEqual({ 72 | category: "travel", 73 | slug: "hello-world-it-me", 74 | }); 75 | }); 76 | -------------------------------------------------------------------------------- /src/worker/worker.js: -------------------------------------------------------------------------------- 1 | import { handleRequest } from "."; 2 | import { 3 | getAssetFromKV, 4 | mapRequestToAsset, 5 | } from "@cloudflare/kv-asset-handler"; 6 | 7 | export async function handleEvent(event, context, DEBUG) { 8 | let options = {}; 9 | 10 | /** 11 | * You can add custom logic to how we fetch your assets 12 | * by configuring the function `mapRequestToAsset` 13 | */ 14 | // options.mapRequestToAsset = handlePrefix(/^\/docs/) 15 | 16 | return await handleRequest(event, context, async () => { 17 | try { 18 | options.cacheControl = { 19 | // By default, cache static assets for one year 20 | browserTTL: 365 * 24 * 60 * 60, 21 | }; 22 | 23 | if (DEBUG) { 24 | // customize caching 25 | options.cacheControl.bypassCache = true; 26 | } 27 | 28 | return await getAssetFromKV(event, options); 29 | } catch (e) { 30 | // if an error is thrown try to serve the asset at 404.html 31 | if (!DEBUG) { 32 | try { 33 | let notFoundResponse = await getAssetFromKV(event, { 34 | mapRequestToAsset: (req) => 35 | new Request(`${new URL(req.url).origin}/404.html`, req), 36 | }); 37 | 38 | return new Response(notFoundResponse.body, { 39 | ...notFoundResponse, 40 | status: 404, 41 | }); 42 | } catch (e) {} 43 | } 44 | 45 | return new Response(e.message || e.toString(), { status: 500 }); 46 | } 47 | }); 48 | } 49 | 50 | /** 51 | * Here's one example of how to modify a request to 52 | * remove a specific prefix, in this case `/docs` from 53 | * the url. This can be useful if you are deploying to a 54 | * route on a zone, or if you only want your static content 55 | * to exist at a specific path. 56 | */ 57 | function handlePrefix(prefix) { 58 | return (request) => { 59 | // compute the default (e.g. / -> index.html) 60 | let defaultAssetKey = mapRequestToAsset(request); 61 | let url = new URL(defaultAssetKey.url); 62 | 63 | // strip the prefix from the path for lookup 64 | url.pathname = url.pathname.replace(prefix, "/"); 65 | 66 | // inherit all other props from the default request 67 | return new Request(url.toString(), defaultAssetKey); 68 | }; 69 | } 70 | -------------------------------------------------------------------------------- /configs/webpack/plugins/build-manifest-plugin.js: -------------------------------------------------------------------------------- 1 | const { RawSource } = require("webpack-sources"); 2 | 3 | module.exports = class BuildManifestPlugin { 4 | createAssets(compilation, assets) { 5 | const namedChunks = compilation.namedChunks; 6 | 7 | const assetMap = { 8 | helpers: ["_buildManifest.js"], 9 | pages: {}, 10 | }; 11 | 12 | const mainJsChunk = namedChunks.get("main"); 13 | const mainJsFiles = [...mainJsChunk.files].filter((file) => 14 | file.endsWith(".js") 15 | ); 16 | 17 | for (const entrypoint of compilation.entrypoints.values()) { 18 | let pagePath = entrypoint.name.match(/^pages[/\\](.*)$/); 19 | 20 | if (!pagePath) continue; 21 | 22 | const pageFiles = [...entrypoint.getFiles()].filter( 23 | (file) => file.endsWith(".css") || file.endsWith(".js") 24 | ); 25 | 26 | let pageName = pagePath[1]; 27 | 28 | // Flatten any dynamic `index` pages 29 | pageName = pageName.replace(/\/index/, ""); 30 | 31 | assetMap.pages[`/${pageName}`] = [...mainJsFiles, ...pageFiles]; 32 | } 33 | 34 | assets["build-manifest.json"] = new RawSource( 35 | JSON.stringify(assetMap, null, 2) 36 | ); 37 | 38 | assets["_buildManifest.js"] = new RawSource( 39 | `self.__BUILD_MANIFEST = ${generateClientManifest( 40 | assetMap 41 | )};self.__BUILD_MANIFEST_CB && self.__BUILD_MANIFEST_CB();` 42 | ); 43 | 44 | return assets; 45 | } 46 | 47 | apply(compiler) { 48 | compiler.hooks.emit.tap("FlareactBuildManifest", (compilation) => { 49 | this.createAssets(compilation, compilation.assets); 50 | }); 51 | } 52 | }; 53 | 54 | /** 55 | * Take an asset map and generate a client version with just pages to be used for 56 | * client page routing, loading and transitions. 57 | * 58 | * @param {object} assetMap 59 | */ 60 | function generateClientManifest(assetMap) { 61 | let clientManifest = {}; 62 | const appDependencies = new Set(assetMap.pages["/_app"]); 63 | 64 | Object.entries(assetMap.pages).forEach(([page, files]) => { 65 | if (page === "/_app") return; 66 | 67 | const filteredDeps = files.filter((file) => !appDependencies.has(file)); 68 | 69 | clientManifest[page] = filteredDeps; 70 | }); 71 | 72 | return JSON.stringify(clientManifest); 73 | } 74 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "flareact", 3 | "version": "0.0.0-development", 4 | "main": "src/index.js", 5 | "module": "src/index.js", 6 | "description": "Edge-Rendered React framework built for Cloudflare Workers", 7 | "files": [ 8 | "src", 9 | "configs", 10 | "link.js", 11 | "head.js", 12 | "router.js", 13 | "webpack.js" 14 | ], 15 | "license": "MIT", 16 | "scripts": { 17 | "test": "jest", 18 | "semantic-release": "semantic-release" 19 | }, 20 | "peerDependencies": { 21 | "react": "^16.13.1", 22 | "react-dom": "^16.13.1" 23 | }, 24 | "bin": { 25 | "flareact": "./src/bin/flareact.js" 26 | }, 27 | "dependencies": { 28 | "@babel/core": "^7.11.0", 29 | "@babel/plugin-transform-runtime": "^7.11.0", 30 | "@babel/preset-env": "^7.11.0", 31 | "@babel/preset-react": "^7.10.4", 32 | "@babel/runtime": "^7.11.0", 33 | "@cloudflare/kv-asset-handler": "^0.0.11", 34 | "@pmmmwh/react-refresh-webpack-plugin": "^0.4.1", 35 | "babel-loader": "^8.1.0", 36 | "babel-plugin-react-require": "^3.1.3", 37 | "concurrently": "^5.2.0", 38 | "copy-webpack-plugin": "^5", 39 | "css-loader": "^4.2.1", 40 | "dotenv": "^8.2.0", 41 | "glob": "^7.1.6", 42 | "mini-css-extract-plugin": "^0.9.0", 43 | "mitt": "^2.1.0", 44 | "nanoid": "^3.1.12", 45 | "null-loader": "^4.0.1", 46 | "optimize-css-assets-webpack-plugin": "^5.0.3", 47 | "postcss-flexbugs-fixes": "^4.2.1", 48 | "postcss-loader": "^3.0.0", 49 | "postcss-preset-env": "^6.7.0", 50 | "react-helmet": "^6.1.0", 51 | "react-refresh": "^0.8.3", 52 | "style-loader": "^1.2.1", 53 | "terser-webpack-plugin": "^4.0.0", 54 | "webpack": "^4.44.1", 55 | "webpack-cli": "^3.3.12", 56 | "webpack-dev-server": "^3.11.0", 57 | "webpack-merge": "^5.1.1", 58 | "webpack-sources": "^1.4.3", 59 | "yargs": "^16.1.0" 60 | }, 61 | "devDependencies": { 62 | "@babel/plugin-proposal-optional-chaining": "^7.11.0", 63 | "@testing-library/react": "^11.1.0", 64 | "babel-jest": "^26.2.2", 65 | "jest": "^26.2.2", 66 | "react": "^16.13.1", 67 | "react-dom": "^16.13.1", 68 | "semantic-release": "^17.1.1" 69 | }, 70 | "repository": { 71 | "type": "git", 72 | "url": "https://github.com/flareact/flareact.git" 73 | }, 74 | "release": { 75 | "branches": [ 76 | "+([0-9])?(.{+([0-9]),x}).x", 77 | "main", 78 | { 79 | "name": "canary", 80 | "prerelease": true 81 | }, 82 | { 83 | "name": "alpha", 84 | "prerelease": true 85 | }, 86 | { 87 | "name": "beta", 88 | "prerelease": true 89 | } 90 | ] 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /docs/deployment.md: -------------------------------------------------------------------------------- 1 | # Deployment 2 | 3 | To deploy your Flareact site to Cloudflare, run: 4 | 5 | ```bash 6 | yarn deploy 7 | ``` 8 | 9 | Behind the scenes, Flareact bundles your client assets in a production-ready format and then runs `wrangler publish`. 10 | 11 | ## Deploying to a custom route 12 | 13 | In your `wrangler.toml` file, you can use specify a route for your site instead of the `workers_dev` default host. 14 | 15 | To set a route, change `workers_dev = false` and specify your route: 16 | 17 | ```toml 18 | workers_dev = false 19 | route = "flareact.com/*" 20 | ``` 21 | 22 | At this point, you'll also be required to include a `zone_id`. If you don't want to include this in your version control (e.g. your project is open-source), you can define it in a local `.env` file which is ignored by Git: 23 | 24 | ``` 25 | CF_ZONE_ID= 26 | CF_ACCOUNT_ID= 27 | ``` 28 | 29 | **Note:**: Per the [Cloudflare Docs](https://developers.cloudflare.com/workers/learning/getting-started#6d-configuring-your-project), if your route is configured to a hostname, you will need to add a DNS record to Cloudflare to ensure that the hostname can be resolved externally. If your Worker acts as your origin (the response comes directly from a Worker), you should enter a placeholder (dummy) AAAA record pointing to `100::`, which is the [reserved IPv6 discard prefixOpen external link](https://tools.ietf.org/html/rfc6666). 30 | 31 | ## Deploying to environments 32 | 33 | Cloudflare allows you to [define additional environments](https://developers.cloudflare.com/workers/platform/environments) for your Workers site. 34 | 35 | After adding a new environment to your `wrangler.toml` file: 36 | 37 | ```toml 38 | [env.staging] 39 | name = "your-site-staging" 40 | workers_dev = true 41 | ``` 42 | 43 | You can pass the `--env ` flag to the `yarn deploy` command: 44 | 45 | ```bash 46 | yarn deploy --env staging 47 | ``` 48 | 49 | ## Deploying from GitHub Actions 50 | 51 | To deploy from GitHub Actions, you can use the [wrangler action](https://github.com/cloudflare/wrangler-action). 52 | 53 | In your project, create a `.github/workflows/deploy.yml` file: 54 | 55 | ```yaml 56 | name: Deploy 57 | on: 58 | - push 59 | jobs: 60 | deploy: 61 | runs-on: ubuntu-latest 62 | timeout-minutes: 10 63 | steps: 64 | - uses: actions/checkout@v2 65 | - uses: actions/setup-node@v1 66 | with: 67 | node-version: 12 68 | - run: yarn install 69 | - run: yarn build 70 | - name: Publish 71 | uses: cloudflare/wrangler-action@1.2.0 72 | with: 73 | apiToken: ${{ secrets.CF_API_TOKEN }} 74 | env: 75 | CF_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }} 76 | IS_WORKER: true 77 | ``` 78 | 79 | Optionally update your `push:` directive to include only certain branches. 80 | 81 | You also might want to add a `CF_ZONE_ID` if you're deploying to a custom domain. 82 | -------------------------------------------------------------------------------- /src/worker/pages.js: -------------------------------------------------------------------------------- 1 | import App from "../components/_app"; 2 | 3 | export const DYNAMIC_PAGE = new RegExp("\\[(\\w+)\\]", "g"); 4 | 5 | export function resolvePagePath(pagePath, keys) { 6 | const pagesMap = keys.map((page) => { 7 | let test = page; 8 | let parts = []; 9 | 10 | const isDynamic = DYNAMIC_PAGE.test(page); 11 | 12 | if (isDynamic) { 13 | for (const match of page.matchAll(/\[(\w+)\]/g)) { 14 | parts.push(match[1]); 15 | } 16 | 17 | test = test.replace(DYNAMIC_PAGE, () => "([\\w_-]+)"); 18 | } 19 | 20 | test = test.replace("/", "\\/").replace(/^\./, "").replace(/\.js$/, ""); 21 | 22 | return { 23 | page, 24 | pagePath: page.replace(/^\./, "").replace(/\.js$/, ""), 25 | parts, 26 | test: new RegExp("^" + test + "$", isDynamic ? "g" : ""), 27 | }; 28 | }); 29 | 30 | /** 31 | * Sort pages to include those with `index` in the name first, because 32 | * we need those to get matched more greedily than their dynamic counterparts. 33 | */ 34 | pagesMap.sort((a) => (a.page.includes("index") ? -1 : 1)); 35 | 36 | let page = pagesMap.find((p) => p.test.test(pagePath)); 37 | 38 | /** 39 | * If an exact match couldn't be found, try giving it another shot with /index at 40 | * the end. This helps discover dynamic nested index pages. 41 | */ 42 | if (!page) { 43 | page = pagesMap.find((p) => p.test.test(pagePath + "/index")); 44 | } 45 | 46 | if (!page) return null; 47 | if (!page.parts.length) return page; 48 | 49 | let params = {}; 50 | 51 | page.test.lastIndex = 0; 52 | 53 | const matches = pagePath.matchAll(page.test); 54 | 55 | for (const match of matches) { 56 | page.parts.forEach((part, idx) => (params[part] = match[idx + 1])); 57 | } 58 | 59 | page.params = params; 60 | 61 | return page; 62 | } 63 | 64 | export function getPage(pagePath, context) { 65 | try { 66 | const resolvedPage = resolvePagePath(pagePath, context.keys()); 67 | const page = context(resolvedPage.page); 68 | 69 | return { 70 | ...resolvedPage, 71 | ...page, 72 | }; 73 | } catch (e) { 74 | if (pagePath === "/_app") { 75 | return { default: App }; 76 | } 77 | 78 | throw new PageNotFoundError(); 79 | } 80 | } 81 | 82 | export async function getPageProps(page, query) { 83 | let pageProps = {}; 84 | 85 | const params = page.params || {}; 86 | 87 | const fetcher = page.getEdgeProps || page.getStaticProps; 88 | 89 | const queryObject = { 90 | ...query, 91 | ...params, 92 | }; 93 | 94 | if (fetcher) { 95 | const { props, revalidate } = await fetcher({ params, query: queryObject }); 96 | 97 | pageProps = { 98 | ...props, 99 | revalidate, 100 | }; 101 | } 102 | 103 | return pageProps; 104 | } 105 | 106 | export class PageNotFoundError extends Error {} 107 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # Flareact 2 | 3 | Flareact is an **edge-rendered** React framework built for [Cloudflare Workers](https://workers.cloudflare.com/). 4 | 5 | It features **file-based page routing** with dynamic page paths and **edge-side data fetching** APIs. 6 | 7 | Flareact is modeled after the terrific [Next.js](https://nextjs.org/) project and its APIs. If you're transitioning from Next.js, a lot of the APIs will seem familiar, _if not identical_! 8 | 9 | [![Deploy to Cloudflare Workers](https://deploy.workers.cloudflare.com/button?paid=true)](https://deploy.workers.cloudflare.com/?url=https://github.com/flareact/flareact-template&paid=true) 10 | 11 | ## Overview 12 | 13 | Flareact will serve each file in the `/pages` directory under a pathname matching the filename. 14 | 15 | For example, the following component at `/pages/about.js`: 16 | 17 | ```js 18 | export default function About() { 19 | return

Who we are

; 20 | } 21 | ``` 22 | 23 | The above page will be served at `site.com/about`. 24 | 25 | Next step: Read the [getting started guide](/docs/getting-started). 26 | 27 | ## But why? 28 | 29 | Right — *another* React framework. Here's why Flareact might be useful to you: 30 | 31 | - **Server-Side Rendering** (technically _edge-side_ rendering): Return the HTML output of your site in the initial request, rather than waiting for the client to render it. This can be helpful for SEO and initial time-to-first-paint/time-to-interactive. 32 | - **Cloudflare**: If you already point your site's DNS to Cloudflare, you might as well host your site there, too! No need to find additional hosting, wire up DNS records, or deal with SSL provisioning issues between services. 33 | - **Speed**: Because your site is being generated and served directly from the Cloudflare edge network, you're reducing network hops between the CDN and your content host. This means your site is delivered _within milliseconds_. Cloudflare even [eliminates cold starts using the TLS handshake period](https://blog.cloudflare.com/eliminating-cold-starts-with-cloudflare-workers/). 34 | - **Infinitely scalable**: Don't worry about scaling up servers or getting hit with steep service provider fees. Cloudflare's network is built to handle huge amounts of traffic with a predictable pricing model. Plus, by caching your page data at the edge with an optional invalidation strategy, you can host dynamic content as if it were a statically-generated site. 35 | - **Familiar API**: Next.js did the hard work to create a great developer experience (DX) for creating and maintaining modern React applications. Flareact borrows many patterns from Next.js, so you'll feel right at home developing your Flareact site. 36 | 37 | ## Examples 38 | 39 | - [Flareact Docs (this site)](https://github.com/flareact/flareact-site/) 40 | - [Headless CMS: WordPress](https://github.com/flareact/flareact/tree/master/examples/with-cms-wordpress) 41 | 42 | ## About 43 | 44 | Flareact is an experiment created by [Josh Larson](https://www.jplhomer.org/) in August 2020. 45 | 46 | Lots of inspiration and thanks to: 47 | 48 | - [Next.js](https://nextjs.org) (obviously) 49 | - [SWR](https://swr.vercel.app/) for this site's design inspiration 50 | - [Tailwind](https://tailwindcss.com) for the styles 51 | - [Kari Linder](https://twitter.com/kkblinder) from Cloudflare for the logo 🔥 52 | -------------------------------------------------------------------------------- /src/bin/flareact.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | const concurrently = require("concurrently"); 3 | const dotenv = require("dotenv"); 4 | dotenv.config(); 5 | 6 | ["react", "react-dom"].forEach((dependency) => { 7 | try { 8 | require.resolve(dependency); 9 | } catch (err) { 10 | console.warn( 11 | `The module '${dependency}' was not found. Flareact requires that you include it in 'dependencies' of your 'package.json'. To add it, run 'npm install ${dependency}'` 12 | ); 13 | } 14 | }); 15 | 16 | const yargs = require("yargs"); 17 | 18 | const argv = yargs 19 | .command("dev", "Starts a Flareact development server") 20 | .command("publish", "Builds Flareact for production and deploys it", { 21 | env: { 22 | description: "The Cloudflare Workers environment to target", 23 | type: "string", 24 | }, 25 | }) 26 | .command("build", "Builds Flareact for production") 27 | .help() 28 | .command({ 29 | command: "*", 30 | handler() { 31 | yargs.showHelp(); 32 | }, 33 | }) 34 | .demandCommand() 35 | .alias("help", "h").argv; 36 | 37 | if (argv._.includes("dev")) { 38 | console.log("Starting Flareact dev server..."); 39 | 40 | concurrently( 41 | [ 42 | { 43 | command: "wrangler dev", 44 | name: "worker", 45 | env: { WORKER_DEV: true, IS_WORKER: true }, 46 | }, 47 | { 48 | command: 49 | "webpack-dev-server --config node_modules/flareact/configs/webpack.client.config.js --mode development", 50 | name: "client", 51 | env: { NODE_ENV: "development" }, 52 | }, 53 | ], 54 | { 55 | prefix: "name", 56 | killOthers: ["failure"], 57 | restartTries: 0, 58 | } 59 | ).then( 60 | () => {}, 61 | (error) => { 62 | console.error(error); 63 | } 64 | ); 65 | } 66 | 67 | if (argv._.includes("publish")) { 68 | const destination = argv.env ? `${argv.env} on Cloudflare` : "Cloudflare"; 69 | 70 | console.log(`Publishing your Flareact project to ${destination}...`); 71 | 72 | let wranglerPublish = `wrangler publish`; 73 | 74 | if (argv.env) { 75 | wranglerPublish += ` --env ${argv.env}`; 76 | } 77 | 78 | concurrently( 79 | [ 80 | { 81 | command: `webpack --config node_modules/flareact/configs/webpack.client.config.js --out ./out --mode production && ${wranglerPublish}`, 82 | name: "publish", 83 | env: { NODE_ENV: "production", IS_WORKER: true }, 84 | }, 85 | ], 86 | { 87 | prefix: "name", 88 | killOthers: ["failure"], 89 | restartTries: 0, 90 | } 91 | ).then( 92 | () => {}, 93 | (error) => { 94 | console.error(error); 95 | } 96 | ); 97 | } 98 | 99 | if (argv._.includes("build")) { 100 | console.log("Building your Flareact project for production..."); 101 | 102 | concurrently( 103 | [ 104 | { 105 | command: 106 | "webpack --config node_modules/flareact/configs/webpack.client.config.js --out ./out --mode production", 107 | name: "publish", 108 | env: { NODE_ENV: "production" }, 109 | }, 110 | ], 111 | { 112 | prefix: "name", 113 | killOthers: ["failure"], 114 | restartTries: 0, 115 | } 116 | ).then( 117 | () => {}, 118 | (error) => { 119 | console.error(error); 120 | } 121 | ); 122 | } 123 | -------------------------------------------------------------------------------- /src/components/_document.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { htmlEscapeJsonString } from "../utils"; 3 | 4 | const dev = typeof DEV !== "undefined" && !!DEV; 5 | 6 | export default function Document({ 7 | initialData, 8 | helmet, 9 | page, 10 | context, 11 | buildManifest, 12 | }) { 13 | const htmlAttrs = helmet.htmlAttributes.toComponent(); 14 | const bodyAttrs = helmet.bodyAttributes.toComponent(); 15 | let currentPage = page.page.replace(/^\./, "").replace(/\.(js|css)$/, ""); 16 | 17 | // Flatten dynamic `index.js` pages 18 | if (currentPage !== "/index" && currentPage.endsWith("/index")) { 19 | currentPage = currentPage.replace(/\/index$/, ""); 20 | } 21 | 22 | // TODO: Drop all these props into a context and consume them in individual components 23 | // so this page can be extended. 24 | 25 | return ( 26 | 27 | 32 | 33 |
34 | 40 | 41 | 42 | ); 43 | } 44 | 45 | export function FlareactHead({ helmet, page, buildManifest }) { 46 | let links = new Set(); 47 | 48 | if (!dev) { 49 | buildManifest.pages["/_app"] 50 | .filter((link) => link.endsWith(".css")) 51 | .forEach((link) => links.add(link)); 52 | buildManifest.pages[page] 53 | .filter((link) => link.endsWith(".css")) 54 | .forEach((link) => links.add(link)); 55 | } 56 | 57 | return ( 58 | 59 | 60 | 61 | {helmet.title.toComponent()} 62 | {helmet.meta.toComponent()} 63 | {helmet.link.toComponent()} 64 | 65 | {[...links].map((link) => ( 66 | 67 | ))} 68 | 69 | ); 70 | } 71 | 72 | export function FlareactScripts({ initialData, page, buildManifest }) { 73 | let prefix = dev ? "http://localhost:8080/" : "/"; 74 | prefix += dev ? "" : "_flareact/static/"; 75 | 76 | let scripts = new Set(); 77 | 78 | if (dev) { 79 | [ 80 | "webpack.js", 81 | "main.js", 82 | `pages/_app.js`, 83 | `pages${page}.js`, 84 | ].forEach((script) => scripts.add(script)); 85 | } else { 86 | buildManifest.helpers.forEach((script) => scripts.add(script)); 87 | buildManifest.pages["/_app"] 88 | .filter((script) => script.endsWith(".js")) 89 | .forEach((script) => scripts.add(script)); 90 | buildManifest.pages[page] 91 | .filter((script) => script.endsWith(".js")) 92 | .forEach((script) => scripts.add(script)); 93 | } 94 | 95 | return ( 96 | <> 97 | 104 | {[...scripts].map((script) => ( 105 | 106 | ))} 107 | 108 | ); 109 | } 110 | -------------------------------------------------------------------------------- /docs/getting-started.md: -------------------------------------------------------------------------------- 1 | # Getting Started 2 | 3 | Howdy! Let's get you set up with Flareact. 4 | 5 | It's important to know that you must have a [Cloudflare account](https://cloudflare.com/) with a **paid (\$5/mo) Bundled plan** to use Flareact. 6 | 7 | ## Quickstart 8 | 9 | If you want to get started right now, you can click the button below to fork the `flareact-template` repo and set up your account without installing any CLI tools: 10 | 11 | [![Deploy to Cloudflare Workers](https://deploy.workers.cloudflare.com/button?paid=true)](https://deploy.workers.cloudflare.com/?url=https://github.com/flareact/flareact-template&paid=true) 12 | 13 | Otherwise, follow the instructions below to get started. 14 | 15 | ## Installation 16 | 17 | Make sure you have the [wrangler CLI](https://github.com/cloudflare/wrangler) tool installed: 18 | 19 | ```bash 20 | npm i @cloudflare/wrangler -g 21 | ``` 22 | 23 | You may need to run `wrangler login` to authenticate your Cloudflare account. 24 | 25 | Next, use wrangler to create a new Flareact project using the [template](https://github.com/flareact/flareact-template): 26 | 27 | ```bash 28 | wrangler generate my-project https://github.com/flareact/flareact-template 29 | ``` 30 | 31 | Finally, switch to your directory and yun `yarn` or `npm install`: 32 | 33 | ```bash 34 | cd my-project 35 | yarn 36 | ``` 37 | 38 | ## Manual Installation 39 | 40 | To add Flareact to an existing project, add `flareact` as a dependency: 41 | 42 | ```js 43 | yarn add flareact 44 | ``` 45 | 46 | Or using npm: 47 | 48 | ```js 49 | npm install flareact 50 | ``` 51 | 52 | Next, make sure you have the following files (check out the [template repo](https://github.com/flareact/flareact-template) to see the contents of each): 53 | 54 | - `index.js` 55 | - `wrangler.toml` 56 | 57 | Open `package.json` file and add the following `scripts`: 58 | 59 | ```json 60 | "scripts": { 61 | "dev": "flareact dev", 62 | "build": "flareact build", 63 | "deploy": "flareact publish" 64 | } 65 | ``` 66 | 67 | These scripts refer to the different stages of developing an application: 68 | 69 | - `dev` - Runs `flareact dev` which kicks off `wrangler dev` and `flareact` in development mode 70 | - `build` - Runs `flareact build` which kicks creates a production client-side bundle (useful for deploying from CI) 71 | - `deploy` - Runs `flareact publish` which builds your application and runs `wrangler publish` to deploy it 72 | 73 | Flareact uses the concept of pages. A page is a React Component exported from a `.js` file in the `pages` directory. 74 | 75 | Pages are associated with a route based on their file name. For example, `pages/about.js` is mapped to `site.com/about`. You can even add dynamic route parameters with the filename. 76 | 77 | You will need **at least one** page inside your `/pages` directory. To add a landing/index page, add the following to `pages/index.js`: 78 | 79 | ```js 80 | export default function Index() { 81 | return

Home

; 82 | } 83 | ``` 84 | 85 | ## Development 86 | 87 | To preview your Flareact site locally, run `yarn dev` in your terminal. Behind the scenes, Wrangler creates a tunnel from your local site to Cloudflare's edge — bringing your development and production environments closer together. 88 | 89 | By default, your site will be available at [http://127.0.0.1:8787/](http://127.0.0.1:8787/). 90 | 91 | **Note**: Be sure to fill in your `account_id` in `wrangler.toml`. You can also add it to a local `.env` file in your project: 92 | 93 | ```bash 94 | CF_ACCOUNT_ID=youraccountid 95 | ``` 96 | 97 | Or pass it to `yarn dev` as an environment variable: 98 | 99 | ```bash 100 | CF_ACCOUNT_ID=youraccountid yarn dev 101 | ``` 102 | -------------------------------------------------------------------------------- /docs/data-fetching.md: -------------------------------------------------------------------------------- 1 | # Data Fetching 2 | 3 | Flareact allows you to fetch data for page components. This happens in in your Cloudflare Worker on the edge, using `getEdgeProps`. 4 | 5 | By default, **all edge props are cached** for the lifetime of your current deployment revision, but you can change that behavior using the `revalidate` property. 6 | 7 | ## Fetching Data using `getEdgeProps` 8 | 9 | To define props for your component, export an asynchronous `getEdgeProps` function from your React component: 10 | 11 | ```js 12 | export async function getEdgeProps() { 13 | const posts = await getBlogPosts(); 14 | 15 | return { 16 | props: { 17 | posts, 18 | }, 19 | }; 20 | } 21 | 22 | export default function Posts({ posts }) { 23 | return ( 24 |
25 |

Posts

26 |
    27 | {posts.map((post) => { 28 | return
  • ...
  • ; 29 | })} 30 |
31 |
32 | ); 33 | } 34 | ``` 35 | 36 | `getEdgeProps` receives one argument object containing the following properties: 37 | 38 | - `params`: Any params corresponding to dynamic routes 39 | 40 | You must return an object from `getEdgeProps`: 41 | 42 | - it should contain a `props` property containing the props to be passed to your component 43 | - You can optionally pass a `revalidate` argument - see below. 44 | 45 | ## Caching and Revalidation 46 | 47 | Flareact caches all pages and props at the edge using the [Worker Cache API](https://developers.cloudflare.com/workers/reference/apis/cache/). This benefits you because: 48 | 49 | - After the first page view, every single page request will be served statically from the edge, making the response even faster 🔥 50 | - You can fetch data from external APIs and headless CMS endpoints without having to worry about scale or load 51 | - One less point of failure, in case an external data API goes down for maintenance, etc. 52 | 53 | However, you might want to fetch a fresh set of data from `getEdgeProps` occasionally. This is where the `revalidate` property comes in. Pass a number to `revalidate` to tell Flareact to cache this page for `N` number of seconds: 54 | 55 | ```js 56 | export async function getEdgeProps() { 57 | const data = await someExpensiveDataRequest(); 58 | 59 | return { 60 | props: { 61 | data, 62 | }, 63 | // Revalidate these props once every 60 seconds 64 | revalidate: 60, 65 | }; 66 | } 67 | ``` 68 | 69 | There might be times when you **never want the page to be cached**. That's possible, too — just return `{ revalidate: 0 }` from `getEdgeProps` to tell Flareact to fetch a fresh page every single request. 70 | 71 | To recap: 72 | 73 | | `revalidate` value | Cache Behavior | 74 | | ------------------ | -------------------------------------------------- | 75 | | (none) | Cache until next deploy | 76 | | `0` | Never cache | 77 | | `1` (or greater) | Cache that number of seconds, and then revalidate. | 78 | 79 | **Note**: In development, props are requested each page load, and no caching is performed. 80 | 81 | ## Additional Notes 82 | 83 | A couple things to note about `getEdgeProps`: 84 | 85 | - The code you write will **always run on the edge** in a Worker context. This means it will _never_ run client-side in the browser. 86 | - You can use `fetch` natively within `getEdgeProps` without needing to require any polyfills, because it is a first-class WebWorker API supported by Workers. 87 | - Code and imports included and used exclusively for `getEdgeProps` will be removed automatically from your client-side builds. This means you can import heavy worker-side libraries without having to worry about impacting your client runtime performance 😍 88 | - In a worker context, **you DO NOT have access to the filesystem**. This means anything that references the Node.js `fs` module will throw errors. 89 | - You can only define `getEdgeProps` for page components living in your `/pages` directory - not for any other components living elsewhere. 90 | - Transitioning from Next.js? `getStaticProps` is aliased to `getEdgeProps`, so you don't need to make any changes! 91 | -------------------------------------------------------------------------------- /src/router.js: -------------------------------------------------------------------------------- 1 | import React, { useContext, useState, useEffect, useMemo } from "react"; 2 | import { extractDynamicParams } from "./utils"; 3 | import { DYNAMIC_PAGE } from "./worker/pages"; 4 | 5 | const RouterContext = React.createContext(); 6 | 7 | let pageCache = {}; 8 | 9 | export function RouterProvider({ 10 | children, 11 | initialUrl, 12 | initialPagePath, 13 | initialComponent, 14 | pageLoader, 15 | }) { 16 | const { protocol, host, pathname: initialPathname, search } = new URL( 17 | initialUrl 18 | ); 19 | const [route, setRoute] = useState({ 20 | href: initialPagePath, 21 | asPath: initialPathname + search, 22 | }); 23 | const [initialPath, setInitialPath] = useState(initialPathname); 24 | const [component, setComponent] = useState({ 25 | Component: initialComponent, 26 | pageProps: null, 27 | }); 28 | 29 | const params = useMemo(() => { 30 | const isDynamic = DYNAMIC_PAGE.test(route.href); 31 | 32 | if (!isDynamic) return {}; 33 | 34 | return extractDynamicParams(route.href, route.asPath); 35 | }, [route.asPath, route.href]); 36 | 37 | const query = useMemo(() => { 38 | const url = new URL(protocol + "//" + host + route.asPath); 39 | const queryParams = Object.fromEntries(url.searchParams.entries()); 40 | 41 | return { 42 | ...queryParams, 43 | ...params, 44 | }; 45 | }, [protocol, host, route.asPath, params]); 46 | 47 | useEffect(() => { 48 | async function loadNewPage() { 49 | const { href, asPath } = route; 50 | const pagePath = normalizePathname(href); 51 | const normalizedAsPath = normalizePathname(asPath); 52 | 53 | if (!pageCache[normalizedAsPath]) { 54 | const page = await pageLoader.loadPage(pagePath); 55 | const { pageProps } = await pageLoader.loadPageProps(normalizedAsPath); 56 | 57 | pageCache[normalizedAsPath] = { 58 | Component: page, 59 | pageProps, 60 | }; 61 | } 62 | 63 | setComponent(pageCache[normalizedAsPath]); 64 | } 65 | 66 | if (initialPath === route.asPath) { 67 | return; 68 | } 69 | 70 | loadNewPage(); 71 | }, [route, initialPath]); 72 | 73 | function push(href, as) { 74 | const asPath = as || href; 75 | 76 | setRoute({ 77 | href, 78 | asPath, 79 | }); 80 | 81 | // Blank this out so any return trips to the original component re-fetches props. 82 | setInitialPath(""); 83 | 84 | window.history.pushState({ href, asPath }, null, asPath); 85 | } 86 | 87 | function prefetch(href, as, { priority } = {}) { 88 | if (process.env.NODE_ENV !== "production") { 89 | return; 90 | } 91 | 92 | const pagePath = normalizePathname(href); 93 | const asPath = normalizePathname(as || href); 94 | 95 | return Promise.all([ 96 | pageLoader.prefetchData(asPath), 97 | pageLoader[priority ? "loadPage" : "prefetch"](pagePath), 98 | ]); 99 | } 100 | 101 | useEffect(() => { 102 | function handlePopState(e) { 103 | let newRoute = {}; 104 | 105 | const { state } = e; 106 | 107 | if (state) { 108 | newRoute = { 109 | href: state.href, 110 | asPath: state.asPath, 111 | }; 112 | } else { 113 | newRoute = { 114 | href: window.location.pathname || "/", 115 | asPath: window.location.pathname || "/", 116 | }; 117 | } 118 | 119 | setRoute(newRoute); 120 | } 121 | 122 | window.addEventListener("popstate", handlePopState); 123 | 124 | return () => { 125 | window.removeEventListener("popstate", handlePopState); 126 | }; 127 | }, [setRoute]); 128 | 129 | const router = { 130 | component, 131 | pathname: route.href, 132 | asPath: route.asPath, 133 | push, 134 | prefetch, 135 | query, 136 | }; 137 | 138 | return ( 139 | {children} 140 | ); 141 | } 142 | 143 | export function useRouter() { 144 | return useContext(RouterContext); 145 | } 146 | 147 | export function normalizePathname(pathname) { 148 | return pathname === "/" ? "/index" : pathname; 149 | } 150 | -------------------------------------------------------------------------------- /src/link.js: -------------------------------------------------------------------------------- 1 | import React, { Children, useEffect, useState } from "react"; 2 | import { useRouter } from "./router"; 3 | 4 | let prefetched = {}; 5 | let cachedIntersectionObserver; 6 | let listeners = new Map(); 7 | 8 | /** 9 | * Heavily-inspired by next/link 10 | * 11 | * @param {object} props 12 | */ 13 | export default function Link(props) { 14 | const router = useRouter(); 15 | const [childElm, setChildElm] = useState(); 16 | const child = Children.only(props.children); 17 | 18 | const { href, as } = props; 19 | 20 | const shouldPrefetch = props.prefetch !== false; 21 | 22 | useEffect(() => { 23 | if ( 24 | shouldPrefetch && 25 | IntersectionObserver && 26 | childElm && 27 | childElm.tagName 28 | ) { 29 | const isPrefetched = prefetched[href + "%" + as]; 30 | 31 | if (!isPrefetched) { 32 | return listenToIntersections(childElm, () => { 33 | prefetch(router, href, as); 34 | }); 35 | } 36 | } 37 | }, [shouldPrefetch, childElm, href, as, router]); 38 | 39 | function linkClicked(e) { 40 | const { nodeName, target } = e.currentTarget; 41 | if ( 42 | nodeName === "A" && 43 | ((target && target !== "_self") || 44 | e.metaKey || 45 | e.ctrlKey || 46 | e.shiftKey || 47 | (e.nativeEvent && e.nativeEvent.which === 2)) 48 | ) { 49 | // ignore click for new tab / new window behavior 50 | return; 51 | } 52 | 53 | if (!isLocal(href)) return; 54 | 55 | e.preventDefault(); 56 | 57 | router.push(href, as); 58 | } 59 | 60 | const childProps = { 61 | // Forward ref if the user has it set 62 | ref: (el) => { 63 | if (el) setChildElm(el); 64 | 65 | if (child && child.ref) { 66 | if (typeof child.ref === "function") child.ref(el); 67 | else if (typeof child.ref === "object") { 68 | child.ref.current = el; 69 | } 70 | } 71 | }, 72 | // Forward onClick handler if the user has it set 73 | onClick: (e) => { 74 | if (child.props && typeof child.props.onClick === "function") { 75 | child.props.onClick(e); 76 | } 77 | 78 | if (!e.defaultPrevented) { 79 | linkClicked(e); 80 | } 81 | }, 82 | }; 83 | 84 | if (shouldPrefetch) { 85 | childProps.onMouseEnter = (e) => { 86 | if (child.props && typeof child.props.onMouseEnter === "function") { 87 | child.props.onMouseEnter(e); 88 | } 89 | 90 | prefetch(router, href, as, { priority: true }); 91 | }; 92 | } 93 | 94 | // If child is an tag and doesn't have a href attribute, or if the 'passHref' property is 95 | // defined, we specify the current 'href', so that repetition is not needed by the user 96 | if (props.passHref || (child.type === "a" && !("href" in child.props))) { 97 | childProps.href = as || href; 98 | } 99 | 100 | return React.cloneElement(child, childProps); 101 | } 102 | 103 | /** 104 | * Detects whether a given url is from the same origin as the current page (browser only). 105 | */ 106 | function isLocal(url) { 107 | const locationOrigin = getLocationOrigin(); 108 | const resolved = new URL(url, locationOrigin); 109 | return resolved.origin === locationOrigin; 110 | } 111 | 112 | function getLocationOrigin() { 113 | const { protocol, hostname, port } = window.location; 114 | return `${protocol}//${hostname}${port ? ":" + port : ""}`; 115 | } 116 | 117 | function prefetch(router, href, as, options) { 118 | if (typeof window === "undefined") return; 119 | 120 | router.prefetch(href, as, options); 121 | 122 | prefetched[href + "%" + as] = true; 123 | } 124 | 125 | function getObserver() { 126 | if (cachedIntersectionObserver) return cachedIntersectionObserver; 127 | 128 | if (!IntersectionObserver) return undefined; 129 | 130 | return (cachedIntersectionObserver = new IntersectionObserver( 131 | (entries) => { 132 | entries.forEach((entry) => { 133 | if (!listeners.has(entry.target)) return; 134 | 135 | const cb = listeners.get(entry.target); 136 | if (entry.isIntersecting || entry.intersectionRatio > 0) { 137 | cachedIntersectionObserver.unobserve(entry.target); 138 | listeners.delete(entry.target); 139 | cb(); 140 | } 141 | }); 142 | }, 143 | { 144 | rootMargin: "200px", 145 | } 146 | )); 147 | } 148 | 149 | function listenToIntersections(el, cb) { 150 | const observer = getObserver(); 151 | 152 | if (!observer) return () => {}; 153 | 154 | observer.observe(el); 155 | listeners.set(el, cb); 156 | 157 | return () => { 158 | try { 159 | observer.unobserve(el); 160 | } catch (e) { 161 | console.error(e); 162 | } 163 | listeners.delete(el); 164 | }; 165 | } 166 | -------------------------------------------------------------------------------- /src/worker/index.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOMServer from "react-dom/server"; 3 | import Document from "../components/_document"; 4 | import { RouterProvider, normalizePathname } from "../router"; 5 | import { getPage, getPageProps, PageNotFoundError } from "./pages"; 6 | import AppProvider from "../components/AppProvider"; 7 | import { Helmet } from "react-helmet"; 8 | 9 | const dev = 10 | (typeof DEV !== "undefined" && !!DEV) || 11 | process.env.NODE_ENV !== "production"; 12 | 13 | const buildManifest = dev ? {} : process.env.BUILD_MANIFEST; 14 | 15 | export async function handleRequest(event, context, fallback) { 16 | const url = new URL(event.request.url); 17 | const { pathname, searchParams } = url; 18 | const query = Object.fromEntries(searchParams.entries()); 19 | 20 | if (pathname.startsWith("/_flareact/static")) { 21 | return await fallback(event); 22 | } 23 | 24 | try { 25 | if (pathname.startsWith("/_flareact/props")) { 26 | const pagePath = pathname.replace(/\/_flareact\/props|\.json/g, ""); 27 | 28 | return await handleCachedPageRequest( 29 | event, 30 | context, 31 | pagePath, 32 | query, 33 | (_, props) => { 34 | return new Response( 35 | JSON.stringify({ 36 | pageProps: props, 37 | }), 38 | { 39 | status: 200, 40 | headers: { 41 | "content-type": "application/json", 42 | }, 43 | } 44 | ); 45 | } 46 | ); 47 | } 48 | 49 | const normalizedPathname = normalizePathname(pathname); 50 | 51 | if (pageIsApi(normalizedPathname)) { 52 | const page = getPage(normalizedPathname, context); 53 | const response = await page.default(event); 54 | 55 | if (response instanceof Object && !(response instanceof Response)) { 56 | return new Response(JSON.stringify(response), { 57 | headers: { 58 | "content-type": "application/json", 59 | }, 60 | }); 61 | } 62 | 63 | if (!(response instanceof Response)) { 64 | return new Response(response); 65 | } 66 | 67 | return response; 68 | } 69 | 70 | return await handleCachedPageRequest( 71 | event, 72 | context, 73 | normalizedPathname, 74 | query, 75 | (page, props) => { 76 | const Component = page.default; 77 | const App = getPage("/_app", context).default; 78 | 79 | const content = ReactDOMServer.renderToString( 80 | 84 | 90 | 91 | ); 92 | 93 | const pageProps = { 94 | props, 95 | page, 96 | }; 97 | 98 | const helmet = Helmet.renderStatic(); 99 | let html = ReactDOMServer.renderToString( 100 | 107 | ); 108 | 109 | html = html.replace( 110 | `
`, 111 | `
${content}
` 112 | ); 113 | 114 | html = "" + html; 115 | 116 | return new Response(html, { 117 | status: 200, 118 | headers: { "content-type": "text/html" }, 119 | }); 120 | } 121 | ); 122 | } catch (e) { 123 | if (e instanceof PageNotFoundError) { 124 | return await fallback(event); 125 | } 126 | 127 | throw e; 128 | } 129 | } 130 | 131 | async function handleCachedPageRequest( 132 | event, 133 | context, 134 | normalizedPathname, 135 | query, 136 | generateResponse 137 | ) { 138 | const cache = caches.default; 139 | const cacheKey = getCacheKey(event.request); 140 | const cachedResponse = await cache.match(cacheKey); 141 | 142 | if (!dev && cachedResponse) return cachedResponse; 143 | 144 | const page = getPage(normalizedPathname, context); 145 | const props = await getPageProps(page, query); 146 | 147 | let response = generateResponse(page, props); 148 | 149 | // Cache by default 150 | let shouldCache = true; 151 | 152 | if (props && typeof props.revalidate !== "undefined") { 153 | // Disable cache if the user has explicitly returned { revalidate: 0 } in `getEdgeProps` 154 | if (props.revalidate === 0) { 155 | shouldCache = false; 156 | } else { 157 | response.headers.append("Cache-Control", `max-age=${props.revalidate}`); 158 | } 159 | } 160 | 161 | if (shouldCache) { 162 | await cache.put(cacheKey, response.clone()); 163 | } 164 | 165 | return response; 166 | } 167 | 168 | function getCacheKey(request) { 169 | const url = request.url + "/" + process.env.BUILD_ID; 170 | return new Request(new URL(url).toString(), request); 171 | } 172 | 173 | function pageIsApi(page) { 174 | return /^\/api\/.+/.test(page); 175 | } 176 | -------------------------------------------------------------------------------- /src/client/page-loader.js: -------------------------------------------------------------------------------- 1 | import mitt from "mitt"; 2 | 3 | const dev = process.env.NODE_ENV !== "production"; 4 | 5 | export default class PageLoader { 6 | constructor(initialPage) { 7 | this.pageCache = {}; 8 | 9 | // These two pages are always loaded at first. 10 | this.loadingRoutes = { 11 | "/_app": true, 12 | [initialPage]: true, 13 | }; 14 | 15 | this.pageRegisterEvents = mitt(); 16 | 17 | this.promisedBuildManifest = new Promise((resolve) => { 18 | if (window.__BUILD_MANIFEST) { 19 | resolve(window.__BUILD_MANIFEST); 20 | } else { 21 | window.__BUILD_MANIFEST_CB = () => resolve(window.__BUILD_MANIFEST); 22 | } 23 | }); 24 | } 25 | 26 | async registerPage(route) { 27 | const [pageName, fn] = route; 28 | const pagePath = pageName.replace(/^pages/, ""); 29 | 30 | try { 31 | const mod = fn(); 32 | const component = mod.default || mod; 33 | 34 | this.pageCache[pagePath] = component; 35 | this.pageRegisterEvents.emit(pagePath, component); 36 | } catch (e) { 37 | console.error(`Error loading page: ${pagePath}`, e); 38 | } 39 | } 40 | 41 | loadPage(route) { 42 | return new Promise((resolve) => { 43 | if (this.pageCache[route]) { 44 | return resolve(this.pageCache[route]); 45 | } 46 | 47 | const load = (page) => { 48 | this.pageRegisterEvents.off(route, load); 49 | delete this.loadingRoutes[route]; 50 | resolve(page); 51 | }; 52 | 53 | this.pageRegisterEvents.on(route, load); 54 | 55 | if (!this.loadingRoutes[route]) { 56 | this.loadingRoutes[route] = true; 57 | 58 | if (dev) { 59 | const url = getPagePathUrl(route); 60 | this.loadScript(url); 61 | return; 62 | } 63 | 64 | this.getDependencies(route).then((deps) => { 65 | deps.forEach((dep) => { 66 | const url = getDependencyUrl(dep); 67 | 68 | if (url.endsWith(".js")) { 69 | this.loadScript(url); 70 | } else { 71 | this.loadPrefetch(url, "fetch"); 72 | } 73 | }); 74 | }); 75 | } 76 | }); 77 | } 78 | 79 | async loadPageProps(pagePath) { 80 | const url = getPagePropsUrl(pagePath); 81 | const res = await fetch(url); 82 | return await res.json(); 83 | } 84 | 85 | prefetchData(route) { 86 | const url = getPagePropsUrl(route); 87 | 88 | this.loadPrefetch(url, "script"); 89 | } 90 | 91 | async prefetch(route) { 92 | if (connectionIsSlow()) return; 93 | 94 | if (dev) { 95 | const url = getPagePathUrl(route); 96 | this.loadPrefetch(url, "script"); 97 | return; 98 | } 99 | 100 | const deps = await this.getDependencies(route); 101 | deps.forEach((dep) => { 102 | const url = getDependencyUrl(dep); 103 | 104 | const as = url.endsWith(".js") ? "script" : "fetch"; 105 | this.loadPrefetch(url, as); 106 | }); 107 | } 108 | 109 | async getDependencies(route) { 110 | const deps = await this.promisedBuildManifest; 111 | 112 | return deps[route]; 113 | } 114 | 115 | loadScript(path) { 116 | const prefix = 117 | process.env.NODE_ENV === "production" ? "" : "http://localhost:8080"; 118 | const url = prefix + path; 119 | 120 | if (document.querySelector(`script[src^="${url}"]`)) return; 121 | 122 | const script = document.createElement("script"); 123 | script.src = url; 124 | document.body.appendChild(script); 125 | } 126 | 127 | loadPrefetch(path, as) { 128 | return new Promise((resolve, reject) => { 129 | if ( 130 | document.querySelector(`link[rel="${relPrefetch}"][href^="${path}"]`) 131 | ) { 132 | return resolve(); 133 | } 134 | 135 | const link = document.createElement("link"); 136 | link.as = as; 137 | link.rel = relPrefetch; 138 | link.onload = resolve; 139 | link.onerror = reject; 140 | link.href = path; 141 | 142 | document.head.appendChild(link); 143 | }); 144 | } 145 | } 146 | 147 | export function getPagePropsUrl(pagePath) { 148 | const [basePath, search] = pagePath.split("?"); 149 | return `/_flareact/props${basePath}.json${search ? "?" + search : ""}`; 150 | } 151 | 152 | // Used in development only 153 | function getPagePathUrl(pagePath) { 154 | const prefix = dev ? "/pages" : "/_flareact/static/pages"; 155 | 156 | return prefix + pagePath + ".js"; 157 | } 158 | 159 | function getDependencyUrl(path) { 160 | const prefix = dev ? "/" : "/_flareact/static/"; 161 | 162 | return prefix + path; 163 | } 164 | 165 | /** 166 | * Borrowed from Next.js 167 | */ 168 | function hasRel(rel, link) { 169 | try { 170 | link = document.createElement("link"); 171 | return link.relList.supports(rel); 172 | } catch {} 173 | } 174 | 175 | /** 176 | * Borrowed from Next.js 177 | */ 178 | const relPrefetch = 179 | hasRel("preload") && !hasRel("prefetch") 180 | ? // https://caniuse.com/#feat=link-rel-preload 181 | // macOS and iOS (Safari does not support prefetch) 182 | "preload" 183 | : // https://caniuse.com/#feat=link-rel-prefetch 184 | // IE 11, Edge 12+, nearly all evergreen 185 | "prefetch"; 186 | 187 | /** 188 | * Borrowed from Next.js. 189 | * Don't prefetch if using 2G or if Save-Data is enabled. 190 | * 191 | * https://github.com/GoogleChromeLabs/quicklink/blob/453a661fa1fa940e2d2e044452398e38c67a98fb/src/index.mjs#L115-L118 192 | * License: Apache 2.0 193 | */ 194 | function connectionIsSlow() { 195 | let cn; 196 | if ((cn = navigator.connection)) { 197 | return cn.saveData || /2g/.test(cn.effectiveType); 198 | } 199 | 200 | return false; 201 | } 202 | -------------------------------------------------------------------------------- /configs/webpack.client.config.js: -------------------------------------------------------------------------------- 1 | const baseConfig = require("./webpack.config"); 2 | const ReactRefreshWebpackPlugin = require("@pmmmwh/react-refresh-webpack-plugin"); 3 | const path = require("path"); 4 | const { stringify } = require("querystring"); 5 | const MiniCssExtractPlugin = require("mini-css-extract-plugin"); 6 | const { flareactConfig } = require("./utils"); 7 | const defaultLoaders = require("./loaders"); 8 | const webpack = require("webpack"); 9 | const TerserJSPlugin = require("terser-webpack-plugin"); 10 | const OptimizeCSSAssetsPlugin = require("optimize-css-assets-webpack-plugin"); 11 | const glob = require("glob"); 12 | const BuildManifestPlugin = require("./webpack/plugins/build-manifest-plugin"); 13 | const crypto = require("crypto"); 14 | 15 | const projectDir = process.cwd(); 16 | const flareact = flareactConfig(projectDir); 17 | const dev = process.env.NODE_ENV === "development"; 18 | const isServer = false; 19 | 20 | const pageManifest = glob.sync("./pages/**/*.js"); 21 | 22 | let entry = { 23 | main: "flareact/src/client/index.js", 24 | }; 25 | 26 | pageManifest.forEach((page) => { 27 | if (/pages\/api\//.test(page)) return; 28 | 29 | let pageName = page.match(/\/(.+)\.js$/)[1]; 30 | 31 | // Flatten any dynamic `index` pages 32 | if (pageName !== "pages/index" && pageName.endsWith("/index")) { 33 | pageName = pageName.replace(/\/index$/, ""); 34 | } 35 | 36 | const pageLoaderOpts = { 37 | page: pageName, 38 | absolutePagePath: path.resolve(projectDir, page), 39 | }; 40 | 41 | const pageLoader = `flareact-client-pages-loader?${stringify( 42 | pageLoaderOpts 43 | )}!`; 44 | 45 | entry[pageName] = pageLoader; 46 | }); 47 | 48 | // Inject default _app unless user has a custom one 49 | if (!entry["pages/_app"]) { 50 | const pageLoaderOpts = { 51 | page: "pages/_app", 52 | absolutePagePath: "flareact/src/components/_app.js", 53 | }; 54 | 55 | const pageLoader = `flareact-client-pages-loader?${stringify( 56 | pageLoaderOpts 57 | )}!`; 58 | 59 | entry["pages/_app"] = pageLoader; 60 | } 61 | 62 | const totalPages = Object.keys(entry).filter( 63 | (key) => key.includes("pages") && !/pages\/api\//.test(key) 64 | ).length; 65 | 66 | // TODO: Revisit 67 | const isModuleCSS = (module) => { 68 | return ( 69 | // mini-css-extract-plugin 70 | module.type === `css/mini-extract` || 71 | // extract-css-chunks-webpack-plugin (old) 72 | module.type === `css/extract-chunks` || 73 | // extract-css-chunks-webpack-plugin (new) 74 | module.type === `css/extract-css-chunks` 75 | ); 76 | }; 77 | 78 | module.exports = (env, argv) => { 79 | const config = { 80 | ...baseConfig({ dev, isServer }), 81 | entry, 82 | optimization: { 83 | minimizer: [ 84 | new TerserJSPlugin({ 85 | terserOptions: { 86 | output: { 87 | comments: false, 88 | }, 89 | }, 90 | extractComments: false, 91 | }), 92 | new OptimizeCSSAssetsPlugin(), 93 | ], 94 | // Split out webpack runtime so it's not included in every single page 95 | runtimeChunk: { 96 | name: "webpack", 97 | }, 98 | splitChunks: dev 99 | ? { 100 | cacheGroups: { 101 | default: false, 102 | vendors: false, 103 | }, 104 | } 105 | : { 106 | /** 107 | * N.B. most of this is borrowed from Next.js source code. I've tried to comment 108 | * my understanding of each item below, but I'm sure there's much to be desired. 109 | */ 110 | chunks: "all", 111 | cacheGroups: { 112 | default: false, 113 | vendors: false, 114 | /** 115 | * First, grab the "normal" things: react, etc from node_modules. These will 116 | * for sure be in *every* component, so we might as well pull this out and make 117 | * it cacheable. 118 | */ 119 | framework: { 120 | chunks: "all", 121 | name: "framework", 122 | test: /[\\/]node_modules[\\/](react|react-dom|scheduler|prop-types)[\\/]/, 123 | priority: 40, 124 | // Don't let webpack eliminate this chunk (prevents this chunk from 125 | // becoming a part of the commons chunk) 126 | enforce: true, 127 | }, 128 | /** 129 | * OK - we've got the framework out of the way. Next, we're going to pull out any LARGE 130 | * modules (> 160kb) that happen to be used in an entrypoint from node_modules. Even if 131 | * it's only used by a single chunk - don't care. 132 | */ 133 | lib: { 134 | test(module) { 135 | return ( 136 | module.size() > 160000 && 137 | /node_modules[/\\]/.test(module.identifier()) 138 | ); 139 | }, 140 | name(module) { 141 | const hash = crypto.createHash("sha1"); 142 | if (isModuleCSS(module)) { 143 | module.updateHash(hash); 144 | } else { 145 | if (!module.libIdent) { 146 | throw new Error( 147 | `Encountered unknown module type: ${module.type}. Please open an issue.` 148 | ); 149 | } 150 | 151 | hash.update(module.libIdent({ context: __dirname })); 152 | } 153 | 154 | return hash.digest("hex").substring(0, 8); 155 | }, 156 | priority: 30, 157 | minChunks: 1, 158 | reuseExistingChunk: true, 159 | }, 160 | /** 161 | * Next, commons. I guess if *every single page* uses some of this code, this chunk is generated. 162 | * I'm not sure exactly what this would be, or why we care to split it out here instead of e.g. `shared`. 163 | */ 164 | commons: { 165 | name: "commons", 166 | minChunks: totalPages, 167 | priority: 20, 168 | }, 169 | /** 170 | * Here's another chunk. Not sure what the difference is between this and our pal `commons`. I guess this is 171 | * reserved for less-common chunks, used by at least two other chunks. 172 | */ 173 | shared: { 174 | name(module, chunks) { 175 | return ( 176 | crypto 177 | .createHash("sha1") 178 | .update( 179 | chunks.reduce((acc, chunk) => { 180 | return acc + chunk.name; 181 | }, "") 182 | ) 183 | .digest("hex") + (isModuleCSS(module) ? "_CSS" : "") 184 | ); 185 | }, 186 | priority: 10, 187 | minChunks: 2, 188 | reuseExistingChunk: true, 189 | }, 190 | }, 191 | maxInitialRequests: 25, 192 | minSize: 20000, 193 | }, 194 | }, 195 | context: projectDir, 196 | target: "web", 197 | resolveLoader: { 198 | alias: { 199 | "flareact-client-pages-loader": path.join( 200 | __dirname, 201 | "webpack", 202 | "loaders", 203 | "flareact-client-pages-loader" 204 | ), 205 | }, 206 | }, 207 | output: { 208 | path: path.resolve(projectDir, "out/_flareact/static"), 209 | chunkFilename: `${dev ? "[name]" : "[name].[contenthash]"}.js`, 210 | }, 211 | plugins: [new MiniCssExtractPlugin(), new BuildManifestPlugin()], 212 | devServer: { 213 | contentBase: path.resolve(projectDir, "out"), 214 | hot: true, 215 | hotOnly: true, 216 | stats: "errors-warnings", 217 | noInfo: true, 218 | headers: { 219 | "access-control-allow-origin": "*", 220 | }, 221 | }, 222 | devtool: dev ? "source-map" : false, 223 | }; 224 | 225 | if (dev) { 226 | config.plugins.push(new ReactRefreshWebpackPlugin()); 227 | 228 | config.output.publicPath = "http://localhost:8080/"; 229 | } 230 | 231 | if (flareact.webpack) { 232 | return flareact.webpack(config, { 233 | dev, 234 | isServer, 235 | isWorker: isServer, 236 | defaultLoaders: defaultLoaders({ dev, isServer }), 237 | webpack, 238 | }); 239 | } 240 | 241 | return config; 242 | }; 243 | -------------------------------------------------------------------------------- /configs/babel/plugins/flareact-edge-transform.js: -------------------------------------------------------------------------------- 1 | const edgeExports = new Set(["getStaticProps", "getEdgeProps"]); 2 | 3 | const isDataIdentifier = (name, state) => edgeExports.has(name); 4 | 5 | /** 6 | * This is a Babel plugin! It performs dead code elimination by removing anything 7 | * in the `getEdgeProps` or `getStaticProps` methods of pages, as well as any 8 | * imports from those functions. 9 | * 10 | * Mostly borrowed from Next.js's implementation: 11 | * @see https://github.com/vercel/next.js/blob/canary/packages/next/build/babel/plugins/next-ssg-transform.ts 12 | */ 13 | module.exports = function flareactEdgeTransform({ types: t }) { 14 | function getIdentifier(path) { 15 | const parentPath = path.parentPath; 16 | if (parentPath.type === "VariableDeclarator") { 17 | const pp = parentPath; 18 | const name = pp.get("id"); 19 | return name.node.type === "Identifier" ? name : null; 20 | } 21 | if (parentPath.type === "AssignmentExpression") { 22 | const pp = parentPath; 23 | const name = pp.get("left"); 24 | return name.node.type === "Identifier" ? name : null; 25 | } 26 | if (path.node.type === "ArrowFunctionExpression") { 27 | return null; 28 | } 29 | return path.node.id && path.node.id.type === "Identifier" 30 | ? path.get("id") 31 | : null; 32 | } 33 | function isIdentifierReferenced(ident) { 34 | const b = ident.scope.getBinding(ident.node.name); 35 | if (b === null || b === void 0 ? void 0 : b.referenced) { 36 | // Functions can reference themselves, so we need to check if there's a 37 | // binding outside the function scope or not. 38 | if (b.path.type === "FunctionDeclaration") { 39 | return !b.constantViolations 40 | .concat(b.referencePaths) 41 | // Check that every reference is contained within the function: 42 | .every((ref) => ref.findParent((p) => p === b.path)); 43 | } 44 | return true; 45 | } 46 | return false; 47 | } 48 | function markFunction(path, state) { 49 | const ident = getIdentifier(path); 50 | if ( 51 | (ident === null || ident === void 0 ? void 0 : ident.node) && 52 | isIdentifierReferenced(ident) 53 | ) { 54 | state.refs.add(ident); 55 | } 56 | } 57 | function markImport(path, state) { 58 | const local = path.get("local"); 59 | if (isIdentifierReferenced(local)) { 60 | state.refs.add(local); 61 | } 62 | } 63 | return { 64 | visitor: { 65 | Program: { 66 | enter(path, state) { 67 | state.refs = new Set(); 68 | state.isPrerender = false; 69 | state.isServerProps = false; 70 | state.done = false; 71 | path.traverse( 72 | { 73 | VariableDeclarator(variablePath, variableState) { 74 | if (variablePath.node.id.type === "Identifier") { 75 | const local = variablePath.get("id"); 76 | if (isIdentifierReferenced(local)) { 77 | variableState.refs.add(local); 78 | } 79 | } else if (variablePath.node.id.type === "ObjectPattern") { 80 | const pattern = variablePath.get("id"); 81 | const properties = pattern.get("properties"); 82 | properties.forEach((p) => { 83 | const local = p.get( 84 | p.node.type === "ObjectProperty" 85 | ? "value" 86 | : p.node.type === "RestElement" 87 | ? "argument" 88 | : (function () { 89 | throw new Error("invariant"); 90 | })() 91 | ); 92 | if (isIdentifierReferenced(local)) { 93 | variableState.refs.add(local); 94 | } 95 | }); 96 | } else if (variablePath.node.id.type === "ArrayPattern") { 97 | const pattern = variablePath.get("id"); 98 | const elements = pattern.get("elements"); 99 | elements.forEach((e) => { 100 | var _a, _b; 101 | let local; 102 | if ( 103 | ((_a = e.node) === null || _a === void 0 104 | ? void 0 105 | : _a.type) === "Identifier" 106 | ) { 107 | local = e; 108 | } else if ( 109 | ((_b = e.node) === null || _b === void 0 110 | ? void 0 111 | : _b.type) === "RestElement" 112 | ) { 113 | local = e.get("argument"); 114 | } else { 115 | return; 116 | } 117 | if (isIdentifierReferenced(local)) { 118 | variableState.refs.add(local); 119 | } 120 | }); 121 | } 122 | }, 123 | FunctionDeclaration: markFunction, 124 | FunctionExpression: markFunction, 125 | ArrowFunctionExpression: markFunction, 126 | ImportSpecifier: markImport, 127 | ImportDefaultSpecifier: markImport, 128 | ImportNamespaceSpecifier: markImport, 129 | ExportNamedDeclaration(exportNamedPath, exportNamedState) { 130 | const specifiers = exportNamedPath.get("specifiers"); 131 | if (specifiers.length) { 132 | specifiers.forEach((s) => { 133 | if ( 134 | isDataIdentifier(s.node.exported.name, exportNamedState) 135 | ) { 136 | s.remove(); 137 | } 138 | }); 139 | if (exportNamedPath.node.specifiers.length < 1) { 140 | exportNamedPath.remove(); 141 | } 142 | return; 143 | } 144 | const decl = exportNamedPath.get("declaration"); 145 | if (decl == null || decl.node == null) { 146 | return; 147 | } 148 | switch (decl.node.type) { 149 | case "FunctionDeclaration": { 150 | const name = decl.node.id.name; 151 | if (isDataIdentifier(name, exportNamedState)) { 152 | exportNamedPath.remove(); 153 | } 154 | break; 155 | } 156 | case "VariableDeclaration": { 157 | const inner = decl.get("declarations"); 158 | inner.forEach((d) => { 159 | if (d.node.id.type !== "Identifier") { 160 | return; 161 | } 162 | const name = d.node.id.name; 163 | if (isDataIdentifier(name, exportNamedState)) { 164 | d.remove(); 165 | } 166 | }); 167 | break; 168 | } 169 | default: { 170 | break; 171 | } 172 | } 173 | }, 174 | }, 175 | state 176 | ); 177 | if (!state.isPrerender && !state.isServerProps) { 178 | return; 179 | } 180 | const refs = state.refs; 181 | let count; 182 | function sweepFunction(sweepPath) { 183 | const ident = getIdentifier(sweepPath); 184 | if ( 185 | (ident === null || ident === void 0 ? void 0 : ident.node) && 186 | refs.has(ident) && 187 | !isIdentifierReferenced(ident) 188 | ) { 189 | ++count; 190 | if ( 191 | t.isAssignmentExpression(sweepPath.parentPath) || 192 | t.isVariableDeclarator(sweepPath.parentPath) 193 | ) { 194 | sweepPath.parentPath.remove(); 195 | } else { 196 | sweepPath.remove(); 197 | } 198 | } 199 | } 200 | function sweepImport(sweepPath) { 201 | const local = sweepPath.get("local"); 202 | if (refs.has(local) && !isIdentifierReferenced(local)) { 203 | ++count; 204 | sweepPath.remove(); 205 | if (sweepPath.parent.specifiers.length === 0) { 206 | sweepPath.parentPath.remove(); 207 | } 208 | } 209 | } 210 | do { 211 | path.scope.crawl(); 212 | count = 0; 213 | path.traverse({ 214 | // eslint-disable-next-line no-loop-func 215 | VariableDeclarator(variablePath) { 216 | if (variablePath.node.id.type === "Identifier") { 217 | const local = variablePath.get("id"); 218 | if (refs.has(local) && !isIdentifierReferenced(local)) { 219 | ++count; 220 | variablePath.remove(); 221 | } 222 | } else if (variablePath.node.id.type === "ObjectPattern") { 223 | const pattern = variablePath.get("id"); 224 | const beforeCount = count; 225 | const properties = pattern.get("properties"); 226 | properties.forEach((p) => { 227 | const local = p.get( 228 | p.node.type === "ObjectProperty" 229 | ? "value" 230 | : p.node.type === "RestElement" 231 | ? "argument" 232 | : (function () { 233 | throw new Error("invariant"); 234 | })() 235 | ); 236 | if (refs.has(local) && !isIdentifierReferenced(local)) { 237 | ++count; 238 | p.remove(); 239 | } 240 | }); 241 | if ( 242 | beforeCount !== count && 243 | pattern.get("properties").length < 1 244 | ) { 245 | variablePath.remove(); 246 | } 247 | } else if (variablePath.node.id.type === "ArrayPattern") { 248 | const pattern = variablePath.get("id"); 249 | const beforeCount = count; 250 | const elements = pattern.get("elements"); 251 | elements.forEach((e) => { 252 | var _a, _b; 253 | let local; 254 | if ( 255 | ((_a = e.node) === null || _a === void 0 256 | ? void 0 257 | : _a.type) === "Identifier" 258 | ) { 259 | local = e; 260 | } else if ( 261 | ((_b = e.node) === null || _b === void 0 262 | ? void 0 263 | : _b.type) === "RestElement" 264 | ) { 265 | local = e.get("argument"); 266 | } else { 267 | return; 268 | } 269 | if (refs.has(local) && !isIdentifierReferenced(local)) { 270 | ++count; 271 | e.remove(); 272 | } 273 | }); 274 | if ( 275 | beforeCount !== count && 276 | pattern.get("elements").length < 1 277 | ) { 278 | variablePath.remove(); 279 | } 280 | } 281 | }, 282 | FunctionDeclaration: sweepFunction, 283 | FunctionExpression: sweepFunction, 284 | ArrowFunctionExpression: sweepFunction, 285 | ImportSpecifier: sweepImport, 286 | ImportDefaultSpecifier: sweepImport, 287 | ImportNamespaceSpecifier: sweepImport, 288 | }); 289 | } while (count); 290 | }, 291 | }, 292 | }, 293 | }; 294 | }; 295 | --------------------------------------------------------------------------------