Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
**/node_modules
**/.next
**/.chros
**/.env
**/.env.local
.git
Expand Down
24 changes: 24 additions & 0 deletions .github/workflows/quality.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
name: Code quality

on:
pull_request:
push:
branches: [develop]

permissions:
contents: read

jobs:
quality:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 22
- uses: pnpm/action-setup@v6
- run: pnpm install --frozen-lockfile
- run: pnpm format:check
- run: pnpm typecheck
- run: pnpm test
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ node_modules/
.env
.env.local
*.tsbuildinfo
.chros/
5 changes: 5 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
**/node_modules/
**/.next/
**/.chros/
chros/app/generated/
pnpm-lock.yaml
4 changes: 4 additions & 0 deletions .prettierrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"singleQuote": true,
"printWidth": 100
}
27 changes: 15 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
通称 : CHroS(チュロス)

大会の進行と、配信に載せる表示を管理するシステムです。
対人戦の結果記録から順位集計・本戦・会場掲示まで操作できる試作を実装しました。
**まず試す場合は [試作提案・操作手順・判断記録](./docs/prototype-proposal.md) を参照してください。**
要件は [docs/requirements.md](./docs/requirements.md)、技術要件は
[docs/technical-requirements.md](./docs/technical-requirements.md) を参照してください。

Expand All @@ -17,25 +19,26 @@ packages/scoring/ スコア計算の純粋ロジック(I/O を持たない)

## 環境構築

Docker を使用する場合は、リポジトリのルートで `docker compose up -d` を実行してください。
`app` (Next.js, :3000) と `db` (PostgreSQL 17, :5432) が立ち上がります。

ローカルで動かす場合:
Node.js 22以降とpnpmで起動できます。今回の試作はファイル保存のためDBを必要としません。

```sh
# DB だけ Docker で立てる
docker compose up -d db

pnpm install
cp chros/.env.example chros/.env
pnpm --filter chros exec prisma migrate deploy
pnpm dev
```

