T-CREATOR

SolidJS 本番運用チェックリスト:CSP・SRI・Preload・エラーレポートの総点検

SolidJS 本番運用チェックリスト:CSP・SRI・Preload・エラーレポートの総点検

SolidJS アプリケーションを本番環境にデプロイする前に、セキュリティとパフォーマンスの観点から確認すべき項目があります。本記事では、Content Security Policy(CSP)、Subresource Integrity(SRI)、Preload、エラーレポートという 4 つの重要な要素について、実践的なチェックリストと実装方法を解説いたします。

これらの設定を適切に行うことで、XSS 攻撃を防ぎ、改ざんされたリソースの読み込みを防止し、初期表示速度を改善し、本番環境で発生するエラーを確実に捕捉できるようになります。

背景

本番環境で直面するセキュリティとパフォーマンスの課題

SolidJS は高速で効率的なフレームワークですが、本番環境では開発環境では気づかなかった問題が顕在化することがあります。

以下の図は、本番運用で考慮すべき 4 つの主要な領域を示しています。

mermaidflowchart TD
    prod["本番環境<br/>SolidJSアプリ"]

    prod --> csp["CSP<br/>スクリプト実行制御"]
    prod --> sri["SRI<br/>リソース改ざん検証"]
    prod --> preload["Preload<br/>リソース事前読込"]
    prod --> error["エラーレポート<br/>障害検知・分析"]

    csp --> csp_result["XSS攻撃防止"]
    sri --> sri_result["CDN改ざん防止"]
    preload --> preload_result["初期表示高速化"]
    error --> error_result["障害早期発見"]

図の要点:本番運用の 4 つの柱

  • CSP:悪意あるスクリプトの実行を防ぐ
  • SRI:外部リソースの完全性を検証
  • Preload:クリティカルパスを最適化
  • エラーレポート:リアルタイムで問題を把握

セキュリティヘッダーの重要性

本番環境では、セキュリティヘッダーの設定が不十分だと、XSS(Cross-Site Scripting)攻撃やデータ漏洩のリスクが高まります。特に SolidJS のような JavaScript フレームワークでは、動的にコンテンツを生成するため、適切なセキュリティポリシーの設定が不可欠です。

パフォーマンス最適化の必要性

初期表示速度は、ユーザー体験と SEO の両方に大きな影響を与えます。SolidJS の小さなバンドルサイズという利点を最大限に活かすためには、リソースの読み込み戦略を最適化する必要があるのです。

課題

セキュリティ設定の複雑さ

本番環境で適切なセキュリティを確保するには、いくつかの課題があります。

#課題項目具体的な問題影響範囲
1CSP 設定の難しさインラインスクリプトとの競合XSS 脆弱性
2SRI 導入の手間ハッシュ値の自動生成が必要運用コスト増
3Preload 設定の判断どのリソースを優先すべきか不明表示速度低下
4エラー監視の欠如本番エラーが見えない障害対応遅延

以下の図は、これらの課題がどのように関連しているかを示しています。

mermaidflowchart LR
    dev["開発環境"] --> deploy["デプロイ"]
    deploy --> prod["本番環境"]

    prod --> issue1["CSP未設定<br/>XSS脆弱性"]
    prod --> issue2["SRI未適用<br/>改ざんリスク"]
    prod --> issue3["Preload未設定<br/>表示遅延"]
    prod --> issue4["エラー監視なし<br/>障害見逃し"]

    issue1 --> risk["セキュリティ<br/>インシデント"]
    issue2 --> risk
    issue4 --> risk
    issue3 --> ux["UX低下<br/>離脱率上昇"]

図の要点:課題の連鎖

設定不足がセキュリティインシデントや UX 低下を引き起こし、ビジネスに直接的な影響を与えます。

CSP と SolidJS の相性問題

SolidJS は効率的なランタイムを持ちますが、デフォルトの設定ではインラインスクリプトを使用する場合があります。厳格な CSP を設定すると、これらのスクリプトが実行されず、アプリケーションが正しく動作しない可能性があります。

CDN リソースの完全性検証

外部 CDN から読み込むライブラリやフォントが改ざんされていないことを保証する必要がありますが、SRI の設定は手動で行うと手間がかかり、ミスも起こりやすくなります。

エラーの可視化不足

開発環境ではconsole.errorで十分ですが、本番環境ではユーザーの環境で発生したエラーを捕捉し、開発チームにレポートする仕組みが必要です。エラーレポートがないと、障害が発生していても気づけないという事態になりかねません。

解決策

総合的な本番運用チェックリスト

これらの課題に対して、体系的なチェックリストを用意し、各項目について具体的な実装方法を提供します。

以下の表は、本番デプロイ前に確認すべき項目の一覧です。

