def-game 5.0.0 → 5.1.0-timeout.0
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 +68 -2
- package/bin/def-game.cjs +133 -0
- package/dist/game-definition.d.ts +2 -1
- package/dist/game-definition.d.ts.map +1 -1
- package/package.json +7 -2
- package/templates/worker/auth.ts +23 -0
- package/templates/worker/env.ts +9 -0
- package/templates/worker/index.ts +84 -0
- package/templates/worker/runtime/game-adapter.ts +15 -0
- package/templates/worker/runtime/parse.ts +14 -0
- package/templates/worker/session.ts +100 -0
- package/templates/worker/timeout/runtime/game-adapter.ts +18 -0
- package/templates/worker/timeout/session.ts +152 -0
package/README.md
CHANGED
|
@@ -37,7 +37,50 @@ V5 は破壊的変更です。GameRule、GameEngine、TaskQueue に関する型
|
|
|
37
37
|
- 参加・開始などもゲームの Command として扱います。
|
|
38
38
|
- 旧 GameEngine の代わりに GameSimulator でコマンド列を実行します。
|
|
39
39
|
|
|
40
|
-
Worker generator
|
|
40
|
+
## Worker generator(5.0.1)
|
|
41
|
+
|
|
42
|
+
Hono Worker、SQLite-backed Durable Object、waki.work JWT 認証、Hibernation WebSocket、保存・配信処理を生成します。GameDefinition の契約変更はありません。標準構成は timeout / Effect 実行なしです。開発版では下記の timeout オプションを追加しています。
|
|
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 自体に実行時依存はありません。
|
|
66
|
+
|
|
67
|
+
生成コードは Node.js 24.20.0、TypeScript 5.6.3、Hono 4.13.7、jose 6.2.12、Wrangler 4.131.1、`@cloudflare/workers-types` 5.20260911.1 の環境で、ビルド・型チェック・HTTP / WebSocket / Durable Object の通信テストを確認しています。
|
|
68
|
+
|
|
69
|
+
ゲーム側は既存ファイルとして `adapter` を用意し、次を export します。このファイル、domain、shared は生成・上書きしません。
|
|
70
|
+
|
|
71
|
+
- `gameAdapter`: 生成される `GameAdapter<State, Command, View, Error>` に適合するオブジェクト。
|
|
72
|
+
- `gameAdapter.game`: ゲーム自身の GameDefinition(Effect は `never`)。
|
|
73
|
+
- `parseCreate(input)` / `parseJoin(input)` / `parseCommand(input)`: 未検証入力から command を返す関数。形式不正なら null。WS の関数へ渡るのは envelope 内の command だけです。
|
|
74
|
+
- `canConnect(state, actorId)`: 接続・配信を許可するかの判定。command の認可は GameDefinition が再検証します。
|
|
75
|
+
- 型の export: `State`、`ActorId`(string)、`ServerMessage`、`ProtocolError`、`PublicError`、`CreateSessionResponse`、`JoinSessionResponse`。
|
|
76
|
+
|
|
77
|
+
公開通信は `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 です。
|
|
78
|
+
|
|
79
|
+
認証は Cookie `__token` を JWKS / ES256 / issuer / audience / exp で検証し、UUID の sub を ActorId として使います。Static Assets も認証必須。ログイン誘導・refresh は client の責務です。
|
|
80
|
+
|
|
81
|
+
出力が同一なら変更しません。既存ファイルと差分がある場合は書き込み前に停止します。確認後に `--force` を付けると生成対象8ファイルだけを更新します。`--check` は欠落・差分を非ゼロ終了で報告し、ファイルを変更しません。生成ファイルは Git 管理し、修正は generator / 設定側へ戻してください。Wrangler 設定も全体が生成対象なので、独自 bindings や migrations の追加には generator の対応が必要です。
|
|
82
|
+
|
|
83
|
+
CLI は dependencies、package scripts、tsconfig を書き換えません。利用側で Worker の型と Hono / jose を設定し、生成された entry を Wrangler から実行します。CLI には Node.js 20 以降が必要です。
|
|
41
84
|
|
|
42
85
|
## 開発と公開
|
|
43
86
|
|
|
@@ -50,6 +93,29 @@ npm pack
|
|
|
50
93
|
|
|
51
94
|
開発時のテストには Node.js 20 以降を使用してください。npm test はビルドと Node 標準のシナリオテストを実行します。
|
|
52
95
|
|
|
53
|
-
npm pack / npm publish の前に prepack で型チェック・ビルド・テストが実行されます。build は dist を作り直すため、削除した旧 API の生成物が混入しません。公開対象は dist、package.json、README、LICENSE です。
|
|
96
|
+
npm pack / npm publish の前に prepack で型チェック・ビルド・テストが実行されます。build は dist を作り直すため、削除した旧 API の生成物が混入しません。公開対象は dist、bin、templates、package.json、README、LICENSE です。
|
|
54
97
|
|
|
55
98
|
main にレビュー済みの変更を反映した後、公開する version と認証アカウントを確認し、検証済みのパッケージを npm publish で公開します。
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
## Decision timeout(5.1.0-timeout.0 開発版)
|
|
102
|
+
|
|
103
|
+
生成設定に `"timeout": true` を追加すると、DO Alarm と system command 配送を生成します。未指定または false なら従来の構成です。開発版は npm 未公開です。
|
|
104
|
+
|
|
105
|
+
`GameAdapter<State, Command, View, Error, Effect>` に次の任意の接続点を設定します。
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
timeout: {
|
|
109
|
+
effect: (effect) => /* { type: "schedule", decisionId: string, deadline: number }
|
|
110
|
+
または { type: "cancel", decisionId: string } */,
|
|
111
|
+
command: (decisionId) => /* ゲーム固有の system command */,
|
|
112
|
+
}
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
ゲームは新しい decision で再利用しない ID と期限を発行します。同じ decision 内の部分完了では Effect を出しません。default action・pending actors・bot policy はゲーム側に残します。runtime は State を解釈せず、1つの現在予約を保存します。独立した複数の同時 decision のスケジューラーではありません。
|
|
116
|
+
|
|
117
|
+
状態・予約・Alarm は同じ storage transaction で確定します。Alarm は保存済み予約から system command を生成し、古い発火が新しい予約を期限前に処理しないよう再予約します。domain も ID・期限を検証してください。期限を迎えた予約は成功した遷移と同じ transaction で消費し、次の予約があれば置き換えます。失敗は例外として伝播し Cloudflare の有限回の再試行に委ねます。無制限の再試行・外部 Effect の配送は含みません。
|
|
118
|
+
|
|
119
|
+
このオプションで扱う Effect は timeout の予約・解除のみです。外部通知と異なり、Alarm は状態と原子的に保存するローカルな永続化処理です。外部サービス呼び出しは transaction 内に追加しないでください。
|
|
120
|
+
|
|
121
|
+
実行環境の検証: Node.js 24、TypeScript 5.6、Hono 4.13、jose 6.2、Wrangler 4.131、Cloudflare Vitest plugin 1.1。標準構成・任意構成の生成一致を generator テストで確認しています。
|
package/bin/def-game.cjs
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
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. Set timeout: true in the config to generate decision timeout scheduling.`;
|
|
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', 'timeout']);
|
|
43
|
+
if (config.timeout !== undefined && typeof config.timeout !== 'boolean') throw new Error('timeout must be boolean');
|
|
44
|
+
for (const key of Object.keys(config)) if (!allowed.has(key)) throw new Error(`Unknown config key: ${key}`);
|
|
45
|
+
const required = (key) => {
|
|
46
|
+
if (typeof config[key] !== 'string' || !config[key].trim() || config[key].includes('\0')) throw new Error(`Invalid config: ${key}`);
|
|
47
|
+
return config[key];
|
|
48
|
+
};
|
|
49
|
+
const inside = (value) => {
|
|
50
|
+
if (path.isAbsolute(value)) throw new Error(`Path must be relative: ${value}`);
|
|
51
|
+
const resolved = path.resolve(root, value);
|
|
52
|
+
const relative = path.relative(root, resolved);
|
|
53
|
+
if (!relative || relative === '..' || relative.startsWith('..' + path.sep)) throw new Error(`Path must stay inside config directory: ${value}`);
|
|
54
|
+
// Do not follow existing symlinks when overwriting output or locating the adapter.
|
|
55
|
+
let current = root;
|
|
56
|
+
for (const part of relative.split(path.sep)) {
|
|
57
|
+
current = path.join(current, part);
|
|
58
|
+
if (fs.existsSync(current) && fs.lstatSync(current).isSymbolicLink()) throw new Error(`Symlink is not supported: ${current}`);
|
|
59
|
+
}
|
|
60
|
+
return resolved;
|
|
61
|
+
};
|
|
62
|
+
const outputDir = inside(required('outputDir'));
|
|
63
|
+
const adapter = inside(required('adapter'));
|
|
64
|
+
if (!/\.(ts|js)$/.test(adapter) || !fs.statSync(adapter).isFile()) throw new Error('adapter must be an existing .ts or .js file');
|
|
65
|
+
const entry = inside(required('entry'));
|
|
66
|
+
if (!entry.endsWith('.ts')) throw new Error('entry must end with .ts');
|
|
67
|
+
const wrangler = inside(required('wranglerConfig'));
|
|
68
|
+
if (!wrangler.endsWith('.jsonc')) throw new Error('wranglerConfig must end with .jsonc');
|
|
69
|
+
const name = required('name');
|
|
70
|
+
if (!/^[a-z0-9][a-z0-9-]{0,62}$/.test(name)) throw new Error('Invalid Worker name');
|
|
71
|
+
const assets = required('assets');
|
|
72
|
+
if (path.isAbsolute(assets)) throw new Error('assets must be relative to the config directory');
|
|
73
|
+
const date = required('compatibilityDate');
|
|
74
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(date) || !Number.isFinite(Date.parse(date)) || new Date(date).toISOString().slice(0, 10) !== date) {
|
|
75
|
+
throw new Error('Invalid compatibilityDate');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const banner = `// Generated by def-game ${version}. Do not edit; change the generator or its config.\n`;
|
|
79
|
+
const files = new Map();
|
|
80
|
+
const add = (file, text) => {
|
|
81
|
+
if (files.has(file) || file === adapter || file === configPath) throw new Error(`Output path collision: ${file}`);
|
|
82
|
+
inside(path.relative(root, file));
|
|
83
|
+
files.set(file, banner + text);
|
|
84
|
+
};
|
|
85
|
+
for (const file of ['index.ts', 'session.ts', 'auth.ts', 'env.ts', 'runtime/parse.ts', 'runtime/game-adapter.ts']) {
|
|
86
|
+
const target = path.join(outputDir, file);
|
|
87
|
+
const template = config.timeout && ['session.ts', 'runtime/game-adapter.ts'].includes(file) ? path.join('timeout', file) : file;
|
|
88
|
+
const source = fs.readFileSync(path.join(__dirname, '../templates/worker', template), 'utf8');
|
|
89
|
+
add(target, source.replaceAll('__ADAPTER_IMPORT__', relativeImport(target, adapter)));
|
|
90
|
+
}
|
|
91
|
+
add(entry, `export { default, SessionDurableObject } from ${relativeImport(entry, path.join(outputDir, 'index.ts'))};\n`);
|
|
92
|
+
const fromWrangler = (target) => path.relative(path.dirname(wrangler), target).split(path.sep).join('/');
|
|
93
|
+
add(wrangler, JSON.stringify({
|
|
94
|
+
name,
|
|
95
|
+
main: fromWrangler(entry),
|
|
96
|
+
compatibility_date: date,
|
|
97
|
+
assets: { directory: fromWrangler(path.resolve(root, assets)), binding: 'ASSETS', run_worker_first: true },
|
|
98
|
+
durable_objects: { bindings: [{ name: 'SESSIONS', class_name: 'SessionDurableObject' }] },
|
|
99
|
+
migrations: [{ tag: 'v1', new_sqlite_classes: ['SessionDurableObject'] }],
|
|
100
|
+
vars: {
|
|
101
|
+
AUTH_ISSUER: 'https://auth.waki.work', AUTH_AUDIENCE: 'waki.work',
|
|
102
|
+
AUTH_JWKS_URL: 'https://auth.waki.work/.well-known/jwks.json',
|
|
103
|
+
},
|
|
104
|
+
}, null, 2) + '\n');
|
|
105
|
+
|
|
106
|
+
// Preflight every output before the first write, so conflicts cannot leave half-generated code.
|
|
107
|
+
const changed = [];
|
|
108
|
+
for (const [file, contents] of files) {
|
|
109
|
+
const exists = fs.existsSync(file);
|
|
110
|
+
if (exists && !fs.statSync(file).isFile()) throw new Error(`Output is not a file: ${file}`);
|
|
111
|
+
if (exists && fs.readFileSync(file, 'utf8') === contents) continue;
|
|
112
|
+
if (exists && !force && !check) throw new Error(`Refusing to overwrite ${file}; review the changes and use --force`);
|
|
113
|
+
changed.push([file, contents]);
|
|
114
|
+
}
|
|
115
|
+
if (check) {
|
|
116
|
+
if (changed.length) throw new Error(`Generated files differ or are missing:\n${changed.map(([file]) => path.relative(root, file)).join('\n')}`);
|
|
117
|
+
return console.log(`Verified ${files.size} generated files (def-game ${version})`);
|
|
118
|
+
}
|
|
119
|
+
for (const [file, contents] of changed) {
|
|
120
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
121
|
+
const temp = file + `.def-game-${process.pid}.tmp`;
|
|
122
|
+
try {
|
|
123
|
+
fs.writeFileSync(temp, contents, { flag: 'wx' });
|
|
124
|
+
fs.renameSync(temp, file);
|
|
125
|
+
} finally {
|
|
126
|
+
if (fs.existsSync(temp)) fs.unlinkSync(temp);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
console.log(`Generated ${files.size} files; changed ${changed.length} (def-game ${version})`);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
try { main(process.argv.slice(2)); }
|
|
133
|
+
catch (error) { console.error(`def-game: ${error.message}`); process.exitCode = 1; }
|
|
@@ -11,7 +11,8 @@ export type CommandContext<ActorId> = {
|
|
|
11
11
|
};
|
|
12
12
|
/**
|
|
13
13
|
* 成功時は次の安定状態と副作用の宣言、失敗時はゲーム上の拒否理由を返す。
|
|
14
|
-
*
|
|
14
|
+
* 実行側は成功した状態を保存してから外部 Effect を処理する。
|
|
15
|
+
* 同じストレージ内の Alarm 予約などは、状態と原子的に保存できる。
|
|
15
16
|
*/
|
|
16
17
|
export type TransitionResult<State, Effect, Error> = {
|
|
17
18
|
readonly ok: true;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"game-definition.d.ts","sourceRoot":"","sources":["../src/game-definition.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,MAAM,MAAM,cAAc,CAAC,OAAO,IAC5B;IAAE,QAAQ,CAAC,MAAM,EAAE,OAAO,CAAC;IAAC,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAA;CAAE,GACvD;IAAE,QAAQ,CAAC,MAAM,EAAE,QAAQ,CAAA;CAAE,CAAC;AAEpC
|
|
1
|
+
{"version":3,"file":"game-definition.d.ts","sourceRoot":"","sources":["../src/game-definition.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,MAAM,MAAM,cAAc,CAAC,OAAO,IAC5B;IAAE,QAAQ,CAAC,MAAM,EAAE,OAAO,CAAC;IAAC,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAA;CAAE,GACvD;IAAE,QAAQ,CAAC,MAAM,EAAE,QAAQ,CAAA;CAAE,CAAC;AAEpC;;;;GAIG;AACH,MAAM,MAAM,gBAAgB,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,IAC3C;IACE,QAAQ,CAAC,EAAE,EAAE,IAAI,CAAC;IAClB,QAAQ,CAAC,KAAK,EAAE,KAAK,CAAC;IACtB,QAAQ,CAAC,OAAO,EAAE,SAAS,MAAM,EAAE,CAAC;CACvC,GACC;IAAE,QAAQ,CAAC,EAAE,EAAE,KAAK,CAAC;IAAC,QAAQ,CAAC,KAAK,EAAE,KAAK,CAAA;CAAE,CAAC;AAEpD;;;GAGG;AACH,MAAM,WAAW,cAAc,CAAC,KAAK,EAAE,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,EAAE,KAAK;IACxE,0CAA0C;IAC1C,kBAAkB,IAAI,KAAK,CAAC;IAE5B;;;;OAIG;IACH,aAAa,CACT,KAAK,EAAE,KAAK,EACZ,OAAO,EAAE,OAAO,EAChB,OAAO,EAAE,cAAc,CAAC,OAAO,CAAC,GACjC,gBAAgB,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,CAAC,CAAC;IAE1C;;;OAGG;IACH,OAAO,CAAC,KAAK,EAAE,KAAK,EAAE,OAAO,EAAE,OAAO,GAAG,IAAI,CAAC;CACjD"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "def-game",
|
|
3
|
-
"version": "5.0.0",
|
|
3
|
+
"version": "5.1.0-timeout.0",
|
|
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,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
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { GameDefinition } from "def-game";
|
|
2
|
+
|
|
3
|
+
export type TimeoutEffect =
|
|
4
|
+
| { readonly type: "schedule"; readonly decisionId: string; readonly deadline: number }
|
|
5
|
+
| { readonly type: "cancel"; readonly decisionId: string };
|
|
6
|
+
|
|
7
|
+
/** Runtime never interprets State. The adapter translates game effects and timeout commands. */
|
|
8
|
+
export interface GameAdapter<State, Command, View, Error, Effect = never> {
|
|
9
|
+
readonly game: GameDefinition<State, Command, string, View, Effect, Error>;
|
|
10
|
+
readonly parseCreate: (input: unknown) => Command | null;
|
|
11
|
+
readonly parseJoin: (input: unknown) => Command | null;
|
|
12
|
+
readonly parseCommand: (input: unknown) => Command | null;
|
|
13
|
+
readonly canConnect: (state: State, actorId: string) => boolean;
|
|
14
|
+
readonly timeout?: {
|
|
15
|
+
readonly effect: (effect: Effect) => TimeoutEffect;
|
|
16
|
+
readonly command: (decisionId: string) => Command;
|
|
17
|
+
};
|
|
18
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
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
|
+
const TIMEOUT_KEY = "decision-timeout";
|
|
8
|
+
type Reservation = { decisionId: string; deadline: number };
|
|
9
|
+
type Success = Extract<ReturnType<typeof gameAdapter.game.handleCommand>, { ok: true }>;
|
|
10
|
+
|
|
11
|
+
/** 生成対象の Session runtime。ゲーム固有の判断は gameAdapter だけを通して呼ぶ。 */
|
|
12
|
+
export class SessionDurableObject extends DurableObject<Env> {
|
|
13
|
+
// State の独自キャッシュを持たない。再起動時にも Storage と attachment だけで復元する。
|
|
14
|
+
async fetch(request: Request): Promise<Response> {
|
|
15
|
+
return this.ctx.blockConcurrencyWhile(async () => {
|
|
16
|
+
const actorId = request.headers.get("x-actor-id");
|
|
17
|
+
const fail = (code: ProtocolError["code"], status: number) => Response.json({ ok: false, error: { code } }, { status });
|
|
18
|
+
if (!actorId) return fail("AuthenticationRequired", 401);
|
|
19
|
+
const path = new URL(request.url).pathname;
|
|
20
|
+
const stored = await this.ctx.storage.get<State>(STATE_KEY);
|
|
21
|
+
if (path === "/connect" && request.method === "GET") {
|
|
22
|
+
if (stored === undefined) return fail("SessionNotFound", 404);
|
|
23
|
+
if (!gameAdapter.canConnect(stored, actorId)) return fail("NotSessionMember", 403);
|
|
24
|
+
const viewState = gameAdapter.game.project(stored, actorId);
|
|
25
|
+
const pair = new WebSocketPair();
|
|
26
|
+
pair[1].serializeAttachment({ actorId });
|
|
27
|
+
this.ctx.acceptWebSocket(pair[1]);
|
|
28
|
+
this.send(pair[1], { type: "ViewStateEvent", viewState });
|
|
29
|
+
return new Response(null, { status: 101, webSocket: pair[0] });
|
|
30
|
+
}
|
|
31
|
+
if (request.method !== "POST" || (path !== "/create" && path !== "/join")) return fail("InvalidRequest", 400);
|
|
32
|
+
if (path === "/create" && stored !== undefined) return fail("InvalidRequest", 409);
|
|
33
|
+
if (path === "/join" && stored === undefined) return fail("SessionNotFound", 404);
|
|
34
|
+
let body: unknown;
|
|
35
|
+
try { body = await request.json(); } catch { return fail("InvalidRequest", 400); }
|
|
36
|
+
const command = path === "/create" ? gameAdapter.parseCreate(body) : gameAdapter.parseJoin(body);
|
|
37
|
+
if (command === null) return fail("InvalidRequest", 400);
|
|
38
|
+
const result = gameAdapter.game.handleCommand(stored === undefined ? gameAdapter.game.createInitialState() : stored, command, { origin: "actor", actorId });
|
|
39
|
+
if (!result.ok) return Response.json(result, { status: 409 });
|
|
40
|
+
// 作成と作成者の参加は、この1回の保存で確定する。
|
|
41
|
+
if (result.state !== stored || result.effects.length > 0) {
|
|
42
|
+
try { await this.commit(result); } catch { return fail("InternalError", 500); }
|
|
43
|
+
this.broadcast(result.state);
|
|
44
|
+
}
|
|
45
|
+
return Response.json({ ok: true });
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async webSocketMessage(socket: WebSocket, raw: string | ArrayBuffer): Promise<void> {
|
|
50
|
+
await this.ctx.blockConcurrencyWhile(async () => {
|
|
51
|
+
const parsed = parseCommandRequest(raw);
|
|
52
|
+
const command = parsed ? gameAdapter.parseCommand(parsed.command) : null;
|
|
53
|
+
if (!parsed || command === null) {
|
|
54
|
+
this.send(socket, { type: "ProtocolErrorEvent", error: { code: "InvalidRequest" } });
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
try {
|
|
58
|
+
const attachment: unknown = socket.deserializeAttachment();
|
|
59
|
+
const state = await this.ctx.storage.get<State>(STATE_KEY);
|
|
60
|
+
if (state === undefined || !isRecord(attachment) || typeof attachment.actorId !== "string" || !gameAdapter.canConnect(state, attachment.actorId)) {
|
|
61
|
+
this.send(socket, { type: "GameCommandResponse", requestId: parsed.requestId, ok: false, error: { code: "NotSessionMember" } });
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
const result = gameAdapter.game.handleCommand(state, command, { origin: "actor", actorId: attachment.actorId });
|
|
65
|
+
if (!result.ok) {
|
|
66
|
+
this.send(socket, { type: "GameCommandResponse", requestId: parsed.requestId, ok: false, error: result.error });
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
await this.commit(result);
|
|
70
|
+
this.send(socket, { type: "GameCommandResponse", requestId: parsed.requestId, ok: true });
|
|
71
|
+
this.broadcast(result.state);
|
|
72
|
+
} catch {
|
|
73
|
+
console.error("Session command failed");
|
|
74
|
+
this.send(socket, { type: "GameCommandResponse", requestId: parsed.requestId, ok: false, error: { code: "InternalError" } });
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Commit state, reservation and alarm together. No external effects inside the transaction. */
|
|
80
|
+
private async commit(result: Success, consumedDecisionId?: string): Promise<void> {
|
|
81
|
+
const effects = result.effects.map(effect => {
|
|
82
|
+
if (!gameAdapter.timeout) throw new Error("Timeout adapter required for effects");
|
|
83
|
+
return gameAdapter.timeout.effect(effect);
|
|
84
|
+
});
|
|
85
|
+
await this.ctx.storage.transaction(async txn => {
|
|
86
|
+
await txn.put(STATE_KEY, result.state);
|
|
87
|
+
if (consumedDecisionId !== undefined) {
|
|
88
|
+
const current = await txn.get<Reservation>(TIMEOUT_KEY);
|
|
89
|
+
if (current?.decisionId === consumedDecisionId) {
|
|
90
|
+
await txn.delete(TIMEOUT_KEY);
|
|
91
|
+
await txn.deleteAlarm();
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
for (const effect of effects) {
|
|
95
|
+
if (effect.type === "schedule") {
|
|
96
|
+
if (!effect.decisionId || !Number.isFinite(effect.deadline)) throw new Error("Invalid timeout reservation");
|
|
97
|
+
await txn.put(TIMEOUT_KEY, { decisionId: effect.decisionId, deadline: effect.deadline });
|
|
98
|
+
await txn.setAlarm(effect.deadline);
|
|
99
|
+
} else {
|
|
100
|
+
const current = await txn.get<Reservation>(TIMEOUT_KEY);
|
|
101
|
+
if (current?.decisionId === effect.decisionId) {
|
|
102
|
+
await txn.delete(TIMEOUT_KEY);
|
|
103
|
+
await txn.deleteAlarm();
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async alarm(): Promise<void> {
|
|
111
|
+
await this.ctx.blockConcurrencyWhile(async () => {
|
|
112
|
+
const reservation = await this.ctx.storage.get<Reservation>(TIMEOUT_KEY);
|
|
113
|
+
if (!reservation) return;
|
|
114
|
+
// An old alarm can wake after a new reservation replaced it. Never expire that decision early.
|
|
115
|
+
if (Date.now() < reservation.deadline) {
|
|
116
|
+
await this.ctx.storage.setAlarm(reservation.deadline);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
const state = await this.ctx.storage.get<State>(STATE_KEY);
|
|
120
|
+
if (state === undefined || !gameAdapter.timeout) throw new Error("Missing timeout state or adapter");
|
|
121
|
+
const result = gameAdapter.game.handleCommand(state, gameAdapter.timeout.command(reservation.decisionId), { origin: "system" });
|
|
122
|
+
if (!result.ok) throw new Error("Timeout command rejected");
|
|
123
|
+
// Failed commits throw so Cloudflare can retry. Delivery failure never rolls back committed state.
|
|
124
|
+
await this.commit(result, reservation.decisionId);
|
|
125
|
+
this.broadcast(result.state);
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
private broadcast(state: State): void {
|
|
130
|
+
for (const socket of this.ctx.getWebSockets()) {
|
|
131
|
+
try {
|
|
132
|
+
const attachment: unknown = socket.deserializeAttachment();
|
|
133
|
+
if (!isRecord(attachment) || typeof attachment.actorId !== "string" || !gameAdapter.canConnect(state, attachment.actorId)) continue;
|
|
134
|
+
this.send(socket, { type: "ViewStateEvent", viewState: gameAdapter.game.project(state, attachment.actorId) });
|
|
135
|
+
} catch {
|
|
136
|
+
console.error("Session view delivery failed");
|
|
137
|
+
this.close(socket);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
private send(socket: WebSocket, message: ServerMessage): void {
|
|
143
|
+
try { socket.send(JSON.stringify(message)); } catch { this.close(socket); }
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
private close(socket: WebSocket): void {
|
|
147
|
+
try { socket.close(1011, "Delivery failed"); } catch { /* 切断済み。確定済みの保存結果へ影響させない。 */ }
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
webSocketClose(socket: WebSocket, code: number, reason: string): void { socket.close(code === 1005 || code === 1006 || code === 1015 ? 1000 : code, reason); }
|
|
151
|
+
webSocketError(socket: WebSocket): void { socket.close(1011, "Connection error"); }
|
|
152
|
+
}
|