Initial commit
This commit is contained in:
commit
8c709fb52f
771 files changed
+222983
No files matched your search
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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(),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
Reference in new issue
Block a user