#カテゴリチェック項目優先度検証方法
1CSPContent-Security-Policy ヘッダー設定ブラウザ DevTools
2CSPnonce 値の動的生成レスポンスヘッダー確認
3SRI外部スクリプトの integrity 属性HTML 検証
4SRICSS ファイルの integrity 属性HTML 検証
5Preloadクリティカル JS の preloadNetwork 分析
6PreloadWeb フォントの preloadLighthouse
7エラーグローバルエラーハンドラー設定意図的エラー発生
8エラー非同期エラーの捕捉Promise reject 確認
9エラーエラー送信先の設定ログ確認

次のセクションでは、これらの項目を実装する具体的な方法を順に解説していきます。

解決アプローチの全体像

以下の図は、4 つの要素を実装する順序と依存関係を示しています。

mermaidstateDiagram-v2
    [*] --> CSP設定
    CSP設定 --> SRI実装
    SRI実装 --> Preload最適化
    Preload最適化 --> エラーレポート
    エラーレポート --> 本番デプロイ
    本番デプロイ --> 継続監視
    継続監視 --> [*]

    note right of CSP設定
        セキュリティ基盤
        XSS攻撃対策
    end note

    note right of SRI実装
        リソース完全性
        改ざん検知
    end note

    note right of Preload最適化
        パフォーマンス
        表示速度向上
    end note

    note right of エラーレポート
        運用監視
        障害早期発見
    end note

段階的実装のポイント:

  • まずセキュリティ基盤(CSP・SRI)を固める
  • 次にパフォーマンス(Preload)を最適化
  • 最後に運用監視(エラーレポート)を整備

具体例

1. Content Security Policy(CSP)の実装

CSP とは

Content Security Policy は、ブラウザに対して「どこからのリソース読み込みを許可するか」を指示するセキュリティ機構です。XSS 攻撃を防ぐ最も効果的な手段の一つとなっています。

Step 1: 基本的な CSP ヘッダーの設定

サーバー側で HTTP ヘッダーを設定します。Nginx の例を見ていきましょう。

nginx# nginx.conf または sites-available の設定ファイル

# 基本的なCSP設定
add_header Content-Security-Policy "
    default-src 'self';
    script-src 'self' 'nonce-{NONCE_VALUE}';
    style-src 'self' 'unsafe-inline';
    img-src 'self' data: https:;
    font-src 'self' data:;
    connect-src 'self' https://api.example.com;
    frame-ancestors 'none';
    base-uri 'self';
    form-action 'self';
" always;

この設定により、以下の制限が適用されます。

  • default-src 'self':基本的に同じオリジンからのみ読み込み
  • script-src 'self' 'nonce-{NONCE_VALUE}':スクリプトは同じオリジンか nonce 付きのみ
  • style-src 'self' 'unsafe-inline':スタイルはインラインも許可(SolidJS 対応)
  • connect-src 'self' https:​/​​/​api.example.com:API 通信先を明示的に指定

Step 2: SolidJS アプリでの nonce 値の生成と埋め込み

SolidJS アプリケーションで nonce 値を動的に生成し、HTML に埋め込む実装です。

typescript// server/middleware/csp.ts

import { nanoid } from 'nanoid';
import type {
  Request,
  Response,
  NextFunction,
} from 'express';

// CSP用のnonce値を生成するミドルウェア
export function generateNonce(
  req: Request,
  res: Response,
  next: NextFunction
): void {
  // ランダムなnonce値を生成(base64で32文字)
  const nonce = nanoid(32);

  // リクエストオブジェクトにnonce値を保存
  res.locals.nonce = nonce;

  next();
}

次に、生成した nonce 値を CSP ヘッダーと HTML の両方に適用します。

typescript// server/middleware/csp.ts(続き)

export function setCSPHeader(
  req: Request,
  res: Response,
  next: NextFunction
): void {
  const nonce = res.locals.nonce;

  // CSPヘッダーにnonce値を埋め込む
  const cspPolicy = `
    default-src 'self';
    script-src 'self' 'nonce-${nonce}' https://cdn.example.com;
    style-src 'self' 'unsafe-inline' https://fonts.googleapis.com;
    font-src 'self' https://fonts.gstatic.com;
    img-src 'self' data: https:;
    connect-src 'self' https://api.example.com;
    frame-ancestors 'none';
    base-uri 'self';
    form-action 'self';
    upgrade-insecure-requests;
  `
    .replace(/\s+/g, ' ')
    .trim();

  res.setHeader('Content-Security-Policy', cspPolicy);

  next();
}

Step 3: HTML テンプレートへの nonce 適用

SolidJS のエントリーポイントとなる HTML ファイルに、nonce 値を設定します。

