技術探し

JavaScriptを中心に記事を書いていきます :;(∩´﹏`∩);:

SPA + SSR + PWA の作り方とセキュリティについて

一年前に以下の記事を書いて、その後放置していたら多くのライブラリのメジャーリリースで完全に動かなくなってしまったのでリニューアルしました。

blog.hiroppy.me

以下のセクションで説明していきますが、コードを読んだほうが早いです。

リポジトリ

github.com

このリポジトリはこれを見れば様々な実装の動く土俵を作れるというのを目的としています。
環境構築ですら毎回忘れるので。。
なので冗長に書いてある部分も多いですが、今後も新しい必要な実装を小さく追加していく予定です。

動きを確認したい人は、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

github.com

未だ、ReactのほうがSSRに対応していないため、引き続きreact-loadableやloadable-componentsは必要となります。

SuspenseはSSR対応進めています。

github.com

メジャーバージョンでreact-loadableと同様にwebpackを使いassetsのmapを作成するようになりました。
これにより、SSRの処理が高速化されました。
しかし、babel-pluginに依存しないと動かないため、babel-preset-typescriptをこのライブラリでは使っています。

データの送り方

SSRで取得したデータはStoreを構築し、それをクライアントサイドにHTML経由で渡します。

以下のようにdata属性を使い、scriptタグ経由で渡すのがいいと自分も思っています。

qiita.com

<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という単語が無くなりそうです。

github.com

今までのreduxは、presentationalとcontainerで責務(関心事)が別れていました。
それは自分にとってはきれいだと思っていました、presentationalでstoreからくる値はpropsを渡す感じ。

しかし、hooksが入ったことにより、dispatchをpresentationalから呼ぶことになったのでcontainerも必要ないです。

apollo

apolloは本当にきれいに書くことができるようになり満足しています。

移行記事は以下を参考にしてください。

blog.hiroppy.me

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);
}

redux-saga設計 · GitHub

これらが終わり次第、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 というファイル名にします。

developer.mozilla.org

仕様

w3c.github.io

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はこんな感じになります。

f:id:about_hiroppy:20191122082702p:plain

Service Worker

Workboxを使います。

developers.google.com

// 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

f:id:about_hiroppy:20191119115332p:plain

Expressのhttp/2対応がマージされれば全部100になります。(or Nginx置いて)

Security

Content Security Policy

developer.mozilla.org

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();
}

f:id:about_hiroppy:20191122082040p:plain

<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の記事が詳しいので読むと良さそうです。

inside.pixiv.blog

Dynamic Import

CSPの問題点として、dynamic importの対応の難しさが上げられます。
dynamic importの場合、nonceが存在しないためです。
CSPには、level3とlevel2が存在し、level3にはstrict-dynamicという仕組みがありそれがこの問題を解決します。
strict-dynamicでは、nonce付きの実行されたスクリプトの子供はnonceが無くても実行可能となります。

FirefoxChromeではすでに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となります。
以下のように、styleSrcfontSrcと同じ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

github.com

今回は、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もよろしくおねがいします。

github.com

あと、webpack@5楽しみ🥰