@uzuhq/code-cli 0.3.14

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.
Files changed (41) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +64 -0
  3. package/dist/auth/browser.js +22 -0
  4. package/dist/auth/config.js +48 -0
  5. package/dist/auth/env.js +42 -0
  6. package/dist/auth/jwt.js +27 -0
  7. package/dist/auth/login-flow.js +44 -0
  8. package/dist/auth/loopback.js +94 -0
  9. package/dist/auth/pkce.js +11 -0
  10. package/dist/auth/publish-token.js +74 -0
  11. package/dist/auth/token-cache.js +70 -0
  12. package/dist/auth/uzu-auth.js +94 -0
  13. package/dist/build-server-logic.js +28 -0
  14. package/dist/cf-images-upload.js +52 -0
  15. package/dist/cli.js +303 -0
  16. package/dist/create-2d-game.js +56 -0
  17. package/dist/dev-server/game-room.js +436 -0
  18. package/dist/dev-server/game-types.js +11 -0
  19. package/dist/dev-server/json-patch.js +114 -0
  20. package/dist/dev-server/load-logic.js +70 -0
  21. package/dist/dev-server/random.js +35 -0
  22. package/dist/dev-server/relay-room.js +84 -0
  23. package/dist/dev-server/server.js +367 -0
  24. package/dist/dev-server/sync-room.js +268 -0
  25. package/dist/dev.js +235 -0
  26. package/dist/harness/admin-client.js +215 -0
  27. package/dist/harness/client-entry.js +90 -0
  28. package/dist/harness/dev-button.js +249 -0
  29. package/dist/harness/mount.js +664 -0
  30. package/dist/harness/page.js +46 -0
  31. package/dist/r2-upload.js +93 -0
  32. package/dist/rest-register.js +50 -0
  33. package/dist/upload-session.js +71 -0
  34. package/game-2d-template/index.html.tpl +18 -0
  35. package/game-2d-template/manifest.json.tpl +6 -0
  36. package/game-2d-template/package.json.tpl +23 -0
  37. package/game-2d-template/src/main.ts +49 -0
  38. package/game-2d-template/src/vite-env.d.ts +1 -0
  39. package/game-2d-template/tsconfig.json +12 -0
  40. package/game-2d-template/vite.config.ts +6 -0
  41. package/package.json +43 -0
