def-game 5.0.0 → 5.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -37,7 +37,48 @@ V5 は破壊的変更です。GameRule、GameEngine、TaskQueue に関する型
37
37
  - 参加・開始などもゲームの Command として扱います。
38
38
  - 旧 GameEngine の代わりに GameSimulator でコマンド列を実行します。
39
39
 
40
- Worker generator はこのリリースには含みません。Selfish の Worker 実装で共通部分を確認した後に追加する予定です。
40
+ ## Worker generator(5.0.1)
41
+
42
+ Selfish で検証した Hono Worker、SQLite-backed Durable Object、waki.work JWT 認証、Hibernation WebSocket、保存・配信処理を生成します。GameDefinition の契約変更はありません。現在は timeout / Effect 実行なしの構成が対象です。
43
+
44
+ server package に `def-game.worker.json` を作ります。全パスはこの設定ファイルのディレクトリ基準です。
45
+
46
+ ```json
47
+ {
48
+ "outputDir": "src/worker",
49
+ "adapter": "src/worker/game-adapter.ts",
50
+ "entry": "src/index.ts",
51
+ "wranglerConfig": "wrangler.jsonc",
52
+ "name": "my-game",
53
+ "assets": "../client/public",
54
+ "compatibilityDate": "2026-09-12"
55
+ }
56
+ ```
57
+
58
+ ```sh
59
+ npm install --save-dev def-game@5.0.1 wrangler @cloudflare/workers-types
60
+ npm install hono jose
61
+ npx def-game generate-worker --config def-game.worker.json
62
+ npx def-game generate-worker --config def-game.worker.json --check
63
+ ```
64
+
65
+ 生成対象は `outputDir` 内の `index.ts`、`session.ts`、`auth.ts`、`env.ts`、`runtime/parse.ts`、`runtime/game-adapter.ts` と、`entry`、`wranglerConfig` の計8ファイルです。Hono / jose は利用側の runtime dependency で、generator 自体に実行時依存はありません。Selfish は Hono 4.13.7、jose 6.2.12、Wrangler 4.131.1 で検証しています。
66
+
67
+ ゲーム側は既存ファイルとして `adapter` を用意し、次を export します。このファイル、domain、shared は生成・上書きしません。
68
+
69
+ - `gameAdapter`: 生成される `GameAdapter<State, Command, View, Error>` に適合するオブジェクト。
70
+ - `gameAdapter.game`: ゲーム自身の GameDefinition(Effect は `never`)。
71
+ - `parseCreate(input)` / `parseJoin(input)` / `parseCommand(input)`: 未検証入力から command を返す関数。形式不正なら null。WS の関数へ渡るのは envelope 内の command だけです。
72
+ - `canConnect(state, actorId)`: 接続・配信を許可するかの判定。command の認可は GameDefinition が再検証します。
73
+ - 型の export: `State`、`ActorId`(string)、`ServerMessage`、`ProtocolError`、`PublicError`、`CreateSessionResponse`、`JoinSessionResponse`。
74
+
75
+ 公開通信は `GameCommandRequest { requestId, command }`、`GameCommandResponse { requestId, ok, error? }`、`ViewStateEvent { viewState }`、`ProtocolErrorEvent { error }` の type で区別します。HTTP 応答は `CreateSessionResponse` / `JoinSessionResponse` の type と、成功時 `ok: true, sessionId`、失敗時 `ok: false, error` を持ちます。ProtocolError の code は `AuthenticationRequired` / `SessionNotFound` / `NotSessionMember` / `InvalidRequest` / `InternalError`。PublicError はそれとゲームエラーの union です。
76
+
77
+ 認証は Cookie `__token` を JWKS / ES256 / issuer / audience / exp で検証し、UUID の sub を ActorId として使います。Static Assets も認証必須。ログイン誘導・refresh は client の責務です。
78
+
79
+ 出力が同一なら変更しません。既存ファイルと差分がある場合は書き込み前に停止します。確認後に `--force` を付けると生成対象8ファイルだけを更新します。`--check` は欠落・差分を非ゼロ終了で報告し、ファイルを変更しません。生成ファイルは Git 管理し、修正は generator / 設定側へ戻してください。Wrangler 設定も全体が生成対象なので、独自 bindings や migrations の追加には generator の対応が必要です。
80
+
81
+ CLI は dependencies、package scripts、tsconfig を書き換えません。利用側で Worker の型と Hono / jose を設定し、生成された entry を Wrangler から実行します。CLI には Node.js 20 以降が必要です。
41
82
 