html<!-- index.html または server/template.html -->

<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="UTF-8" />
    <meta
      name="viewport"
      content="width=device-width, initial-scale=1.0"
    />
    <title>SolidJS App</title>

    <!-- nonce値を持つインラインスタイル(必要な場合) -->
    <style nonce="{{NONCE}}">
      /* クリティカルCSS */
      body {
        margin: 0;
        font-family: sans-serif;
      }
    </style>
  </head>
  <body>
    <div id="root"></div>

    <!-- nonce値を持つスクリプトタグ -->
    <script
      nonce="{{NONCE}}"
      type="module"
      src="/src/index.tsx"
    ></script>
  </body>
</html>

サーバー側でこのテンプレートをレンダリングする際、{{NONCE}}を実際の値に置き換えます。

typescript// server/render.ts

import fs from 'fs';
import path from 'path';

export function renderHTML(nonce: string): string {
  // HTMLテンプレートを読み込む
  const template = fs.readFileSync(
    path.resolve('./dist/index.html'),
    'utf-8'
  );

  // nonce値を埋め込んでHTMLを返す
  return template.replace(/\{\{NONCE\}\}/g, nonce);
}

CSP 設定の検証方法

ブラウザの開発者ツールで、CSP が正しく動作しているか確認できます。

javascript// ブラウザのコンソールで実行してテスト

// CSPに違反するスクリプトを実行しようとする
const script = document.createElement('script');
script.textContent = 'console.log("Unauthorized script")';
document.body.appendChild(script);

// CSPが正しく設定されていれば、以下のエラーが表示される
// Refused to execute inline script because it violates
// the following Content Security Policy directive

CSP の違反は、開発者ツールの Console タブで確認できます。正しく設定されている場合、不正なスクリプト実行は自動的にブロックされます。

2. Subresource Integrity(SRI)の実装

SRI とは

Subresource Integrity は、CDN などから読み込むリソースが改ざんされていないことを検証する仕組みです。リソースのハッシュ値をintegrity属性に指定することで、ブラウザが自動的に検証を行います。

Step 1: 外部スクリプトへの SRI 適用

CDN から読み込むライブラリに SRI を設定します。

html<!-- 外部ライブラリの読み込み(SRIあり) -->

<!-- React(SolidJSと併用する場合) -->
<script
  src="https://cdn.example.com/react@18.2.0/umd/react.production.min.js"
  integrity="sha384-KyZXEAg3QhqLMpG8r+Knujsl5+5hb7IE6w4A5sI9P3C8a5e3sF9d0h8j7P3kL2mN"
  crossorigin="anonymous"
></script>

<!-- Webフォント -->
<link
  rel="stylesheet"
  href="https://fonts.googleapis.com/css2?family=Roboto&display=swap"
  integrity="sha384-oS3vJWv+0UjzBfQzYUhtDYW+Pj2yciDJxpsK1OYPAYjqT085Qq/1cq5FLXAZQ7Ay"
  crossorigin="anonymous"
/>

crossorigin="anonymous"属性は、CORS リクエストを有効にし、SRI チェックを可能にします。

Step 2: ビルド時の自動 SRI 生成

Vite を使用している場合、プラグインで自動的に SRI ハッシュを生成できます。

typescript// vite.config.ts

import { defineConfig } from 'vite';
import solidPlugin from 'vite-plugin-solid';
import { createHtmlPlugin } from 'vite-plugin-html';
import crypto from 'crypto';
import fs from 'fs';

// SRIハッシュを生成するヘルパー関数
function generateSRIHash(filePath: string): string {
  const content = fs.readFileSync(filePath);
  const hash = crypto
    .createHash('sha384')
    .update(content)
    .digest('base64');

  return `sha384-${hash}`;
}

export default defineConfig({
  plugins: [
    solidPlugin(),
    createHtmlPlugin({
      minify: true,
    }),
  ],

  build: {
    rollupOptions: {
      output: {
        // ファイル名にハッシュを含める
        entryFileNames: 'assets/[name].[hash].js',
        chunkFileNames: 'assets/[name].[hash].js',
        assetFileNames: 'assets/[name].[hash].[ext]',
      },
    },
  },
});

ビルド後に SRI ハッシュを自動生成するスクリプトも作成しましょう。

typescript// scripts/generate-sri.ts

import fs from 'fs';
import path from 'path';
import crypto from 'crypto';
import { glob } from 'glob';

interface SRIMap {
  [filename: string]: string;
}

