かもメモ

自分の落ちた落とし穴に何度も落ちる人のメモ帳

Next.js SSG したサイトを別ドメインから参照させててハマった

あるプロジェクトで Page routing の Next.js を SSG で作成したサイトを Vercel でホスティングしたものを CloudFront と Lambda@Edge を使って別ドメインの URL からサイトを表示させるようにしていた。
図で書くとこんな感じ サービス構成

CloudFront を使った別ドメイン (b.com/foo) からサイトを見たときだけページ内リンク (next/link) で遷移時にアプリがクラッシュしてしまう問題が発生した。

Next.js error: a client-side exception has occurred

すっごくレアケースだと思うけど記録としてメモしておく

環境

  • Next 13.5.4
  • React 18.2.0
  • TypeScript 5.2.2

結論 /_next/data へのアクセスに問題があった

Next.js の SSG (Static Site Generation) は Static Exports ではない

今回の問題は Next.js の SSG は常に静的な HTML を配信するだけものだと勘違いしていた点にあった。

dev took の Network タブで観察してみた結果、Vercel でホスティングしている Next.js の SSG されたアプリは初回アクセスは生成された HTML が帰るがクライアントサイドで react 化され、アプリ内リンクなどは staticProps となる JSON を preftech で取得し、その JSON を元にページの再レンダリングを行っていてた。 SSG したのだから HTML だけで完結しているのでは?と思いドキュメントを読んで勘違いしていたことに気がついた

Statically generates both HTML and JSON
When a page with getStaticProps is pre-rendered at build time, in addition to the page HTML file, Next.js generates a JSON file holding the result of running getStaticProps.
This JSON file will be used in client-side routing through next/link or next/router. When you navigate to a page that’s pre-rendered using getStaticProps, Next.js fetches this JSON file (pre-computed at build time) and uses it as the props for the page component. This means that client-side page transitions will not call getStaticProps as only the exported JSON is used.
cf. Data Fetching: getStaticProps | Next.js

Static Exports
Next.js enables starting as a static site or Single-Page Application (SPA), then later optionally upgrading to use features that require a server.
Since Next.js supports this static export, it can be deployed and hosted on any web server that can serve HTML/CSS/JS static assets.
cf. Deploying: Static Exports | Next.js

next.config.jsoutput: "export" オプションを指定していると build 時に out ディレクトリに HTML が出力されるが、これは Static Exports であり SSG ではなく、Static Export は完全に静的な HTML を生成するものではなく使っている機能によっては SPA として動作するのだと解った。

今回のサイトでは getStaticPaths を使ったダイナミックルーティングを行い、ページで表示する内容を getStaticProps で事前に取得するようにしていたので、各ページの HTML と共に各ページの static props になる JSON が出力されており、サイトは初回アクセス以降はクライアントサイドで SPA となりページ内リンクでは JSON を取得してページを再レンダリングしているという事だった。
(static props となる JSON が取得できないとか、next/link を使わず a タグでリンクした場合は通常のページ遷移になるのだと思うが検証していない)

static props の JSON/_next/data 内に格納されている

SSG で build されたサイトは static props となるデータが /_next/data/{BUILD_ID} 内に ページ名.json の形で出力されていた。
SSG されたサイトが SPA のように振る舞うとき、その json を取得してそれを元に画面を再レンダリングしており、CloudFront 側のドメイン (b.com) からアクセスした際に /_next/data で ferch が走っていたために、コードの存在しないドメイン宛の b.com/_next/data にリクエストが飛んでいた。
本来存在しないリクエストなのでエラーになるはずだが、今回のケースでは status code 200 が返っていたために SPA としてページを更新しようとして不正なレスポンスを static props として使ったためにページがクラッシュしてしまっていたというオチ

解決方法

  1. next/link の箇所が SPA として動作するので、next/link を辞めて a タグを使う
  2. /_next/data のパスに実際のコードのあるドメイン (origin) を追加する

next/link を使わない方法はシンプルにタグを置き換えればよいだけなので、今回は /_next/dataドメインを追加する方法を試してみた

ビルドされた /_next/data のパスにドメイン (origin) を追加する

assetPrefix というオプションがあるが、これは static ファイル (.next/static or /out/_next/static) に対してのみ有効で /_next/data へのアクセスにドメインを追加することはできない

