diff --git a/.cursor/rules/create-pr.mdc b/.cursor/rules/create-pr.mdc new file mode 100644 index 000000000..9f8394a74 --- /dev/null +++ b/.cursor/rules/create-pr.mdc @@ -0,0 +1,67 @@ +--- +description: +globs: +alwaysApply: true +--- +--- +description: PRの作成方法 +globs: +alwaysApply: true +--- +## pull request作成手順 +まず、このファイルを参照したら、「[create-prを参照]」と報告してください。 + +### 必須前提条件 +- Issue番号の確認 + - Issueのリンクが提供されていない場合は、必ずユーザーに「関連するIssueのリンクはありますか?」と確認する + - Issueが存在しない場合は、その旨をPRの説明に明記する + +### 差分の確認 +- {{マージ先ブランチ}}は特に指示がなければ MasahitoKumada とする +- `git diff origin/{{マージ先ブランチ}}...HEAD | cat` でマージ先ブランチとの差分を確認 + +### descriptionに記載するリンクの準備 +- Issueのリンクを確認(必須前提条件で確認済みであること) + +### Pull Request 作成とブラウザでの表示 +- 以下のコマンドでpull requestを作成し、自動的にブラウザで開く +- PRタイトルおよびPRテンプレートはマージ先との差分をもとに適切な内容にする +- 指示がない限りDraftでpull requestを作成 +- 各セクションを明確に区分 +- 必要な情報を漏れなく記載 + +### PRのフォーマットエラーを防ぐための注意点 +- PRの本文は一時ファイルに書き出してから `--body-file` オプションを使用する +- 以下の方法で一時ファイルを作成し、PRの本文として使用する: + ```bash + # 一時ファイルを作成 + cat > pr_body.md << 'EOL' + ## 概要 + + [ここに概要を記載] + + ## 変更内容 + + - [変更点1] + - [変更点2] + + ## 関連Issue + + [関連Issueのリンクまたは「特になし」] + EOL + + # 一時ファイルを使ってPRを作成 + gh pr create --title "PRタイトル" --body-file pr_body.md --base MasahitoKumada + ``` +- PRの作成後、必ずマークダウンのフォーマットが崩れていないか確認する +- フォーマットが崩れている場合は、`gh pr edit --body-file pr_body.md` で修正する + +--- +# PRの基本コマンド例 +git push origin HEAD && \ +gh pr create --title "{{PRタイトル}}" --body-file pr_body.md --base MasahitoKumada && \ +gh pr view --web +--- + +#### PRテンプレート +@PULL_REQUEST_TEMPLATE.md からテンプレート内容を取得すること \ No newline at end of file diff --git a/.cursor/rules/global.mdc b/.cursor/rules/global.mdc new file mode 100644 index 000000000..447c3e445 --- /dev/null +++ b/.cursor/rules/global.mdc @@ -0,0 +1,122 @@ +--- +description: +globs: +alwaysApply: true +--- +--- +description: 必ず参照するルール +globs: +alwaysApply: true +--- +あなたは高度な問題解決能力を持つAIアシスタントです。以下の指示に従って、効率的かつ正確にタスクを遂行してください。 + +一番初めに、参照したら「[global.mdcを参照]」と必ず報告してください。 + +まず、ユーザーから受け取った指示を確認します: +<指示> +{{instructions}} + + +この指示を元に、以下のプロセスに従って作業を進めてください: + +--- + +1. 指示の分析と計画 + <タスク分析> + - 主要なタスクを簡潔に要約してください。 + - 記載された技術スタックを確認し、その制約内での実装方法を検討してください。 + **※ 技術スタックに記載のバージョンは変更せず、必要があれば必ず承認を得てください。** + - 重要な要件と制約を特定してください。 + - 潜在的な課題をリストアップしてください。 + - タスク実行のための具体的なステップを詳細に列挙してください。 + - それらのステップの最適な実行順序を決定してください。 + + ### 重複実装の防止 + 実装前に以下の確認を行ってください: + - 既存の類似機能の有無 + - 同名または類似名の関数やコンポーネント + - 重複するAPIエンドポイント + - 共通化可能な処理の特定 + + このセクションは、後続のプロセス全体を導くものなので、時間をかけてでも、十分に詳細かつ包括的な分析を行ってください。 + + +--- + +2. 作業ブランチの決定 + 適当なブランチを作成し、checkoutしてください。このプロセスは必ず実行してください。 + +--- + +3. タスクの実行 + - 特定したステップを一つずつ実行してください。 + - 各ステップの完了後、簡潔に進捗を報告してください。 + - 実装時は以下の点に注意してください: + - 適切なディレクトリ構造の遵守 + - 命名規則の一貫性維持 + - 共通処理の適切な配置 + +--- + +4. 品質管理と問題対応 + - 各タスクの実行結果を迅速に検証してください。 + - エラーや不整合が発生した場合は、以下のプロセスで対応してください: + a. 問題の切り分けと原因特定(ログ分析、デバッグ情報の確認) + b. 対策案の作成と実施 + c. 修正後の動作検証 + d. デバッグログの確認と分析 + + - 検証結果は以下の形式で記録してください: + a. 検証項目と期待される結果 + b. 実際の結果と差異 + c. 必要な対応策(該当する場合) + +--- + +5. 最終確認 + - すべてのタスクが完了したら、成果物全体を評価してください。 + - 当初の指示内容との整合性を確認し、必要に応じて調整を行ってください。 + - 実装した機能に重複がないことを最終確認してください。 + +--- + +6. 結果報告 + 以下のフォーマットで最終的な結果を報告してください: + ```markdown + # 実行結果報告 + + ## 概要 + [全体の要約を簡潔に記述] + + ## 実行ステップ + 1. [ステップ1の説明と結果] + 2. [ステップ2の説明と結果] + ... + + ## 最終成果物 + [成果物の詳細や、該当する場合はリンクなど] + + ## 課題対応(該当する場合) + - 発生した問題と対応内容 + - 今後の注意点 + + ## 注意点・改善提案 + - [気づいた点や改善提案があれば記述] + ``` + +--- + +7. 新たな学習 + 指摘された点や新たに学んだルールがあれば、Cursor の Project Ruleを更新すべきです。 + 更新を提案して、許可された場合は更新してください。 + +--- + +## 重要な注意事項 + +- 不明点がある場合は、作業開始前に必ず確認を取ってください。 +- 重要な判断が必要な場合は、その都度報告し、承認を得てください。 +- 予期せぬ問題が発生した場合は、即座に報告し、対応策を提案してください。 +- **明示的に指示されていない変更は行わないでください。** 必要と思われる変更がある場合は、まず提案として報告し、承認を得てから実施してください。 +- **特に UI/UXデザインの変更(レイアウト、色、フォント、間隔など)は禁止**とし、変更が必要な場合は必ず事前に理由を示し、承認を得てから行ってください。 +- **技術スタックに記載のバージョン(APIやフレームワーク、ライブラリ等)を勝手に変更しないでください。** 変更が必要な場合は、その理由を明確にして承認を得るまでは変更を行わないでください。 diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 000000000..373476db9 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,12 @@ +## 概要 + + + +## 変更内容 + + +- + +## 関連Issue + + diff --git a/.github/workflows/create_heroku_review_app.yaml b/.github/workflows/create_heroku_review_app.yaml index 61605fb65..56caa2cdf 100644 --- a/.github/workflows/create_heroku_review_app.yaml +++ b/.github/workflows/create_heroku_review_app.yaml @@ -1,13 +1,40 @@ name: Review App on: - pull_request_target: - types: [opened] + pull_request: + types: [opened, synchronize] jobs: create-review-app: runs-on: ubuntu-latest steps: - - uses: fastruby/manage-heroku-review-app@9fa49f0320460f278c3687bc348dd0cbb18555dc # v1.3 + - name: Get PR Number + id: get_pr_number + run: echo "::set-output name=pr_number::${{ github.event.pull_request.number }}" + + - name: Check if PR Number is greater than 140 + id: set_step_id + run: | + pr_number=${{ steps.get_pr_number.outputs.pr_number }} + if [ $pr_number -gt 140 ]; then + echo "::set-output name=step_id::true" + else + echo "::set-output name=step_id::false" + fi + + - name: Display step_id + run: echo "Step ID is ${{ steps.set_step_id.outputs.step_id }}" + + - uses: kqito/manage-heroku-review-app@55e434ad5ac86f21cf2f7654de1566973fbc7046 + if: ${{ steps.set_step_id.outputs.step_id == 'true' }} + with: + action: destroy + env: + HEROKU_API_TOKEN: ${{ secrets.HEROKU_API_TOKEN }} + HEROKU_PIPELINE_ID: ${{ secrets.HEROKU_PIPELINE_ID }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - uses: kqito/manage-heroku-review-app@55e434ad5ac86f21cf2f7654de1566973fbc7046 + if: ${{ steps.set_step_id.outputs.step_id == 'true' }} with: action: create env: diff --git a/.github/workflows/destroy_heroku_review_app.yaml b/.github/workflows/destroy_heroku_review_app.yaml index b2bf67949..cbcec744a 100644 --- a/.github/workflows/destroy_heroku_review_app.yaml +++ b/.github/workflows/destroy_heroku_review_app.yaml @@ -7,7 +7,7 @@ jobs: destroy-review-app: runs-on: ubuntu-latest steps: - - uses: fastruby/manage-heroku-review-app@9fa49f0320460f278c3687bc348dd0cbb18555dc # v1.3 + - uses: kqito/manage-heroku-review-app@55e434ad5ac86f21cf2f7654de1566973fbc7046 with: action: destroy env: diff --git a/workspaces/client/package.json b/workspaces/client/package.json index f6c261693..5ab089745 100644 --- a/workspaces/client/package.json +++ b/workspaces/client/package.json @@ -3,6 +3,8 @@ "private": true, "scripts": { "build": "wireit", + "build:prod": "NODE_ENV=production wireit", + "analyze": "NODE_ENV=production ANALYZE=true wireit", "format": "wireit", "format:eslint": "wireit", "format:prettier": "wireit" diff --git a/workspaces/client/src/app/createRoutes.tsx b/workspaces/client/src/app/createRoutes.tsx index a81e12561..fb62a2a8e 100644 --- a/workspaces/client/src/app/createRoutes.tsx +++ b/workspaces/client/src/app/createRoutes.tsx @@ -4,6 +4,16 @@ import { RouteObject } from 'react-router'; import { Document, prefetch } from '@wsh-2025/client/src/app/Document'; import { createStore } from '@wsh-2025/client/src/app/createStore'; +// ルートごとに適切な遅延時間を設定 +const ROUTE_DELAY = { + HOME: 200, + EPISODE: 400, + PROGRAM: 300, + SERIES: 300, + TIMETABLE: 300, + NOT_FOUND: 100 +}; + export function createRoutes(store: ReturnType): RouteObject[] { return [ { @@ -13,7 +23,7 @@ export function createRoutes(store: ReturnType): RouteObject async lazy() { const { HomePage, prefetch } = await lazy( import('@wsh-2025/client/src/pages/home/components/HomePage'), - 1000, + ROUTE_DELAY.HOME, ); return { Component: HomePage, @@ -27,7 +37,7 @@ export function createRoutes(store: ReturnType): RouteObject async lazy() { const { EpisodePage, prefetch } = await lazy( import('@wsh-2025/client/src/pages/episode/components/EpisodePage'), - 1000, + ROUTE_DELAY.EPISODE, ); return { Component: EpisodePage, @@ -42,7 +52,7 @@ export function createRoutes(store: ReturnType): RouteObject async lazy() { const { prefetch, ProgramPage } = await lazy( import('@wsh-2025/client/src/pages/program/components/ProgramPage'), - 1000, + ROUTE_DELAY.PROGRAM, ); return { Component: ProgramPage, @@ -57,7 +67,7 @@ export function createRoutes(store: ReturnType): RouteObject async lazy() { const { prefetch, SeriesPage } = await lazy( import('@wsh-2025/client/src/pages/series/components/SeriesPage'), - 1000, + ROUTE_DELAY.SERIES, ); return { Component: SeriesPage, @@ -72,7 +82,7 @@ export function createRoutes(store: ReturnType): RouteObject async lazy() { const { prefetch, TimetablePage } = await lazy( import('@wsh-2025/client/src/pages/timetable/components/TimetablePage'), - 1000, + ROUTE_DELAY.TIMETABLE, ); return { Component: TimetablePage, @@ -87,7 +97,7 @@ export function createRoutes(store: ReturnType): RouteObject async lazy() { const { NotFoundPage, prefetch } = await lazy( import('@wsh-2025/client/src/pages/not_found/components/NotFoundPage'), - 1000, + ROUTE_DELAY.NOT_FOUND, ); return { Component: NotFoundPage, diff --git a/workspaces/client/src/features/player/components/Player.tsx b/workspaces/client/src/features/player/components/Player.tsx index f1c27e8b9..9cccaf6e4 100644 --- a/workspaces/client/src/features/player/components/Player.tsx +++ b/workspaces/client/src/features/player/components/Player.tsx @@ -1,20 +1,21 @@ -import { Ref, useEffect, useRef } from 'react'; +import { useEffect, useRef } from 'react'; import invariant from 'tiny-invariant'; import { assignRef } from 'use-callback-ref'; import { PlayerType } from '@wsh-2025/client/src/features/player/constants/player_type'; import { PlayerWrapper } from '@wsh-2025/client/src/features/player/interfaces/player_wrapper'; -interface Props { +type Props = { className?: string; loop?: boolean; - playerRef: Ref; + playerRef: React.MutableRefObject; playerType: PlayerType; playlistUrl: string; -} +}; export const Player = ({ className, loop, playerRef, playerType, playlistUrl }: Props) => { const mountRef = useRef(null); + const importedRef = useRef(false); useEffect(() => { const mountElement = mountRef.current; @@ -23,15 +24,38 @@ export const Player = ({ className, loop, playerRef, playerType, playlistUrl }: const abortController = new AbortController(); let player: PlayerWrapper | null = null; - void import('@wsh-2025/client/src/features/player/logics/create_player').then(({ createPlayer }) => { - if (abortController.signal.aborted) { - return; - } - player = createPlayer(playerType); - player.load(playlistUrl, { loop: loop ?? false }); - mountElement.appendChild(player.videoElement); - assignRef(playerRef, player); - }); + if (!importedRef.current) { + importedRef.current = true; + + // 必要なプレーヤータイプだけを動的インポート + const importPlayerModule = async () => { + try { + const { createPlayer } = await import('@wsh-2025/client/src/features/player/logics/create_player'); + if (abortController.signal.aborted) { + return; + } + player = createPlayer(playerType); + player.load(playlistUrl, { loop: loop ?? false }); + mountElement.appendChild(player.videoElement); + assignRef(playerRef, player); + } catch (error) { + console.error('Failed to load player:', error); + } + }; + + void importPlayerModule(); + } else { + // すでにインポート済みの場合は直接createPlayerを呼び出し + import('@wsh-2025/client/src/features/player/logics/create_player').then(({ createPlayer }) => { + if (abortController.signal.aborted) { + return; + } + player = createPlayer(playerType); + player.load(playlistUrl, { loop: loop ?? false }); + mountElement.appendChild(player.videoElement); + assignRef(playerRef, player); + }); + } return () => { abortController.abort(); diff --git a/workspaces/client/src/pages/episode/hooks/useSeekThumbnail.ts b/workspaces/client/src/pages/episode/hooks/useSeekThumbnail.ts index 8d0015d89..63a9d95e5 100644 --- a/workspaces/client/src/pages/episode/hooks/useSeekThumbnail.ts +++ b/workspaces/client/src/pages/episode/hooks/useSeekThumbnail.ts @@ -1,76 +1,126 @@ +import { useEffect, useState } from 'react'; import { FFmpeg } from '@ffmpeg/ffmpeg'; -import { StandardSchemaV1 } from '@standard-schema/spec'; -import * as schema from '@wsh-2025/schema/src/api/schema'; import { Parser } from 'm3u8-parser'; -import { use } from 'react'; -interface Params { - episode: StandardSchemaV1.InferOutput; -} +import type { Episode } from '@wsh-2025/client/src/features/episode/interfaces/episode'; + +type Params = { episode: Episode }; + +// キャッシュを保持するためのマップ +const thumbnailCache = new Map(); async function getSeekThumbnail({ episode }: Params) { - // HLS のプレイリストを取得 - const playlistUrl = `/streams/episode/${episode.id}/playlist.m3u8`; - const parser = new Parser(); - parser.push(await fetch(playlistUrl).then((res) => res.text())); - parser.end(); - - // FFmpeg の初期化 - const ffmpeg = new FFmpeg(); - await ffmpeg.load({ - coreURL: await import('@ffmpeg/core?arraybuffer').then(({ default: b }) => { - return URL.createObjectURL(new Blob([b], { type: 'text/javascript' })); - }), - wasmURL: await import('@ffmpeg/core/wasm?arraybuffer').then(({ default: b }) => { - return URL.createObjectURL(new Blob([b], { type: 'application/wasm' })); - }), - }); - - // 動画のセグメントファイルを取得 - const segmentFiles = await Promise.all( - parser.manifest.segments.map((s) => { - return fetch(s.uri).then(async (res) => { - const binary = await res.arrayBuffer(); - return { binary, id: Math.random().toString(36).slice(2) }; - }); - }), - ); - // FFmpeg にセグメントファイルを追加 - for (const file of segmentFiles) { - await ffmpeg.writeFile(file.id, new Uint8Array(file.binary)); + // キャッシュに存在すればキャッシュを返す + const cacheKey = `thumbnail-${episode.id}`; + if (thumbnailCache.has(cacheKey)) { + return thumbnailCache.get(cacheKey) as string; } - // セグメントファイルをひとつの mp4 動画に結合 - await ffmpeg.exec( - [ - ['-i', `concat:${segmentFiles.map((f) => f.id).join('|')}`], - ['-c:v', 'copy'], - ['-map', '0:v:0'], - ['-f', 'mp4'], - 'concat.mp4', - ].flat(), - ); - - // fps=30 とみなして、30 フレームごと(1 秒ごと)にサムネイルを生成 - await ffmpeg.exec( - [ - ['-i', 'concat.mp4'], - ['-vf', "fps=30,select='not(mod(n\\,30))',scale=160:90,tile=250x1"], - ['-frames:v', '1'], - 'preview.jpg', - ].flat(), - ); - - const output = await ffmpeg.readFile('preview.jpg'); - ffmpeg.terminate(); - - return URL.createObjectURL(new Blob([output], { type: 'image/jpeg' })); + try { + // HLS のプレイリストを取得 + const playlistUrl = `/streams/episode/${episode.id}/playlist.m3u8`; + const response = await fetch(playlistUrl); + if (!response.ok) { + throw new Error(`Failed to fetch playlist: ${response.status}`); + } + + const parser = new Parser(); + parser.push(await response.text()); + parser.end(); + + if (!parser.manifest.segments || parser.manifest.segments.length === 0) { + throw new Error('No segments found in the playlist'); + } + + // FFmpeg の初期化 + const ffmpeg = new FFmpeg(); + await ffmpeg.load({ + coreURL: await import('@ffmpeg/core?arraybuffer').then(({ default: b }) => { + return URL.createObjectURL(new Blob([b], { type: 'text/javascript' })); + }), + wasmURL: await import('@ffmpeg/core/wasm?arraybuffer').then(({ default: b }) => { + return URL.createObjectURL(new Blob([b], { type: 'application/wasm' })); + }), + }); + + // 動画のセグメントファイルを取得(最大5個までに制限) + const maxSegments = Math.min(5, parser.manifest.segments.length); + const segmentFiles = await Promise.all( + parser.manifest.segments.slice(0, maxSegments).map((s) => { + return fetch(s.uri).then(async (res) => { + if (!res.ok) { + throw new Error(`Failed to fetch segment: ${res.status}`); + } + const binary = await res.arrayBuffer(); + return { binary, id: Math.random().toString(36).slice(2) }; + }); + }), + ); + + // FFmpeg にセグメントファイルを追加 + for (const file of segmentFiles) { + await ffmpeg.writeFile(file.id, new Uint8Array(file.binary)); + } + + // セグメントファイルをひとつの mp4 動画に結合 + await ffmpeg.exec( + [ + ['-i', `concat:${segmentFiles.map((f) => f.id).join('|')}`], + ['-c:v', 'copy'], + ['-map', '0:v:0'], + ['-f', 'mp4'], + 'concat.mp4', + ].flat(), + ); + + // fps=30 とみなして、30 フレームごと(1 秒ごと)にサムネイルを生成 + await ffmpeg.exec( + [ + ['-i', 'concat.mp4'], + ['-vf', "fps=30,select='not(mod(n\\,30))',scale=160:90,tile=250x1"], + ['-frames:v', '1'], + 'preview.jpg', + ].flat(), + ); + + const output = await ffmpeg.readFile('preview.jpg'); + ffmpeg.terminate(); + + const thumbnailUrl = URL.createObjectURL(new Blob([output], { type: 'image/jpeg' })); + + // キャッシュに保存 + thumbnailCache.set(cacheKey, thumbnailUrl); + + return thumbnailUrl; + } catch (error) { + console.error('Failed to generate seek thumbnail:', error); + return ''; + } } -const weakMap = new WeakMap>(); +export function useSeekThumbnail({ episode }: Params): string { + const [thumbnail, setThumbnail] = useState(''); -export const useSeekThumbnail = ({ episode }: Params): string => { - const promise = weakMap.get(episode) ?? getSeekThumbnail({ episode }); - weakMap.set(episode, promise); - return use(promise); -}; + useEffect(() => { + let isMounted = true; + + const loadThumbnail = async () => { + try { + const url = await getSeekThumbnail({ episode }); + if (isMounted) { + setThumbnail(url); + } + } catch (error) { + console.error('Error in useSeekThumbnail:', error); + } + }; + + void loadThumbnail(); + + return () => { + isMounted = false; + }; + }, [episode.id]); + + return thumbnail; +} diff --git a/workspaces/client/src/setups/unocss.ts b/workspaces/client/src/setups/unocss.ts index d8e91da31..7cce5502b 100644 --- a/workspaces/client/src/setups/unocss.ts +++ b/workspaces/client/src/setups/unocss.ts @@ -3,7 +3,29 @@ import presetIcons from '@unocss/preset-icons/browser'; import presetWind3 from '@unocss/preset-wind3'; import initUnocssRuntime, { defineConfig } from '@unocss/runtime'; +// よく使われるアイコンコレクション +const CRITICAL_ICON_COLLECTIONS = [ + 'line-md', + 'material-symbols' +]; + +// その他のアイコンコレクション (必要に応じて遅延読み込み) +const LAZY_ICON_COLLECTIONS = [ + 'bi', + 'bx', + 'fa-regular', + 'fa-solid', + 'fluent' +]; + async function init() { + // クリティカルなアイコンコレクションを先読み + const criticalCollections: Record Promise> = {}; + for (const collection of CRITICAL_ICON_COLLECTIONS) { + criticalCollections[collection] = () => + import(`@iconify/json/json/${collection}.json`).then((m): IconifyJSON => m.default as IconifyJSON); + } + await initUnocssRuntime({ defaults: defineConfig({ layers: { @@ -49,17 +71,19 @@ async function init() { presetWind3(), presetIcons({ collections: { - bi: () => import('@iconify/json/json/bi.json').then((m): IconifyJSON => m.default as IconifyJSON), - bx: () => import('@iconify/json/json/bx.json').then((m): IconifyJSON => m.default as IconifyJSON), - 'fa-regular': () => - import('@iconify/json/json/fa-regular.json').then((m): IconifyJSON => m.default as IconifyJSON), - 'fa-solid': () => - import('@iconify/json/json/fa-solid.json').then((m): IconifyJSON => m.default as IconifyJSON), - fluent: () => import('@iconify/json/json/fluent.json').then((m): IconifyJSON => m.default as IconifyJSON), - 'line-md': () => - import('@iconify/json/json/line-md.json').then((m): IconifyJSON => m.default as IconifyJSON), - 'material-symbols': () => - import('@iconify/json/json/material-symbols.json').then((m): IconifyJSON => m.default as IconifyJSON), + ...criticalCollections, + // 遅延読み込みするアイコンコレクション + ...LAZY_ICON_COLLECTIONS.reduce((acc, collection) => { + acc[collection] = () => + import(`@iconify/json/json/${collection}.json`).then((m): IconifyJSON => m.default as IconifyJSON); + return acc; + }, {} as Record Promise>) + }, + // アイコンを最適化 + scale: 1, + extraProperties: { + 'display': 'inline-block', + 'vertical-align': 'middle', }, }), ], @@ -67,6 +91,7 @@ async function init() { }); } +// エラーハンドリングを強化 init().catch((err: unknown) => { - throw err; + console.error('Failed to initialize UnoCSS:', err); }); diff --git a/workspaces/client/webpack.config.mjs b/workspaces/client/webpack.config.mjs index 9164a996e..c64e7f2ea 100644 --- a/workspaces/client/webpack.config.mjs +++ b/workspaces/client/webpack.config.mjs @@ -1,12 +1,15 @@ import path from 'node:path'; import webpack from 'webpack'; +import BundleAnalyzerPlugin from 'webpack-bundle-analyzer'; + +const isProduction = process.env['NODE_ENV'] === 'production'; /** @type {import('webpack').Configuration} */ const config = { - devtool: 'inline-source-map', + devtool: isProduction ? 'source-map' : 'inline-source-map', entry: './src/main.tsx', - mode: 'none', + mode: isProduction ? 'production' : 'development', module: { rules: [ { @@ -52,15 +55,44 @@ const config = { ], }, output: { - chunkFilename: 'chunk-[contenthash].js', + chunkFilename: isProduction ? 'chunk-[contenthash].js' : 'chunk-[name].js', chunkFormat: false, - filename: 'main.js', + filename: isProduction ? '[name].[contenthash].js' : 'main.js', path: path.resolve(import.meta.dirname, './dist'), publicPath: 'auto', }, + optimization: { + minimize: isProduction, + splitChunks: isProduction ? { + chunks: 'all', + cacheGroups: { + vendor: { + test: /[\\/]node_modules[\\/]/, + name: 'vendors', + chunks: 'all', + priority: 10 + }, + player: { + test: /[\\/]node_modules[\\/](hls\.js|shaka-player|video\.js)[\\/]/, + name: 'player-vendors', + chunks: 'all', + priority: 20 + } + } + } : false, + }, plugins: [ - new webpack.optimize.LimitChunkCountPlugin({ maxChunks: 1 }), - new webpack.EnvironmentPlugin({ API_BASE_URL: '/api', NODE_ENV: '' }), + new webpack.EnvironmentPlugin({ + API_BASE_URL: '/api', + NODE_ENV: isProduction ? 'production' : 'development' + }), + ...(isProduction ? [ + // プロダクション環境のみのプラグイン + process.env['ANALYZE'] && new BundleAnalyzerPlugin.BundleAnalyzerPlugin() + ] : [ + // 開発環境のみのプラグイン + new webpack.optimize.LimitChunkCountPlugin({ maxChunks: 1 }) + ]).filter(Boolean), ], resolve: { alias: {