// distディレクトリ内のすべてのJSとCSSファイルに対してSRIを生成
async function generateSRIForBuild(): Promise<void> {
  const distDir = path.resolve('./dist');
  const files = await glob('**/*.{js,css}', {
    cwd: distDir,
  });

  const sriMap: SRIMap = {};

  files.forEach((file) => {
    const filePath = path.join(distDir, file);
    const content = fs.readFileSync(filePath);

    // SHA-384ハッシュを生成
    const hash = crypto
      .createHash('sha384')
      .update(content)
      .digest('base64');

    sriMap[file] = `sha384-${hash}`;

    console.log(`Generated SRI for ${file}`);
  });

  // SRIマップをJSONファイルに保存
  fs.writeFileSync(
    path.join(distDir, 'sri-map.json'),
    JSON.stringify(sriMap, null, 2)
  );

  console.log('SRI generation complete!');
}

generateSRIForBuild().catch(console.error);

このスクリプトをpackage.jsonのビルドプロセスに追加します。

json{
  "scripts": {
    "build": "vite build && tsx scripts/generate-sri.ts",
    "dev": "vite",
    "preview": "vite preview"
  }
}

Step 3: SRI ハッシュを HTML に注入

生成された SRI ハッシュを HTML テンプレートに自動的に注入します。

typescript// server/inject-sri.ts

import fs from 'fs';
import path from 'path';

interface SRIMap {
  [filename: string]: string;
}

export function injectSRIIntoHTML(
  htmlPath: string
): string {
  // SRIマップを読み込む
  const sriMapPath = path.resolve('./dist/sri-map.json');
  const sriMap: SRIMap = JSON.parse(
    fs.readFileSync(sriMapPath, 'utf-8')
  );

  // HTMLファイルを読み込む
  let html = fs.readFileSync(htmlPath, 'utf-8');

  // script タグとlink タグを検索してSRIを追加
  Object.entries(sriMap).forEach(([file, integrity]) => {
    const fileName = path.basename(file);

    // script タグにintegrity属性を追加
    html = html.replace(
      new RegExp(
        `<script([^>]*src=["'][^"']*${fileName}["'][^>]*)>`,
        'g'
      ),
      `<script$1 integrity="${integrity}" crossorigin="anonymous">`
    );

    // link タグにintegrity属性を追加
    html = html.replace(
      new RegExp(
        `<link([^>]*href=["'][^"']*${fileName}["'][^>]*)>`,
        'g'
      ),
      `<link$1 integrity="${integrity}" crossorigin="anonymous">`
    );
  });

  return html;
}

SRI 検証のテスト

SRI が正しく機能しているか確認するため、意図的にハッシュ値を変更してテストします。

javascript// ブラウザコンソールでのテスト

// 正しいSRI
// <script src="/app.js" integrity="sha384-correct-hash"></script>
// → 読み込み成功

// 間違ったSRI
// <script src="/app.js" integrity="sha384-wrong-hash"></script>
// → Failed to find a valid digest in the 'integrity' attribute

3. Preload(リソース事前読込)の実装

Preload とは

Preload は、ブラウザに対して「このリソースを優先的に読み込んでほしい」と指示する仕組みです。クリティカルな JavaScript、CSS、フォントを事前に読み込むことで、初期表示を高速化できます。

以下の図は、Preload の効果を示しています。

mermaidsequenceDiagram
    participant Browser
    participant Server
    participant CDN

    Note over Browser,CDN: Preloadなしの場合
    Browser->>Server: HTML要求
    Server->>Browser: HTML返却
    Browser->>Browser: HTMLパース開始
    Browser->>Server: app.js要求
    Server->>Browser: app.js返却
    Browser->>CDN: font.woff2要求
    CDN->>Browser: font.woff2返却

    Note over Browser,CDN: Preloadありの場合
    Browser->>Server: HTML要求
    Server->>Browser: HTML返却(Preloadヒント含む)
    Browser->>Server: app.js要求(即座)
    Browser->>CDN: font.woff2要求(即座)
    Server->>Browser: app.js返却
    CDN->>Browser: font.woff2返却
    Browser->>Browser: HTMLパース(すでにリソース取得済み)

Preload の効果:並列読み込みによる時間短縮

Preload を使うことで、HTML のパースを待たずにリソース取得を開始できます。

Step 1: クリティカル JavaScript の Preload

SolidJS アプリのメインバンドルを Preload で事前読み込みします。

html<!-- index.html -->

<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="UTF-8" />
    <meta
      name="viewport"
      content="width=device-width, initial-scale=1.0"
    />

    <!-- クリティカルJavaScriptのPreload -->
    <link
      rel="preload"
      href="/assets/index.js"
      as="script"
      crossorigin="anonymous"
    />

    <!-- クリティカルCSSのPreload -->
    <link
      rel="preload"
      href="/assets/index.css"
      as="style"
    />

    <title>SolidJS App</title>
  </head>
  <body>
    <div id="root"></div>

    <!-- 実際のスクリプト読み込み -->
    <script type="module" src="/assets/index.js"></script>
  </body>
