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
2 changes: 2 additions & 0 deletions packages/workshop-backend/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -643,6 +643,8 @@ class PublicApiImpl extends RpcTarget implements PublicApi {
this.users = this.ctx.exports.UserDurableObject;
}

async ping(): Promise<void> {}

async getServerConfig(): Promise<ServerConfig> {
return getServerConfig(this.env);
}
Expand Down
10 changes: 8 additions & 2 deletions packages/workshop-frontend/src/GadgetEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
} from '@phosphor-icons/react'
import { RpcStub, RpcTarget } from 'capnweb'
import { useAuthenticatedApi } from './AuthContext'
import { useConnectionLost } from './RpcContext'
import UserMenu from './components/UserMenu'
import SiteLogo from './components/SiteLogo'

Expand Down Expand Up @@ -449,6 +450,7 @@ export default function GadgetEditor() {
isEditingTitleRef.current = isEditingTitle
const [titleInput, setTitleInput] = useState('')

const rpcConnectionLost = useConnectionLost()
const {
overseer,
metadata,
Expand All @@ -473,6 +475,10 @@ export default function GadgetEditor() {
})
const [userInfo, setUserInfo] = useState<AiChatAuthorInfo | null>(null)

// The workspace-level flag covers reopen failures; the socket-level flag covers the outage
// window itself, during which the dead stub stays published and no reopen is attempted yet.
const showReconnecting = connectionLost || rpcConnectionLost

// ── role gating ────────────────────────────────────────────────────────────────
// "use"-role collaborators receive a restricted overseer that only permits rendering and
// interacting with the gadget's deployed UI. We render the minimal use-only view for them (see
Expand Down Expand Up @@ -1434,7 +1440,7 @@ export default function GadgetEditor() {
onViewActivity={openActivity}
/>

{connectionLost && <ReconnectingChip />}
{showReconnecting && <ReconnectingChip />}

<WorkshopIconButton
onClick={() => setShareModalOpen(true)}
Expand Down Expand Up @@ -1466,7 +1472,7 @@ export default function GadgetEditor() {

</div>
<div className="ml-1 flex shrink-0 items-center gap-2">
<span className="md:hidden">{connectionLost && <ReconnectingChip />}</span>
<span className="md:hidden">{showReconnecting && <ReconnectingChip />}</span>
{/* Desktop reaches Export from the gadget pane's tab bar, which is hidden on phones. */}
<span className="md:hidden">
<GadgetExportMenu
Expand Down
138 changes: 101 additions & 37 deletions packages/workshop-frontend/src/main.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { StrictMode, useState, useEffect } from 'react'
import { createRoot } from 'react-dom/client'
import { RouterProvider } from '@tanstack/react-router'
import { RpcStub, newWebSocketRpcSession } from 'capnweb'
import { RpcPromise, RpcStub, newWebSocketRpcSession } from 'capnweb'
import { PublicApi, ServerConfig } from '@gadgets/workshop-shared/api'
import { RpcContext } from './RpcContext'
import { ServerConfigContext, ServerConfigErrorContext } from './ServerConfigContext'
Expand Down Expand Up @@ -56,7 +56,31 @@ async function devAutoLogin(stub: RpcStub<PublicApi>): Promise<void> {
//
// Anyway, I pulled the connection management out into these globals instead.
let lastConnectTime: number = 0;
let backoff: number = 1000;

const INITIAL_BACKOFF_MS = 1000;
const MAX_BACKOFF_MS = 10000;
// Generous probe deadlines let a slow-but-alive backend settle instead of connect/dispose looping
// (or, on wake, tearing down a healthy socket under load).
const RECONNECT_PROBE_TIMEOUT_MS = 20000;
const WAKE_PROBE_TIMEOUT_MS = 10000;
const WAKE_PROBE_MIN_IDLE_MS = 15000;

// Callbacks to call whenever `currentStub` or connection state is updated.
const subscribers = new Set<() => void>();
const notifySubscribers = () => subscribers.forEach(cb => cb());
let isConnectionLost = false;
let probing = false;
let lastProvenAt = Date.now();

const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));

const withTimeout = <T,>(promise: Promise<T>, ms: number): Promise<T> => {
let timer: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error(`timed out after ${ms}ms`)), ms);
});
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
};