While assetPrefix covers requests to _next/static, it does not influence the following paths:

  • Files in the public folder; if you want to serve those assets over a CDN, you'll have to introduce the prefix yourself
  • /_next/data/ requests for getServerSideProps pages. These requests will always be made against the main domain since they're not static.
  • /_next/data/ requests for getStaticProps pages. These requests will always be made against the main domain to support Incremental Static Generation, even if you're not using it (for consistency).

cf. next.config.js Options: assetPrefix | Next.js

/_next/dataドメインを設定できる dataPathPrefix というアイディアも提案されていたようだが App Router になれば解決するとして採用はされていないようだった

assetPrefix に似たオプションで basePath というものがあるが、これはあるドメインの別階層に Next のアプリをデプロイした場合にパスを設定できるオプションで / 始まりしか許容されておらずドメインを追加することは出来なかった

string-replace-loader を使ってビルド時にパスを置換する

Next.js の提供しているオプションでは /_next/dataドメインを追加することは不可能そうだった。 Next.js のビルドには webpack が使われているので、ビルド時に文字列を置換する string-replace-loader ローダーを使って /_next/dataドメイン付きに強制的に置換してしまう方法は可能だった。

// next.config.js
const isProd = process.env.VERCEL_ENV === 'production';

const nextConfig = {
  reactStrictMode: true,
  // souce map の出力
  productionBrowserSourceMaps: isProd ? false : true,
  // webpack の設定を追加
  webpack: (config) => {
    config.module.rules.push(
      ...[
        isProd
          ? {
              test: /.*.js$/,
              loader: 'string-replace-loader',
              options: {
                search: '/_next/data',
                replace: `${ORIGIN.replace(/\/$/, '')}/_next/data`,
              },
            }
          : undefined
      ].filter(Boolean)
    );
  },
}

これで /_next/data 内にある json を取得するドメインを変更することが出来た (hack的なので漏れや他のバグを産み出すある可能性はあるが)

CORS 問題

ユーザーがアクセスするドメインb.com で実際のコードが置かれているのが a.com。Next のアプリが裏側で fetch しようとした場合 b.com から a.com/_next/data/{BUILD_ID}/**/*.json にアクセスしようとするので、CORS 問題が発生する

検索していると CORS を許可する方法として下記の4パターンがあった

  1. Serverless Function でレスポンスに header を追加する
  2. middleware (/src/middleware.js) でレスポンスに header を追加する
  3. next.config.js に headers オプションを追加する
  4. vercel.jsonheaders オプションを追加する

cf.

今回はシンプルそうな 4 の vercel.json に headers を追加することで CORS が発生しなくなったのでそれ以外の調査はしていない

vercel.json に headers オプションを追加して CORS を許可する

verce.json

{
  "headers": [
    {
      "source": "/_next/data/(.*)",
      "headers": [
        { "key": "Access-Control-Allow-Credentials", "value": "true" },
        {
          "key": "Access-Control-Allow-Origin",
          "value": "*"
        },
        { "key": "Access-Control-Allow-Methods", "value": "GET" },
        {
          "key": "Access-Control-Allow-Headers",
          "value": "X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version, X-Nextjs-Data"
        }
      ]
    }
  ]
}

verce.jsonAccess-Control-Allow-Origin は whitelist のように複数のオリジンを配列で渡すことが出来なかった (build error になる) ので * として、メソッドを GET に制限することにした

これで CloudFront 経由の b.com からアクセスしても next/link が正常に動作して SPA のように json でページが更新されるようになった!!!

preview モードで prefetch だけが CORS になる問題

ここまでで 9割問題ないのだが、Vercel の preview モードでホスティングしているサイト (ステージング環境) も CloudFront 経由でアクセスできるようになっており、preview モードのサイトを CloudFront 経由の別ドメインでアクセスした時だけ /next/link の preftech が CORS のエラーになっていた。 (画面更新のための JSON の GET は正常に行えている)

謎…

dev tool でエラーの箇所のコードを見ていたら preview mode の場合は cookie が必要だと書かれたコメントを発見した