</html>

as属性で、リソースの種類(script、style、font 等)を指定することで、ブラウザが適切な優先度で読み込みを行います。

Step 2: Web フォントの最適化 Preload

Web フォントは、テキスト表示に直接影響するため、優先的に Preload すべきです。

html<!-- Webフォントのpreload設定 -->

<head>
  <!-- Google Fontsの場合:まずCSSをpreconnect -->
  <link
    rel="preconnect"
    href="https://fonts.googleapis.com"
  />
  <link
    rel="preconnect"
    href="https://fonts.gstatic.com"
    crossorigin
  />

  <!-- フォントファイル自体をpreload -->
  <link
    rel="preload"
    href="https://fonts.gstatic.com/s/roboto/v30/KFOmCnqEu92Fr1Mu4mxK.woff2"
    as="font"
    type="font/woff2"
    crossorigin="anonymous"
  />

  <!-- 自前のフォントファイルの場合 -->
  <link
    rel="preload"
    href="/fonts/custom-font.woff2"
    as="font"
    type="font/woff2"
    crossorigin="anonymous"
  />

  <!-- 通常のCSSリンク -->
  <link
    href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;700&display=swap"
    rel="stylesheet"
  />
</head>

フォントの Preload にはcrossorigin属性が必須です。これを忘れると、フォントが二重に読み込まれる可能性があります。

Step 3: 動的 Preload の実装

SolidJS アプリ内で、次に必要になるリソースを動的に Preload する実装です。

typescript// src/utils/preload.ts

// リソースをプリロードするユーティリティ関数
export function preloadResource(
  href: string,
  as: 'script' | 'style' | 'image' | 'font' | 'fetch',
  type?: string
): void {
  // すでにpreloadされているか確認
  const existing = document.querySelector(
    `link[rel="preload"][href="${href}"]`
  );

  if (existing) {
    return; // すでに存在する場合はスキップ
  }

  // link要素を作成
  const link = document.createElement('link');
  link.rel = 'preload';
  link.href = href;
  link.as = as;

  if (type) {
    link.type = type;
  }

  // フォントとfetchの場合はcrossorigin属性が必要
  if (as === 'font' || as === 'fetch') {
    link.crossOrigin = 'anonymous';
  }

  // headに追加
  document.head.appendChild(link);
}

次に、ルート遷移時に必要なリソースを Preload します。

typescript// src/routes/Router.tsx

import { Router, Route } from '@solidjs/router';
import { lazy } from 'solid-js';
import { preloadResource } from '../utils/preload';

// コード分割されたコンポーネント
const Home = lazy(() => import('./pages/Home'));
const About = lazy(() => import('./pages/About'));
const Dashboard = lazy(() => import('./pages/Dashboard'));

// ルート変更時にリソースをpreloadする関数
function preloadRouteResources(path: string): void {
  switch (path) {
    case '/dashboard':
      // ダッシュボードで使用するAPIデータをpreload
      preloadResource(
        'https://api.example.com/dashboard/stats',
        'fetch'
      );
      // ダッシュボード用のチャートライブラリをpreload
      preloadResource('/assets/chart-library.js', 'script');
      break;

    case '/about':
      // Aboutページで使用する画像をpreload
      preloadResource('/images/team-photo.webp', 'image');
      break;
  }
}

export default function AppRouter() {
  return (
    <Router
      onBeforeEnter={(to) => {
        // ルート遷移前にリソースをpreload
        preloadRouteResources(to.path);
      }}
    >
      <Route path='/' component={Home} />
      <Route path='/about' component={About} />
      <Route path='/dashboard' component={Dashboard} />
    </Router>
  );
}

Step 4: Preload のパフォーマンス測定

Preload の効果を測定するため、Performance API を使用します。

typescript// src/utils/performance.ts

interface PreloadMetrics {
  resourceName: string;
  startTime: number;
  duration: number;
  wasPreloaded: boolean;
}

// Preloadされたリソースのパフォーマンスを測定
export function measurePreloadPerformance(): PreloadMetrics[] {
  const resources =
    performance.getEntriesByType('resource');
  const preloadLinks = document.querySelectorAll(
    'link[rel="preload"]'
  );

  const preloadedUrls = new Set(
    Array.from(preloadLinks).map((link) =>
      link.getAttribute('href')
    )
  );

  return resources.map(
    (resource: PerformanceResourceTiming) => ({
      resourceName: resource.name,
      startTime: resource.startTime,
      duration: resource.duration,
      wasPreloaded: preloadedUrls.has(
        new URL(resource.name).pathname
      ),
    })
  );
}

