Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions src/core/supabase.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import { createClient } from '@supabase/supabase-js';
import type { Database } from './database.types';

export const SUPABASE_URL = 'https://nymrtujjmzbhxcimjsci.supabase.co';
export const SUPABASE_PUBLISHABLE_KEY =
'sb_publishable_b2ytleL3Uz9w5NKkd1v7hg_FPiAl888';

export const supabaseClient = createClient<Database>(
'https://nymrtujjmzbhxcimjsci.supabase.co',
'sb_publishable_b2ytleL3Uz9w5NKkd1v7hg_FPiAl888',
SUPABASE_URL,
SUPABASE_PUBLISHABLE_KEY,
);
2 changes: 2 additions & 0 deletions src/core/zustand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { factoryViewSortActions } from '@/factories/store/factoryViewSortActions
import { gamesSlice } from '@/games/gamesSlice';
import { gameSaveSlice } from '@/games/save/gameSaveSlice';
import { gameFactoriesActions } from '@/games/store/gameFactoriesActions';
import { gameRealtimeActions } from '@/games/store/gameRealtimeActions';
import { gameRemoteActions } from '@/games/store/gameRemoteActions';
import { peersSlice } from '@/games/sync/peersSlice';
import { notesUiSlice } from '@/notes/store/notesUiSlice';
Expand Down Expand Up @@ -48,6 +49,7 @@ const slicesWithActions = withActions(
gameFactoriesActions,
solverFactoriesActions,
gameRemoteActions,
gameRealtimeActions,
factoryViewSortActions,
);

Expand Down
6 changes: 3 additions & 3 deletions src/games/menu/GameMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,9 +55,9 @@ export function GameMenu(props: IGameMenuProps) {
state => !!state.games.games[selectedId ?? '']?.savedId,
);
const isSaving = useStore(state => state.gameSave.isSaving);
const isSyncConnected =
useStore(state => state.gameSave.isRealtimeSyncConnected) ||
import.meta.env.DEV;
const isSyncConnected = useStore(
state => state.gameSave.isRealtimeSyncConnected,
);
const navigate = useNavigate();

const [opened, { toggle, open, close }] = useDisclosure();
Expand Down
21 changes: 21 additions & 0 deletions src/games/store/gameRealtimeActions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { applyPatches, type Patch } from 'immer';
import { createActions } from '@/core/zustand-helpers/actions';

/**
* Applies a list of immer patches received from a peer to the root state.
*
* Goes through the normal action wrapper so the resulting state stays frozen
* (immer invariant) and zustand's setState replaces atomically. The wrapper
* will emit its own patches as a side-effect; callers must wrap this call in
* `withSuppressedBroadcast(...)` to avoid bouncing the same patches back to
* the peer that sent them.
*
* Note: when `state` is an immer draft (it is here, because produceWithPatches
* is in the wrapper), `applyPatches` mutates in-place instead of returning a
* new object.
*/
export const gameRealtimeActions = createActions({
applyRemotePatches: (patches: Patch[]) => state => {
applyPatches(state, patches);
},
});
41 changes: 41 additions & 0 deletions src/games/sync/flushRemoteGameOnUnload.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { SUPABASE_PUBLISHABLE_KEY, SUPABASE_URL } from '@/core/supabase';
import { useStore } from '@/core/zustand';
import { serializeGame } from '@/games/store/gameFactoriesActions';

/**
* Last-resort save used on page unload (beforeunload event).
*
* We use `fetch` with `keepalive: true` (direct PostgREST call) instead of
* the Supabase JS client because:
* 1. During unload, the browser aborts pending JS Promises: any async work
* started by `supabaseClient.from(...).upsert(...)` is killed before it
* reaches the network. `keepalive` tells the browser to let this specific
* request complete in the background even after the page is gone.
* 2. `navigator.sendBeacon` would also survive unload but doesn't allow
* custom headers (we need `Authorization: Bearer ...` and `apikey`).
*
* Fire-and-forget: we can't observe the response, by design. If it fails the
* data is still in IndexedDB locally, so worst case the user loses the last
* few seconds of edits (bounded by AUTO_SAVE_DEBOUNCE_MS).
*/
export function flushRemoteGameOnUnload(gameId: string): void {
const state = useStore.getState();
const game = state.games.games[gameId];
const session = state.auth.session;
if (!game?.savedId || !session) return;

fetch(`${SUPABASE_URL}/rest/v1/games?id=eq.${game.savedId}`, {
method: 'PATCH',
keepalive: true,
headers: {
apikey: SUPABASE_PUBLISHABLE_KEY,
Authorization: `Bearer ${session.access_token}`,
'Content-Type': 'application/json',
Prefer: 'return=minimal',
},
body: JSON.stringify({
data: serializeGame(gameId),
updated_at: new Date().toISOString(),
}),
}).catch(() => {});
}
31 changes: 30 additions & 1 deletion src/games/sync/peersSlice.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import type { RealtimeChannel } from '@supabase/supabase-js';
import { useMemo } from 'react';
import { useStore } from '@/core/zustand';
import { createSlice } from '@/core/zustand-helpers/slices';
import { SENDER_ID } from './realtimeSyncTypes';
import { type PresencePayload, SENDER_ID } from './realtimeSyncTypes';

