feat(web): pending-message orchestrator (TTL discard, deferred send, online retry)
This commit is contained in:
@@ -78,6 +78,15 @@ export class RateLimitError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class HttpError extends Error {
|
||||||
|
readonly status: number;
|
||||||
|
constructor(status: number, message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'HttpError';
|
||||||
|
this.status = status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export class BackspaceApiClient {
|
export class BackspaceApiClient {
|
||||||
readonly auth: {
|
readonly auth: {
|
||||||
register: (data: RegisterRequest) => Promise<AuthResponse>;
|
register: (data: RegisterRequest) => Promise<AuthResponse>;
|
||||||
@@ -316,7 +325,7 @@ export class BackspaceApiClient {
|
|||||||
throw new RateLimitError(retryAfter);
|
throw new RateLimitError(retryAfter);
|
||||||
}
|
}
|
||||||
const error = await response.json().catch(() => ({ error: 'Request failed' }));
|
const error = await response.json().catch(() => ({ error: 'Request failed' }));
|
||||||
throw new Error((error as { error: string }).error || `HTTP ${response.status}`);
|
throw new HttpError(response.status, (error as { error: string }).error || `HTTP ${response.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.json() as Promise<T>;
|
return response.json() as Promise<T>;
|
||||||
@@ -363,7 +372,7 @@ export class BackspaceApiClient {
|
|||||||
throw new RateLimitError(retryAfter);
|
throw new RateLimitError(retryAfter);
|
||||||
}
|
}
|
||||||
const error = await response.json().catch(() => ({ error: 'Upload failed' }));
|
const error = await response.json().catch(() => ({ error: 'Upload failed' }));
|
||||||
throw new Error((error as { error: string }).error || `HTTP ${response.status}`);
|
throw new HttpError(response.status, (error as { error: string }).error || `HTTP ${response.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.json() as Promise<Attachment>;
|
return response.json() as Promise<Attachment>;
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
export function useNetworkStatus(): boolean {
|
||||||
|
const [online, setOnline] = useState<boolean>(navigator.onLine);
|
||||||
|
useEffect(() => {
|
||||||
|
const on = () => setOnline(true);
|
||||||
|
const off = () => setOnline(false);
|
||||||
|
window.addEventListener('online', on);
|
||||||
|
window.addEventListener('offline', off);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('online', on);
|
||||||
|
window.removeEventListener('offline', off);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
return online;
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import React from 'react';
|
|||||||
import ReactDOM from 'react-dom/client';
|
import ReactDOM from 'react-dom/client';
|
||||||
import { BrowserRouter } from 'react-router-dom';
|
import { BrowserRouter } from 'react-router-dom';
|
||||||
import { App } from './App';
|
import { App } from './App';
|
||||||
|
import { startPendingMessageOrchestrator } from './stores/pendingMessageRehydrate';
|
||||||
import './styles/globals.css';
|
import './styles/globals.css';
|
||||||
|
|
||||||
class ErrorBoundary extends React.Component<
|
class ErrorBoundary extends React.Component<
|
||||||
@@ -105,6 +106,8 @@ class ErrorBoundary extends React.Component<
|
|||||||
const root = document.getElementById('root');
|
const root = document.getElementById('root');
|
||||||
if (!root) throw new Error('Root element not found');
|
if (!root) throw new Error('Root element not found');
|
||||||
|
|
||||||
|
startPendingMessageOrchestrator();
|
||||||
|
|
||||||
ReactDOM.createRoot(root).render(
|
ReactDOM.createRoot(root).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import { usePendingMessageStore, type PendingBubble } from './pendingMessageStore';
|
||||||
|
import { useTransferStore } from './transferStore';
|
||||||
|
import { useUIStore } from './uiStore';
|
||||||
|
import { getApiForOrigin, getChannelOrigin, isDmChannel } from './spaceStore';
|
||||||
|
import { HttpError, RateLimitError } from '../api/client';
|
||||||
|
|
||||||
|
let started = false;
|
||||||
|
|
||||||
|
/** Call once at app start. Idempotent against StrictMode/HMR remounts. */
|
||||||
|
export function startPendingMessageOrchestrator(): void {
|
||||||
|
if (started) return;
|
||||||
|
started = true;
|
||||||
|
|
||||||
|
// 1. TTL discard with toast
|
||||||
|
const dropped = usePendingMessageStore.getState().discardExpired(Date.now());
|
||||||
|
for (const b of dropped) {
|
||||||
|
useUIStore.getState().addToast(
|
||||||
|
`Couldn't send "${b.content || '(attachment-only)'}" — upload expired.`,
|
||||||
|
'warning',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. All-transfers-already-complete branch (handles bubbles whose uploads
|
||||||
|
// finished while the tab was closed)
|
||||||
|
const ready = usePendingMessageStore.getState().listReadyForDeferredSend();
|
||||||
|
for (const b of ready) {
|
||||||
|
if (!sentClientIds.has(b.clientId)) {
|
||||||
|
sentClientIds.add(b.clientId);
|
||||||
|
void deferredSend(b);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Subscribe: any time relevant transfer state changes, re-check ready
|
||||||
|
// bubbles. Dedup by a (id|state|attachmentId) signature so we don't
|
||||||
|
// re-run on every progress tick. `sentClientIds` further dedups
|
||||||
|
// per-bubble dispatch.
|
||||||
|
let lastStateSig = '';
|
||||||
|
useTransferStore.subscribe((s) => {
|
||||||
|
const sig = Array.from(s.transfers.values())
|
||||||
|
.map((t) => `${t.id}|${t.state}|${t.attachmentId ?? ''}`)
|
||||||
|
.sort()
|
||||||
|
.join(',');
|
||||||
|
if (sig === lastStateSig) return;
|
||||||
|
lastStateSig = sig;
|
||||||
|
|
||||||
|
const fresh = usePendingMessageStore.getState().listReadyForDeferredSend();
|
||||||
|
for (const b of fresh) {
|
||||||
|
if (!sentClientIds.has(b.clientId)) {
|
||||||
|
sentClientIds.add(b.clientId);
|
||||||
|
void deferredSend(b);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. Auto-retry on `online`: bump+resend bubbles that failed once with no
|
||||||
|
// prior retries (network-induced failure most likely). Uses the
|
||||||
|
// failed-retryable query — listReadyForDeferredSend gates state==='sending'
|
||||||
|
// and would never return failed bubbles.
|
||||||
|
window.addEventListener('online', () => {
|
||||||
|
const list = usePendingMessageStore.getState().listFailedRetryable();
|
||||||
|
for (const b of list) {
|
||||||
|
usePendingMessageStore.getState().bumpRetry(b.clientId);
|
||||||
|
usePendingMessageStore.getState().markSending(b.clientId);
|
||||||
|
// Add to dispatch set: the markSending transition could re-fire the
|
||||||
|
// subscribe handler indirectly via downstream transferStore changes,
|
||||||
|
// and we want only one in-flight send for this bubble.
|
||||||
|
sentClientIds.add(b.clientId);
|
||||||
|
void deferredSend(b, 1); // online auto-retry counts as the one allowed retry
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const sentClientIds = new Set<string>();
|
||||||
|
|
||||||
|
async function deferredSend(b: PendingBubble, attempt = 0): Promise<void> {
|
||||||
|
const transfers = useTransferStore.getState().transfers;
|
||||||
|
const attachmentIds = b.transferIds
|
||||||
|
.map((tid) => transfers.get(tid)?.attachmentId)
|
||||||
|
.filter((id): id is string => Boolean(id));
|
||||||
|
|
||||||
|
let client;
|
||||||
|
let isDm: boolean;
|
||||||
|
try {
|
||||||
|
const origin = getChannelOrigin(b.channelId);
|
||||||
|
client = getApiForOrigin(origin);
|
||||||
|
isDm = isDmChannel(b.channelId);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[pendingMessageRehydrate] origin resolution failed for', b.clientId, err);
|
||||||
|
usePendingMessageStore.getState().markFailed(b.clientId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (isDm) {
|
||||||
|
await client.dm.sendMessage(b.channelId, {
|
||||||
|
content: b.content,
|
||||||
|
attachments: attachmentIds,
|
||||||
|
replyToId: b.replyToId ?? undefined,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await client.channels.sendMessage(b.channelId, {
|
||||||
|
content: b.content,
|
||||||
|
attachments: attachmentIds,
|
||||||
|
replyToId: b.replyToId ?? undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
usePendingMessageStore.getState().removeByClientId(b.channelId, b.clientId);
|
||||||
|
} catch (err) {
|
||||||
|
const status = err instanceof HttpError ? err.status : 0;
|
||||||
|
const isRateLimited = err instanceof RateLimitError;
|
||||||
|
|
||||||
|
// 4xx (client error) and rate limits are permanent — mark failed,
|
||||||
|
// user retries manually.
|
||||||
|
if ((status >= 400 && status < 500) || isRateLimited) {
|
||||||
|
usePendingMessageStore.getState().markFailed(b.clientId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Network error / 5xx: one immediate retry while online, then mark failed.
|
||||||
|
if (attempt === 0 && navigator.onLine) {
|
||||||
|
usePendingMessageStore.getState().bumpRetry(b.clientId);
|
||||||
|
void deferredSend(b, 1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
usePendingMessageStore.getState().markFailed(b.clientId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -107,4 +107,36 @@ describe('pendingMessageStore', () => {
|
|||||||
expect(dropped).toEqual([]);
|
expect(dropped).toEqual([]);
|
||||||
expect(after).toBe(before); // same Map reference
|
expect(after).toBe(before); // same Map reference
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('listFailedRetryable returns failed bubbles with retryCount=0 and all transfers complete', () => {
|
||||||
|
useTransferStore.setState({
|
||||||
|
transfers: new Map([
|
||||||
|
['t-1', makeTransfer({ id: 't-1', state: 'completed', attachmentId: 'att-1' })],
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
usePendingMessageStore.getState().append(bubble({ clientId: 'fail-ready', transferIds: ['t-1'] }));
|
||||||
|
usePendingMessageStore.getState().markFailed('fail-ready');
|
||||||
|
expect(usePendingMessageStore.getState().listFailedRetryable().map((b) => b.clientId)).toEqual(['fail-ready']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('listFailedRetryable excludes bubbles already retried', () => {
|
||||||
|
useTransferStore.setState({
|
||||||
|
transfers: new Map([['t-1', makeTransfer({ id: 't-1', state: 'completed', attachmentId: 'att-1' })]]),
|
||||||
|
});
|
||||||
|
usePendingMessageStore.getState().append(bubble({ clientId: 'retried', transferIds: ['t-1'], retryCount: 1 }));
|
||||||
|
usePendingMessageStore.getState().markFailed('retried');
|
||||||
|
expect(usePendingMessageStore.getState().listFailedRetryable()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('listFailedRetryable excludes bubbles whose transfers are not all complete', () => {
|
||||||
|
useTransferStore.setState({
|
||||||
|
transfers: new Map([
|
||||||
|
['t-1', makeTransfer({ id: 't-1', state: 'completed', attachmentId: 'att-1' })],
|
||||||
|
['t-2', makeTransfer({ id: 't-2', state: 'paused' })], // no attachmentId
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
usePendingMessageStore.getState().append(bubble({ clientId: 'partial', transferIds: ['t-1', 't-2'] }));
|
||||||
|
usePendingMessageStore.getState().markFailed('partial');
|
||||||
|
expect(usePendingMessageStore.getState().listFailedRetryable()).toEqual([]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ interface PendingMessageStoreActions {
|
|||||||
) => PendingBubble | null;
|
) => PendingBubble | null;
|
||||||
discardExpired: (now: number) => PendingBubble[];
|
discardExpired: (now: number) => PendingBubble[];
|
||||||
listReadyForDeferredSend: () => PendingBubble[];
|
listReadyForDeferredSend: () => PendingBubble[];
|
||||||
|
listFailedRetryable: () => PendingBubble[];
|
||||||
}
|
}
|
||||||
|
|
||||||
type PendingMessageStore = PendingMessageStoreState & PendingMessageStoreActions;
|
type PendingMessageStore = PendingMessageStoreState & PendingMessageStoreActions;
|
||||||
@@ -200,6 +201,23 @@ export const usePendingMessageStore = create<PendingMessageStore>()(
|
|||||||
}
|
}
|
||||||
return ready;
|
return ready;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
listFailedRetryable: () => {
|
||||||
|
const transfers = useTransferStore.getState().transfers;
|
||||||
|
const out: PendingBubble[] = [];
|
||||||
|
for (const list of get().bubbles.values()) {
|
||||||
|
for (const b of list) {
|
||||||
|
if (b.state !== 'failed') continue;
|
||||||
|
if (b.retryCount > 0) continue;
|
||||||
|
const allDone = b.transferIds.every((tid) => {
|
||||||
|
const t = transfers.get(tid);
|
||||||
|
return t?.state === 'completed' && !!t.attachmentId;
|
||||||
|
});
|
||||||
|
if (allDone) out.push(b);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: 'pendingMessageStore@v1',
|
name: 'pendingMessageStore@v1',
|
||||||
|
|||||||
Reference in New Issue
Block a user