この関数をブラウザのコンソールで実行すると、Preload の効果を数値で確認できます。

typescript// ブラウザコンソールでの実行例

const metrics = measurePreloadPerformance();
console.table(metrics.filter((m) => m.wasPreloaded));

// 出力例:
// | resourceName           | duration | wasPreloaded |
// |------------------------|----------|--------------|
// | /assets/index.js       | 45.3ms   | true         |
// | /fonts/custom.woff2    | 23.1ms   | true         |

4. エラーレポートの実装

エラーレポートの重要性

本番環境で発生するエラーを捕捉し、開発チームに通知する仕組みは必須です。ユーザーの環境でしか再現しないバグを見逃さないために、包括的なエラーレポートシステムを構築しましょう。

以下の図は、エラーレポートのフローを示しています。

mermaidflowchart TD
    user_action["ユーザー操作"] --> app["SolidJSアプリ"]

    app --> error_type{エラー種別}

    error_type --> sync["同期エラー<br/>(実行時エラー)"]
    error_type --> async["非同期エラー<br/>(Promise reject)"]
    error_type --> resource["リソースエラー<br/>(404等)"]

    sync --> error_handler["グローバル<br/>エラーハンドラー"]
    async --> unhandled["unhandledrejection<br/>ハンドラー"]
    resource --> resource_handler["Resource Error<br/>ハンドラー"]

    error_handler --> collector["エラー情報<br/>収集"]
    unhandled --> collector
    resource_handler --> collector

    collector --> send["エラーレポート<br/>送信"]
    send --> backend["バックエンド<br/>ログ集約"]
    backend --> alert["アラート通知<br/>(Slack等)"]

エラーレポートの流れ:包括的な捕捉と通知

Step 1: グローバルエラーハンドラーの設定

まず、すべてのエラーを捕捉するグローバルハンドラーを設定します。

typescript// src/error/global-handler.ts

interface ErrorReport {
  message: string;
  stack?: string;
  url: string;
  lineNumber?: number;
  columnNumber?: number;
  timestamp: number;
  userAgent: string;
  errorType: 'javascript' | 'promise' | 'resource';
}

// エラー情報を収集する関数
function collectErrorInfo(
  error: Error | ErrorEvent | PromiseRejectionEvent,
  errorType: ErrorReport['errorType']
): ErrorReport {
  const baseInfo = {
    url: window.location.href,
    timestamp: Date.now(),
    userAgent: navigator.userAgent,
    errorType,
  };

  // Error オブジェクトの場合
  if (error instanceof Error) {
    return {
      ...baseInfo,
      message: error.message,
      stack: error.stack,
    };
  }

  // ErrorEvent の場合(window.onerror)
  if ('error' in error && error.error instanceof Error) {
    return {
      ...baseInfo,
      message: error.error.message,
      stack: error.error.stack,
      lineNumber: error.lineno,
      columnNumber: error.colno,
    };
  }

  // PromiseRejectionEvent の場合
  if ('reason' in error) {
    return {
      ...baseInfo,
      message: String(error.reason),
      stack: error.reason?.stack,
    };
  }

  // その他の場合
  return {
    ...baseInfo,
    message: String(error),
  };
}

次に、収集したエラー情報をバックエンドに送信する関数を実装します。

typescript// src/error/global-handler.ts(続き)

// エラーレポートを送信する関数
async function sendErrorReport(
  report: ErrorReport
): Promise<void> {
  try {
    // エラーレポート用のエンドポイントに送信
    await fetch('https://api.example.com/errors', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify(report),
      // keepalive: ページ離脱時でも送信を完了
      keepalive: true,
    });
  } catch (sendError) {
    // エラー送信自体が失敗した場合はコンソールに記録
    console.error(
      'Failed to send error report:',
      sendError
    );
  }
}

グローバルエラーハンドラーを設定します。

typescript// src/error/global-handler.ts(続き)

// グローバルエラーハンドラーを初期化
export function initializeErrorHandlers(): void {
  // JavaScript実行時エラーを捕捉
  window.addEventListener('error', (event: ErrorEvent) => {
    const report = collectErrorInfo(event, 'javascript');
    sendErrorReport(report);

    // デフォルトの動作(コンソール出力)は継続
    return false;
  });

  // Promise の unhandled rejection を捕捉
  window.addEventListener(
    'unhandledrejection',
    (event: PromiseRejectionEvent) => {
      const report = collectErrorInfo(event, 'promise');
      sendErrorReport(report);

      // デフォルトの動作を継続
      return false;
    }
  );

  // リソース読み込みエラーを捕捉(画像、スクリプト等)
  window.addEventListener(
    'error',
    (event: Event) => {
      const target = event.target as HTMLElement;

      // リソース要素の場合のみ処理
      if (
        target instanceof HTMLImageElement ||
        target instanceof HTMLScriptElement ||
        target instanceof HTMLLinkElement
      ) {
        const report: ErrorReport = {
          message: `Resource failed to load: ${
            target instanceof HTMLImageElement
              ? target.src
              : target instanceof HTMLScriptElement
              ? target.src
              : (target as HTMLLinkElement).href
          }`,
          url: window.location.href,
          timestamp: Date.now(),
          userAgent: navigator.userAgent,
          errorType: 'resource',
        };

        sendErrorReport(report);
      }
    },
    true // キャプチャフェーズで実行
  );
}

