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:
+132
-31
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user