42
83
  ## 開発と公開
43
84
 
@@ -50,6 +91,6 @@ npm pack
50
91
 
51
92
  開発時のテストには Node.js 20 以降を使用してください。npm test はビルドと Node 標準のシナリオテストを実行します。
52
93
 
53
- npm pack / npm publish の前に prepack で型チェック・ビルド・テストが実行されます。build は dist を作り直すため、削除した旧 API の生成物が混入しません。公開対象は dist、package.json、README、LICENSE です。
94
+ npm pack / npm publish の前に prepack で型チェック・ビルド・テストが実行されます。build は dist を作り直すため、削除した旧 API の生成物が混入しません。公開対象は dist、bin、templates、package.json、README、LICENSE です。
54
95
 
55
96
  main にレビュー済みの変更を反映した後、公開する version と認証アカウントを確認し、検証済みのパッケージを npm publish で公開します。
@@ -0,0 +1,131 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const fs = require('node:fs');
5
+ const path = require('node:path');
6
+ const { version } = require('../package.json');
7
+
8
+ const help = `def-game ${version}
9
+ Usage: def-game generate-worker --config <file.json> [--force | --check]
10
+
11
+ Generate a Hono Worker, SQLite Durable Object, waki.work authentication and Wrangler config.
12
+ --force Replace differing output files (never edits the game adapter).
13
+ --check Verify generated files match without writing anything.
14
+ Node.js 20+ is required. Timeout and Effect execution are not included in this version.`;
15
+
16
+ function relativeImport(from, target) {
17
+ let value = path.relative(path.dirname(from), target).split(path.sep).join('/').replace(/\.ts$/, '.js');
18
+ if (!value.startsWith('.')) value = './' + value;
19
+ return JSON.stringify(value);
20
+ }
21
+
22
+ function main(args) {
23
+ if (args.length === 0 || args[0] === '--help') return console.log(help);
24
+ if (args[0] === '--version') return console.log(version);
25
+ if (Number(process.versions.node.split('.')[0]) < 20) throw new Error('Node.js 20+ is required');
26
+ if (args.shift() !== 'generate-worker') throw new Error('Unknown command. Use --help.');
27
+ let configPath;
28
+ let force = false;
29
+ let check = false;
30
+ while (args.length) {
31
+ const arg = args.shift();
32
+ if (arg === '--config' && !configPath && args[0] && !args[0].startsWith('--')) configPath = args.shift();
33
+ else if (arg === '--force' && !force) force = true;
34
+ else if (arg === '--check' && !check) check = true;
35
+ else throw new Error(`Unknown or repeated option: ${arg}`);
36
+ }
37
+ if (!configPath || (force && check)) throw new Error('Provide --config and at most one of --force / --check');
38
+ configPath = path.resolve(configPath);
39
+ const root = path.dirname(configPath);
40
+ const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
41
+ if (!config || typeof config !== 'object' || Array.isArray(config)) throw new Error('Config must be an object');
42
+ const allowed = new Set(['outputDir', 'adapter', 'entry', 'wranglerConfig', 'name', 'assets', 'compatibilityDate']);
43
+ for (const key of Object.keys(config)) if (!allowed.has(key)) throw new Error(`Unknown config key: ${key}`);
44
+ const required = (key) => {
45
+ if (typeof config[key] !== 'string' || !config[key].trim() || config[key].includes('\0')) throw new Error(`Invalid config: ${key}`);
46
+ return config[key];
47
+ };
48
+ const inside = (value) => {
49
+ if (path.isAbsolute(value)) throw new Error(`Path must be relative: ${value}`);
50
+ const resolved = path.resolve(root, value);
51
+ const relative = path.relative(root, resolved);
52
+ if (!relative || relative === '..' || relative.startsWith('..' + path.sep)) throw new Error(`Path must stay inside config directory: ${value}`);
53
+ // Do not follow existing symlinks when overwriting output or locating the adapter.
54
+ let current = root;
55
+ for (const part of relative.split(path.sep)) {
56
+ current = path.join(current, part);
57
+ if (fs.existsSync(current) && fs.lstatSync(current).isSymbolicLink()) throw new Error(`Symlink is not supported: ${current}`);
58
+ }
59
+ return resolved;
60
+ };
61
+ const outputDir = inside(required('outputDir'));
62
+ const adapter = inside(required('adapter'));
63
+ if (!/\.(ts|js)$/.test(adapter) || !fs.statSync(adapter).isFile()) throw new Error('adapter must be an existing .ts or .js file');
64
+ const entry = inside(required('entry'));
65
+ if (!entry.endsWith('.ts')) throw new Error('entry must end with .ts');
66
+ const wrangler = inside(required('wranglerConfig'));
67
+ if (!wrangler.endsWith('.jsonc')) throw new Error('wranglerConfig must end with .jsonc');
68
+ const name = required('name');
69
+ if (!/^[a-z0-9][a-z0-9-]{0,62}$/.test(name)) throw new Error('Invalid Worker name');
70
+ const assets = required('assets');
71
+ if (path.isAbsolute(assets)) throw new Error('assets must be relative to the config directory');
72
+ const date = required('compatibilityDate');
73
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(date) || !Number.isFinite(Date.parse(date)) || new Date(date).toISOString().slice(0, 10) !== date) {
74
+ throw new Error('Invalid compatibilityDate');
75
+ }
76
+
77
+ const banner = `// Generated by def-game ${version}. Do not edit; change the generator or its config.\n`;
78
+ const files = new Map();
79
+ const add = (file, text) => {
80
+ if (files.has(file) || file === adapter || file === configPath) throw new Error(`Output path collision: ${file}`);
81
+ inside(path.relative(root, file));
82
+ files.set(file, banner + text);
83
+ };
84
+ for (const file of ['index.ts', 'session.ts', 'auth.ts', 'env.ts', 'runtime/parse.ts', 'runtime/game-adapter.ts']) {
85
+ const target = path.join(outputDir, file);
86
+ const source = fs.readFileSync(path.join(__dirname, '../templates/worker', file), 'utf8');
87
+ add(target, source.replaceAll('__ADAPTER_IMPORT__', relativeImport(target, adapter)));
88
+ }
89
+ add(entry, `export { default, SessionDurableObject } from ${relativeImport(entry, path.join(outputDir, 'index.ts'))};\n`);
90
+ const fromWrangler = (target) => path.relative(path.dirname(wrangler), target).split(path.sep).join('/');
91
+ add(wrangler, JSON.stringify({
92
+ name,
93
+ main: fromWrangler(entry),
94
+ compatibility_date: date,
95
+ assets: { directory: fromWrangler(path.resolve(root, assets)), binding: 'ASSETS', run_worker_first: true },
96
+ durable_objects: { bindings: [{ name: 'SESSIONS', class_name: 'SessionDurableObject' }] },
97
+ migrations: [{ tag: 'v1', new_sqlite_classes: ['SessionDurableObject'] }],
98
+ vars: {
99
+ AUTH_ISSUER: 'https://auth.waki.work', AUTH_AUDIENCE: 'waki.work',
100
+ AUTH_JWKS_URL: 'https://auth.waki.work/.well-known/jwks.json',
101
+ },
102
+ }, null, 2) + '\n');
103
+
104
+ // Preflight every output before the first write, so conflicts cannot leave half-generated code.
105
+ const changed = [];
106
+ for (const [file, contents] of files) {
107
+ const exists = fs.existsSync(file);
108
+ if (exists && !fs.statSync(file).isFile()) throw new Error(`Output is not a file: ${file}`);
109
+ if (exists && fs.readFileSync(file, 'utf8') === contents) continue;
110
+ if (exists && !force && !check) throw new Error(`Refusing to overwrite ${file}; review the changes and use --force`);
111
+ changed.push([file, contents]);
112
+ }
113
+ if (check) {
114
+ if (changed.length) throw new Error(`Generated files differ or are missing:\n${changed.map(([file]) => path.relative(root, file)).join('\n')}`);
115
+ return console.log(`Verified ${files.size} generated files (def-game ${version})`);
116
+ }
117
+ for (const [file, contents] of changed) {
118
+ fs.mkdirSync(path.dirname(file), { recursive: true });
119
+ const temp = file + `.def-game-${process.pid}.tmp`;
120
+ try {
121
+ fs.writeFileSync(temp, contents, { flag: 'wx' });
122
+ fs.renameSync(temp, file);
123
+ } finally {
124
+ if (fs.existsSync(temp)) fs.unlinkSync(temp);
125
+ }
126
+ }
127
+ console.log(`Generated ${files.size} files; changed ${changed.length} (def-game ${version})`);
128
+ }
129
+
130
+ try { main(process.argv.slice(2)); }
131
+ catch (error) { console.error(`def-game: ${error.message}`); process.exitCode = 1; }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "def-game",
3
- "version": "5.0.0",
3
+ "version": "5.0.1",
4
4
  "description": "Game definition contracts and an in-memory simulator for turn-based games",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -17,10 +17,15 @@
