SPA + SSR + PWA の作り方とセキュリティについて
一年前に以下の記事を書いて、その後放置していたら多くのライブラリのメジャーリリースで完全に動かなくなってしまったのでリニューアルしました。
以下のセクションで説明していきますが、コードを読んだほうが早いです。
- リポジトリ
- 技術スタック
- Server Side Rendering
- Single Page Application
- Progressive Web Application
- Audits
- Security
- おわり
リポジトリ
このリポジトリはこれを見れば様々な実装の動く土俵を作れるというのを目的としています。
環境構築ですら毎回忘れるので。。
なので冗長に書いてある部分も多いですが、今後も新しい必要な実装を小さく追加していく予定です。
動きを確認したい人は、cloneして手元で動かしてみてください。
技術スタック
これはあくまでもサンプルなので、sagaとapolloが混じってますが実際はどちらかで大丈夫です。
SPAのベースはredux, sagaで設計していて、apollo-stateは使わずあくまでもqueryとmutationのみです。
主要なライブラリは以下のとおりです。
deps | devDeps |
---|---|
react | typescript |
redux | webpack |
react-router | babel |
react-helmet | storybook |
redux-saga | storyshots |
styled-components | jest |
loadable-components | testing-library |
apollo-boost | nodemon |
express | prettier |
nanoid | workbox |
typescript-eslint | |
autocannon |
Server Side Rendering
注目するべき点はloadable-componentsの大幅なアルゴリズム改善だと思っています。
これにより、パフォーマンスは改善されました。
loadable-components
未だ、ReactのほうがSSRに対応していないため、引き続きreact-loadableやloadable-componentsは必要となります。
SuspenseはSSR対応進めています。
メジャーバージョンでreact-loadableと同様にwebpackを使いassetsのmapを作成するようになりました。
これにより、SSRの処理が高速化されました。
しかし、babel-pluginに依存しないと動かないため、babel-preset-typescriptをこのライブラリでは使っています。
データの送り方
SSRで取得したデータはStoreを構築し、それをクライアントサイドにHTML経由で渡します。
以下のようにdata
属性を使い、scriptタグ経由で渡すのがいいと自分も思っています。
<script nonce="xxxxx" id="initial-data" type="text/plain" data-json="${preloadedState}"></script>
このpreloadedState
はエスケープ処理が必要なので注意してください。
クライアント側の読み込み方
const initialData = JSON.parse(document.getElementById('initial-data')!.getAttribute('data-json')!); const { store } = configureStore(initialData);
ssr-sample/index.tsx at master · hiroppy/ssr-sample · GitHub
useEffect
SSRでは、componentDidMount前までしか実行されません。
つまり、hooksではuseEffect
は呼び出されずFCにはconstructorは存在しません。
一体どこに初期化処理とかあれば書けばいいのかベストプラクティスは自分はわかりません。
if (!process.env.IS_BROWSER) { dispatch(loadSagaPage(maxLength)); } else { useEffect(() => { dispatch(loadSagaPage(maxLength)); }, []); }
今はこんなふうに書いているけど気持ち悪いので辞めたい。。
レンダリングコード
// ここでassetsのmapを取得する const statsFile = resolve( __dirname, process.env.NODE_ENV !== 'production' ? '../../../../dist/client/loadable-stats.json' : '../../../../client/loadable-stats.json' ); export async function get(req: Request, res: Response) { const baseUrl = `${req.protocol}://${req.get('Host')}`; const { nonce }: { nonce: string } = res.locals; const { store, runSaga } = configureStore(); const client = createClient({ link: new SchemaLink({ schema }) }); const sheet = new ServerStyleSheet(); const context = {}; // Node.jsでは完全なurlが必要なのでstoreにわたす store.dispatch(setBaseUrl(baseUrl)); const App = () => ( <ApolloProvider client={client}> <Provider store={store}> <StaticRouter location={req.url} context={context}> {/* add `div` because of `hydrate` */} <div id="root"> <Router /> </div> </StaticRouter> </Provider> </ApolloProvider> ); try { const extractor = new ChunkExtractor({ statsFile }); // assets mapがあるのでrenderToStringを走らせる必要がなくなった const tree = extractor.collectChunks(<App />); await Promise.all([ // react-apolloの処理をキックすることにより、redux-saga, react-helmet, styled-componentsの処理を実行 getMarkupFromTree({ tree, renderFunction: renderToStaticMarkup // あくまでも処理を実行するためなので軽量なstaticMarkupで良い }), // 上記のrenderToStaticMarkupで実行され、sagaの終了を待つ runSaga() ]); const body = renderToString(tree); // ここでクライアントに渡すhtmlのレンダリングを行う // ここからはhtmlに埋め込むscriptタグの生成やstoreのデータをクライアントに渡すためのjson等を作成 const preloadedState = JSON.stringify(store.getState()); const helmetContent = Helmet.renderStatic(); const meta = ` ${helmetContent.meta.toString()} ${helmetContent.title.toString()} `.trim(); const style = sheet.getStyleTags(); const scripts = extractor.getScriptTags({ nonce }); const graphql = JSON.stringify(client.extract()); return res.send(renderFullPage({ meta, body, style, preloadedState, scripts, graphql, nonce })); } catch (e) { console.error(e); return res.status(500).send(e.message); } }
ssr-sample/renderer.tsx at master · hiroppy/ssr-sample · GitHub
Single Page Application
SPAのベースはreduxのstore(or apollo-state), routingがreact-router, 副作用の操作をredux-sagaでこのサンプルは行っています。
hooks
reactにhooksが入ったことにより、react-routerやredux、apolloのhooks対応されました。
export const Saga: React.FC = () => { const dispatch = useDispatch(); // reduxのdispatch const samples = useSelector(getSagaCode); // reduxのselector const { search } = useLocation(); // react-routerでlocationを取得 const maxLength = new URLSearchParams(search).get('max'); if (!process.env.IS_BROWSER) { dispatch(loadSagaPage(maxLength)); // actionを実行し、typeとpreloadをdispatchへ(containersが行っていたこと) } else { useEffect(() => { dispatch(loadSagaPage(maxLength)); }, []); } const like = useCallback((id: number) => { // reactのuseCallback dispatch(addLike(id)); }, []); return ( <> <Head title="saga-page" /> <p>get => get all samples</p> <p>post => add a like count</p> {samples.length !== 0 && <CodeSamplesBox samples={samples} addLike={like} />} </> ); };
ssr-sample/Saga.tsx at master · hiroppy/ssr-sample · GitHub
redux
reduxはhooksが入ったことにより大きな変更があります。
それは、presentationalとcontainerという単語が無くなりそうです。
今までのreduxは、presentationalとcontainerで責務(関心事)が別れていました。
それは自分にとってはきれいだと思っていました、presentationalでstoreからくる値はpropsを渡す感じ。
しかし、hooksが入ったことにより、dispatchをpresentationalから呼ぶことになったのでcontainerも必要ないです。
apollo
apolloは本当にきれいに書くことができるようになり満足しています。
移行記事は以下を参考にしてください。
export const GET_SAMPLES = gql` query getSamples($maxLength: Int) { samples(maxLength: $maxLength) { id name code likeCount description } } `; export const ADD_LIKE = gql` mutation addLike($id: Int) { addLike(id: $id) { id } } `; export const Apollo = () => { const dispatch = useDispatch(); const { search } = useLocation(); const maxLength = new URLSearchParams(search).get('max'); const { loading: queryLoading, error: queryError, data: queryData } = useQuery<{ // queryのhooks samples: Samples; }>(GET_SAMPLES, { variables: { maxLength: Number(maxLength) } }); const [ addLike, { loading: mutationLoading, error: mutationError, data: mutationData } ] = useMutation(ADD_LIKE, { // mutationのhooks // ここは実際、refetch行うべきじゃないけど、これサンプルなので手抜きです refetchQueries: [{ query: GET_SAMPLES, variables: { maxLength: Number(maxLength) } }] }); const like = useCallback((id: number) => { addLike({ variables: { id } }); // mutationを実行 }, []); // SPAをsagaで管理している関係上、ここでもstopだけ行わないといけない if (!process.env.IS_BROWSER) { dispatch(loadApolloPage()); } return ( <> <Head title="apollo-page" /> <p>query => get all samples</p> <p>mutation => add a like count</p> {queryLoading && <p>loading...</p>} {queryError && <p>error...</p>} {queryData && <CodeSamplesBox samples={queryData.samples} addLike={like} />} </> ); };
ssr-sample/Apollo.tsx at master · hiroppy/ssr-sample · GitHub
はぁ、きれい。。。(saga捨てたい顔)
redux-saga
sagaが行うこととして、クライアントサイドとサーバーサイドで一点異なる点があります。
それは、sagaのプロセスをサーバーサイドの場合停止させないといけなく、そうしないとクライアントにhtmlを返せません。
なので、以下のように止めるようにします。
function* loadTopPage(actions: ReturnType<typeof LoadTopPage>) { yield changePage(); yield put(loadTopPageSuccess()); if (!process.env.IS_BROWSER) { yield call(stopSaga); // ENDを呼ぶ } }
ssr-sample/pages.ts at master · hiroppy/ssr-sample · GitHub
自分がSPAで実装を行うときは、sagaを2ライン走らせます。
- 全体を管理するappProcess
- 読み込み完了、エラー(502, etc...)、どこのページでも行う処理(e.g. login, ga, etc..)
- 各ページのpageProcess
- ページ固有の処理(e.g. fetching, etc...)
export function* pagesProcess() { yield takeLatest(LOAD_APP_PROCESS, appProcess); yield takeLatest(LOAD_TOP_PAGE, loadTopPage); yield takeLatest(LOAD_SAGA_PAGE, loadSagaPage); yield takeLatest(LOAD_APOLLO_PAGE, loadApolloPage); }
これらが終わり次第、ENDを呼ぶ構築が一番いいと思います。
App Shell
PWAと少し被りますが、sagaとの話もあるのでここで。
react-routerでパスに応じたcomponentsはここに流れてきます。
つまり、このコンポーネントが上位階層で、ここでheaderだけのレンダリング(App)と共通処理(appProcess)を行います。
export const App: React.FC = ({ children }) => { const location = useLocation(); const dispatch = useDispatch(); // ここはmiddlewareで行う共通の処理(appProcess)を実行(起動させる) if (!process.env.IS_BROWSER) { dispatch(loadAppProcess()); } else { useEffect(() => { dispatch(loadAppProcess()); }, []); } // e.g. send to Google Analytics... useEffect(() => {}, [location]); // ここでSPA全体のパス変更を監視し、イベントを発火させる(e.g. GA) return ( <> <Header /> {/* ここは変わることがない(あってもredux経由でHeader内selectorによる再描画) */} <GlobalStyle /> <Container>{children}</Container> {/* childrenはreact-routerから来たコンポーネント */} </> ); };
ssr-sample/App.tsx at master · hiroppy/ssr-sample · GitHub
Progressive Web Application
Manifest
PWAのmanifestは、manifest.json
ではなくmanifest.webmanifest
というファイル名にします。
仕様
webpack-pwa-manifestを使い、生成します。
// webpack.config.js new PwaManifest({ filename: 'manifest.webmanifest', name: 'ssr-sample', short_name: 'ssr-sample', theme_color: '#3498db', description: 'introducing SPA and SSR', background_color: '#f5f5f5', crossorigin: 'use-credentials', icons: [ { src: resolve('./assets/avatar.png'), sizes: [96, 128, 192, 256, 384, 512] } ] })
ssr-sample/webpack.client.prod.config.js at master · hiroppy/ssr-sample · GitHub
add to home screen等はデバッグしづらいので、もし原因がわからなかったらchrome://flags/#bypass-app-banner-engagement-checks
をオンにすると幸せになります。
PCでのadd to home screenはこんな感じになります。
Service Worker
Workboxを使います。
// webpack.config.js new GenerateSW({ clientsClaim: true, skipWaiting: true, include: [/\.js$/], // 今回出力がjsしかないため runtimeCaching: [ { urlPattern: new RegExp('.'), // start_urlに合わせる handler: 'StaleWhileRevalidate' // cacheを使い裏でfetchする }, { urlPattern: new RegExp('api|graphql'), handler: 'NetworkFirst' // ネットワークアクセスを優先する }, { urlPattern: new RegExp('https://fonts.googleapis.com|https://fonts.gstatic.com'), handler: 'CacheFirst' // cacheを優先する。expire設定したほうがいい } ] })
ssr-sample/webpack.client.prod.config.js at master · hiroppy/ssr-sample · GitHub
後ほど、説明しますが、CSPには注意してください。
service-workerからのアクセスはconnect
となります。
Audits
Expressのhttp/2対応がマージされれば全部100になります。(or Nginx置いて)
Security
Content Security Policy
CSPとは、XSSを防ぐために信頼したものしかブラウザが実行しないように制御できます。
サーバーで毎回ハッシュ値を生成し、それをscriptタグにつけhttp headerからContent-Security-Policy
の属性を照会し一致するものだけを実行します。
これは、script以外にもcssやfont, images, connect等幅広く設定できます。
今回のサンプルでは、google fontを使うためgoogle fontとreadmeのバッジで使われるshieldsを許可しています。
// google font: https://stackoverflow.com/a/34576000/7014700 const baseDirectives: helmet.IHelmetContentSecurityPolicyDirectives = { defaultSrc: ["'self'"], styleSrc: ["'unsafe-inline'", 'fonts.googleapis.com'], // for styled-components fontSrc: ["'self'", 'data: fonts.gstatic.com'], imgSrc: ["'self'", 'img.shields.io'], // for README connectSrc: ["'self'", 'img.shields.io', 'fonts.googleapis.com', 'fonts.gstatic.com'], // for service-worker workerSrc: ["'self'"] };
ssr-sample/csp.ts at master · hiroppy/ssr-sample · GitHub
このように指定したところへのアクセスだけを許可することよりXSSに対して強固なwebアプリケーションが作成できます。
このアプリケーションでは、nonce
方式を説明していきます。
export function generateNonceId(req: Request, res: Response, next: NextFunction) { res.locals.nonce = Buffer.from(nanoid(32)).toString('base64'); next(); }
<meta property="csp-nonce" content="ZmZIaTAyQ25rZ2FxVERUVVI4VUhBTUVJc29uTV9leUo="> <script async data-chunk="components-pages-Top" src="/public/vendors~components-pages-Apollo~components-pages-NotFound~components-pages-Saga~components-pages-Top.bundle.js" nonce="ZmZIaTAyQ25rZ2FxVERUVVI4VUhBTUVJc29uTV9leUo="></script>
もっと詳しく知りたい方はPixivの記事が詳しいので読むと良さそうです。
Dynamic Import
CSPの問題点として、dynamic importの対応の難しさが上げられます。
dynamic importの場合、nonce
が存在しないためです。
CSPには、level3とlevel2が存在し、level3にはstrict-dynamicという仕組みがありそれがこの問題を解決します。
strict-dynamicでは、nonce付きの実行されたスクリプトの子供はnonceが無くても実行可能となります。
FirefoxやChromeではすでにlevel3が対応済みなのでこの問題は解決できますが、level3に対応していないブラウザに対してdynamic importは解決が行なえません。
__webpack_nonce__
を使えば、動きますがnonceは本来毎アクセス時にhashを生成しないと攻撃者に推測される可能性があるため、ビルド時ではいけなく根本的な解決ではありません。
// chrome, firefox const lv3Directives: helmet.IHelmetContentSecurityPolicyDirectives = { ...baseDirectives, scriptSrc: [(req, res) => `'nonce-${res.locals.nonce}'`, "'strict-dynamic'", "'unsafe-eval'"] }; // safari const lv2Directives: helmet.IHelmetContentSecurityPolicyDirectives = { ...baseDirectives, scriptSrc: [ "'self", (req, res) => `'nonce-${res.locals.nonce}'`, "'unsafe-eval'", "'unsafe-inline'" ] };
Service Worker
service workerからの問い合わせはconnect-src
となります。
以下のように、styleSrc
やfontSrc
と同じurlがconnectSrc
に書いてあるのがわかります。
const baseDirectives: helmet.IHelmetContentSecurityPolicyDirectives = { defaultSrc: ["'self'"], styleSrc: ["'unsafe-inline'", 'fonts.googleapis.com'], // for styled-components fontSrc: ["'self'", 'data: fonts.gstatic.com'], imgSrc: ["'self'", 'img.shields.io'], // for README connectSrc: ["'self'", 'img.shields.io', 'fonts.googleapis.com', 'fonts.gstatic.com'], // for service-worker workerSrc: ["'self'"] };
GraphQL
GraphQLはスキーマが自由であるため、サーバーへの負荷対策を行う必要があります。
例えば、入れ子の深い不正クエリーが送られてきたときにはサーバー側の処理に負荷がかかる可能性があるため、その処理に到達させる前に弾く必要があります。
DoSを防ぐためにも必ず入れる対策がこの対策です。
有名な方法は以下のとおりです。
- ホワイトリスト
- リストに書かれたクエリのみを通過させる
- 深さ制限
- 指定したクエリの深さ以下を通過させる
- 重み(コスト)制限
- クエリーに重さ(深さ含む)付けをし、それの合計値が指定値以下の場合は通過させる
このサンプルでは、重み制限を使用しています。
例えば、今回は使ってないですがgraphql-validation-complexityであれば以下の計算式となります。(実際、fragments等でもう少し難しくなりますが)
// Conclusion // Field: 1 // root: scalarCost * 1 // not root: objectCost * 1 // list: listFactor * 10 // query { // a { # * objectCost // a1a # * scalarCost // a1b { # * objectCost // b1a # * scalerCost // b1b # * scalerCost // } // } // arr { # * objectCost // arr1 { # * objectCost * listFactor // name # listFactor // } // arr2 { # objectCost * listFactor // name # listFactor // id # listFactor // } // } // } // a * objectCost + a.a1a * scalarCost + a.a1b * objectCost + a.a1b.b1a * scalerCost + a.a1b.b1b * scalerCost // + arr * objectCost + arr.arr1 * objectCost * listFactor + arr.arr1.name * listFactor // + arr.arr2 * objectCost * listFactor + arr.arr2.name * listFactor + arr.arr2.id * listFactor
今回は、graphql-query-complexityを使っています。
const apollo = new ApolloServer({ plugins: [ { requestDidStart: () => ({ didResolveOperation({ request, document }) { const complexity = getComplexity({ schema, query: request.operationName ? separateOperations(document)[request.operationName] : document, variables: request.variables, estimators: [fieldExtensionsEstimator(), simpleEstimator({ defaultComplexity: 1 })] }); // graphqlのスキーマのコストがlimitCost(今回は10)以上であれば、throwし中断させる if (complexity >= limitCost) { throw new Error(`${complexity} is over ${limitCost}`); } console.log('Used query complexity points:', complexity); }, didEncounterErrors(err) { console.error(err); } }) } ] });
ssr-sample/apollo.ts at master · hiroppy/ssr-sample · GitHub
GraphQLはプロダクションでリリースするときには必ず、このような対策が必要となります。
おわり
長文になりましたが、機能追加等のPR/Issue歓迎しています。
また、もし興味あればGitHub Sponsorsもよろしくおねがいします。
あと、webpack@5楽しみ🥰
webpack5の大きな変更
— hiroppy (@about_hiroppy) November 19, 2019
- Node.jsのpolyfillが自動で入らなくなる
- Tree Shakingのアルゴリズム改善
- ビルトイン出力ファイルのバージョンを指定するoutput.ecmaVersionが追加
- 永続キャッシュによる開発効率化
- webpackChunkName の自動化
- file-loaderがビルトイン
- top-level-await