Stage 7 (wip): wire remaining ops (backend REST, FBS, gateway transcode) + real UI transport

backend: REST handlers for pass/exchange/resign/hint/evaluate/check_word/complaint/history/chat-list/nudge + new game.ListForAccount (my games) + seat display_name resolution
pkg/fbs: GameActionRequest/ExchangeRequest/EvalRequest/EvalResult/CheckWordRequest/WordCheckResult/ComplaintRequest/HintResult/History/GameList/ChatList + SeatView.display_name; committed Go regenerated (flatc 23.5.26)
gateway: 11 new transcode ops + backendclient methods + FB encoders
ui: edge TS codegen (flatc --ts + protoc-gen-es, committed), FlatBuffers<->model codec, real connect-web transport (binary, bearer auth, Subscribe). prod bundle ~69KB gzip JS
This commit is contained in:
Ilia Denisov
2026-06-03 00:49:07 +02:00
parent 453ddc5e94
commit 65689b903f
64 changed files with 5151 additions and 52 deletions
+132 -31
View File
@@ -1,36 +1,137 @@
// Placeholder for the real Connect-web + FlatBuffers transport, wired in the edge
// codegen task. Until then, selecting a non-mock mode surfaces a clear error instead
// of failing silently. The mock (lib/mock) backs `pnpm start`.
// The real Connect-RPC + FlatBuffers transport. Every unary op rides the single
// Execute envelope (message_type + FlatBuffers payload); the live stream is
// Subscribe. The session token rides in the Authorization header; domain outcomes
// come back in result_code, edge failures as Connect error codes — both normalised to
// a thrown GatewayError. In dev the Vite proxy forwards the RPC path to the h2c
// gateway; in a packaged app VITE_GATEWAY_URL points at the real origin.
import type { GatewayClient } from './client';
import { GatewayError } from './client';
import { Code, ConnectError, createClient } from '@connectrpc/connect';
import { createConnectTransport } from '@connectrpc/connect-web';
import { Gateway } from '../gen/edge/v1/edge_pb';
import { GatewayError, type GatewayClient } from './client';
import * as codec from './codec';
function toGatewayError(e: unknown): GatewayError {
if (e instanceof ConnectError) {
switch (e.code) {
case Code.Unauthenticated:
return new GatewayError('session_invalid', e.message);
case Code.ResourceExhausted:
return new GatewayError('rate_limited', e.message);
case Code.Unavailable:
return new GatewayError('unavailable', e.message);
case Code.NotFound:
return new GatewayError('not_found', e.message);
default:
return new GatewayError('internal', e.message);
}
}
return new GatewayError('unavailable', String(e));
}
export function createTransport(baseUrl: string): GatewayClient {
const origin = baseUrl || (typeof location !== 'undefined' ? location.origin : '');
const transport = createConnectTransport({ baseUrl: origin, useBinaryFormat: true });
const client = createClient(Gateway, transport);
let token: string | null = null;
const headers = (): Record<string, string> | undefined =>
token ? { authorization: `Bearer ${token}` } : undefined;
async function exec(messageType: string, payload: Uint8Array): Promise<Uint8Array> {
let res;
try {
res = await client.execute({ messageType, payload, requestId: '' }, { headers: headers() });
} catch (e) {
throw toGatewayError(e);
}
if (res.resultCode && res.resultCode !== 'ok') throw new GatewayError(res.resultCode);
return res.payload;
}
export function createTransport(_baseUrl: string): GatewayClient {
const ni = (): never => {
throw new GatewayError('unavailable', 'real transport not wired yet');
};
return {
setToken: () => {},
authGuest: ni,
authEmailRequest: ni,
authEmailLogin: ni,
profileGet: ni,
gamesList: ni,
lobbyEnqueue: ni,
lobbyPoll: ni,
gameState: ni,
gameHistory: ni,
submitPlay: ni,
pass: ni,
exchange: ni,
resign: ni,
hint: ni,
evaluate: ni,
checkWord: ni,
complaint: ni,
chatPost: ni,
chatList: ni,
nudge: ni,
subscribe: ni,
setToken(t) {
token = t;
},
async authGuest(locale) {
return codec.decodeSession(await exec('auth.guest', codec.encodeGuestLogin(locale ?? '')));
},
async authEmailRequest(email) {
await exec('auth.email.request', codec.encodeEmailRequest(email));
},
async authEmailLogin(email, code) {
return codec.decodeSession(await exec('auth.email.login', codec.encodeEmailLogin(email, code)));
},
async profileGet() {
return codec.decodeProfile(await exec('profile.get', codec.empty()));
},
async gamesList() {
return codec.decodeGameList(await exec('games.list', codec.empty()));
},
async lobbyEnqueue(variant) {
return codec.decodeMatchResult(await exec('lobby.enqueue', codec.encodeEnqueue(variant)));
},
async lobbyPoll() {
return codec.decodeMatchResult(await exec('lobby.poll', codec.empty()));
},
async gameState(id) {
return codec.decodeStateView(await exec('game.state', codec.encodeStateRequest(id)));
},
async gameHistory(id) {
return codec.decodeHistory(await exec('game.history', codec.encodeGameAction(id)));
},
async submitPlay(id, dir, tiles) {
return codec.decodeMoveResult(await exec('game.submit_play', codec.encodeSubmitPlay(id, dir, tiles)));
},
async pass(id) {
return codec.decodeMoveResult(await exec('game.pass', codec.encodeGameAction(id)));
},
async exchange(id, tiles) {
return codec.decodeMoveResult(await exec('game.exchange', codec.encodeExchange(id, tiles)));
},
async resign(id) {
return codec.decodeMoveResult(await exec('game.resign', codec.encodeGameAction(id)));
},
async hint(id) {
return codec.decodeHintResult(await exec('game.hint', codec.encodeGameAction(id)));
},
async evaluate(id, dir, tiles) {
return codec.decodeEvalResult(await exec('game.evaluate', codec.encodeEval(id, dir, tiles)));
},
async checkWord(id, word) {
return codec.decodeWordCheck(await exec('game.check_word', codec.encodeCheckWord(id, word)));
},
async complaint(id, word, note) {
await exec('game.complaint', codec.encodeComplaint(id, word, note));
},
async chatPost(id, body) {
return codec.decodeChatMessage(await exec('chat.post', codec.encodeChatPost(id, body)));
},
async chatList(id) {
return codec.decodeChatList(await exec('chat.list', codec.encodeGameAction(id)));
},
async nudge(id) {
return codec.decodeChatMessage(await exec('chat.nudge', codec.encodeGameAction(id)));
},
subscribe(onEvent, onError) {
const ctrl = new AbortController();
void (async () => {
try {
for await (const ev of client.subscribe({}, { headers: headers(), signal: ctrl.signal })) {
const pe = codec.decodeEvent(ev.kind, ev.payload);
if (pe) onEvent(pe);
}
} catch (e) {
if (!ctrl.signal.aborted) onError?.(toGatewayError(e));
}
})();
return () => ctrl.abort();
},
};
}