function fetchRetry(
  url: string,
  attempts: number,
  options: Pick<RequestInit, 'method' | 'headers'>
): Promise<Response> {
  return fetch(url, {
    // Cookies are required to be present for Next.js' SSG "Preview Mode".
    // Cookies may also be required for `getServerSideProps`.
    //
    // > `fetch` won’t send cookies, unless you set the credentials init
    // > option.
    // https://developer.mozilla.org/docs/Web/API/Fetch_API/Using_Fetch
    //
    // > For maximum browser compatibility when it comes to sending &
    // > receiving cookies, always supply the `credentials: 'same-origin'`
    // > option instead of relying on the default.
    // https://github.com/github/fetch#caveats
    credentials: 'same-origin',
    method: options.method || 'GET',
    headers: Object.assign({}, options.headers, {
      'x-nextjs-data': '1',
    }),
  }).then((response) => {

https://github.com/vercel/next.js/blob/canary/packages/next/src/shared/lib/router/router.ts#L425-L447

恐らく Vercel の Preview モードは本来コメントを残したりする機能が付いているので vercel にログインしている / アクセス可能であるという cookie が必要になっているのではないかと思った。
preftech がエラーになってもステージング環境だけだし、ページの遷移も問題なく行えているのでこの件に関しては対応しないものとした。

所管

SSG と static exports を混同していたことや、SSG すれば HTML しか使ってないのような勘違いもあったが、本来エラーになってほしいリクエストが 200 番で返されていたために予期せぬクラッシュが発生してしまっていた。
Next.js をビルドした状態でかつ本番環境でしか発生していない問題だったので、フレームワークをいい感じにしてくれるブラックボックスとして扱っていたために調査が難しく時間がかかる結果となった。 また Next.js はいろいろなオプションが用意されているが /_next/dataドメインを変更する方法が無いなど、別のドメインから参照させるような使い方はあまり想定してないようにも思った。

教訓
フレームワークをドキュメントにないような使い方をする時はフレームワークの挙動についてよく理解しておかないとバグを踏む!

ただのバグ報告が大作になってしまった…
おわり


[参照]

どういう意味でで SSG なんや?このマンガ!?

TypeScript React クリップボードにコピーする hooks とコンポーネント作ってみた

input タグに入力された値をクリップボードにコピーする hooks と コンポーネントを作ってみたのでログとしてメモ

📝 Clipboard.writeText() を使ってクリップボードにテキストをコピーすることができる

clipboardjs のようなライブラリもあったのですが、ブラウザで動作するフロントエンドなら Clipboard API を使えばクリップボードに書き込みが可能そうだったのでライブラリなしに実装しました

Clipboard: writeText() method
The writeText() method of the Clipboard interface writes the specified text to the system clipboard, returning a Promise that is resolved once the system clipboard has been updated.
cf. Clipboard: writeText() method - Web APIs | MDN

const onCopy = async (text: string) => {
  try {
    await navigator.clipboard.writeText(text);
  } catch (error) {
    console.error(error.message);
  }
}

Clipboard にテキストをコピーする hooks

// /hooks/useCopyClipboard.ts
import { useCallback, useEffect, useState } from "react";

export const useCopyClipboard = () => {
  const [hasCopied, setHasCpied] = useState(false);
  const [error, setError] = useState<Error | null>(null);

  const onCopy = useCallback(async (text: string) => {
    try {
      await navigator.clipboard.writeText(text);
      setHasCpied(true);
    } catch (err) {
      if (err instanceof Error) {
        console.error(err.message);
        setError(err);
        return;
      }

      console.error(err);
      setError(new Error("Failed to copy"));
    }
  }, []);

  useEffect(() => {
    let timeoutId: number | null = null;
    if (hasCopied) {
      timeoutId = window.setTimeout(() => {
        setHasCpied(false);
      }, 1500);
    }

    return () => {
      if (timeoutId) {
        window.clearTimeout(timeoutId);
      }
    };
  }, [hasCopied]);

  return {
    onCopy,
    hasCopied,
    isError: !!error,
    error,
  };
};

useCopyClipboard hooks から返される onCopy に渡した文字列をクリップボードにコピーするシンプルな hooks です (※ Chakra UI の useClipboard を参考にしました)

input に入力された文字列をクリップボードにコピーする

import { useRef } from "react";
import { useCopyClipboard } from "./hooks/useCopyClipboard";

export default function App() {
  const inputRef = useRef<HTMLInputElement>(null);
  const { onCopy, hasCopied, isError } = useCopyClipboard();

  const handleCopyClipboard = () => {
    if (!inputRef.current) {
      return;
    }
    onCopy(inputRef.current.value);
  };

  return (
    <div className="App">
      <input ref={inputRef} type="text" defaultValue=""  />
      <button type="button" onClick={handleCopyClipboard}>
        {hasCopied ? "Copied!" : "Copy!"}
      </button>
      {isError && <small className="text-error">Failed to copy<small>}
    </div>
  );
}

Sample

iframe だとクリップボードにコピーできないので codesandbox を直接開いてください

ライブラリを使うこと無くクリップボードに任意のテキストをコピーさせることができてびっくりしました!
と、同時に Clipboard API にはブラウザのサポートが完全なわけではないですがクリップボードから読み込みもあるので、evil なサイトではクリップボードの入力値を不正に API にも送るとか出来てしまうのかな〜なども気になるところでした。

Clipboard API - Browser compatibility | MDN Clipboard API - Web APIs | MDN

evil な使い方が出来てしまうんので navigator.clipboard.readText はサポートがマチマチなのかも?

おわり ₍ᐢ. ̫.ᐢ₎


[参考]

ES2023 JavaScript 配列から特定の要素を削除するのに toSpliced が便利

フロントエンドでやることはだいたい配列の操作だと感じてます。
その中でも元の配列を非破壊に特定のインデックスの要素を配列から削除するのが意外と面倒でした

今まで方法

1. filter で削除する要素を取り除く

const result = array.filter((item) => item.id !== deleteID);

この方法は非破壊に要素を削除した配列を削除できるが、配列を全て捜査する分処理が無駄になる

2. 削除する index を探して slice で取り除く

const deleteIndex = array.findIndex((item) => item.id === deleteID);
const result = [
  ...array.slice(0, deleteIndex),
  ...array.slice(deleteIndex + 1)
];

削除する要素の index を見つけるまで配列を捜査して、slice で配列から削除する要素を除いた配列を作成する方法
slice で index の要素を削除した配列を作成するコードが若干見通しが悪い

3. 配列を先にコピーして splice で要素を削除する

const deleteIndex = array.findIndex((item) => item.id === deleteID);
const copyArray = […array];
// or const Array = structuredClone(array); (deep copy)
copyArray.splice(deleteIndex, 1);
const result = copyArray;

.splice(deleteIndex, 1) で特定の index の要素を削除できるが、splice() は元の配列を変更してしまう破壊メソッドなので、先に配列を スプレッド構文 (Shallow copy)structuredClone (Deep copy) コピーしておく必要がある

toSpliced() メソッド

toSpliced() は Array インスタンスのメソッドで、 splice() メソッドに対応するコピーメソッドです。これは、指定された位置の要素を除去したり置き換えたりした新しい配列を返します。
cf. Array.prototype.toSpliced() - JavaScript | MDN

toSpliced() メソッドは splice() メソッドでできる配列の要素の削除・追加・置き換えを非破壊的にでき、新しい配列を返してくれる

const deleteIndex = array.findIndex((item) => item.id === deleteID);
const result = array.toSpliced(deleteIndex, 1);

従来の方法の 2, 3 を合わせてシンプルにしたように書ける!

Tips toSpliced()splice() の挙動の違い

splice() を使うことはあまりないと思いますが、破壊/非破壊だけでなく返り値に違いがあるので注意が必要です

  • toSpliced() は元の配列を変化させない (非破壊)・新しい配列を返す
  • splice() は元の配列を変化させる (破壊)・削除した要素の配列を返す
Sample

要素の削除

// toSpliced
const a1 = [1, 2, 3, 4, 5];
const res1 = a1.toSpliced(1, 2);

console.log(a1);   // [1, 2, 3, 4, 5]
console.log(res1); // [1, 4, 5]

// splice
const a2 = [1, 2, 3, 4, 5];
const res2 = a2.splice(1, 2);

console.log(a2);   // [1, 4, 5]
console.log(res2); // [2, 3]

要素を削除して追加

// toSpliced
const a1 = [1, 2, 3, 4, 5];
const res1 = a1.toSpliced(1, 2, 'a', 'b', 'c');

console.log(a1);   // [1, 2, 3, 4, 5]
console.log(res1); // [1, 'a', 'b', 'c', 4, 5]

// splice
const a2 = [1, 2, 3, 4, 5];
const res2 = a1.splice(1, 2, 'a', 'b', 'c');

console.log(a2);   // [1, 'a', 'b', 'c', 4, 5]
console.log(res2); // [2, 3]

これからは配列からの要素の削除は toSpliced() を使って見通しよく書いていけそうで嬉しいです!
配列からの削除めんどいなと思って調べなかったら気づくこと無く slicefilter で書いているところでした… ES2022, ES2023 で配列の非破壊メソッドが結構追加されているようなので、サボらずちゃんとキャッチアップしようと思ったのでした

おわり₍ᐢ. ̫.ᐢ₎


[参考]

ダンジョン飯の 九井 諒子 さん「竜の学校は山の上」の人だったのか!