Step 2: SolidJS ErrorBoundary の実装

SolidJS のコンポーネントツリー内でのエラーを捕捉するため、ErrorBoundary を実装します。

typescript// src/components/ErrorBoundary.tsx

import { ErrorBoundary as SolidErrorBoundary } from 'solid-js';
import type { ParentProps } from 'solid-js';

interface ErrorBoundaryProps extends ParentProps {
  fallback?: (
    error: Error,
    reset: () => void
  ) => JSX.Element;
}

// エラー発生時に表示するデフォルトUI
function DefaultErrorFallback(
  error: Error,
  reset: () => void
): JSX.Element {
  return (
    <div class='error-boundary'>
      <h2>エラーが発生しました</h2>
      <p>
        申し訳ございません。予期しないエラーが発生しました。
      </p>
      <details>
        <summary>詳細情報</summary>
        <pre>{error.message}</pre>
        <pre>{error.stack}</pre>
      </details>
      <button onClick={reset}>再試行</button>
    </div>
  );
}

export function ErrorBoundary(props: ErrorBoundaryProps) {
  return (
    <SolidErrorBoundary
      fallback={(error: Error, reset: () => void) => {
        // エラーをレポート
        const report = {
          message: error.message,
          stack: error.stack,
          url: window.location.href,
          timestamp: Date.now(),
          userAgent: navigator.userAgent,
          errorType: 'component' as const,
        };

        // バックエンドに送信
        fetch('https://api.example.com/errors', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify(report),
        }).catch(console.error);

        // カスタムfallbackまたはデフォルトを表示
        return props.fallback
          ? props.fallback(error, reset)
          : DefaultErrorFallback(error, reset);
      }}
    >
      {props.children}
    </SolidErrorBoundary>
  );
}

アプリケーション全体を ErrorBoundary で囲みます。

typescript// src/App.tsx

import { ErrorBoundary } from './components/ErrorBoundary';
import { Router } from '@solidjs/router';
import { routes } from './routes';

export default function App() {
  return (
    <ErrorBoundary>
      <Router>{routes}</Router>
    </ErrorBoundary>
  );
}

Step 3: 非同期処理のエラーハンドリング

SolidJS のリソース(データフェッチング)でのエラーを適切に処理します。

typescript// src/api/fetch-data.ts

import { createResource } from 'solid-js';

interface User {
  id: number;
  name: string;
  email: string;
}

// ユーザーデータを取得する関数
async function fetchUser(userId: number): Promise<User> {
  const response = await fetch(
    `https://api.example.com/users/${userId}`
  );

  if (!response.ok) {
    // HTTPエラーを明示的にスロー
    throw new Error(
      `HTTP Error ${response.status}: ${response.statusText}`
    );
  }

  return response.json();
}

// SolidJSのResourceとして使用
export function createUserResource(userId: () => number) {
  const [user, { mutate, refetch }] = createResource(
    userId,
    fetchUser,
    {
      // エラー時の動作を設定
      onError: (error: Error) => {
        // エラーをレポート
        const report = {
          message: `API Error: ${error.message}`,
          stack: error.stack,
          url: window.location.href,
          timestamp: Date.now(),
          userAgent: navigator.userAgent,
          errorType: 'api' as const,
        };

        fetch('https://api.example.com/errors', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify(report),
        }).catch(console.error);
      },
    }
  );

  return { user, mutate, refetch };
}

コンポーネント側でエラー状態を処理します。

typescript// src/components/UserProfile.tsx

import { Show, Suspense } from 'solid-js';
import { createUserResource } from '../api/fetch-data';

interface UserProfileProps {
  userId: number;
}

export function UserProfile(props: UserProfileProps) {
  const { user, refetch } = createUserResource(
    () => props.userId
  );

  return (
    <Suspense fallback={<div>読み込み中...</div>}>
      <Show
        when={!user.error}
        fallback={
          <div class='error-message'>
            <p>ユーザー情報の取得に失敗しました</p>
            <button onClick={() => refetch()}>
              再試行
            </button>
          </div>
        }
      >
        <div class='user-profile'>
          <h2>{user()?.name}</h2>
          <p>{user()?.email}</p>
        </div>
      </Show>
    </Suspense>
  );
}