function getBackendHost(): string {
// Only the Vite dev server is hosted separately from the backend. Built assets are served from
Expand All @@ -71,52 +95,92 @@ function startConnection(): RpcStub<PublicApi> {
lastConnectTime = Date.now();
const apiHost = getBackendHost();
const wsUrl = (window.location.protocol === 'https:' ? 'wss:' : 'ws:') + '//' + apiHost + '/api';
return newWebSocketRpcSession<PublicApi>(wsUrl);
const stub = newWebSocketRpcSession<PublicApi>(wsUrl);
stub.onRpcBroken(handleBroken);
return stub;
}

async function handleBroken(error: any) {
console.warn('RPC connection lost:', error);
const disposeQuietly = (stub: RpcStub<PublicApi>) => {
try { stub[Symbol.dispose](); } catch { /* already broken */ }
};

isConnectionLost = true;
for (let cb of notifyCurrentStubUpdated) { cb(); }

let timeSinceConnect = Date.now() - lastConnectTime;
if (timeSinceConnect < backoff) {
let waitTime = backoff - timeSinceConnect;
console.warn(`Will try again in ${Math.round(waitTime / 1000)} seconds...`)
await new Promise(resolve => setTimeout(resolve, waitTime));
console.warn(`Retrying connection...`);
backoff = Math.min(backoff * 2, 10000);
} else {
backoff = 1000;
}
// Connects with jittered backoff until a candidate answers a probe, and resolves only to that
// proven connection: capnweb queues sends while a socket is still CONNECTING, so an unproven stub
// looks fine right up until everything pipelined onto it fails at once.
async function reconnect(): Promise<RpcStub<PublicApi>> {
// Fast recovery from one-off blips: skip the first backoff if the dying connection was up a while.
let skipSleep = Date.now() - lastConnectTime >= INITIAL_BACKOFF_MS;
let backoff = INITIAL_BACKOFF_MS;
for (;;) {
if (!skipSleep) {
await sleep(backoff * (0.85 + 0.3 * Math.random())); // jittered against stampedes
backoff = Math.min(backoff * 2, MAX_BACKOFF_MS);
}
skipSleep = false;

currentStub = startConnection();
currentStub.onRpcBroken(handleBroken);
const candidate = startConnection();
try {
await withTimeout(candidate.ping(), RECONNECT_PROBE_TIMEOUT_MS);
} catch (probeError) {
console.debug('Reconnect attempt failed:', probeError);
disposeQuietly(candidate);
continue;
}

// Don't clear isConnectionLost here — the new connection hasn't proven
// it works yet. It gets cleared by markConnectionRestored() once the
// app successfully communicates with the backend.
for (let cb of notifyCurrentStubUpdated) {
cb();
lastProvenAt = Date.now();
isConnectionLost = false;
console.warn('RPC connection restored.');
notifySubscribers();
return candidate;
}
}

// Callbacks to call whenever `currentStub` or connection state is updated.
let notifyCurrentStubUpdated: Set<() => void> = new Set();
let isConnectionLost = false;
// Subscribers hear exactly twice per outage — lost here, restored in `reconnect` — because
// `currentStub` is replaced once, by a promise, rather than once per attempt.
function handleBroken(error: unknown) {
if (isConnectionLost) return; // stale/disposed stub, or recovery already underway
isConnectionLost = true;

console.warn('RPC connection lost:', error);

/** Called externally (e.g., by auth) to indicate the connection is alive. */
export function markConnectionRestored() {
if (!isConnectionLost) return;
isConnectionLost = false;
for (let cb of notifyCurrentStubUpdated) { cb(); }
// Publish a stub for the connection we have not made yet, so the dead one stops being reachable
// immediately. capnweb queues calls pipelined onto an unresolved `RpcPromise` and delivers them,
// in order, once it resolves — so work issued during the outage waits for the replacement
// instead of failing against a socket known to be gone. The `RpcPromise` takes ownership of its
// resolution, keeping the proven stub on a single disposal path.
currentStub = new RpcPromise<PublicApi>(reconnect());
notifySubscribers();
}

// Passive close detection misses sockets killed during laptop sleep or tab throttling, so on
// tab-visible / network-online signals probe the connection instead of letting the user's next
// action hang on a zombie socket.
async function probeOnWake() {
if (isConnectionLost || probing || Date.now() - lastProvenAt < WAKE_PROBE_MIN_IDLE_MS) return;
probing = true;
const suspect = currentStub;
try {
await withTimeout(suspect.ping(), WAKE_PROBE_TIMEOUT_MS);
lastProvenAt = Date.now();
} catch (error) {
if (currentStub !== suspect || isConnectionLost) return; // a real broken event won the race
console.warn('Connection unresponsive after wake:', error);
// Disposal fires onRpcBroken → handleBroken recovers. Its skip-first-backoff path retries
// immediately — right for "the network just came back".
disposeQuietly(suspect);
} finally {
probing = false;
}
}

document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') void probeOnWake();
});
window.addEventListener('online', () => void probeOnWake());

// Current stub. handleBroken() will replace this on disconnect.
installWorkshopErrorReporting()
let currentStub = startConnection();
currentStub.onRpcBroken(handleBroken);

const router = createRouter()
applyStoredThemeMode()
Expand Down Expand Up @@ -153,9 +217,9 @@ function AppWithConnection() {
}, []);

useEffect(() => {
let cb = () => setRpcState({ stub: currentStub, connectionLost: isConnectionLost });
notifyCurrentStubUpdated.add(cb);
return () => { notifyCurrentStubUpdated.delete(cb); };
const cb = () => setRpcState({ stub: currentStub, connectionLost: isConnectionLost });
subscribers.add(cb);
return () => { subscribers.delete(cb); };
}, []);

// Fetch deployment config once the (re)connected stub is available. Re-fetch on reconnect so a
Expand Down
6 changes: 0 additions & 6 deletions packages/workshop-frontend/src/routes/__root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import { TooltipProvider, Toasty } from '@cloudflare/kumo'
import { RpcStub } from 'capnweb'
import { AuthenticatedApi } from '@gadgets/workshop-shared/api'
import { useRpcStub, useConnectionLost } from '../RpcContext'
import { markConnectionRestored } from '../main'
import { useAuth, CF_ACCESS_MODE } from '../useAuth'
import { AuthProvider } from '../AuthContext'
import { FeatureFlagsProvider } from '../FeatureFlagsContext'
Expand All @@ -25,11 +24,6 @@ function RootComponent() {
const { isAuthenticated, authenticatedApi, isLoading, error, logout, login } = useAuth(rpcStub)
const pathname = useRouterState({ select: (s) => s.location.pathname })

// When authenticatedApi becomes available, the connection is proven alive.
useEffect(() => {
if (authenticatedApi) markConnectionRestored()
}, [authenticatedApi])

// Routes that don't require auth (public routes)
const isSignup = pathname === '/signup'
const isBlueprint = pathname.startsWith('/blueprint/')
Expand Down
3 changes: 3 additions & 0 deletions packages/workshop-shared/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ export interface LoginAttempt extends RpcTarget {

/** Public API exposed to the internet. */
export interface PublicApi extends RpcTarget {
/** Confirms that the RPC connection can round-trip without performing application work. */
ping(): Promise<void>;

/**
* Returns deployment-level configuration the client needs at boot (auth mode, available sign-in
* vendors, whether the Cloudflare limits flow is enabled). Contains no secrets.
Expand Down
Loading
Loading