From 1ed83b62ee8f85d52832f7eba27f208bdc23a60c Mon Sep 17 00:00:00 2001 From: Konstantin Tarkus Date: Tue, 25 Apr 2017 12:57:56 +0300 Subject: [PATCH] Initialize a new HTTP client for each web request (#1237) --- LICENSE.txt | 2 +- docs/data-fetching.md | 78 ++++--- docs/getting-started.md | 4 +- docs/recipes/how-to-implement-routing.md | 24 +-- package.json | 44 ++-- src/{core => }/DOMUtils.js | 0 src/client.js | 18 +- src/components/App.js | 2 + src/components/Html.js | 23 +-- src/components/Link/Link.js | 2 +- src/config.js | 71 ++++--- src/core/fetch/fetch.client.js | 15 -- src/core/fetch/fetch.server.js | 33 --- src/core/fetch/package.json | 6 - src/createFetch.js | 49 +++++ src/data/queries/news.js | 6 +- src/data/sequelize.js | 4 +- src/{core => }/devUtils.js | 0 src/{core => }/history.js | 0 src/{core => }/passport.js | 8 +- src/{core => }/router.js | 2 +- src/routes/home/index.js | 9 +- src/server.js | 24 ++- yarn.lock | 250 +++++++++++------------ 24 files changed, 355 insertions(+), 319 deletions(-) rename src/{core => }/DOMUtils.js (100%) delete mode 100644 src/core/fetch/fetch.client.js delete mode 100644 src/core/fetch/fetch.server.js delete mode 100644 src/core/fetch/package.json create mode 100644 src/createFetch.js rename src/{core => }/devUtils.js (100%) rename src/{core => }/history.js (100%) rename src/{core => }/passport.js (95%) rename src/{core => }/router.js (91%) diff --git a/LICENSE.txt b/LICENSE.txt index 3e95f03c6..2e4e7b16b 100644 --- a/LICENSE.txt +++ b/LICENSE.txt @@ -1,6 +1,6 @@ The MIT License -Copyright (c) 2014-present Konstantin Tarkus, KriaSoft LLC. +Copyright (c) 2014-present Konstantin Tarkus, Kriasoft LLC. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/docs/data-fetching.md b/docs/data-fetching.md index b7a3ffc6d..71fa32d55 100644 --- a/docs/data-fetching.md +++ b/docs/data-fetching.md @@ -1,32 +1,60 @@ -## Data Fetching with WHATWG Fetch - -There is isomorphic `core/fetch` module that can be used the same way in both -client-side and server-side code as follows: - -```jsx -import fetch from '../core/fetch'; - -export const path = '/products'; -export const action = async () => { - const response = await fetch('/graphql?query={products{id,name}}'); - const data = await response.json(); - return ; -}; +## Data Fetching + +At a bare minimum you may want to use [HTML5 Fetch API][fetch] as an HTTP client utility for +making Ajax request to the [data API server][nodeapi]. This API is supported natively in all the +major browsers except for IE (note, that Edge browser does support Fetch). + +**React Starter Kit** is pre-configured with [`whatwg-fetch`][wfetch] polyfill for the browser +environment and [`node-fetch`][nfetch] module for the server-side environment (see +[`src/createFetch.js`](../src/createFetch.js)), allowing you to use the `fetch(url, options)` +method universally in both the client-side and server-side code bases. + +In order to avoid the the amount of boilerplate code needed when using the raw `fetch(..)` +function, a simple wrapper was created that provides a base URL of the data API server, credentials +(cookies), CORS etc. For example, in a browser environment the base URL of the data API server +might be an empty string, so when you make an Ajax request to the `/graphql` endpoint it's being +sent to the same origin, and when the same code is executed on the server, during server-side +rendering, it fetches data from the `http://api:8080/graphql` endpoint (`node-fetch` doesn't +support relative URLs for obvious reasons). + +Because of these subtle differences of how the `fetch` method works internally, it makes total +sense to pass it as a `context` variable to your React application, so it can be used from either +routing level or from inside your React components as follows: + +#### Route Example + +```js +{ + path: '/posts/:id', + async action({ params, fetch }) { + const resp = await fetch(`/api/posts/${params.id}`, { method: 'GET' }); + const data = await resp.json(); + return { title: data.title, component: }; + } +} ``` -When this code executes on the client, the Ajax request will be sent via -GitHub's [fetch](https://github.com/github/fetch) library (`whatwg-fetch`), -that itself uses XHMLHttpRequest behind the scene unless `fetch` is supported -natively by the user's browser. +#### React Component + +```js +class Post extends React.Component { + static context = { fetch: PropTypes.func.isRequired }; + handleDelete = (event) => { + event.preventDefault(); + const id = event.target.dataset['id']; + this.context.fetch(`/api/posts/${id}`, { method: 'DELETE' }).then(...); + }; + render() { ... } +} +``` -Whenever the same code executes on the server, it uses -[node-fetch](https://github.com/bitinn/node-fetch) module behind the scene that -itself sends an HTTP request via Node.js `http` module. It also converts -relative URLs to absolute (see `./core/fetch/fetch.server.js`). +#### Related articles -Both `whatwg-fetch` and `node-fetch` modules have almost identical API. If -you're new to this API, the following article may give you a good introduction: +* [That's so fetch!](https://jakearchibald.com/2015/thats-so-fetch/) by [Jake Archibald](https://twitter.com/jaffathecake) -https://jakearchibald.com/2015/thats-so-fetch/ +[fetch]: https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch +[wfetch]: https://github.com/github/fetchno +[nfetch]: https://github.com/bitinn/node-fetch +[nodeapi]: https://github.com/kriasoft/nodejs-api-starter diff --git a/docs/getting-started.md b/docs/getting-started.md index 171db6912..c288d12b5 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -18,12 +18,12 @@ Before you start, take a moment to see how the project structure looks like: ├── /public/ # Static files which are copied into the /build/public folder ├── /src/ # The source code of the application │ ├── /components/ # React components -│ ├── /core/ # Core framework and utility functions │ ├── /data/ # GraphQL server schema and data models │ ├── /routes/ # Page/screen components along with the routing information │ ├── /client.js # Client-side startup script │ ├── /config.js # Global application settings -│ └── /server.js # Server-side startup script +│ ├── /server.js # Server-side startup script +│ └── ... # Other core framework modules ├── /test/ # Unit and end-to-end tests ├── /tools/ # Build automation scripts and utilities │ ├── /lib/ # Library for utility snippets diff --git a/docs/recipes/how-to-implement-routing.md b/docs/recipes/how-to-implement-routing.md index 496523caa..6493b9ca9 100644 --- a/docs/recipes/how-to-implement-routing.md +++ b/docs/recipes/how-to-implement-routing.md @@ -4,7 +4,7 @@ Let's see how a custom routing solution under 100 lines of code may look like. First, you will need to implement the **list of application routes** in which each route can be represented as an object with properties of `path` (a parametrized URL path string), `action` -(a function), and optionally `children` (a list of sub-routes, each of which is a route object). +(a function), and optionally `children` (a list of sub-routes, each of which is a route object). The `action` function returns anything - a string, a React component, etc. For example: #### `src/routes/index.js` @@ -42,7 +42,7 @@ return `{ id: '123' }` while calling `matchURI('/tasks/:id', '/foo')` must retur Fortunately, there is a great library called [`path-to-regexp`](https://github.com/pillarjs/path-to-regexp) that makes this task very easy. Here is how a URL matcher function may look like: -#### `src/core/router.js` +#### `src/router.js` ```js import toRegExp from 'path-to-regexp'; @@ -67,7 +67,7 @@ action method returns anything other than `null` or `undefined` return that to t Otherwise, it should continue iterating over the remaining routes. If none of the routes match to the provided URL string, it should throw an exception (Not found). Here is how this function may look like: -#### `src/core/router.js` +#### `src/router.js` ```js import toRegExp from 'path-to-regexp'; @@ -93,12 +93,12 @@ export default { resolve }; That's it! Here is a usage example: ```js -import router from './core/router'; +import router from './router'; import routes from './routes'; router.resolve(routes, { pathname: '/tasks' }).then(result => { console.log(result); - // => { title: 'To-do', component: } + // => { title: 'To-do', component: } }); ``` @@ -108,10 +108,10 @@ npm module to handles this task for you. It is the same library used in React Ro wrapper over [HTML5 History API](https://developer.mozilla.org/docs/Web/API/History_API) that handles all the tricky browser compatibility issues related to client-side navigation. -First, create `src/core/history.js` file that will initialize a new instance of the `history` module +First, create `src/history.js` file that will initialize a new instance of the `history` module and export is as a singleton: -#### `src/core/history.js` +#### `src/history.js` ```js import createHistory from 'history/lib/createBrowserHistory'; @@ -125,8 +125,8 @@ Then plug it in, in your client-side bootstrap code as follows: ```js import ReactDOM from 'react-dom'; -import history from './core/history'; -import router from './core/router'; +import history from './history'; +import router from './router'; import routes from './routes'; const container = document.getElementById('root'); @@ -157,7 +157,7 @@ In order to trigger client-side navigation without causing full-page refresh, yo ```js import React from 'react'; -import history from '../core/history'; +import history from '../history'; class App extends React.Component { transition = event => { @@ -181,9 +181,9 @@ class App extends React.Component { Though, it is a common practice to extract that transitioning functionality into a stand-alone (`Link`) component that can be used as follows: - + ```html -View Task #123 +View Task #123 ``` ### Routing in React Starter Kit diff --git a/package.json b/package.json index 2ee257935..200e580b2 100644 --- a/package.json +++ b/package.json @@ -21,36 +21,36 @@ "core-js": "^2.4.1", "express": "^4.15.2", "express-graphql": "^0.6.4", - "express-jwt": "^5.1.0", + "express-jwt": "^5.3.0", "fastclick": "^1.0.6", - "graphql": "^0.9.2", + "graphql": "^0.9.3", "history": "^4.6.1", - "isomorphic-style-loader": "^1.1.0", + "isomorphic-fetch": "^2.2.1", + "isomorphic-style-loader": "^2.0.0", "jsonwebtoken": "^7.3.0", - "node-fetch": "^1.6.3", "normalize.css": "^6.0.0", "passport": "^0.3.2", "passport-facebook": "^2.1.1", "pretty-error": "^2.1.0", - "prop-types": "^15.5.6", - "query-string": "^4.3.2", - "react": "^15.5.3", - "react-dom": "^15.5.3", + "prop-types": "^15.5.8", + "query-string": "^4.3.4", + "react": "^15.5.4", + "react-dom": "^15.5.4", "sequelize": "^3.30.4", + "serialize-javascript": "^1.3.0", "source-map-support": "^0.4.14", "sqlite3": "^3.1.8", - "universal-router": "^3.0.0", - "whatwg-fetch": "^2.0.3" + "universal-router": "^3.1.0" }, "devDependencies": { "assets-webpack-plugin": "^3.5.1", "autoprefixer": "^6.7.7", "babel-cli": "^6.24.1", "babel-core": "^6.24.1", - "babel-eslint": "^7.2.1", - "babel-loader": "^6.4.1", + "babel-eslint": "^7.2.3", + "babel-loader": "^7.0.0", "babel-plugin-rewire": "^1.1.0", - "babel-preset-env": "^1.3.3", + "babel-preset-env": "^1.4.0", "babel-preset-react": "^6.24.1", "babel-preset-react-optimize": "^1.0.1", "babel-preset-stage-2": "^6.24.1", @@ -62,7 +62,7 @@ "chokidar": "^1.6.1", "css-loader": "^0.28.0", "editorconfig-tools": "^0.1.1", - "enzyme": "^2.8.0", + "enzyme": "^2.8.2", "eslint": "^3.19.0", "eslint-config-airbnb": "^14.1.0", "eslint-loader": "^1.7.1", @@ -77,21 +77,21 @@ "lint-staged": "^3.4.0", "markdown-it": "^8.3.1", "mkdirp": "^0.5.1", - "mocha": "^3.2.0", + "mocha": "^3.3.0", "pixrem": "^3.0.2", "pleeease-filters": "^3.0.1", - "postcss": "^5.2.16", + "postcss": "^5.2.17", "postcss-calc": "^5.3.1", "postcss-color-function": "^3.0.0", "postcss-custom-media": "^5.0.1", "postcss-custom-properties": "^5.0.2", "postcss-custom-selectors": "^3.0.0", - "postcss-flexbugs-fixes": "^2.1.0", + "postcss-flexbugs-fixes": "^2.1.1", "postcss-global-import": "^1.0.0", "postcss-import": "^9.1.0", "postcss-loader": "^1.3.3", "postcss-media-minmax": "^2.1.2", - "postcss-nested": "^1.0.0", + "postcss-nested": "^1.0.1", "postcss-nesting": "^2.3.1", "postcss-pseudoelements": "^4.0.0", "postcss-selector-matches": "^2.0.5", @@ -108,11 +108,11 @@ "stylelint": "^7.10.1", "stylelint-config-standard": "^16.0.0", "url-loader": "^0.5.8", - "webpack": "^2.3.3", - "webpack-bundle-analyzer": "^2.3.1", - "webpack-dev-middleware": "^1.10.1", + "webpack": "^2.4.1", + "webpack-bundle-analyzer": "^2.4.0", + "webpack-dev-middleware": "^1.10.2", "webpack-hot-middleware": "^2.18.0", - "write-file-webpack-plugin": "^4.0.0" + "write-file-webpack-plugin": "^4.0.2" }, "babel": { "presets": [ diff --git a/src/core/DOMUtils.js b/src/DOMUtils.js similarity index 100% rename from src/core/DOMUtils.js rename to src/DOMUtils.js diff --git a/src/client.js b/src/client.js index 36ffb1561..f43bd258b 100644 --- a/src/client.js +++ b/src/client.js @@ -12,10 +12,11 @@ import ReactDOM from 'react-dom'; import FastClick from 'fastclick'; import queryString from 'query-string'; import { createPath } from 'history/PathUtils'; -import history from './core/history'; import App from './components/App'; -import { updateMeta } from './core/DOMUtils'; -import { ErrorReporter, deepForceUpdate } from './core/devUtils'; +import createFetch from './createFetch'; +import history from './history'; +import { updateMeta } from './DOMUtils'; +import { ErrorReporter, deepForceUpdate } from './devUtils'; /* eslint-disable global-require */ @@ -29,6 +30,10 @@ const context = { const removeCss = styles.map(x => x._insertCss()); return () => { removeCss.forEach(f => f()); }; }, + // Universal HTTP client + fetch: createFetch({ + baseUrl: window.App.apiUrl, + }), }; // Switch off the native scroll restoration behavior and handle it manually @@ -87,7 +92,7 @@ FastClick.attach(document.body); const container = document.getElementById('app'); let appInstance; let currentLocation = history.location; -let router = require('./core/router').default; +let router = require('./router').default; // Re-render the app when window.location changes async function onLocationChange(location, action) { @@ -109,6 +114,7 @@ async function onLocationChange(location, action) { const route = await router.resolve({ path: location.pathname, query: queryString.parse(location.search), + fetch: context.fetch, }); // Prevent multiple page renders during the routing process @@ -161,8 +167,8 @@ if (__DEV__) { // Enable Hot Module Replacement (HMR) if (module.hot) { - module.hot.accept('./core/router', () => { - router = require('./core/router').default; + module.hot.accept('./router', () => { + router = require('./router').default; if (appInstance) { try { diff --git a/src/components/App.js b/src/components/App.js index b4fa46ade..1dc8443b0 100644 --- a/src/components/App.js +++ b/src/components/App.js @@ -14,6 +14,8 @@ const ContextType = { // Enables critical path CSS rendering // https://github.com/kriasoft/isomorphic-style-loader insertCss: PropTypes.func.isRequired, + // Universal HTTP client + fetch: PropTypes.func.isRequired, }; /** diff --git a/src/components/Html.js b/src/components/Html.js index 03f8e632a..5eeee0a45 100644 --- a/src/components/Html.js +++ b/src/components/Html.js @@ -9,7 +9,10 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { analytics } from '../config'; +import serialize from 'serialize-javascript'; +import config from '../config'; + +/* eslint-disable react/no-danger */ class Html extends React.Component { static propTypes = { @@ -20,6 +23,7 @@ class Html extends React.Component { cssText: PropTypes.string.isRequired, }).isRequired), scripts: PropTypes.arrayOf(PropTypes.string.isRequired), + app: PropTypes.object, // eslint-disable-line children: PropTypes.string.isRequired, }; @@ -29,7 +33,7 @@ class Html extends React.Component { }; render() { - const { title, description, styles, scripts, children } = this.props; + const { title, description, styles, scripts, app, children } = this.props; return ( @@ -43,27 +47,22 @@ class Html extends React.Component {