export interface PeerInfo {
senderId: string;
Expand Down Expand Up @@ -44,3 +45,31 @@ export function useFactoryPeers(factoryId: string): PeerInfo[] {
[peers, factoryId],
);
}

export function countOtherPeers(peers: Record<string, PeerInfo>): number {
let count = 0;
for (const senderId of Object.keys(peers)) {
if (senderId !== SENDER_ID) count++;
}
return count;
}

/**
* Reads peers directly from the realtime channel's presenceState instead of
* the zustand slice. The slice is updated only on `presence.sync` events
* (handled by `computeLeaderAndPeers`), so it lags behind the channel by up
* to a few hundred ms after subscribe — long enough that the first patches
* after joining could be wrongly skipped as "no peers". The channel itself
* always knows the current presence list.
*/
export function hasOtherPeersConnectedOnChannel(
channel: RealtimeChannel,
): boolean {
const state = channel.presenceState<PresencePayload>();
for (const presences of Object.values(state)) {
for (const p of presences) {
if (p.senderId && p.senderId !== SENDER_ID) return true;
}
}
return false;
}
46 changes: 29 additions & 17 deletions src/games/sync/realtimeSyncHandlers.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import type { RealtimeChannel } from '@supabase/supabase-js';
import { applyPatches } from 'immer';
import { loglev } from '@/core/logger/log';
import { supabaseClient } from '@/core/supabase';
import { useStore } from '@/core/zustand';
Expand All @@ -17,7 +16,9 @@ import {
type PatchBroadcastPayload,
type PresencePayload,
SENDER_ID,
withSuppressedBroadcast,
} from './realtimeSyncTypes';
import { safeChannelSend } from './safeChannelSend';

const logger = loglev.getLogger('games:realtime-sync');

Expand Down Expand Up @@ -63,9 +64,12 @@ export function handleIncomingPatches(
);
refs.isApplyingRemote.current = true;
try {
const currentState = useStore.getState();
const nextState = applyPatches(currentState, data.patches);
useStore.setState(nextState);
// Go through the dedicated action so the wrapper preserves the immer
// frozen invariant. withSuppressedBroadcast prevents the action's own
// emitted patches from being broadcast back to the sender.
withSuppressedBroadcast(() => {
useStore.getState().applyRemotePatches(data.patches);
});
} catch (err) {
logger.error('Failed to apply patches, requesting full state', err);
requestFullState();
Expand Down Expand Up @@ -96,15 +100,19 @@ export function handleFullStateRequest(
share_token: latestGame.shareToken,
};

channel.send({
type: 'broadcast',
event: BROADCAST_FULL_RESPONSE,
payload: {
senderId: SENDER_ID,
seq: refs.seq.current,
serialized,
remoteData,
} satisfies FullStateResponsePayload,
safeChannelSend({
channel,
message: {
type: 'broadcast',
event: BROADCAST_FULL_RESPONSE,
payload: {
senderId: SENDER_ID,
seq: refs.seq.current,
serialized,
remoteData,
} satisfies FullStateResponsePayload,
},
context: 'full state response',
});
} catch (err) {
logger.error('Failed to send full state response', err);
Expand Down Expand Up @@ -142,10 +150,14 @@ export function requestFullStateWithFallback(
refs: SyncRefs,
timers: SyncTimers,
) {
channel.send({
type: 'broadcast',
event: BROADCAST_FULL_REQUEST,
payload: { senderId: SENDER_ID } satisfies FullStateRequestPayload,
safeChannelSend({
channel,
message: {
type: 'broadcast',
event: BROADCAST_FULL_REQUEST,
payload: { senderId: SENDER_ID } satisfies FullStateRequestPayload,
},
context: 'full state request',
});

if (timers.dbFallback !== null) clearTimeout(timers.dbFallback);
Expand Down
2 changes: 1 addition & 1 deletion src/games/sync/realtimeSyncTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import type { SerializedGame } from '@/games/store/gameFactoriesActions';

export const SENDER_ID = crypto.randomUUID();
export const PATCH_DEBOUNCE_MS = 150;
export const AUTO_SAVE_DEBOUNCE_MS = 60_000;
export const AUTO_SAVE_DEBOUNCE_MS = 40_000;
export const DB_FALLBACK_MS = 3_000;
export const BROADCAST_EVENT = 'game:patch';
export const BROADCAST_FULL_REQUEST = 'game:full-request';
Expand Down
33 changes: 33 additions & 0 deletions src/games/sync/safeChannelSend.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import type { RealtimeChannel } from '@supabase/supabase-js';
import { loglev } from '@/core/logger/log';

const logger = loglev.getLogger('games:realtime-sync');

/**
* Wraps `channel.send` to log non-ok responses and rejections. The Supabase
* client returns a Promise<'ok' | 'timed out' | 'error'> that we otherwise
* ignore — failures (e.g. RLS denies, channel closed, payload too big) would
* be silent and very hard to diagnose at runtime.
*/
export interface SafeChannelSendArgs {
channel: RealtimeChannel;
message: Parameters<RealtimeChannel['send']>[0];
context: string;
}

export function safeChannelSend({
channel,
message,
context,
}: SafeChannelSendArgs): void {
channel
.send(message)
.then(status => {
if (status !== 'ok') {
logger.warn(`channel.send (${context}) returned: ${status}`);
}
})
.catch(err => {
logger.error(`channel.send (${context}) failed`, err);
});
}
Loading
Loading