@@ -0,0 +1,268 @@
1
+ /**
2
+ * dev-server 版 SyncRoom (sync() モード用)。
3
+ *
4
+ * v3-play-server の `sync-room.ts` (Cloudflare Worker DO 版) と同一プロトコル
5
+ * (`__state` / `__patch_ack` / `__patch_failed` / `__state_cleared` / `__init_state` /
6
+ * `__patch` / `__request_state` / `__clear_state` / `__room_init`) を喋る Node.js 版。
7
+ * SDK 側の `syncOnline` client がそのまま接続できる。
8
+ */
9
+ import { randomUUID } from 'crypto';
10
+ import { applyPatch, applyJsonMergePatch, applyJsonPatch } from './json-patch.js';
11
+ const SERVER_TIME_SENTINEL = '__SERVER_TIME__';
12
+ function resolveServerTime(ops, now) {
13
+ for (const op of ops) {
14
+ if (op.value === SERVER_TIME_SENTINEL) {
15
+ op.value = now;
16
+ }
17
+ }
18
+ }
19
+ export class SyncRoom {
20
+ cachedState = null;
21
+ stateInitialized = false;
22
+ seq = 0;
23
+ patchesSinceReconciliation = 0;
24
+ static RECONCILIATION_INTERVAL = 30;
25
+ sockets = new Set();
26
+ attachments = new WeakMap();
27
+ snapshotSubscribers = new Set();
28
+ broadcastFullState() {
29
+ if (!this.stateInitialized || this.cachedState === null)
30
+ return;
31
+ const stateMsg = JSON.stringify({
32
+ type: '__state',
33
+ state: this.cachedState,
34
+ seq: this.seq,
35
+ serverTime: Date.now(),
36
+ });
37
+ for (const peer of this.sockets) {
38
+ try {
39
+ peer.send(stateMsg);
40
+ }
41
+ catch {
42
+ /* disconnected */
43
+ }
44
+ }
45
+ this.notifySnapshotSubscribers();
46
+ }
47
+ notifySnapshotSubscribers() {
48
+ this.snapshotSubscribers.forEach((cb) => {
49
+ try {
50
+ cb(this.cachedState);
51
+ }
52
+ catch (err) {
53
+ console.warn('[SyncRoom] snapshot subscriber threw:', err);
54
+ }
55
+ });
56
+ }
57
+ handleConnection(ws, url) {
58
+ const playerId = url.searchParams.get('playerId');
59
+ if (!playerId) {
60
+ ws.close(1008, 'Missing required query parameter: playerId');
61
+ return;
62
+ }
63
+ const connectionId = randomUUID();
64
+ console.log(`[SyncRoom] 🔗 New connection: connectionId=${connectionId} playerId=${playerId}`);
65
+ this.sockets.add(ws);
66
+ this.attachments.set(ws, { connectionId, playerId });
67
+ ws.on('message', (raw) => this.handleMessage(ws, raw.toString()));
68
+ ws.on('close', () => this.handleClose(ws));
69
+ ws.on('error', () => this.handleClose(ws));
70
+ ws.send(JSON.stringify({ type: '__room_init', myId: playerId }));
71
+ if (this.stateInitialized && this.cachedState !== null) {
72
+ ws.send(JSON.stringify({
73
+ type: '__state',
74
+ state: this.cachedState,
75
+ seq: this.seq,
76
+ serverTime: Date.now(),
77
+ }));
78
+ }
79
+ }
80
+ handleMessage(ws, msg) {
81
+ const attachment = this.attachments.get(ws);
82
+ if (!attachment)
83
+ return;
84
+ const senderId = attachment.playerId;
85
+ let parsed;
86
+ try {
87
+ parsed = JSON.parse(msg);
88
+ }
89
+ catch {
90
+ return;
91
+ }
92
+ const msgType = parsed.type;
93
+ if (msgType === '__ping') {
94
+ try {
95
+ ws.send(JSON.stringify({ type: '__pong' }));
96
+ }
97
+ catch {
98
+ /* disconnected */
99
+ }
100
+ return;
101
+ }
102
+ console.log(`[SyncRoom] ⬅ recv from=${senderId} type=${msgType}`);
103
+ if (msgType === '__init_state') {
104
+ if (this.stateInitialized) {
105
+ console.log(`[SyncRoom] __init_state skipped (already initialized)`);
106
+ return;
107
+ }
108
+ this.stateInitialized = true;
109
+ this.cachedState = parsed.state;
110
+ this.seq = 0;
111
+ this.patchesSinceReconciliation = 0;
112
+ console.log(`[SyncRoom] ✅ State initialized`);
113
+ this.broadcastFullState();
114
+ return;
115
+ }
116
+ if (msgType === '__clear_state') {
117
+ this.cachedState = null;
118
+ this.stateInitialized = false;
119
+ this.seq = 0;
120
+ this.patchesSinceReconciliation = 0;
121
+ console.log(`[SyncRoom] 🗑 State cleared`);
122
+ const clearedMsg = JSON.stringify({ type: '__state_cleared' });
123
+ for (const peer of this.sockets) {
124
+ try {
125
+ peer.send(clearedMsg);
126
+ }
127
+ catch {
128
+ /* disconnected */
129
+ }
130
+ }
131
+ this.notifySnapshotSubscribers();
132
+ return;
133
+ }
134
+ if (msgType === '__request_state') {
135
+ if (!this.stateInitialized || this.cachedState === null)
136
+ return;
137
+ try {
138
+ ws.send(JSON.stringify({
139
+ type: '__state',
140
+ state: this.cachedState,
141
+ seq: this.seq,
142
+ serverTime: Date.now(),
143
+ }));
144
+ }
145
+ catch {
146
+ /* disconnected */
147
+ }
148
+ return;
149
+ }
150
+ if (msgType === '__patch') {
151
+ if (!this.stateInitialized || this.cachedState === null)
152
+ return;
153
+ const ops = parsed.ops;
154
+ if (!ops || !Array.isArray(ops) || ops.length === 0)
155
+ return;
156
+ const serverTime = Date.now();
157
+ resolveServerTime(ops, serverTime);
158
+ // applyPatch は in-place かつ非アトミック (途中失敗で前半 ops が残る) なので、
159
+ // working copy に適用して成功時のみ cachedState へコミットする。
160
+ const workingCopy = structuredClone(this.cachedState);
161
+ const ok = applyPatch(workingCopy, ops);
162
+ if (!ok) {
163
+ try {
164
+ ws.send(JSON.stringify({ type: '__patch_failed', reason: 'apply_error' }));
165
+ ws.send(JSON.stringify({
166
+ type: '__state',
167
+ state: this.cachedState,
168
+ seq: this.seq,
169
+ serverTime: Date.now(),
170
+ }));
171
+ }
172
+ catch {
173
+ /* disconnected */
174
+ }
175
+ return;
176
+ }
177
+ this.cachedState = workingCopy;
178
+ this.seq++;
179
+ const ackMsg = JSON.stringify({
180
+ type: '__patch_ack',
181
+ ops,
182
+ seq: this.seq,
183
+ serverTime,
184
+ senderId,
185
+ });
186
+ for (const peer of this.sockets) {
187
+ try {
188
+ peer.send(ackMsg);
189
+ }
190
+ catch {
191
+ /* disconnected */
192
+ }
193
+ }
194
+ this.notifySnapshotSubscribers();
195
+ this.patchesSinceReconciliation++;
196
+ if (this.patchesSinceReconciliation >= SyncRoom.RECONCILIATION_INTERVAL) {
197
+ this.patchesSinceReconciliation = 0;
198
+ this.broadcastFullState();
199
+ }
200
+ return;
201
+ }
202
+ // Relay-compatible fallback (for existing games like tetris/snake).
203
+ const outData = JSON.stringify({ ...parsed, __from: senderId });
204
+ if (parsed.__to && typeof parsed.__to === 'string') {
205
+ for (const peer of this.sockets) {
206
+ const pa = this.attachments.get(peer);
207
+ if (pa && pa.playerId === parsed.__to) {
208
+ try {
209
+ peer.send(outData);
210
+ }
211
+ catch {
212
+ /* disconnected */
213
+ }
214
+ }
215
+ }
216
+ }
217
+ else {
218
+ for (const peer of this.sockets) {
219
+ if (peer === ws)
220
+ continue;
221
+ try {
222
+ peer.send(outData);
223
+ }
224
+ catch {
225
+ /* disconnected */
226
+ }
227
+ }
228
+ }
229
+ }
230
+ handleClose(ws) {
231
+ const attachment = this.attachments.get(ws);
232
+ if (attachment) {
233
+ console.log(`[SyncRoom] ❌ Disconnected: connectionId=${attachment.connectionId} playerId=${attachment.playerId}`);
234
+ }
235
+ this.sockets.delete(ws);
236
+ this.attachments.delete(ws);
237
+ }
238
+ // ─── Admin API (parent frame __uzu_dev から使う) ──────────────────
239
+ admin() {
240
+ return {
241
+ getSnapshot: () => this.cachedState,
242
+ getRawState: () => this.cachedState,
243
+ setRawState: (next) => {
244
+ this.cachedState = next;
245
+ this.stateInitialized = true;
246
+ this.broadcastFullState();
247
+ },
248
+ mergeRawState: (patch) => {
249
+ if (!this.cachedState)
250
+ return;
251
+ applyJsonMergePatch(this.cachedState, patch);
252
+ this.broadcastFullState();
253
+ },
254
+ patchRawState: (ops) => {
255
+ if (!this.cachedState)
256
+ return;
257
+ applyJsonPatch(this.cachedState, ops);
258
+ this.broadcastFullState();
259
+ },
260
+ subscribeSnapshot: (cb) => {
261
+ this.snapshotSubscribers.add(cb);
262
+ return () => {
263
+ this.snapshotSubscribers.delete(cb);
264
+ };
265
+ },
266
+ };
267
+ }
268
+ }
package/dist/dev.js ADDED
@@ -0,0 +1,235 @@
1
+ /**
2
+ * `uzu dev` サブコマンドの本体。
3
+ *
4
+ * 概要:
5
+ * 1. `manifest.json` を読み、 `dev.command` (default `pnpm run dev`) を子 process で spawn
6
+ * 2. child stdout/stderr を line-by-line で監視し、 `dev.readyPattern` (default vite 向け)
7
+ * の regex match をトリガに scenario dev server の URL を確定
8
+ * 3. harness http+WebSocket server を立て、 iframe grid + HUD を親 frame に描画
9
+ * 4. `?server=ws://localhost:<port>&roomId=<key>&seatId=dev_N&seats=<json>&revisionId=dev`
10
+ * を子 iframe URL に組み立てて mount。 SDK は既存 online mode でそのまま接続する
11
+ * 5. `window.__uzu_dev` は harness page が dev-server の管理 channel と WS で会話して expose
12
+ */
13
+ import { spawn } from 'child_process';
14
+ import { readFileSync, existsSync } from 'fs';
15
+ import { dirname, resolve } from 'path';
16
+ import { fileURLToPath } from 'url';
17
+ import { createInterface } from 'readline';
18
+ import { currentLanHosts, startHarnessServer } from './dev-server/server.js';
19
+ import { loadLogicFromPath } from './dev-server/load-logic.js';
20
+ import { harnessHtml, buildHarnessClientJs } from './harness/page.js';
21
+ const DEFAULT_DEV_COMMAND = 'pnpm run dev';
22
+ const DEFAULT_READY_PATTERN = 'Local:\\s+(https?://[^\\s]+)';
23
+ const DEFAULT_MIN_IFRAME_SHORT_EDGE = 360;
24
+ export async function runDevCommand() {
25
+ const cwd = process.cwd();
26
+ const manifestPath = resolve(cwd, 'manifest.json');
27
+ if (!existsSync(manifestPath)) {
28
+ console.error('manifest.json が見つかりません。 scenario ディレクトリで実行してください。');
29
+ process.exit(1);
30
+ }
31
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8'));
32
+ const playerCount = manifest.characters?.length ?? manifest.playerCount ?? 2;
33
+ const orientation = manifest.orientation ?? 'portrait';
34
+ const devCommand = manifest.dev?.command ?? DEFAULT_DEV_COMMAND;
35
+ const readyPattern = new RegExp(manifest.dev?.readyPattern ?? DEFAULT_READY_PATTERN);
36
+ // 1. server logic を build & import (`serverActionLogicPath` があるときのみ)
37
+ let loaded = null;
38
+ if (manifest.serverActionLogicPath) {
39
+ console.log(`[uzu dev] Building server logic from ${manifest.serverActionLogicPath}...`);
40
+ const logicPath = resolve(cwd, manifest.serverActionLogicPath);
41
+ loaded = await loadLogicFromPath(logicPath);
42
+ console.log('[uzu dev] Server logic ready');
43
+ }
44
+ // 2. harness client bundle を build
45
+ const thisDir = dirname(fileURLToPath(import.meta.url));
46
+ const clientEntryJs = resolve(thisDir, 'harness', 'client-entry.js');
47
+ const clientEntryTs = resolve(thisDir, 'harness', 'client-entry.ts');
48
+ const clientEntry = existsSync(clientEntryJs)
49
+ ? clientEntryJs
50
+ : existsSync(clientEntryTs)
51
+ ? clientEntryTs
52
+ : null;
53
+ if (!clientEntry) {
54
+ console.error(`harness client entry が見つかりません: ${clientEntryJs}`);
55
+ process.exit(1);
56
+ }
57
+ console.log('[uzu dev] Bundling harness client...');
58
+ const harnessJs = await buildHarnessClientJs(clientEntry);
59
+ console.log(`[uzu dev] Harness client ready (${harnessJs.length} bytes)`);
60
+ // 3. scenario dev server を spawn
61
+ console.log(`[uzu dev] Spawning: ${devCommand}`);
62
+ const child = spawn(devCommand, {
63
+ cwd,
64
+ shell: true,
65
+ env: { ...process.env, FORCE_COLOR: '1' },
66
+ stdio: ['ignore', 'pipe', 'pipe'],
67
+ });
68
+ const scenarioUrl = await watchForReady(child, readyPattern);
69
+ console.log(`[uzu dev] Scenario ready at ${scenarioUrl}`);
70
+ // 4. harness server を起動 (free port)
71
+ // roomKey は固定値。 過去は Math.random() だったが、 uzu dev restart で roomKey が
72
+ // 変わると browser tab に残った古い roomKey で reconnect → 新 GameRoom 作成、
73
+ // admin.reset() が意図しない room を対象にする問題があった。 固定にすることで
74
+ // restart 後の reconnect も同じ GameRoom に collapse する。
75
+ // roster は dev-server が manifest から組んで GameRoom に注入する (server 権威)
76
+ // ため、 旧世代 page の残タブが reconnect しても roster は汚染されない。
77
+ const roomKey = 'devroom';
78
+ const harnessPort = await findFreePort();
79
+ const lanHosts = currentLanHosts();
80
+ // 完全な roster をここで一度だけ組む。 grid・iframe URL・GameRoom 注入の単一の源。
81
+ const seats = [
82
+ ...Array.from({ length: playerCount }, (_, i) => ({
83
+ id: `dev_${i}`,
84
+ nickname: manifest.characters?.[i]?.name ?? `Player ${i + 1}`,
85
+ iconUrl: manifest.characters?.[i]?.icon ?? '',
86
+ characterId: manifest.characters?.[i]?.id,
87
+ kind: 'player',
88
+ })),
89
+ ...(manifest.spectator
90
+ ? [{ id: 'spec_0', nickname: '観戦', iconUrl: '', kind: 'spectator' }]
91
+ : []),
92
+ ...(manifest.admin
93
+ ? [{ id: 'admin_0', nickname: 'Admin', iconUrl: '', kind: 'admin' }]
94
+ : []),
95
+ ];
96
+ const meta = {
97
+ scenarioUrl,
98
+ playerCount,
99
+ orientation,
100
+ seats,
101
+ roomKey,
102
+ serverBaseUrl: `ws://localhost:${harnessPort}`,
103
+ revisionId: 'dev',
104
+ devMinIframeShortEdge: DEFAULT_MIN_IFRAME_SHORT_EDGE,
105
+ };
106
+ const server = startHarnessServer({
107
+ port: harnessPort,
108
+ // 同一 LAN の実機 (スマホ等) から harness / 単一プレイヤー画面を開けるよう
109
+ // 全 interface で listen する。 GameRoom は in-memory の dev 専用 state のみ。
110
+ host: '0.0.0.0',
111
+ logic: loaded?.logic ?? null,
112
+ harness: { html: harnessHtml(), js: harnessJs },
113
+ meta,
114
+ });
115
+ const harnessUrl = `http://localhost:${harnessPort}/`;
116
+ console.log('');
117
+ console.log(` 🎮 UZU dev harness: ${harnessUrl}`);
118
+ console.log(` scenario: ${scenarioUrl}`);
119
+ console.log(` players: ${playerCount} orientation: ${orientation} roomKey: ${roomKey}`);
120
+ const extraSeats = seats.filter((seat) => seat.kind !== 'player');
121
+ if (extraSeats.length > 0) {
122
+ console.log(` extra seats: ${extraSeats.map((s) => `${s.kind} (${s.id})`).join(', ')}`);
123
+ }
124
+ if (lanHosts.length) {
125
+ console.log('');
126
+ console.log(` 📱 同一 Wi-Fi の実機から (LAN に公開されています):`);
127
+ for (const host of lanHosts) {
128
+ console.log(` http://${host}:${harnessPort}/ (5人グリッド)`);
129
+ console.log(` http://${host}:${harnessPort}/?player=0 (Player 1 単体, ?player=0..${playerCount - 1})`);
130
+ }
131
+ console.log(` ※ scenario への通信は harness が proxy するため vite 側の設定は不要です`);
132
+ }
133
+ console.log('');
134
+ // 5. process cleanup
135
+ const cleanup = async () => {
136
+ console.log('\n[uzu dev] Shutting down...');
137
+ try {
138
+ await server.stop();
139
+ }
140
+ catch {
141
+ /* ignore */
142
+ }
143
+ if (child.pid && !child.killed) {
144
+ child.kill('SIGTERM');
145
+ }
146
+ if (loaded)
147
+ loaded.dispose();
148
+ };
149
+ process.on('SIGINT', () => {
150
+ void cleanup().then(() => process.exit(0));
151
+ });
152
+ process.on('SIGTERM', () => {
153
+ void cleanup().then(() => process.exit(0));
154
+ });
155
+ child.on('exit', (code) => {
156
+ console.log(`[uzu dev] scenario dev server exited (code=${code})`);
157
+ void cleanup().then(() => process.exit(code ?? 0));
158
+ });
159
+ }
160
+ /** ANSI escape sequences (color / bold 等) を除去する。 vite 等が FORCE_COLOR=1 で
161
+ * 出す stdout を regex match する前に噛ませる。 */
162
+ function stripAnsi(s) {
163
+ // eslint-disable-next-line no-control-regex
164
+ return s.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '');
165
+ }
166
+ function watchForReady(child, pattern) {
167
+ return new Promise((resolve, reject) => {
168
+ const timeout = setTimeout(() => {
169
+ reject(new Error('Timed out waiting for scenario dev server to become ready (30s)'));
170
+ }, 30_000);
171
+ let resolved = false;
172
+ const onLine = (line) => {
173
+ process.stdout.write(line + '\n');
174
+ if (resolved)
175
+ return;
176
+ const clean = stripAnsi(line);
177
+ const m = pattern.exec(clean);
178
+ if (!m)
179
+ return;
180
+ const captured = m[1];
181
+ if (!captured)
182
+ return;
183
+ const url = /^https?:\/\//i.test(captured) ? captured : `http://localhost:${captured}`;
184
+ resolved = true;
185
+ clearTimeout(timeout);
186
+ resolve(url.replace(/\/$/, ''));
187
+ };
188
+ if (child.stdout) {
189
+ const rl = createInterface({ input: child.stdout });
190
+ rl.on('line', onLine);
191
+ }
192
+ if (child.stderr) {
193
+ const rl = createInterface({ input: child.stderr });
194
+ rl.on('line', onLine);
195
+ }
196
+ child.on('error', (err) => {
197
+ clearTimeout(timeout);
198
+ reject(err);
199
+ });
200
+ child.on('exit', (code) => {
201
+ if (!resolved) {
202
+ clearTimeout(timeout);
203
+ reject(new Error(`scenario dev server exited before ready (code=${code})`));
204
+ }
205
+ });
206
+ });
207
+ }
208
+ const DEFAULT_HARNESS_PORT = 10001;
209
+ const MAX_HARNESS_PORT_ATTEMPTS = 100;
210
+ async function findFreePort() {
211
+ const net = await import('net');
212
+ const tryPort = (port) => new Promise((resolve) => {
213
+ const server = net.createServer();
214
+ server.unref();
215
+ server.once('error', () => resolve(null));
216
+ server.listen(port, () => {
217
+ // listen(0) は OS が空きポートを動的割当するため、引数ではなく実際に割り当てられた
218
+ // 番号を読む必要がある。close 後は address() が null を返すので閉じる前に取る。
219
+ const address = server.address();
220
+ const assigned = typeof address === 'object' && address !== null ? address.port : port;
221
+ server.close(() => resolve(assigned));
222
+ });
223
+ });
224
+ // 10001 から順に空きを探す。 100 回試して見つからなければ OS 自動割当に fallback。
225
+ for (let i = 0; i < MAX_HARNESS_PORT_ATTEMPTS; i++) {
226
+ const candidate = DEFAULT_HARNESS_PORT + i;
227
+ const ok = await tryPort(candidate);
228
+ if (ok != null)
229
+ return ok;
230
+ }
231
+ const ok = await tryPort(0);
232
+ if (ok != null)
233
+ return ok;
234
+ throw new Error('could not determine free port');
235
+ }