- Console: http://localhost:3000/console
- Viewer: http://localhost:3000/display
- [Console](http://localhost:3000/console)
- [Viewer](http://localhost:3000/display)

初回は架空の8名によるサンプル大会です。「設定・履歴」から空の大会を作成できます。
データは `chros/.chros/` に自動保存されます。保存先は `CHROS_DATA_DIR` で変更できます。
大会切り替え前のデータも同ディレクトリに退避します。復旧手順は [技術要件](./docs/technical-requirements.md) を参照してください。

本番形式で確認する場合は `pnpm build` → `pnpm start`。
Dockerでは `docker compose up -d --build` でアプリと永続化用volumeを使用します。
従来のPostgreSQLサービスは `database` プロファイルに残していますが、今回の試作では使いません。

## リアルタイム配信

Viewer は `GET /api/stream` の SSE を購読します。接続直後に全状態のスナップショットが
1件届き、以降は差分イベントが流れます。WebSocket は使用しません(理由は技術要件 §1.1)。
1件届き、以降も保存のたびに最新の全状態が流れます。WebSocketは使用しません。
これは掲示状態の即時反映であり、試合の得点入力は各戦終了後に行います。
8 changes: 5 additions & 3 deletions chros/.env.example
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# PostgreSQL 接続文字列
# 今回の試作: 永続化ディレクトリ。未指定はNext.jsの作業ディレクトリの .chros
# CHROS_DATA_DIR=/absolute/path/to/chros-data

# 将来のPostgreSQL実装用(現在の試作では未使用)
DATABASE_URL=postgresql://user:password@localhost:5432/database

# Viewer が SSE を張る先。同一オリジンで運用する場合は空でよい。
NEXT_PUBLIC_APP_URL=http://localhost:3000
# ViewerのSSE接続先は同一オリジン /api/stream。
1 change: 0 additions & 1 deletion chros/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ RUN pnpm install --frozen-lockfile

COPY . .

RUN pnpm --filter chros exec prisma generate
RUN pnpm --filter chros build

EXPOSE 3000
Expand Down
21 changes: 21 additions & 0 deletions chros/app/api/export/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { exportResultsCsv } from '@/lib/export-results';
import { readState } from '@/lib/store';

export const dynamic = 'force-dynamic';
export const runtime = 'nodejs';

export function GET(request: Request) {
const state = readState();
const format = new URL(request.url).searchParams.get('format') === 'csv' ? 'csv' : 'json';
const content = format === 'csv' ? exportResultsCsv(state) : JSON.stringify(state, null, 2);
const contentType = format === 'csv' ? 'text/csv' : 'application/json';
const date = new Date().toISOString().slice(0, 10);

return new Response(content, {
headers: {
'Content-Type': `${contentType}; charset=utf-8`,
'Content-Disposition': `attachment; filename="chros-${date}.${format}"`,
'Cache-Control': 'no-store',
},
});
}
42 changes: 9 additions & 33 deletions chros/app/api/screen/route.ts
Original file line number Diff line number Diff line change
@@ -1,36 +1,12 @@
import { apiError, changeScreenRequestSchema } from '@chros/shared';
import { broadcast, getCurrentScreen } from '@/lib/broadcast';

import { readState } from '@/lib/store';
export const dynamic = 'force-dynamic';
export const runtime = 'nodejs';

/** GET /api/screen — 現在の配信画面。 */
export function GET(): Response {
return Response.json({ screen: getCurrentScreen() });
export function GET() {
const state = readState();
return Response.json({ screen: state.scene, phase: state.phase, revision: state.revision });
}

/**
* POST /api/screen — 配信画面の切り替え。§7
* 旧 chros-websock の POST /api/change-screen の置き換え。
*/
export async function POST(request: Request): Promise<Response> {
let body: unknown;
try {
body = await request.json();
} catch {
return Response.json(apiError('invalid_json', 'リクエストボディが JSON ではありません'), {
status: 400,
});
}

const parsed = changeScreenRequestSchema.safeParse(body);
if (!parsed.success) {
return Response.json(apiError('invalid_request', parsed.error.issues[0]?.message ?? '不正なリクエストです'), {
status: 400,
});
}

broadcast({ type: 'screen.changed', payload: { screen: parsed.data.screen } });

return Response.json({ screen: parsed.data.screen });
export function POST() {
return Response.json(
{ error: '画面変更は /api/state の show コマンドを使用してください。' },
{ status: 410 },
);
}
55 changes: 55 additions & 0 deletions chros/app/api/state/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { requestSchema } from '@chros/shared';
import { dispatch, readState, RevisionConflict } from '@/lib/store';

export const dynamic = 'force-dynamic';
export const runtime = 'nodejs';

const MAX_REQUEST_LENGTH = 2_000_000;

export function GET() {
return Response.json(readState(), { headers: { 'Cache-Control': 'no-store' } });
}
export async function POST(request: Request) {
const origin = request.headers.get('origin');
if (
origin &&
origin !== new URL(request.url).origin &&
new URL(origin).host !== request.headers.get('host')
) {
return Response.json({ error: '異なるサイトからの操作は受け付けません。' }, { status: 403 });
}

const body = await request.text();
if (body.length > MAX_REQUEST_LENGTH) {
return Response.json({ error: 'データが大きすぎます。' }, { status: 413 });
}

let input;
try {
input = requestSchema.safeParse(JSON.parse(body));
} catch {
return Response.json({ error: '入力形式が不正です。' }, { status: 400 });
}
if (!input.success) {
const validationErrors = input.error.issues.map((issue) => issue.message).join(' / ');
return Response.json(
{
error: `入力内容を確認してください: ${validationErrors}`,
},
{ status: 400 },
);
}

try {
return Response.json(dispatch(input.data.command, input.data.expectedRevision));
} catch (error) {
const conflict = error instanceof RevisionConflict;
return Response.json(
{
error: error instanceof Error ? error.message : '保存できませんでした。',
...(conflict ? { state: readState() } : {}),
},
{ status: conflict ? 409 : 400 },
);
}
}
71 changes: 26 additions & 45 deletions chros/app/api/stream/route.ts
Original file line number Diff line number Diff line change
@@ -1,75 +1,56 @@
import type { ChrosEvent } from '@chros/shared';
import { buildSnapshot, nextEventId, replayAfter, subscribe } from '@/lib/broadcast';
import { readState, subscribeState } from '@/lib/store';
import type { ControlState } from '@chros/shared';

export const dynamic = 'force-dynamic';
export const runtime = 'nodejs';

/** プロキシによるアイドル切断を防ぐコメント行の間隔。§6.1 */
const PING_INTERVAL_MS = 15_000;
const HEARTBEAT_INTERVAL_MS = 15_000;

function encodeEvent(id: number, event: ChrosEvent): string {
return `id: ${id}\nevent: ${event.type}\ndata: ${JSON.stringify(event.payload)}\n\n`;
function encodeSnapshot(state: ControlState) {
return `id: ${state.revision}\nevent: snapshot\ndata: ${JSON.stringify(state)}\n\n`;
}

/**
* GET /api/stream — 全 Viewer が購読する SSE ストリーム。§6.1
* 旧 chros-websock (Express + socket.io) の置き換え。
*/
export function GET(request: Request): Response {
export function GET(request: Request) {
const encoder = new TextEncoder();
const lastEventIdHeader = request.headers.get('last-event-id');
const lastEventId = lastEventIdHeader ? Number.parseInt(lastEventIdHeader, 10) : NaN;

let unsubscribe: (() => void) | undefined;
let ping: ReturnType<typeof setInterval> | undefined;

const stream = new ReadableStream<Uint8Array>({
let cleanup = () => {};
const stream = new ReadableStream({
start(controller) {
let closed = false;
const send = (chunk: string) => {
const send = (message: string) => {
if (closed) return;
try {
controller.enqueue(encoder.encode(chunk));
controller.enqueue(encoder.encode(message));
} catch {
// 送信先が既に切れている。次の abort で後始末される。
closed = true;
cleanup();
}
};

// 再送可能なら差分だけ、無理なら全状態を送り直す。
const replayed = Number.isNaN(lastEventId) ? null : replayAfter(lastEventId);
if (replayed) {
for (const entry of replayed) send(encodeEvent(entry.id, entry.event));
} else {
send(encodeEvent(nextEventId() - 1, { type: 'snapshot', payload: buildSnapshot() }));
}

unsubscribe = subscribe((entry) => send(encodeEvent(entry.id, entry.event)));
ping = setInterval(() => send(': ping\n\n'), PING_INTERVAL_MS);

request.signal.addEventListener('abort', () => {
const sendSnapshot = (state: ControlState) => send(encodeSnapshot(state));
const unsubscribe = subscribeState(sendSnapshot);
const heartbeat = setInterval(() => send(': heartbeat\n\n'), HEARTBEAT_INTERVAL_MS);
cleanup = () => {
if (closed) return;
closed = true;
unsubscribe?.();
if (ping) clearInterval(ping);
unsubscribe();
clearInterval(heartbeat);
request.signal.removeEventListener('abort', cleanup);
try {
controller.close();
} catch {
// 既に閉じている
}
});
} catch {}
};
request.signal.addEventListener('abort', cleanup);
// 再接続時も保存済みの全状態を返す。再起動・取りこぼしに依存しない。
sendSnapshot(readState());
if (request.signal.aborted) cleanup();
},
cancel() {
unsubscribe?.();
if (ping) clearInterval(ping);
cleanup();
},
});

return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream; charset=utf-8',
'Cache-Control': 'no-cache, no-transform',
Connection: 'keep-alive',
// Nginx 等のバッファリングを止めないとイベントが溜まってから届く
'X-Accel-Buffering': 'no',
},
});
Expand Down
13 changes: 0 additions & 13 deletions chros/app/console/actions.ts

This file was deleted.

31 changes: 0 additions & 31 deletions chros/app/console/components/ConsoleNavTabs.tsx

This file was deleted.

7 changes: 0 additions & 7 deletions chros/app/console/components/DisplayPreview.tsx

This file was deleted.

Loading
Loading