Initial commit

This commit is contained in:
Quintine committed 2026-09-08 07:37:52 +10:00
commit 8c709fb52f
771 files changed
+222983

No files matched your search

+112
View File
@@ -0,0 +1,112 @@
export const CLIENT_REQUEST_TIMEOUT_MS = 15_000;
export class ClientRequestError extends Error {
constructor(
message: string,
readonly kind:
| 'timeout'
| 'aborted'
| 'network'
| 'http'
| 'invalid-response',
readonly status?: number,
) {
super(message);
this.name = 'ClientRequestError';
}
}
/** Gateways can report failure after the server has already committed a mutation. */
export function requestMayHaveCompleted(error: unknown): boolean {
if (!(error instanceof ClientRequestError) || error.kind !== 'http')
return true;
return (
error.status === undefined || error.status === 408 || error.status >= 500
);
}
type RequestOptions = {
timeoutMs?: number;
fetcher?: typeof fetch;
};
/** One deadline covers headers and the complete JSON body. Never retries a request. */
export async function requestJson<T>(
url: string,
init: RequestInit = {},
{
timeoutMs = CLIENT_REQUEST_TIMEOUT_MS,
fetcher = fetch,
}: RequestOptions = {},
): Promise<T> {
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0)
throw new RangeError('Request timeout must be a positive finite number.');
const controller = new AbortController();
let timer: ReturnType<typeof setTimeout> | undefined;
let detachAbort = () => {};
const deadline = new Promise<never>((_, reject) => {
const abort = () => {
reject(new ClientRequestError('Request canceled.', 'aborted'));
controller.abort(init.signal?.reason);
};
if (init.signal?.aborted) abort();
else {
init.signal?.addEventListener('abort', abort, { once: true });
detachAbort = () => init.signal?.removeEventListener('abort', abort);
}
timer = setTimeout(() => {
reject(
new ClientRequestError(
'The server took too long to respond.',
'timeout',
),
);
controller.abort();
}, timeoutMs);
});
const operation = async () => {
if (controller.signal.aborted)
throw new ClientRequestError('Request canceled.', 'aborted');
const response = await fetcher(url, { ...init, signal: controller.signal });
let data: unknown;
try {
data = await response.json();
} catch {
if (!response.ok)
throw new ClientRequestError(
`Request failed (${response.status}).`,
'http',
response.status,
);
throw new ClientRequestError(
'The server returned an unreadable response.',
'invalid-response',
response.status,
);
}
if (!response.ok) {
const message =
data &&
typeof data === 'object' &&
'error' in data &&
typeof data.error === 'string'
? data.error
: `Request failed (${response.status}).`;
throw new ClientRequestError(message, 'http', response.status);
}
return data as T;
};
try {
// The explicit race also bounds mocks/transports that fail to honor abort.
return await Promise.race([deadline, operation()]);
} catch (error) {
if (error instanceof ClientRequestError) throw error;
throw new ClientRequestError(
'Could not reach the server. Check your connection.',
'network',
);
} finally {
clearTimeout(timer);
detachAbort();
}
}
+204
View File
@@ -0,0 +1,204 @@
import { EXPANSIONS, FACTIONS } from '../game/catalog';
import { requestMayHaveCompleted } from './client-request';
/** Tab-scoped proof for one explicit create/join. Never place this record in a URL or log. */
export const ROOM_ENTRY_STORAGE_KEY = 'dune.pending-room-entry.v1';
export type RoomEntryAttempt = Readonly<{
schemaVersion: 1;
createdAt: number;
kind: 'create' | 'join';
roomCode: string | null;
bodyText: string;
}>;
type EntryFields = {
name: string;
faction: string;
expansions: string[];
advanced: boolean;
};
const ROOM = /^[A-Z2-9]{8}$/;
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
const TOKEN = /^[0-9a-f]{64}$/;
const invalid = () =>
new Error(
'The saved room request is invalid or uses an unsupported format. It has been kept in this tab; do not retry it with changed details.',
);
function object(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value);
}
function keys(value: Record<string, unknown>, expected: string[]) {
return (
Object.keys(value).length === expected.length &&
expected.every((key) => Object.hasOwn(value, key))
);
}
export function parseRoomEntry(text: string): RoomEntryAttempt {
if (text.length > 8192) throw invalid();
let record: unknown;
try {
record = JSON.parse(text);
} catch {
throw invalid();
}
if (
!object(record) ||
!keys(record, [
'schemaVersion',
'createdAt',
'kind',
'roomCode',
'bodyText',
]) ||
record.schemaVersion !== 1 ||
typeof record.createdAt !== 'number' ||
!Number.isSafeInteger(record.createdAt) ||
record.createdAt < 0 ||
(record.kind !== 'create' && record.kind !== 'join') ||
(record.kind === 'create'
? record.roomCode !== null
: typeof record.roomCode !== 'string' || !ROOM.test(record.roomCode)) ||
typeof record.bodyText !== 'string'
)
throw invalid();
let body: unknown;
try {
body = JSON.parse(record.bodyText);
} catch {
throw invalid();
}
if (
!object(body) ||
JSON.stringify(body) !== record.bodyText ||
!keys(
body,
record.kind === 'join'
? ['type', 'name', 'faction', 'entry']
: ['name', 'faction', 'expansions', 'advanced', 'entry'],
) ||
typeof body.name !== 'string' ||
body.name.length > 256 ||
!body.name.trim() ||
body.name.trim().length > 32 ||
!FACTIONS.some((faction) => faction.id === body.faction) ||
!object(body.entry) ||
!keys(body.entry, ['operationId', 'sessionToken']) ||
typeof body.entry.operationId !== 'string' ||
!UUID.test(body.entry.operationId) ||
typeof body.entry.sessionToken !== 'string' ||
!TOKEN.test(body.entry.sessionToken)
)
throw invalid();
if (record.kind === 'join') {
if (body.type !== 'join') throw invalid();
} else if (
typeof body.advanced !== 'boolean' ||
!Array.isArray(body.expansions) ||
body.expansions.length > 3 ||
!body.expansions.every((id) =>
EXPANSIONS.some((expansion) => expansion.id === id),
)
)
throw invalid();
return Object.freeze(record as RoomEntryAttempt);
}
export function createRoomEntry(
fields: EntryFields,
roomCode?: string,
): RoomEntryAttempt {
const entry = {
operationId: globalThis.crypto.randomUUID(),
sessionToken: Array.from(
globalThis.crypto.getRandomValues(new Uint8Array(32)),
(byte) => byte.toString(16).padStart(2, '0'),
).join(''),
};
const body =
roomCode === undefined
? { ...fields, entry }
: { type: 'join', name: fields.name, faction: fields.faction, entry };
return parseRoomEntry(
JSON.stringify({
schemaVersion: 1,
createdAt: Date.now(),
kind: roomCode === undefined ? 'create' : 'join',
roomCode: roomCode ?? null,
bodyText: JSON.stringify(body),
}),
);
}
export function roomEntryUrl(attempt: RoomEntryAttempt): string {
return attempt.kind === 'create'
? '/api/rooms'
: `/api/rooms/${attempt.roomCode}`;
}
/** A cookie-only GET is not evidence that this operation created the returned seat. */
export function roomEntryConfirmed(
attempt: RoomEntryAttempt,
response: unknown,
firstDispatch = false,
): boolean {
if (
!object(response) ||
typeof response.code !== 'string' ||
!ROOM.test(response.code) ||
typeof response.me !== 'string' ||
!UUID.test(response.me) ||
!Array.isArray(response.players) ||
!response.players.some(
(player) => object(player) && player.id === response.me,
) ||
typeof response.version !== 'number' ||
!Number.isSafeInteger(response.version) ||
response.version < 0 ||
(attempt.kind === 'join' && response.code !== attempt.roomCode)
)
return false;
const { entry } = JSON.parse(attempt.bodyText);
if (Object.hasOwn(response, 'entryReceipt'))
return (
object(response.entryReceipt) &&
response.entryReceipt.operationId === entry.operationId &&
typeof response.entryReceipt.replayed === 'boolean'
);
return (
firstDispatch && attempt.kind === 'join' && response.alreadySeated === true
);
}
export function saveRoomEntry(
storage: Pick<Storage, 'getItem' | 'setItem'>,
attempt: RoomEntryAttempt,
): void {
const text = JSON.stringify(attempt);
const existing = storage.getItem(ROOM_ENTRY_STORAGE_KEY);
if (existing !== null && existing !== text)
throw new Error(
'Another saved room request is still present. Resolve it before starting a different request.',
);
storage.setItem(ROOM_ENTRY_STORAGE_KEY, text);
if (storage.getItem(ROOM_ENTRY_STORAGE_KEY) !== text)
throw new Error(
'The room request could not be saved in this tab. No request was sent.',
);
}
export function clearRoomEntry(
storage: Pick<Storage, 'removeItem' | 'getItem'>,
): void {
storage.removeItem(ROOM_ENTRY_STORAGE_KEY);
if (storage.getItem(ROOM_ENTRY_STORAGE_KEY) !== null)
throw new Error(
'The saved room request could not be removed. Its private retry proof is still stored in this tab.',
);
}
/** Only the first POST's definite rejection permits editing without abandoning uncertain work. */
export function roomEntryRejectedBeforeCommit(
firstDispatch: boolean,
error: unknown,
): boolean {
return firstDispatch && !requestMayHaveCompleted(error);
}
+112
View File
@@ -0,0 +1,112 @@
export type SeatRecoveryKit = Readonly<{
format: 'dune-seat-recovery';
version: 1;
roomCode: string;
playerId: string;
recoverySecret: string;
}>;
export type SeatRecoveryAttempt = Readonly<{
type: 'recoverSeat';
playerId: string;
recoverySecret: string;
operationId: string;
newSessionToken: string;
}>;
const ROOM = /^[A-Z2-9]{8}$/;
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
const SECRET = /^[0-9a-f]{64}$/;
/** Secrets remain in caller memory. This module has no storage or network access. */
function secret256(): string {
return Array.from(
globalThis.crypto.getRandomValues(new Uint8Array(32)),
(byte) => byte.toString(16).padStart(2, '0'),
).join('');
}
export function createRecoveryKit(
roomCode: string,
playerId: string,
): SeatRecoveryKit {
if (!ROOM.test(roomCode) || !UUID.test(playerId))
throw new Error(
'The current room or seat identifier is invalid. Refresh the table before creating a recovery kit.',
);
return Object.freeze({
format: 'dune-seat-recovery',
version: 1,
roomCode,
playerId,
recoverySecret: secret256(),
});
}
export function serializeRecoveryKit(kit: SeatRecoveryKit): string {
return JSON.stringify(kit, null, 2);
}
export function parseRecoveryKit(text: string): SeatRecoveryKit {
if (text.length > 4096)
throw new Error(
'This recovery kit is too large. Paste only the saved kit.',
);
let data: unknown;
try {
data = JSON.parse(text.trim());
} catch {
throw new Error(
'Paste the complete saved recovery kit, including its opening and closing braces.',
);
}
if (!data || typeof data !== 'object' || Array.isArray(data))
throw new Error('This is not a Dune seat recovery kit.');
const candidate = data as Record<string, unknown>;
if (candidate.format !== 'dune-seat-recovery' || candidate.version !== 1)
throw new Error('This recovery kit format is not supported.');
if (
Object.keys(candidate).some(
(key) =>
![
'format',
'version',
'roomCode',
'playerId',
'recoverySecret',
].includes(key),
)
)
throw new Error(
'The recovery kit contains unexpected fields. Paste only the original saved kit.',
);
if (
typeof candidate.roomCode !== 'string' ||
!ROOM.test(candidate.roomCode) ||
typeof candidate.playerId !== 'string' ||
!UUID.test(candidate.playerId) ||
typeof candidate.recoverySecret !== 'string' ||
!SECRET.test(candidate.recoverySecret)
)
throw new Error(
'The recovery kit has an invalid room, seat identifier or recovery key.',
);
return Object.freeze({
format: 'dune-seat-recovery',
version: 1,
roomCode: candidate.roomCode,
playerId: candidate.playerId,
recoverySecret: candidate.recoverySecret,
});
}
/** Generate once per explicit attempt. Reuse this exact object for every retry. */
export function createRecoveryAttempt(
kit: SeatRecoveryKit,
): SeatRecoveryAttempt {
return Object.freeze({
type: 'recoverSeat',
playerId: kit.playerId,
recoverySecret: kit.recoverySecret,
operationId: globalThis.crypto.randomUUID(),
newSessionToken: secret256(),
});
}
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}