17
17
  "typescript": "^5.8.2"
18
18
  },
19
19
  "files": [
20
- "dist"
20
+ "dist",
21
+ "bin",
22
+ "templates"
21
23
  ],
22
24
  "repository": {
23
25
  "type": "git",
24
26
  "url": "git+https://github.com/MFQWKMR4/def-game.git"
27
+ },
28
+ "bin": {
29
+ "def-game": "bin/def-game.cjs"
25
30
  }
26
31
  }
@@ -0,0 +1,23 @@
1
+ import { createRemoteJWKSet, jwtVerify } from "jose";
2
+ import type { Env } from "./env.js";
3
+
4
+ let cachedKeys: { url: string; keys: ReturnType<typeof createRemoteJWKSet> } | undefined;
5
+
6
+ /** JWT は Worker 境界だけで扱う。署名・必須 claims を検証した sub だけを DO へ渡す。 */
7
+ export async function authenticate(token: string | undefined, env: Env): Promise<string | null> {
8
+ if (!token) return null;
9
+ try {
10
+ if (cachedKeys?.url !== env.AUTH_JWKS_URL) {
11
+ // jose がキャッシュと未知 kid に対する再取得(cooldown 付き)を扱う。
12
+ cachedKeys = { url: env.AUTH_JWKS_URL, keys: createRemoteJWKSet(new URL(env.AUTH_JWKS_URL)) };
13
+ }
14
+ const { payload } = await jwtVerify(token, cachedKeys.keys, {
15
+ issuer: env.AUTH_ISSUER, audience: env.AUTH_AUDIENCE,
16
+ algorithms: ["ES256"], requiredClaims: ["sub", "exp", "iat"],
17
+ });
18
+ return typeof payload.sub === "string" && /^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/i.test(payload.sub)
19
+ ? payload.sub : null;
20
+ } catch {
21
+ return null;
22
+ }
23
+ }
@@ -0,0 +1,9 @@
1
+ import type { SessionDurableObject } from "./session.js";
2
+
3
+ export interface Env {
4
+ SESSIONS: DurableObjectNamespace<SessionDurableObject>;
5
+ ASSETS: Fetcher;
6
+ AUTH_ISSUER: string;
7
+ AUTH_AUDIENCE: string;
8
+ AUTH_JWKS_URL: string;
9
+ }
@@ -0,0 +1,84 @@
1
+ import { Hono, type Context } from "hono";
2
+ import { getCookie } from "hono/cookie";
3
+ import type { ActorId, CreateSessionResponse, JoinSessionResponse, ProtocolError, PublicError } from __ADAPTER_IMPORT__;
4
+ import type { Env } from "./env.js";
5
+ import { authenticate } from "./auth.js";
6
+ export { SessionDurableObject } from "./session.js";
7
+
8
+ type AppEnv = { Bindings: Env; Variables: { actorId: ActorId } };
9
+ const app = new Hono<AppEnv>();
10
+
11
+ // Static Assets を含む全ルートで認証し、以後は検証済み ActorId を context から取得する。
12
+ app.use("*", async (c, next) => {
13
+ const actorId = await authenticate(getCookie(c, "__token"), c.env);
14
+ if (!actorId) return errorResponse(c, "AuthenticationRequired", 401);
15
+ c.set("actorId", actorId);
16
+ await next();
17
+ });
18
+
19
+ app.use("*", async (c, next) => {
20
+ const origin = c.req.header("origin");
21
+ const isWebSocket = c.req.path.startsWith("/ws/");
22
+ if (origin && origin !== new URL(c.req.url).origin && (c.req.method !== "GET" || isWebSocket)) {
23
+ return errorResponse(c, "InvalidRequest", 403);
24
+ }
25
+ await next();
26
+ });
27
+
28
+ app.post("/api/sessions", (c) => joinSession(c, c.env.SESSIONS.newUniqueId(), "create"));
29
+
30
+ app.post("/api/sessions/:sessionId/join", (c) => {
31
+ const sessionId = c.req.param("sessionId");
32
+ if (!/^[0-9a-f]{64}$/.test(sessionId)) return errorResponse(c, "InvalidRequest", 404);
33
+ return joinSession(c, c.env.SESSIONS.idFromString(sessionId), "join");
34
+ });
35
+
36
+ app.get("/ws/:sessionId", (c) => {
37
+ const sessionId = c.req.param("sessionId");
38
+ if (!/^[0-9a-f]{64}$/.test(sessionId)) return errorResponse(c, "InvalidRequest", 404);
39
+ if (c.req.header("upgrade")?.toLowerCase() !== "websocket") return errorResponse(c, "InvalidRequest", 426);
40
+ const id = c.env.SESSIONS.idFromString(sessionId);
41
+ // DO の Hibernation 接続をそのまま返す。token や外部のヘッダーは転送しない。
42
+ return c.env.SESSIONS.get(id).fetch(new Request("https://session/connect", {
43
+ headers: { upgrade: "websocket", "x-actor-id": c.get("actorId") },
44
+ }));
45
+ });
46
+
47
+ app.all("/api/*", (c) => errorResponse(c, "InvalidRequest", 404));
48
+ app.all("/ws/*", (c) => errorResponse(c, "InvalidRequest", 404));
49
+ app.all("*", (c) => c.env.ASSETS.fetch(c.req.raw));
50
+
51
+ app.onError((_error, c) => {
52
+ console.error("Worker request failed");
53
+ return errorResponse(c, "InternalError", 500);
54
+ });
55
+
56
+ export default app;
57
+
58
+ /** create と join に共通する本文検証と DO 呼び出し。本文のゲーム固有形式と参加可否は DO 内の adapter / domain が判断する。 */
59
+ async function joinSession(c: Context<AppEnv>, id: DurableObjectId, operation: "create" | "join"): Promise<Response> {
60
+ if (!c.req.header("content-type")?.startsWith("application/json")) return errorResponse(c, "InvalidRequest", 400);
61
+ const raw = await c.req.text();
62
+ if (raw.length > 64 * 1024) return errorResponse(c, "InvalidRequest", 400);
63
+ let body: unknown;
64
+ try { body = JSON.parse(raw); } catch { return errorResponse(c, "InvalidRequest", 400); }
65
+
66
+ const result = await c.env.SESSIONS.get(id).fetch(new Request(`https://session/${operation}`, {
67
+ method: "POST", headers: { "x-actor-id": c.get("actorId"), "content-type": "application/json" },
68
+ body: JSON.stringify(body),
69
+ }));
70
+ const data = await result.json<{ ok: true } | { ok: false; error: PublicError }>();
71
+ const type = operation === "create" ? "CreateSessionResponse" : "JoinSessionResponse";
72
+ const response: CreateSessionResponse | JoinSessionResponse = data.ok
73
+ ? { type, ok: true, sessionId: id.toString() }
74
+ : { type, ok: false, error: data.error };
75
+ return Response.json(response, { status: result.status });
76
+ }
77
+
78
+ /** middleware で拒否する場合も、既存の公開レスポンス形式を維持する。 */
79
+ function errorResponse(c: Context<AppEnv>, code: ProtocolError["code"], status: 400 | 401 | 403 | 404 | 426 | 500): Response {
80
+ const type = c.req.method === "POST" && c.req.path === "/api/sessions"
81
+ ? "CreateSessionResponse"
82
+ : /^\/api\/sessions\/[0-9a-f]{64}\/join$/.test(c.req.path) ? "JoinSessionResponse" : undefined;
83
+ return c.json({ ...(type ? { type } : {}), ok: false, error: { code } }, status);
84
+ }
@@ -0,0 +1,15 @@
1
+ import type { GameDefinition } from "def-game";
2
+
3
+ /**
4
+ * 生成対象の runtime とゲーム固有コードの接続契約。GameDefinition 自体は変更しない。
5
+ * decoder は未検証の本文からゲーム command を作り、形式不正なら null を返す。
6
+ * 参加・復帰の意味や認可は game.handleCommand が判断する。
7
+ * 現段階は Effect なし。timeout / Effect の生成オプションは実装時に別途追加する。
8
+ */
9
+ export interface GameAdapter<State, Command, View, Error> {
10
+ readonly game: GameDefinition<State, Command, string, View, never, Error>;
11
+ readonly parseCreate: (input: unknown) => Command | null;
12
+ readonly parseJoin: (input: unknown) => Command | null;
13
+ readonly parseCommand: (input: unknown) => Command | null;
14
+ readonly canConnect: (state: State, actorId: string) => boolean;
15
+ }
@@ -0,0 +1,14 @@
1
+ export function isRecord(value: unknown): value is Record<string, unknown> {
2
+ return typeof value === "object" && value !== null && !Array.isArray(value);
3
+ }
4
+
5
+ /** 通信 envelope だけを検証する。command の名前・payload の意味は adapter が判断する。 */
6
+ export function parseCommandRequest(raw: string | ArrayBuffer): { requestId: string; command: unknown } | null {
7
+ if (typeof raw !== "string" || raw.length > 64 * 1024) return null;
8
+ try {
9
+ const value: unknown = JSON.parse(raw);
10
+ if (!isRecord(value) || value.type !== "GameCommandRequest" || typeof value.requestId !== "string"
11
+ || value.requestId.length === 0 || value.requestId.length > 128 || !Object.hasOwn(value, "command")) return null;
12
+ return { requestId: value.requestId, command: value.command };
13
+ } catch { return null; }
14
+ }
@@ -0,0 +1,100 @@
1
+ import { DurableObject } from "cloudflare:workers";
2
+ import { gameAdapter, type State, type ServerMessage, type ProtocolError } from __ADAPTER_IMPORT__;
3
+ import type { Env } from "./env.js";
4
+ import { isRecord, parseCommandRequest } from "./runtime/parse.js";
5
+
6
+ const STATE_KEY = "game-state";
7
+
8
+ /** 生成対象の Session runtime。ゲーム固有の判断は gameAdapter だけを通して呼ぶ。 */
9
+ export class SessionDurableObject extends DurableObject<Env> {
10
+ // State の独自キャッシュを持たない。再起動時にも Storage と attachment だけで復元する。
11
+ async fetch(request: Request): Promise<Response> {
12
+ return this.ctx.blockConcurrencyWhile(async () => {
13
+ const actorId = request.headers.get("x-actor-id");
14
+ const fail = (code: ProtocolError["code"], status: number) => Response.json({ ok: false, error: { code } }, { status });
15
+ if (!actorId) return fail("AuthenticationRequired", 401);
16
+ const path = new URL(request.url).pathname;
17
+ const stored = await this.ctx.storage.get<State>(STATE_KEY);
18
+ if (path === "/connect" && request.method === "GET") {
19
+ if (stored === undefined) return fail("SessionNotFound", 404);
20
+ if (!gameAdapter.canConnect(stored, actorId)) return fail("NotSessionMember", 403);
21
+ const viewState = gameAdapter.game.project(stored, actorId);
22
+ const pair = new WebSocketPair();
23
+ pair[1].serializeAttachment({ actorId });
24
+ this.ctx.acceptWebSocket(pair[1]);
25
+ this.send(pair[1], { type: "ViewStateEvent", viewState });
26
+ return new Response(null, { status: 101, webSocket: pair[0] });
27
+ }
28
+ if (request.method !== "POST" || (path !== "/create" && path !== "/join")) return fail("InvalidRequest", 400);
29
+ if (path === "/create" && stored !== undefined) return fail("InvalidRequest", 409);
30
+ if (path === "/join" && stored === undefined) return fail("SessionNotFound", 404);
31
+ let body: unknown;
32
+ try { body = await request.json(); } catch { return fail("InvalidRequest", 400); }
33
+ const command = path === "/create" ? gameAdapter.parseCreate(body) : gameAdapter.parseJoin(body);
34
+ if (command === null) return fail("InvalidRequest", 400);
35
+ const result = gameAdapter.game.handleCommand(stored === undefined ? gameAdapter.game.createInitialState() : stored, command, { origin: "actor", actorId });
36
+ if (!result.ok) return Response.json(result, { status: 409 });
37
+ // 作成と作成者の参加は、この1回の保存で確定する。
38
+ if (result.state !== stored) {
39
+ await this.ctx.storage.put(STATE_KEY, result.state);
40
+ this.broadcast(result.state);
41
+ }
42
+ return Response.json({ ok: true });
43
+ });
44
+ }
45
+
46
+ async webSocketMessage(socket: WebSocket, raw: string | ArrayBuffer): Promise<void> {
47
+ await this.ctx.blockConcurrencyWhile(async () => {
48
+ const parsed = parseCommandRequest(raw);
49
+ const command = parsed ? gameAdapter.parseCommand(parsed.command) : null;
50
+ if (!parsed || command === null) {
51
+ this.send(socket, { type: "ProtocolErrorEvent", error: { code: "InvalidRequest" } });
52
+ return;
53
+ }
54
+ try {
55
+ const attachment: unknown = socket.deserializeAttachment();
56
+ const state = await this.ctx.storage.get<State>(STATE_KEY);
57
+ if (state === undefined || !isRecord(attachment) || typeof attachment.actorId !== "string" || !gameAdapter.canConnect(state, attachment.actorId)) {
58
+ this.send(socket, { type: "GameCommandResponse", requestId: parsed.requestId, ok: false, error: { code: "NotSessionMember" } });
59
+ return;
60
+ }
61
+ const result = gameAdapter.game.handleCommand(state, command, { origin: "actor", actorId: attachment.actorId });
62
+ if (!result.ok) {
63
+ this.send(socket, { type: "GameCommandResponse", requestId: parsed.requestId, ok: false, error: result.error });
64
+ return;
65
+ }
66
+ await this.ctx.storage.put(STATE_KEY, result.state);
67
+ // Effect は現在 never。実装時もこの保存の後に実行し、配送失敗で rollback しない。
68
+ this.send(socket, { type: "GameCommandResponse", requestId: parsed.requestId, ok: true });
69
+ this.broadcast(result.state);
70
+ } catch {
71
+ console.error("Session command failed");
72
+ this.send(socket, { type: "GameCommandResponse", requestId: parsed.requestId, ok: false, error: { code: "InternalError" } });
73
+ }
74
+ });
75
+ }
76
+
77
+ private broadcast(state: State): void {
78
+ for (const socket of this.ctx.getWebSockets()) {
79
+ try {
80
+ const attachment: unknown = socket.deserializeAttachment();
81
+ if (!isRecord(attachment) || typeof attachment.actorId !== "string" || !gameAdapter.canConnect(state, attachment.actorId)) continue;
82
+ this.send(socket, { type: "ViewStateEvent", viewState: gameAdapter.game.project(state, attachment.actorId) });
83
+ } catch {
84
+ console.error("Session view delivery failed");
85
+ this.close(socket);
86
+ }
87
+ }
88
+ }
89
+
90
+ private send(socket: WebSocket, message: ServerMessage): void {
91
+ try { socket.send(JSON.stringify(message)); } catch { this.close(socket); }
92
+ }
93
+
94
+ private close(socket: WebSocket): void {
95
+ try { socket.close(1011, "Delivery failed"); } catch { /* 切断済み。確定済みの保存結果へ影響させない。 */ }
96
+ }
97
+
98
+ webSocketClose(socket: WebSocket, code: number, reason: string): void { socket.close(code, reason); }
99
+ webSocketError(socket: WebSocket): void { socket.close(1011, "Connection error"); }
100
+ }