├── .circleci └── config.yml ├── .eslintrc ├── .gitignore ├── .nvmrc ├── .prettierrc ├── Brewfile ├── LICENSE ├── README.md ├── bin └── react-scripts.js ├── config ├── babelOptions.js ├── packageConfig.js ├── webpack.client.dev.js ├── webpack.client.prod.js ├── webpack.defaults.js ├── webpack.server.dev.js └── webpack.server.js ├── docs └── PersistedQueries.md ├── lib ├── assetMiddleware.js ├── buildPersistedQueries.js ├── defaultHeadersMiddleware.js ├── errorHandlerMiddleware.js ├── etag.js ├── graphqlProxyMiddleware.js └── manifest.js ├── package.json ├── scripts ├── build-queries.js ├── build.js ├── init.js ├── start.js ├── test.js ├── upload-queries.js ├── upload.js └── utils │ ├── __mocks__ │ ├── file.js │ ├── react-relay.js │ ├── react-relay │ │ ├── classic.js │ │ └── compat.js │ └── style.js │ ├── babelLoaderConfig.json │ ├── babelTransform.js │ ├── jestConfig.js │ ├── relayCompilerArguments.js │ └── testSetup.js ├── template ├── .eslintrc ├── .gitignore ├── .template.dependencies.json ├── README.md ├── redirects.json ├── scripts │ └── updateSchema.js ├── server.js ├── src │ ├── .eslintrc │ ├── components │ │ └── App │ │ │ ├── index.js │ │ │ └── styles.css │ ├── fetcher.js │ ├── globals.css │ ├── index.js │ ├── routes.js │ └── shared.css └── tsconfig.json └── yarn.lock /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | jobs: 3 | build: 4 | working_directory: ~/react-scripts 5 | docker: 6 | - image: circleci/node:12 7 | steps: 8 | - checkout 9 | - restore_cache: 10 | key: dependency-cache-{{ checksum "yarn.lock" }} 11 | - run: 12 | command: yarn install 13 | - save_cache: 14 | key: dependency-cache-{{ checksum "yarn.lock" }} 15 | paths: 16 | - ./node_modules 17 | - run: 18 | name: test 19 | command: echo "No tests defined!" 20 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["airbnb-base"] 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | template/yarn.lock 3 | template/package.json 4 | template/schema.json 5 | template/schema.graphql 6 | yarn-error.log 7 | .watchmanconfig 8 | .tern-port 9 | complete.queryMap.json -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 8 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "all", 3 | "singleQuote": true, 4 | "printWidth": 90 5 | } 6 | -------------------------------------------------------------------------------- /Brewfile: -------------------------------------------------------------------------------- 1 | # https://robots.thoughtbot.com/brewfile-a-gemfile-but-for-homebrew 2 | 3 | brew 'watchman' 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # firstlookmedia/react-scripts 2 | 3 | Provides configuration for universal React/Relay apps. 4 | 5 | By default we expect a graphql server to exist as a separate service. 6 | The default template will query for `{ viewer { id } }` but this is not 7 | required of the schema. 8 | 9 | ## Install 10 | 11 | ``` bash 12 | npm install -g create-react-app 13 | 14 | create-react-app --scripts-version=git+ssh://git@github.com/firstlookmedia/react-scripts.git my-app 15 | cd my-app 16 | yarn update-schema 17 | yarn start 18 | ``` 19 | 20 | ## Usage 21 | 22 | `react-scripts` expects at least the following files: 23 | 24 | ``` 25 | src/index.js # entry to the client-side app 26 | server.js # entry to the server 27 | schema.graphql # your graphql schema 28 | ``` 29 | 30 | The output will become: 31 | 32 | ``` 33 | build/server.js # compiled server 34 | build/manifest.json # manifest pointing source files to compiled 35 | build/assets/32f2q8fj3.js # example compiled app 36 | build/assets/2d0823jd.css # any other compiled assets (css, images, fonts) 37 | ``` 38 | 39 | --- 40 | 41 | #### `yarn start` 42 | 43 | Starts the development environment: 44 | 45 | - The app server, which auto-reloads on [http://localhost:3232](http://localhost:3232) 46 | - A webpack dev server, which hot-reloads and proxies requests to the app server, 47 | on [http://localhost:3233](http://localhost:3233) 48 | 49 | #### `yarn build` 50 | 51 | Builds the production assets to the `build` folder. 52 | 53 | #### `yarn test` 54 | 55 | Runs jest tests. `react-scripts` will look for any file named `__spec.js`. 56 | 57 | You will need `watchman` to use `yarn test` without `CI=true`. 58 | To install on OSX `brew bundle` in this directory. 59 | 60 | ## Persisted queries 61 | 62 | To enable persisted queries: 63 | 64 | 1. Add `PERSIST_QUERIES: "true"` in the build and runtime environments 65 | 2. Point `QUERIES_S3_BUCKET` to an s3 bucket during build time to deploy queries, making them accessible to the graphql backend 66 | 3. Upgrade to the newest version of React scripts that has the `GET` and `POST` fetcher methods 67 | 4. Upgrade to relay >= 3.0 68 | 69 | Note: persisted queries are always turned off during local development. 70 | -------------------------------------------------------------------------------- /bin/react-scripts.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | "use strict"; 4 | 5 | const crypto = require('crypto'); 6 | const path = require('path'); 7 | const spawn = require('cross-spawn'); 8 | 9 | const script = process.argv[2]; 10 | const args = process.argv.slice(3); 11 | 12 | switch (script) { 13 | case 'build': 14 | case 'build-queries': 15 | case 'start': 16 | case 'upload': 17 | case 'upload-queries': 18 | case 'test': { 19 | const result = spawn.sync( 20 | 'node', 21 | [require.resolve(path.join('../scripts', script))].concat(args), 22 | { stdio: 'inherit' } 23 | ); 24 | process.exit(result.status); 25 | break; 26 | } 27 | case 'pwhash': { 28 | let stdin = process.openStdin(); 29 | let data = ""; 30 | stdin.on('data', function(chunk) { 31 | data += chunk; 32 | }); 33 | 34 | stdin.on('end', function() { 35 | let hash = crypto.createHash('md5').update(data).digest('hex'); 36 | console.log(hash); 37 | }); 38 | break; 39 | } 40 | default: 41 | console.log(`Unknown script "${script}".`); 42 | break; 43 | } 44 | -------------------------------------------------------------------------------- /config/babelOptions.js: -------------------------------------------------------------------------------- 1 | const packageConfig = require('./packageConfig'); 2 | 3 | const useReactHotLoader = typeof packageConfig.useReactHotLoader !== 'undefined' 4 | ? packageConfig.useReactHotLoader 5 | : true; 6 | 7 | const babelOptions = { 8 | passPerPreset: true, 9 | presets: [ 10 | [ 11 | '@babel/preset-env', 12 | { 13 | useBuiltIns: 'entry', 14 | corejs: 3, 15 | }, 16 | ], 17 | '@babel/typescript', 18 | '@babel/react', 19 | ], 20 | plugins: [ 21 | [ 22 | '@babel/transform-runtime', 23 | { 24 | corejs: 3, 25 | }, 26 | ], 27 | '@loadable/babel-plugin', 28 | useReactHotLoader && 'react-hot-loader/babel', 29 | [ 30 | 'relay', 31 | { 32 | compat: packageConfig.relayCompatMode || false, 33 | artifactDirectory: 'src/__generated__', 34 | schema: 'schema.graphql', 35 | }, 36 | ], 37 | '@babel/plugin-syntax-dynamic-import', 38 | '@babel/plugin-syntax-import-meta', 39 | '@babel/plugin-proposal-class-properties', 40 | '@babel/plugin-proposal-json-strings', 41 | '@babel/plugin-proposal-function-sent', 42 | '@babel/plugin-proposal-export-namespace-from', 43 | '@babel/plugin-proposal-numeric-separator', 44 | '@babel/plugin-proposal-throw-expressions', 45 | '@babel/plugin-proposal-optional-chaining', 46 | '@babel/plugin-proposal-nullish-coalescing-operator', 47 | '@babel/plugin-proposal-object-rest-spread', 48 | '@babel/plugin-transform-for-of', 49 | ].filter(Boolean), 50 | }; 51 | 52 | module.exports = babelOptions; 53 | -------------------------------------------------------------------------------- /config/packageConfig.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | const packageConfig = require(path.resolve('package.json')); 4 | 5 | const reactScriptPackageConfig = { 6 | ...packageConfig['react-scripts'], 7 | }; 8 | 9 | module.exports = reactScriptPackageConfig; 10 | -------------------------------------------------------------------------------- /config/webpack.client.dev.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack'); 2 | const merge = require('webpack-merge'); 3 | const path = require('path'); 4 | const LoadablePlugin = require('@loadable/webpack-plugin'); 5 | const defaults = require('./webpack.defaults'); 6 | 7 | const config = merge.smart( 8 | { 9 | module: { 10 | rules: [ 11 | { 12 | test: /\.css$/, 13 | include: path.resolve('src'), 14 | use: ['style-loader'], 15 | }, 16 | { 17 | test: /\.css$/, 18 | include: path.resolve('node_modules'), 19 | use: ['style-loader'], 20 | }, 21 | { 22 | test: /\.scss$/, 23 | use: ['style-loader'], 24 | }, 25 | ], 26 | }, 27 | }, 28 | defaults, 29 | { 30 | devtool: 'eval-source-map', 31 | entry: ['webpack-hot-middleware/client'], 32 | plugins: [ 33 | new LoadablePlugin({ filename: 'stats.json', writeToDisk: true }), 34 | new webpack.HotModuleReplacementPlugin(), 35 | ], 36 | }, 37 | ); 38 | 39 | module.exports = config; 40 | -------------------------------------------------------------------------------- /config/webpack.client.prod.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack'); 2 | const ManifestPlugin = require('webpack-manifest-plugin'); 3 | const ProgressBarPlugin = require('progress-bar-webpack-plugin'); 4 | const merge = require('webpack-merge'); 5 | const MiniCSSExtractPlugin = require('mini-css-extract-plugin'); 6 | const OptimizeCSSAssetsPlugin = require('optimize-css-assets-webpack-plugin'); 7 | const path = require('path'); 8 | const LoadablePlugin = require('@loadable/webpack-plugin'); 9 | const defaults = require('./webpack.defaults'); 10 | const babelOptions = require('./babelOptions'); 11 | 12 | const config = merge.smart( 13 | { 14 | module: { 15 | rules: [ 16 | { 17 | test: /\.css$/, 18 | include: path.resolve('src'), 19 | use: [MiniCSSExtractPlugin.loader], 20 | }, 21 | { 22 | test: /\.css$/, 23 | include: path.resolve('node_modules'), 24 | use: [MiniCSSExtractPlugin.loader], 25 | }, 26 | { 27 | test: /\.scss$/, 28 | use: [MiniCSSExtractPlugin.loader], 29 | }, 30 | { 31 | test: /\.tsx*$/, 32 | include: [path.resolve('src')], 33 | use: [ 34 | { 35 | loader: 'babel-loader', 36 | options: babelOptions, 37 | }, 38 | { 39 | loader: 'ts-loader', 40 | }, 41 | ], 42 | }, 43 | ], 44 | }, 45 | }, 46 | defaults, 47 | { 48 | mode: 'production', 49 | output: { 50 | filename: '[hash].js', 51 | }, 52 | plugins: [ 53 | new LoadablePlugin({ filename: 'stats.json', writeToDisk: true }), 54 | new ManifestPlugin({ fileName: 'manifest.json' }), 55 | new ManifestPlugin({ fileName: `manifest.${Date.now()}.json` }), 56 | new webpack.DefinePlugin({ 57 | 'process.env.NODE_ENV': '"production"', 58 | }), 59 | new ProgressBarPlugin(), 60 | new MiniCSSExtractPlugin({ 61 | filename: '[contenthash].css', 62 | }), 63 | new OptimizeCSSAssetsPlugin({ 64 | cssProcessorOptions: { 65 | map: { 66 | inline: false, 67 | }, 68 | reduceIdents: false, 69 | }, 70 | }), 71 | ], 72 | }, 73 | ); 74 | 75 | module.exports = config; 76 | -------------------------------------------------------------------------------- /config/webpack.defaults.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const autoprefixer = require('autoprefixer'); 3 | const precss = require('precss'); 4 | const postcssCalc = require('postcss-calc'); 5 | const packageConfig = require('./packageConfig'); 6 | const babelOptions = require('./babelOptions'); 7 | 8 | const cssOptions = { 9 | sourceMap: true, 10 | modules: true, 11 | importLoaders: 1, 12 | context: 'src/components', 13 | localIdentName: '[path][local]', 14 | }; 15 | 16 | module.exports = { 17 | mode: 'development', 18 | context: __dirname, 19 | entry: [path.resolve(packageConfig.clientEntry || 'src/index.js')], 20 | output: { 21 | filename: '[name].js', 22 | path: path.resolve('build/assets'), 23 | publicPath: '/assets/', 24 | }, 25 | plugins: [], 26 | resolve: { 27 | modules: ['node_modules'], 28 | extensions: [ 29 | '.js', 30 | '.json', 31 | '.ts', 32 | '.tsx', 33 | ], 34 | alias: { 35 | Types: path.resolve(__dirname, 'src/__generated__'), 36 | }, 37 | }, 38 | module: { 39 | rules: [ 40 | { 41 | test: /\btranslations\.(json|ya?ml)$/, 42 | type: 'javascript/auto', 43 | loader: 'messageformat-loader', 44 | options: { 45 | locale: packageConfig.locale || 'en', 46 | }, 47 | }, 48 | { 49 | test: /\.tsx*$/, 50 | include: [path.resolve('src')], 51 | use: [ 52 | { 53 | loader: 'babel-loader', 54 | options: babelOptions, 55 | }, 56 | ], 57 | }, 58 | { 59 | test: /\.jsx*$/, 60 | include: [path.resolve('src'), path.resolve('server.js')], 61 | use: [ 62 | { 63 | loader: 'babel-loader', 64 | options: babelOptions, 65 | }, 66 | ], 67 | }, 68 | { 69 | test: /\.css$/, 70 | include: path.resolve('src'), 71 | use: [ 72 | { 73 | loader: 'css-loader', 74 | options: cssOptions, 75 | }, 76 | { 77 | loader: 'postcss-loader', 78 | options: { 79 | sourceMap: true, 80 | ident: 'postcss', 81 | plugins: [autoprefixer(), postcssCalc(), precss()], 82 | }, 83 | }, 84 | ], 85 | }, 86 | { 87 | test: /\.css$/, 88 | include: path.resolve('node_modules'), 89 | use: [ 90 | { 91 | loader: 'css-loader', 92 | options: { 93 | modules: false, 94 | }, 95 | }, 96 | ], 97 | }, 98 | { 99 | test: /\.scss$/, 100 | use: [ 101 | { 102 | loader: 'css-loader', 103 | options: { ...cssOptions, importLoaders: 3 }, 104 | }, 105 | 'resolve-url-loader', 106 | { 107 | loader: 'postcss-loader', 108 | options: { 109 | sourceMap: true, 110 | 111 | // needed to have two different postcss configs 112 | // without this, it just silently fails 113 | ident: 'postcss-sass', 114 | 115 | plugins: [autoprefixer()], 116 | }, 117 | }, 118 | { 119 | loader: 'sass-loader', 120 | options: { 121 | sourceMap: true, 122 | }, 123 | }, 124 | ], 125 | }, 126 | { 127 | test: /\.(jpe?g|png|gif|svg)$/i, 128 | use: [ 129 | { 130 | loader: 'file-loader', 131 | options: { 132 | hash: 'sha512', 133 | digest: 'hex', 134 | name: '[hash].[ext]', 135 | }, 136 | }, 137 | { 138 | loader: 'image-webpack-loader', 139 | options: { 140 | bypassOnDebug: true, 141 | }, 142 | }, 143 | ], 144 | }, 145 | { 146 | test: /\.woff2?$/, 147 | use: [ 148 | { 149 | loader: 'url-loader', 150 | options: { 151 | limit: 10000, 152 | mimetype: 'application/font-woff', 153 | }, 154 | }, 155 | ], 156 | }, 157 | { 158 | test: /masonry|imagesloaded|fizzy\-ui\-utils|desandro\-|outlayer|get\-size|doc\-ready|eventie|eventemitter/, 159 | use: 'imports-loader?define=>false&this=>window', 160 | }, 161 | ], 162 | }, 163 | }; 164 | -------------------------------------------------------------------------------- /config/webpack.server.dev.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack'); 2 | const nodeExternals = require('webpack-node-externals'); 3 | const LoadablePlugin = require('@loadable/webpack-plugin'); 4 | const StartServerPlugin = require('start-server-webpack-plugin'); 5 | const defaults = require('./webpack.server'); 6 | 7 | module.exports = Object.assign({}, defaults, { 8 | mode: 'development', 9 | entry: ['webpack/hot/poll?1000'].concat(defaults.entry), 10 | watch: true, 11 | externals: [nodeExternals({ 12 | whitelist: ['webpack/hot/poll?1000', /\.css$/], 13 | })], 14 | plugins: defaults.plugins.concat( 15 | new LoadablePlugin({ filename: 'stats.json', writeToDisk: true }), 16 | new webpack.HotModuleReplacementPlugin(), 17 | new StartServerPlugin('server.js'), 18 | ), 19 | }); 20 | -------------------------------------------------------------------------------- /config/webpack.server.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const webpack = require('webpack'); 3 | const nodeExternals = require('webpack-node-externals'); 4 | const ProgressBarPlugin = require('progress-bar-webpack-plugin'); 5 | const merge = require('webpack-merge'); 6 | const defaults = require('./webpack.defaults.js'); 7 | const packageConfig = require('./packageConfig'); 8 | 9 | const config = merge.smart(defaults, { 10 | target: 'node', 11 | mode: 'production', 12 | module: { 13 | rules: [ 14 | { 15 | test: /\.css$/, 16 | include: path.resolve('src'), 17 | use: [ 18 | { 19 | loader: 'css-loader', 20 | options: { 21 | exportOnlyLocals: true, 22 | }, 23 | }, 24 | ], 25 | }, 26 | { 27 | test: /\.css$/, 28 | include: path.resolve('node_modules'), 29 | use: [ 30 | { 31 | loader: 'css-loader', 32 | options: { 33 | exportOnlyLocals: true, 34 | }, 35 | }, 36 | ], 37 | }, 38 | { 39 | test: /\.scss$/, 40 | use: [ 41 | { 42 | loader: 'css-loader', 43 | options: { 44 | exportOnlyLocals: true, 45 | }, 46 | }, 47 | ], 48 | }, 49 | ], 50 | }, 51 | node: { 52 | console: false, 53 | global: false, 54 | process: false, 55 | Buffer: false, 56 | __filename: false, 57 | __dirname: false, 58 | setImmediate: false, 59 | }, 60 | entry: path.resolve(packageConfig.serverEntry || 'server.js'), 61 | output: { 62 | filename: 'server.js', 63 | path: path.resolve('build'), 64 | }, 65 | // put all node_modules into externals (require() them as usual w/o webpack) 66 | externals: [nodeExternals({ 67 | whitelist: /\.css$/, 68 | })], 69 | plugins: [ 70 | new webpack.BannerPlugin({ 71 | banner: 'require("source-map-support").install();', 72 | raw: true, 73 | entryOnly: true, 74 | }), 75 | new ProgressBarPlugin(), 76 | ], 77 | devtool: 'source-map', 78 | }); 79 | 80 | module.exports = config; 81 | -------------------------------------------------------------------------------- /docs/PersistedQueries.md: -------------------------------------------------------------------------------- 1 | # Persisted Static Queries 2 | 3 | ## Background 4 | 5 | - Relay Classic generates queries at runtime. It can do things like allow string interpolation in the queries themselves. Unfortunately, this leads to a big performance cost at runtime and there is no compiled query that one could save and store. One of the biggest changes in Relay Modern is that all queries are compiled at build time. This [blog post](https://code.fb.com/data-infrastructure/relay-modern-simpler-faster-more-extensible/) goes into detail about this and other aspects of Relay Modern. 6 | 7 | - With Relay Modern (but before persisted queries support), we have access to static queries that are generated before runtime, but we still send each query to the client. The query is then included in the `POST` request body from the client. 8 | 9 | **Persisted static queries TLDR:** instead of sending the queries to the client and including them in the `POST` request, we now have the option to persist the queries on AWS. Each query has an associated deterministic (MD5) hash that we send to the client. The client then makes `GET` requests with the query hash and the query variables. This: 10 | 11 | 1. reduces the bundle size we ship to the client 12 | 13 | 2. reduces the size of the requests we make for each query 14 | 15 | 3. opens up everything else `GET` requests get, like better caching, CDN 16 | 17 | ## Prerequisites 18 | 19 | 1. Codebase must be entirely Relay Modern 20 | 21 | 2. An S3 bucket for storing queries 22 | 23 | 3. `react-scripts` version >= 2.0.0-rc2 24 | 25 | 4. First Look Media fork of `relay-compiler`: `https://github.com/firstlookmedia/relay/releases/download/v1.5.0-flm.1/relay-compiler-1.5.0-flm.1.tar.gz` 26 | 27 | 5. `PERSIST_QUERIES: "true"` and `QUERIES_S3_BUCKET` circle configuration 28 | 29 | ## Front-end details 30 | 31 | 1. Upgrading to `react-scripts` with persisted queries support requires no changes for apps that do not want to use persisted queries 32 | 33 | 2. Persisted queries are always turned off during local development 34 | 35 | 3. The client needs access to the `PERSIST_QUERIES` environment variable (`env-config`) 36 | 37 | 4. Most of the code changes live in `fetcher.js`. The APIs for `ClientFetcher` & `ServerFetcher` have not changed. Under the hood they make `GET` or `POST` requests depending on the value of `PERSIST_QUERIES` 38 | 39 | 5. The fork of `relay-compiler` takes a `--persist` flag to turn persisted queries on. There is [a PR open to merge this feature into Relay itself](https://github.com/facebook/relay/pull/2354), for now we will will need to maintain a fork that we can upgrade when we upgrade Relay. This requires an update to the fork of Relay, a build of the complier package, and a release of our fork -------------------------------------------------------------------------------- /lib/assetMiddleware.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const path = require('path'); 3 | 4 | // middleware to serve build assets 5 | // 6 | // USAGE 7 | // 8 | // app.use('/assets', assetMiddleware); 9 | 10 | module.exports = (req, res, next) => { 11 | express.static( 12 | path.resolve('build/assets'), 13 | { maxAge: '1y', fallthrough: false } 14 | )(req, res, next); 15 | }; 16 | -------------------------------------------------------------------------------- /lib/buildPersistedQueries.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | 4 | const BASE_DIR = path.resolve('build/queries'); 5 | 6 | module.exports = () => { 7 | try { 8 | if (!fs.existsSync(BASE_DIR)) { 9 | fs.mkdirSync(BASE_DIR); 10 | } 11 | const queryMap = JSON.parse(fs.readFileSync(path.resolve('complete.queryMap.json'))); 12 | Object.keys(queryMap).forEach((key) => { 13 | fs.writeFileSync(`${BASE_DIR}/${key}.query.txt`, queryMap[key]); 14 | }); 15 | console.log('Persisted queries built'); 16 | } catch (e) { 17 | console.error('Persisted queries build step failed:', e); 18 | } 19 | }; 20 | -------------------------------------------------------------------------------- /lib/defaultHeadersMiddleware.js: -------------------------------------------------------------------------------- 1 | // sets default headers for all responses 2 | 3 | // USAGE 4 | // (should come AFTER any express.static middleware, including 5 | // react-scripts/lib/assetMiddleware) 6 | // 7 | // app.use(defaultHeadersMiddleware); 8 | 9 | module.exports = (req, res, next) => { 10 | res.set({ 11 | // this should come after express.static otherwise it overrides 12 | 'Cache-Control': 'max-age=300', 13 | 14 | // https://observatory.mozilla.org/ for recommended settings 15 | 'Strict-Transport-Security': 'max-age=15768000; includeSubDomains; preload', 16 | //'Referrer-Policy': 'no-referrer', // Revisit when this is more widely supported. 17 | 'X-Content-Type-Options': 'nosniff', 18 | 'X-XSS-Protection': '1; mode=block', 19 | 'X-Frame-Options': 'SAMEORIGIN' 20 | }); 21 | next(); 22 | }; 23 | -------------------------------------------------------------------------------- /lib/errorHandlerMiddleware.js: -------------------------------------------------------------------------------- 1 | // standard error handler middleware. 2 | 3 | // USAGE 4 | // (this should be included at the bottom of server.js) 5 | // 6 | // app.use(errorHandlerMiddleware); 7 | 8 | module.exports = (err, req, res, next) => { // eslint-disable-line no-unused-vars 9 | console.error(err.stack || err.message || err); 10 | if (err.statusCode) { 11 | return res.sendStatus(err.statusCode); 12 | } 13 | return res.sendStatus(500); 14 | }; 15 | -------------------------------------------------------------------------------- /lib/etag.js: -------------------------------------------------------------------------------- 1 | const crypto = require('crypto'); 2 | 3 | // TODO 4 | // this hacks around Relay sending incremental IDs, see: 5 | // https://github.com/facebook/relay/blob/master/src/query/generateConcreteFragmentID.js 6 | // we should file a bug and get them to fix this. 7 | 8 | // USAGE 9 | // 10 | // app.set('etag', etag); 11 | 12 | module.exports = (body) => { 13 | const bodyStr = body.toString(); 14 | const bodyExcludingRelay = bodyStr.substring(0, bodyStr.indexOf( 15 | ' 71 | 72 | Building server... 73 | `); 74 | }); 75 | } else { 76 | res.sendStatus(404); 77 | } 78 | }); 79 | 80 | const PORT = process.env.PORT || 3233; 81 | 82 | app.listen(PORT, (err) => { 83 | if (err) { 84 | return console.error(err); 85 | } 86 | const url = `http://localhost:${PORT}`; 87 | return console.log(` 88 | Development server started. Visit ${chalk.bold.green(url)} 89 | `); 90 | }); 91 | 92 | const serverCompiler = webpack(Object.assign({}, serverConfig)); 93 | 94 | serverCompiler.watch({ poll: 1000 }, (err, stats) => { 95 | if (err) { 96 | console.error(err.message || err); 97 | } 98 | if (stats.hasErrors()) { 99 | console.log(stats.toString('errors-only')); 100 | } 101 | }); 102 | 103 | const relayCompiler = spawn( 104 | path.resolve('./node_modules/.bin/relay-compiler'), 105 | relayCompilerArguments.concat('--watch'), 106 | { stdio: 'inherit' }, 107 | ); 108 | 109 | relayCompiler.on('close', code => process.exit(code)); 110 | 111 | process.on('close', code => relayCompiler.exit(code)); 112 | -------------------------------------------------------------------------------- /scripts/test.js: -------------------------------------------------------------------------------- 1 | const jest = require('jest'); 2 | 3 | const argv = process.argv.slice(2); 4 | 5 | // Watch unless on CI or in coverage mode 6 | if (!process.env.CI && argv.indexOf('--coverage') < 0) { 7 | argv.push('--watch'); 8 | } 9 | 10 | const path = require('path'); 11 | 12 | argv.push( 13 | '--config', 14 | path.resolve(__dirname, 'utils', 'jestConfig.js'), 15 | ); 16 | 17 | jest.run(argv); 18 | -------------------------------------------------------------------------------- /scripts/upload-queries.js: -------------------------------------------------------------------------------- 1 | process.env.NODE_ENV = 'production'; 2 | 3 | const AWS = require('aws-sdk'); 4 | const path = require('path'); 5 | const fs = require('fs'); 6 | const mime = require('mime'); 7 | 8 | const s3 = new AWS.S3({ apiVersion: '2006-03-01' }); 9 | const queriesBucket = process.env.QUERIES_S3_BUCKET; 10 | const persist = process.env.PERSIST_QUERIES === 'true'; 11 | const uploadParams = { 12 | Bucket: queriesBucket, 13 | Key: '', 14 | Body: '', 15 | ACL: 'public-read', 16 | // Sets Cache-Control header and in Metadata 17 | CacheControl: 'public, max-age=31536000', 18 | }; 19 | const baseDir = './build/queries'; 20 | 21 | if (!persist) { 22 | console.error('Persist queries turned off. Exiting.'); 23 | process.exit(0); 24 | } 25 | 26 | if (!queriesBucket) { 27 | console.error('QUERIES_S3_BUCKET is empty. Exiting.'); 28 | process.exit(1); 29 | } 30 | 31 | fs.readdir(baseDir, (err, files) => { 32 | if (!files || files.length < 1) return; 33 | 34 | files.forEach((file) => { 35 | const fileStream = fs.createReadStream(path.join(baseDir, file)); 36 | fileStream.on('error', (err) => { 37 | console.log('File Error', err); 38 | }); 39 | uploadParams.Body = fileStream; 40 | // FIXME: remove STATIC_QUERY_SUFFIX when the following is resolved: 41 | // https://github.com/facebook/relay/pull/2641 42 | const hash = file.substring(0, file.length - 10); 43 | const queryFile = `${hash}${process.env.STATIC_QUERY_SUFFIX || ''}.query.txt`; 44 | uploadParams.Key = `queries/${queryFile}`; 45 | 46 | // Sets Content-Type header and in Metadata 47 | const type = mime.getType(file); 48 | if (type) { 49 | uploadParams.ContentType = type; 50 | } 51 | s3.upload(uploadParams, (err, data) => { 52 | if (err) { 53 | console.log('Error', err); 54 | } 55 | if (data) { 56 | console.log('Queries upload Success', data.Location); 57 | } 58 | }); 59 | }); 60 | }); 61 | -------------------------------------------------------------------------------- /scripts/upload.js: -------------------------------------------------------------------------------- 1 | process.env.NODE_ENV = 'production'; 2 | 3 | const AWS = require('aws-sdk'); 4 | const path = require('path'); 5 | const fs = require('fs'); 6 | const mime = require('mime'); 7 | 8 | const s3 = new AWS.S3({ apiVersion: '2006-03-01' }); 9 | 10 | const bucket = process.env.ASSETS_S3_BUCKET; 11 | const key_prefix = process.env.ASSETS_S3_KEY_PREFIX 12 | || require(path.join(process.cwd(), 'package.json')).name; 13 | 14 | const uploadParams = { 15 | Bucket: bucket, 16 | Key: '', 17 | Body: '', 18 | ACL: 'public-read', 19 | // Sets Cache-Control header and in Metadata 20 | CacheControl: 'public, max-age=31536000', 21 | }; 22 | 23 | const base_dir = './build/assets'; 24 | 25 | if (!bucket) { 26 | console.error('ASSETS_S3_BUCKET is empty. Exiting.'); 27 | process.exit(1); 28 | } 29 | 30 | if (!key_prefix) { 31 | console.error('S3 key prefix is empty. Exiting.'); 32 | process.exit(1); 33 | } 34 | 35 | fs.readdir(base_dir, (err, files) => { 36 | files.forEach((file) => { 37 | const fileStream = fs.createReadStream(path.join(base_dir, file)); 38 | fileStream.on('error', (err) => { 39 | console.log('File Error', err); 40 | }); 41 | uploadParams.Body = fileStream; 42 | uploadParams.Key = `${key_prefix}/assets/${file}`; 43 | 44 | // Sets Content-Type header and in Metadata 45 | const type = mime.getType(file); 46 | if (type) { 47 | uploadParams.ContentType = type; 48 | } 49 | s3.upload(uploadParams, (err, data) => { 50 | if (err) { 51 | console.log('Error', err); 52 | } 53 | if (data) { 54 | console.log('Upload Success', data.Location); 55 | } 56 | }); 57 | }); 58 | }); 59 | -------------------------------------------------------------------------------- /scripts/utils/__mocks__/file.js: -------------------------------------------------------------------------------- 1 | module.exports = 'test-file-stub'; 2 | -------------------------------------------------------------------------------- /scripts/utils/__mocks__/react-relay.js: -------------------------------------------------------------------------------- 1 | const Relay = jest.genMockFromModule('react-relay'); 2 | 3 | Relay.createFragmentContainer = (component) => component; 4 | 5 | Relay.QueryRenderer = ({ render }) => render({ props: {}, error: null }); 6 | 7 | module.exports = Relay; 8 | -------------------------------------------------------------------------------- /scripts/utils/__mocks__/react-relay/classic.js: -------------------------------------------------------------------------------- 1 | const Relay = require('react-relay/classic'); 2 | 3 | class MockStore { 4 | reset() { 5 | this.successResponse = undefined; 6 | } 7 | 8 | succeedWith(response) { 9 | this.reset(); 10 | this.successResponse = response; 11 | } 12 | 13 | failWith(response) { 14 | this.reset(); 15 | this.failureResponse = response; 16 | } 17 | 18 | update(callbacks) { 19 | if (this.successResponse) { 20 | callbacks.onSuccess(this.successResponse); 21 | } else if (this.failureResponse) { 22 | callbacks.onFailure(this.failureResponse); 23 | } 24 | this.reset(); 25 | } 26 | 27 | commitUpdate(mutation, callbacks) { 28 | return this.update(callbacks); 29 | } 30 | 31 | applyUpdate(mutation, callbacks) { 32 | return this.update(callbacks); 33 | } 34 | } 35 | 36 | module.exports = { 37 | QL: Relay.QL, 38 | Mutation: Relay.Mutation, 39 | Route: Relay.Route, 40 | RootContainer: ({ renderFetched }) => renderFetched({}), 41 | createContainer: component => component, 42 | Store: new MockStore(), 43 | }; 44 | -------------------------------------------------------------------------------- /scripts/utils/__mocks__/react-relay/compat.js: -------------------------------------------------------------------------------- 1 | const Relay = require('react-relay/compat'); 2 | 3 | Relay.createFragmentContainer = component => component; 4 | 5 | Relay.QueryRenderer = ({ render }) => render({ props: {}, error: null }); 6 | 7 | module.exports = Relay; 8 | -------------------------------------------------------------------------------- /scripts/utils/__mocks__/style.js: -------------------------------------------------------------------------------- 1 | module.exports = {}; 2 | -------------------------------------------------------------------------------- /scripts/utils/babelLoaderConfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "passPerPreset": true, 3 | "presets": ["@babel/preset-typescript", "@babel/preset-react", "@babel/preset-env"], 4 | "plugins": [ 5 | [ 6 | "@babel/plugin-transform-runtime", 7 | { 8 | "corejs": 3 9 | } 10 | ], 11 | "macros", 12 | [ 13 | "babel-plugin-relay", 14 | { 15 | "artifactDirectory": "src/__generated__", 16 | "schema": "schema.graphql" 17 | } 18 | ], 19 | "@babel/plugin-syntax-dynamic-import", 20 | "@babel/plugin-syntax-import-meta", 21 | "@babel/plugin-proposal-class-properties", 22 | "@babel/plugin-proposal-json-strings", 23 | "@babel/plugin-proposal-function-sent", 24 | "@babel/plugin-proposal-export-namespace-from", 25 | "@babel/plugin-proposal-numeric-separator", 26 | "@babel/plugin-proposal-throw-expressions", 27 | "@babel/plugin-proposal-optional-chaining", 28 | "@babel/plugin-proposal-nullish-coalescing-operator" 29 | ] 30 | } 31 | -------------------------------------------------------------------------------- /scripts/utils/babelTransform.js: -------------------------------------------------------------------------------- 1 | const babelJest = require('babel-jest'); 2 | const config = require('./babelLoaderConfig.json'); 3 | 4 | module.exports = babelJest.createTransformer(config); 5 | -------------------------------------------------------------------------------- /scripts/utils/jestConfig.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | module.exports = { 4 | rootDir: process.cwd(), 5 | moduleNameMapper: { 6 | '\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga|ico)$': path.resolve( 7 | __dirname, 8 | '__mocks__/file.js', 9 | ), 10 | '\\.(css|scss)$': require.resolve('identity-obj-proxy'), 11 | '^react-relay/compat$': path.resolve(__dirname, '__mocks__/react-relay/compat.js'), 12 | '^react-relay/classic$': path.resolve(__dirname, '__mocks__/react-relay/classic.js'), 13 | '^react-relay$': path.resolve(__dirname, '__mocks__/react-relay.js'), 14 | }, 15 | moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], 16 | transform: { 17 | '\\.js$': path.resolve(__dirname, 'babelTransform.js'), 18 | '^.+\\.(ts|tsx)$': path.resolve(__dirname, 'babelTransform.js'), 19 | }, 20 | 21 | collectCoverageFrom: ['src/**/*.{js,jsx,ts,tsx}', '!src/**/stories.{js,jsx,ts,tsx}'], 22 | coveragePathIgnorePatterns: ['/node_modules/', '/src/.*__generated__/', '/src/assets/'], 23 | testRegex: 'src/.*__spec\\.(jsx?|tsx?)$', 24 | snapshotSerializers: [require.resolve('enzyme-to-json/serializer')], 25 | setupFiles: [path.resolve(__dirname, 'testSetup.js')], 26 | }; 27 | -------------------------------------------------------------------------------- /scripts/utils/relayCompilerArguments.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const packageConfig = require('../../config/packageConfig.js'); 3 | 4 | // relay compiler 5 | 6 | let includePaths = ['src/**']; 7 | const excludePaths = ['**/__generated__/**']; 8 | const moduleName = packageConfig.sharedComponentModule; 9 | if (moduleName) { 10 | includePaths = includePaths.concat(`node_modules/${moduleName}/src/**`); 11 | } 12 | const extensions = ['js', 'jsx', 'ts', 'tsx']; 13 | 14 | module.exports = [ 15 | '--src', 16 | path.resolve('.'), 17 | 18 | '--extensions', 19 | extensions, 20 | 21 | '--include', 22 | includePaths, 23 | 24 | '--exclude', 25 | excludePaths, 26 | 27 | '--schema', 28 | 'schema.json', 29 | 30 | '--language', 31 | 'typescript', 32 | 33 | '--artifactDirectory', 34 | 'src/__generated__', 35 | 36 | process.env.PERSIST_QUERIES === 'true' ? '--persistOutput' : '', 37 | process.env.PERSIST_QUERIES === 'true' ? './complete.queryMap.json' : '', 38 | ].reduce((acc, item) => acc.concat(item), []); 39 | -------------------------------------------------------------------------------- /scripts/utils/testSetup.js: -------------------------------------------------------------------------------- 1 | global.requestAnimationFrame = (callback) => { 2 | setTimeout(callback, 0); 3 | }; 4 | 5 | const observeMock = () => null; 6 | global.IntersectionObserver = function IntersectionObserver() { 7 | return { 8 | observe: observeMock, 9 | }; 10 | }; 11 | global.IntersectionObserverEntry = { 12 | prototype: { intersectionRatio: {} }, 13 | }; 14 | 15 | const Enzyme = require('enzyme'); 16 | const Adapter = require('enzyme-adapter-react-16'); 17 | 18 | Enzyme.configure({ adapter: new Adapter() }); 19 | 20 | global.mount = Enzyme.mount; 21 | global.shallow = Enzyme.shallow; 22 | -------------------------------------------------------------------------------- /template/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "babel-eslint", 3 | "plugins": ["react"], 4 | "extends": ["airbnb-base", "plugin:react/recommended"], 5 | "parserOptions": { 6 | "ecmaFeatures": { 7 | "jsx": true 8 | } 9 | }, 10 | "rules": { 11 | "react/prop-types": 0, 12 | "react/no-render-return-value": 1, 13 | "no-underscore-dangle": 0, 14 | "global-require": 1, 15 | "prefer-arrow-callback": 1 16 | }, 17 | "env": { 18 | "jest": true 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /template/.gitignore: -------------------------------------------------------------------------------- 1 | .bundle 2 | **/.DS_Store 3 | tmp 4 | node_modules 5 | build 6 | __generated__ 7 | yarn-error.log 8 | -------------------------------------------------------------------------------- /template/.template.dependencies.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "babel-eslint": "^8", 4 | "babel-plugin-relay": "^1.5", 5 | "babel-polyfill": "^6.26.0", 6 | "compression": "^1.6.2", 7 | "env-config": "git+https://github.com/firstlookmedia/env-config.git", 8 | "eslint": "^4", 9 | "eslint-config-airbnb-base": "^12", 10 | "eslint-plugin-import": "^2", 11 | "eslint-plugin-react": "^7", 12 | "farce": "^0.2.5", 13 | "found": "^0.3.4", 14 | "found-relay": "0.3.0-alpha.12", 15 | "graphql": "^0.8", 16 | "lodash": "^4.17.1", 17 | "qs": "^6.5.2", 18 | "react": "^16", 19 | "react-dom": "^16", 20 | "react-helmet": "^5", 21 | "react-relay": "^1.5.0", 22 | "react-test-renderer": "^16", 23 | "relay-compiler": "^1.5.0", 24 | "relay-runtime": "^1.5.0", 25 | "serialize-javascript": "^1.4.0", 26 | "source-map-support": "^0.4.6", 27 | "sync-request": "^6" 28 | }, 29 | "scripts": { 30 | "build": "react-scripts build", 31 | "start": "react-scripts start", 32 | "test": "react-scripts test", 33 | "update-schema": "node scripts/updateSchema.js" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /template/README.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/firstlookmedia/react-scripts/00cc917e579babc7206c43d049b3d8b2e755413c/template/README.md -------------------------------------------------------------------------------- /template/redirects.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /template/scripts/updateSchema.js: -------------------------------------------------------------------------------- 1 | const utils = require('graphql/utilities'); 2 | const request = require('sync-request'); 3 | const fs = require('fs'); 4 | const path = require('path'); 5 | 6 | const introspectionQuery = utils.introspectionQuery; 7 | const buildClientSchema = utils.buildClientSchema; 8 | const printSchema = utils.printSchema; 9 | 10 | const GRAPHQL_ORIGIN = process.env.GRAPHQL_ORIGIN || 'http://localhost:3002'; 11 | 12 | const resp = request('POST', `${GRAPHQL_ORIGIN}/graphql`, { 13 | headers: { 14 | 'content-type': 'application/json', 15 | }, 16 | body: JSON.stringify({ query: introspectionQuery }), 17 | }).getBody(); 18 | 19 | fs.writeFileSync(path.join(__dirname, '../schema.json'), resp); 20 | 21 | fs.writeFileSync( 22 | path.join(__dirname, '../schema.graphql'), 23 | printSchema(buildClientSchema(JSON.parse(resp).data)) 24 | ); 25 | -------------------------------------------------------------------------------- /template/server.js: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import compression from 'compression'; 3 | import ReactDOMServer from 'react-dom/server'; 4 | import manifest from 'react-scripts/lib/manifest'; 5 | import assetMiddleware from 'react-scripts/lib/assetMiddleware'; 6 | import errorHandlerMiddleware from 'react-scripts/lib/errorHandlerMiddleware'; 7 | import defaultHeadersMiddleware from 'react-scripts/lib/defaultHeadersMiddleware'; 8 | import graphqlProxyMiddleware from 'react-scripts/lib/graphqlProxyMiddleware'; 9 | import { getFarceResult } from 'found/lib/server'; 10 | import serialize from 'serialize-javascript'; 11 | import envConfig from 'env-config'; 12 | import 'babel-polyfill'; 13 | import { ServerFetcher } from './src/fetcher'; 14 | import { 15 | createResolver, 16 | historyMiddlewares, 17 | render, 18 | routeConfig, 19 | } from './src/routes'; 20 | 21 | const { 22 | APP_PORT = 3232, 23 | GRAPHQL_ORIGIN = 'http://localhost:3002', 24 | GRAPHQL_PATH = '/graphql', 25 | PERSIST_QUERIES = false, 26 | } = process.env; 27 | 28 | envConfig.register({ 29 | PERSIST_QUERIES, 30 | }); 31 | 32 | const GRAPHQL_URL = `${GRAPHQL_ORIGIN}${GRAPHQL_PATH}`; 33 | 34 | const app = express(); 35 | app.use(compression()); 36 | app.use('/assets', assetMiddleware); 37 | app.use(express.static('public')); 38 | app.use(defaultHeadersMiddleware); 39 | app.use('/graphql', graphqlProxyMiddleware(GRAPHQL_URL)); 40 | 41 | const stylesheetTag = (href) => { 42 | if (!href) { 43 | return ''; 44 | } 45 | 46 | return `` 47 | }; 48 | 49 | app.get('*', async (req, res) => { 50 | const fetcher = new ServerFetcher(GRAPHQL_URL); 51 | 52 | const { redirect, status, element } = await getFarceResult({ 53 | url: req.url, 54 | historyMiddlewares, 55 | routeConfig, 56 | resolver: createResolver(fetcher), 57 | render, 58 | }); 59 | 60 | if (redirect) { 61 | res.redirect(302, redirect.url); 62 | return; 63 | } 64 | 65 | res.status(status).send(` 66 | 67 | 68 | 69 | Hello world 70 | 71 | ${stylesheetTag(manifest['main.css'])} 72 | 73 | 74 |
${ReactDOMServer.renderToString(element)}
75 | 78 | ${envConfig.renderScriptTag()} 79 | 80 | 81 | `); 82 | }); 83 | 84 | app.use(errorHandlerMiddleware); 85 | 86 | app.listen(APP_PORT, (err) => { 87 | if (err) { 88 | return console.error(err.message || err); 89 | } 90 | return console.log(`Server started on port ${APP_PORT}`); 91 | }); 92 | -------------------------------------------------------------------------------- /template/src/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /template/src/components/App/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { graphql, createFragmentContainer } from 'react-relay'; 3 | import styles from './styles.css'; 4 | 5 | const App = ({ viewer }) => ( 6 |
7 |

Hello {viewer.id}!

8 |

Try updating this text or the styles to see hot-reload in action

9 |
10 | ); 11 | 12 | export default createFragmentContainer(App, { 13 | viewer: graphql`fragment App_viewer on Viewer { 14 | id 15 | }`, 16 | }); 17 | -------------------------------------------------------------------------------- /template/src/components/App/styles.css: -------------------------------------------------------------------------------- 1 | @import '../../shared.css'; 2 | 3 | .container { 4 | @extend %border-radius-m; 5 | @extend %box-shadow-m; 6 | margin: 5em auto; 7 | padding: 3em; 8 | border: 1px solid var(--grey); 9 | max-width: 50%; 10 | text-align: center; 11 | h1 { 12 | color: var(--green); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /template/src/fetcher.js: -------------------------------------------------------------------------------- 1 | import 'isomorphic-fetch'; 2 | import envConfig from 'env-config'; 3 | import qs from 'qs'; 4 | 5 | // TODO: Update this when someone releases a real, production-quality solution 6 | // for handling universal rendering with Relay Modern. For now, this is just 7 | // enough to get things working. 8 | 9 | class FetcherBase { 10 | constructor(url, isStaticQueries = false) { 11 | this.url = url; 12 | this.fetchVerb = isStaticQueries ? this.get : this.post; 13 | } 14 | 15 | async get(operation, variables) { 16 | const queryParams = qs.stringify(variables, { sort: (a, b) => a.localeCompare(b) }); 17 | const staticQueryUrl = `${this.url}/${operation.id}?${queryParams}`; 18 | const response = await fetch(staticQueryUrl, { 19 | method: 'GET', 20 | credentials: 'same-origin', 21 | }); 22 | return response; 23 | } 24 | 25 | async post(operation, variables) { 26 | const response = await fetch(this.url, { 27 | method: 'POST', 28 | headers: { 29 | 'Content-Type': 'application/json', 30 | }, 31 | body: JSON.stringify({ query: operation.text, variables }), 32 | credentials: 'same-origin', 33 | }); 34 | return response; 35 | } 36 | 37 | async fetch(operation, variables) { 38 | try { 39 | const response = await this.fetchVerb(operation, variables); 40 | return response.json(); 41 | } catch (err) { 42 | return { error: err }; 43 | } 44 | } 45 | } 46 | 47 | export class ServerFetcher extends FetcherBase { 48 | constructor(url) { 49 | const isStaticQueries = process.env.PERSIST_QUERIES === 'true'; 50 | super(url, isStaticQueries); 51 | 52 | this.payloads = []; 53 | } 54 | 55 | async fetch(...args) { 56 | const i = this.payloads.length; 57 | this.payloads.push(null); 58 | const payload = await super.fetch(...args); 59 | this.payloads[i] = payload; 60 | return payload; 61 | } 62 | 63 | toJSON() { 64 | return this.payloads; 65 | } 66 | } 67 | 68 | export class ClientFetcher extends FetcherBase { 69 | constructor(url, payloads) { 70 | const isStaticQueries = envConfig.PERSIST_QUERIES === 'true'; 71 | super(url, isStaticQueries); 72 | 73 | this.payloads = payloads; 74 | } 75 | 76 | async fetch(...args) { 77 | if (this.payloads.length) { 78 | return this.payloads.shift(); 79 | } 80 | 81 | return super.fetch(...args); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /template/src/globals.css: -------------------------------------------------------------------------------- 1 | @import './shared.css'; 2 | 3 | * { 4 | box-sizing: border-box; 5 | } 6 | 7 | body { 8 | font-family: system, -apple-system, 9 | ".SFNSText-Regular", HelveticaNeue, LucidaGrande; 10 | } 11 | 12 | :--heading { 13 | @extend %font-size-xl; 14 | } 15 | -------------------------------------------------------------------------------- /template/src/index.js: -------------------------------------------------------------------------------- 1 | import BrowserProtocol from 'farce/lib/BrowserProtocol'; 2 | import createInitialFarceRouter from 'found/lib/createInitialFarceRouter'; 3 | import React from 'react'; 4 | import ReactDOM from 'react-dom'; 5 | import envConfig from 'env-config'; 6 | import { ClientFetcher } from './fetcher'; 7 | 8 | import './globals.css'; 9 | 10 | const root = document.getElementById('root'); 11 | 12 | envConfig.hydrate(); 13 | 14 | const renderApp = async () => { 15 | const { 16 | createResolver, 17 | historyMiddlewares, 18 | render, 19 | routeConfig, 20 | } = require('./routes'); 21 | 22 | // eslint-disable-next-line no-underscore-dangle 23 | const fetcher = new ClientFetcher('/graphql', window.__RELAY_PAYLOADS__); 24 | const resolver = createResolver(fetcher); 25 | 26 | const Router = await createInitialFarceRouter({ 27 | historyProtocol: new BrowserProtocol(), 28 | historyMiddlewares, 29 | routeConfig, 30 | resolver, 31 | render, 32 | }); 33 | 34 | ReactDOM.hydrate(, root); 35 | }; 36 | 37 | renderApp(); 38 | 39 | if (module.hot) { 40 | module.hot.accept('./routes', () => { 41 | ReactDOM.unmountComponentAtNode(root); 42 | renderApp(); 43 | }); 44 | } 45 | -------------------------------------------------------------------------------- /template/src/routes.js: -------------------------------------------------------------------------------- 1 | import queryMiddleware from 'farce/lib/queryMiddleware'; 2 | import createRender from 'found/lib/createRender'; 3 | import makeRouteConfig from 'found/lib/makeRouteConfig'; 4 | import Route from 'found/lib/Route'; 5 | import { Resolver } from 'found-relay'; 6 | import React from 'react'; 7 | import { graphql } from 'react-relay'; 8 | import { Environment, Network, RecordSource, Store } from 'relay-runtime'; 9 | 10 | import App from './components/App'; 11 | 12 | export const historyMiddlewares = [queryMiddleware]; 13 | 14 | export function createResolver(fetcher) { 15 | const environment = new Environment({ 16 | network: Network.create((...args) => fetcher.fetch(...args)), 17 | store: new Store(new RecordSource()), 18 | }); 19 | 20 | return new Resolver(environment); 21 | } 22 | 23 | // eslint-disable-next-line function-paren-newline 24 | export const routeConfig = makeRouteConfig( 25 | , 36 | ); 37 | 38 | export const render = createRender({}); 39 | -------------------------------------------------------------------------------- /template/src/shared.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --font-size-base: 14; 3 | --green: #34a853; 4 | --grey: #ccc; 5 | } 6 | 7 | @custom-media --medium only screen and (min-width: 580px); 8 | 9 | @custom-selector :--heading h1, h2, h3, h4, h5, h6; 10 | 11 | @define-mixin font-size $size { 12 | font-size: calc($(size)rem / var(--font-size-base)); 13 | } 14 | 15 | %subpixel { 16 | -webkit-font-smoothing: subpixel-antialiased; 17 | -moz-osx-font-smoothing: auto; 18 | } 19 | 20 | %grayscale { 21 | -webkit-font-smoothing: antialiased; 22 | -moz-osx-font-smoothing: grayscale; 23 | } 24 | 25 | %font-size-xs { 26 | @mixin font-size 9; 27 | } 28 | 29 | %font-size-s { 30 | @mixin font-size 11; 31 | } 32 | 33 | %font-size-m { 34 | @mixin font-size 14; 35 | } 36 | 37 | %font-size-l { 38 | @mixin font-size 18; 39 | } 40 | 41 | %font-size-xl { 42 | @mixin font-size 24; 43 | } 44 | 45 | %border-radius-s { 46 | border-radius: 2px; 47 | } 48 | 49 | %border-radius-m { 50 | border-radius: 5px; 51 | } 52 | 53 | %box-shadow-m { 54 | box-shadow: 1px 3px 5px color(black alpha(10%)); 55 | } 56 | -------------------------------------------------------------------------------- /template/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "./build/", 4 | "sourceMap": true, 5 | "allowJs": true, 6 | "jsx": "react", 7 | "target": "es2016", 8 | "module": "es2015", 9 | "moduleResolution": "node", 10 | "allowSyntheticDefaultImports": true, 11 | "noImplicitAny": true, 12 | "noUnusedLocals": true, 13 | "noUnusedParameters": true, 14 | "removeComments": false, 15 | "preserveConstEnums": true, 16 | "skipLibCheck": true 17 | }, 18 | "include": [ 19 | "./src/**/*" 20 | ] 21 | } --------------------------------------------------------------------------------