Step 4: エラーレポートの初期化

アプリケーションのエントリーポイントでエラーハンドラーを初期化します。

typescript// src/index.tsx

import { render } from 'solid-js/web';
import App from './App';
import { initializeErrorHandlers } from './error/global-handler';

// グローバルエラーハンドラーを初期化
initializeErrorHandlers();

// アプリケーションをマウント
const root = document.getElementById('root');

if (!root) {
  throw new Error('Root element not found');
}

render(() => <App />, root);

エラーレポートの確認

本番環境でエラーが正しくレポートされているか、意図的にエラーを発生させてテストします。

typescript// 開発環境でのテスト用コード(本番では削除)

// テスト1: 同期エラー
function testSyncError() {
  throw new Error('Test sync error');
}

// テスト2: 非同期エラー
function testAsyncError() {
  return Promise.reject(new Error('Test async error'));
}

// テスト3: リソースエラー
function testResourceError() {
  const img = document.createElement('img');
  img.src = 'https://example.com/nonexistent.jpg';
  document.body.appendChild(img);
}

// ブラウザコンソールで実行
// testSyncError();
// testAsyncError();
// testResourceError();

これらのテストを実行すると、バックエンドのエラーログエンドポイントにエラー情報が送信されることを確認できます。

本番運用チェックリストの最終確認

すべての実装が完了したら、以下のチェックリストで最終確認を行いましょう。

#項目確認内容確認方法ステータス
1CSP ヘッダーContent-Security-Policy が設定されているDevTools Network → Headers
2CSP nonceスクリプトに nonce 属性が付与されているHTML ソース確認
3CSP 違反検知不正なスクリプト実行がブロックされるConsole で CSP violation 確認
4SRI 外部スクリプトCDN スクリプトに integrity 属性ありHTML ソース確認
5SRI 自動生成ビルド時に SRI ハッシュが生成されるyarn build実行確認
6Preload JSクリティカル JS に preload 設定HTML ソース確認
7Preload FontWeb フォントに preload 設定HTML ソース確認
8Preload 効果Performance API で preload 確認measurePreloadPerformance()実行
9エラーハンドラーグローバルエラーハンドラー初期化済みwindow.onerror確認
10ErrorBoundaryコンポーネントエラーを捕捉意図的エラーでテスト
11API エラーAPI エラーがレポートされる存在しない API 呼び出し
12エラー送信エラーがバックエンドに届くログ確認

すべての項目にチェックが入れば、本番デプロイの準備が整ったことになります。

まとめ

本記事では、SolidJS アプリケーションを本番環境で安全かつ高速に運用するための 4 つの重要な要素について解説しました。

実装した 4 つの柱

  1. Content Security Policy(CSP):XSS 攻撃を防ぐためのセキュリティポリシーを設定し、nonce 値を使ってインラインスクリプトを安全に実行できるようにしました。

  2. Subresource Integrity(SRI):CDN などの外部リソースが改ざんされていないことを検証する仕組みを実装し、ビルド時に自動的にハッシュ値を生成する方法を紹介しました。

  3. Preload(リソース事前読込):クリティカルな JavaScript、CSS、Web フォントを優先的に読み込むことで、初期表示速度を改善する手法を実装しました。

  4. エラーレポート:本番環境で発生するすべてのエラーを捕捉し、バックエンドに送信する包括的なエラーハンドリングシステムを構築しました。

本番運用で得られるメリット

これらの実装により、以下のメリットが得られます。

メリット具体的な効果
セキュリティ向上XSS 攻撃やリソース改ざんのリスクを大幅に低減
パフォーマンス改善初期表示速度が向上し、Core Web Vitals スコアが改善
運用安定性エラーを早期に発見し、迅速な対応が可能になる
ユーザー体験向上高速で安全なアプリケーションにより、満足度が向上
SEO 効果ページ速度の改善が SEO ランキングに好影響

次のステップ

本記事で紹介したチェックリストを活用して、段階的に実装を進めていきましょう。まずは CSP と SRI でセキュリティ基盤を固め、次に Preload でパフォーマンスを最適化し、最後にエラーレポートで運用体制を整えるという順序がおすすめです。

すべての実装が完了したら、実際に本番環境でテストを行い、各項目が正しく機能していることを確認してください。継続的な監視と改善により、より安全で高速な SolidJS アプリケーションを維持できるようになります。

関連リンク

公式ドキュメント

セキュリティ関連

パフォーマンス関連

エラー監視サービス