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
77 changes: 64 additions & 13 deletions packages/workshop-backend/src/server.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { RpcStub, RpcTarget, newWorkersRpcResponse } from "capnweb";
import { RpcStub, RpcTarget, newHttpBatchRpcResponse, newWebSocketRpcSession, RpcSessionOptions } from "capnweb";
import { validateRpc } from "capnweb-validate";
import type { JWTPayload } from "jose";
import { PublicApi, AuthenticatedApi, Overseer, GadgetMetadataWithTimestamps, AiChatAuthorInfo, AiModelConfig, AiGatewayInfo, AiModelProvider, ConnectedAccountsSubscriber, ConnectedAccountsFilter, GatekeeperVendorFilter, ObserverConfigCallback, BlueprintLibrarySummary, BlueprintPublicInfo, BlueprintUserSummary, BlueprintBindingAssignment, AgentSpawnerConfig, WorkpieceId, BLUEPRINT_SCREENSHOT_PATH_PREFIX, BLUEPRINT_SCREENSHOT_R2_PREFIX, blueprintScreenshotUrl, ServerConfig, CloudflareUsageInfo, CloudflareAccountOption, LoginAttempt, GatekeeperAppInfo, AdminApi, GatekeeperVendorInfo, OutputFormatOffer, ListOutputsResult, createOpenGadgetError, getOpenGadgetErrorCode, OPEN_GADGET_ERROR_CODES, AUTH_ERROR_CODES, createAuthError } from '@gadgets/workshop-shared/api';
Expand Down Expand Up @@ -843,25 +843,76 @@ export default {

// HACK: Implement `abortSession` callback by closing the websocket.
// TODO: When ctx.abort() becomes non-experimental, consider using that instead.
let resp: Response | undefined;
let aborted = false;
let abortController = new AbortController();
let abortSession = (reason: Error) => {
// Closing the socket fails no invocation, so nothing else logs this.
logger.warn("aborting api session", { event: "session.abort", error: reason });
aborted = true;
resp?.webSocket?.close();
abortController.abort(reason);
};

resp = await newWorkersRpcResponse(req,
new PublicApiImpl(ctx, env, abortSession, accessPayload));

if (aborted) {
// Oops, we missed the abortSession() call while awaiting, apply now.
resp?.webSocket?.close();
}
return resp;
return await newWorkersRpcResponse(req,
new PublicApiImpl(ctx, env, abortSession, accessPayload),
{ abortSignal: abortController.signal });
}

return new Response("Not Found", {status: 404});
}
} satisfies ExportedHandler<Env>;

// Extend Cap'n Web's RpcSessionOptions with an AbortSignal.
//
// TODO: Consider adding this feature to Cap'n Web. However, we might not actually need it for
// long: ctx.abort() will soon be available non-experimentally, in which case we can just use
// that instead.
type ExtendedRpcSessionOptions = RpcSessionOptions & {
// Abort WebSocket sessions when this AbortSignal is aborted. (No effect on HTTP batch sessions.)
abortSignal: AbortSignal;
};

// Clone of newWorkersRpcResponse() from Cap'n Web, except the `options` has been extended with
// `abortSignal`.
async function newWorkersRpcResponse(
request: Request, localMain: any, options?: ExtendedRpcSessionOptions) {
if (request.method === "POST") {
let response = await newHttpBatchRpcResponse(request, localMain, options);
// Since we're exposing the same API over WebSocket, too, and WebSocket always allows
// cross-origin requests, the API necessarily must be safe for cross-origin use (e.g. because
// it uses in-band authorization, as recommended in the readme). So, we might as well allow
// batch requests to be made cross-origin as well.
response.headers.set("Access-Control-Allow-Origin", "*");
return response;
} else if (request.headers.get("Upgrade")?.toLowerCase() === "websocket") {
return newWorkersWebSocketRpcResponse(request, localMain, options);
} else {
return new Response("This endpoint only accepts POST or WebSocket requests.", { status: 400 });
}
}

function newWorkersWebSocketRpcResponse(
request: Request, localMain?: any, options?: ExtendedRpcSessionOptions): Response {
if (request.headers.get("Upgrade")?.toLowerCase() !== "websocket") {
return new Response("This endpoint only accepts WebSocket requests.", { status: 400 });
}

let pair = new WebSocketPair();
let server = pair[0];
server.accept()
let stub = newWebSocketRpcSession(server, localMain, options);

// -- ADDED FOR GADGETS --
if (options?.abortSignal) {
if (options.abortSignal.aborted) {
stub[Symbol.dispose]();
} else {
options.abortSignal.addEventListener("abort", () => {
stub[Symbol.dispose]();
});
}
}
// -- END ADDED FOR GADGETS --

return new Response(null, {
status: 101,
webSocket: pair[1],
});
}
9 changes: 8 additions & 1 deletion packages/workshop-frontend/src/Connections.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -118,9 +118,16 @@ export default function Connections({ overseer, gadget, chatId, authenticatedApi
}
}

// Keyed on the `gadget` stub rather than `overseer`, even though the load uses both. The gadget
// stub is derived from the overseer by an effect in the parent, so on reconnect it arrives one
// render *after* the replacement overseer: keying on `overseer` fired this load while `gadget`
// still pointed into the dead session (a guaranteed spurious failure), and then never fired
// again once the live stub showed up, leaving the panel showing pre-disconnect state. Keying on
// the derived stub can't observe that intermediate render, and a new overseer always yields a
// new gadget stub, so reconnects are still covered.
useEffect(() => {
loadGatekeepers()
}, [overseer, chatId])
}, [gadget, chatId])

// Re-load when the tab becomes visible, so hooks enabled elsewhere (e.g. from the Activity log)
// show up without a full page reload.
Expand Down
7 changes: 2 additions & 5 deletions packages/workshop-frontend/src/GadgetEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ import TopBarNotice from './TopBarNotice'
import { WorkshopButton, WorkshopIconButton, WorkshopInput } from './components/WorkshopControls'
import { useActions } from './useActions'
import DeleteConfirmationDialog from './components/DeleteConfirmationDialog'
import ReconnectingChip from './components/ReconnectingChip'
import WorkspaceOpenErrorPage from './components/WorkspaceOpenErrorPage'
import { useWorkspaceOpen } from './useWorkspaceOpen'
import { reportIssue } from './errorReporting'
Expand Down Expand Up @@ -1413,11 +1414,7 @@ export default function GadgetEditor() {
onViewActivity={openActivity}
/>

{connectionLost && (
<span className="text-xs text-kumo-warning px-2 py-0.5 rounded-full bg-kumo-warning-tint border border-kumo-warning/20">
Reconnecting…
</span>
)}
{connectionLost && <ReconnectingChip />}

<WorkshopIconButton
onClick={() => setShareModalOpen(true)}
Expand Down
15 changes: 12 additions & 3 deletions packages/workshop-frontend/src/components/AppShell/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { useCallback, useEffect, useState } from 'react'
import { useRouterState } from '@tanstack/react-router'
import { List, X } from '@phosphor-icons/react'
import TopBarNotice from '../../TopBarNotice'
import ReconnectingChip from '../ReconnectingChip'
import { useConnectionLost } from '../../RpcContext'
import Sidebar from './Sidebar'
import CommandPalette from './CommandPalette'
import { OPEN_COMMAND_PALETTE_EVENT } from './commandPaletteBus'
Expand All @@ -28,6 +30,7 @@ export default function AppShell({ children }: { children: React.ReactNode }) {
const [collapsed, setCollapsed] = useState<boolean>(readCollapsed)
const [mobileOpen, setMobileOpen] = useState(false)
const [paletteOpen, setPaletteOpen] = useState(false)
const connectionLost = useConnectionLost()

const toggleCollapsed = useCallback(() => {
setCollapsed((prev) => {
Expand Down Expand Up @@ -97,8 +100,8 @@ export default function AppShell({ children }: { children: React.ReactNode }) {
{/* Main column */}
<div className="flex min-w-0 flex-1 flex-col">
{/* Top bar. Same height as the sidebar's brand row (h-14) so they read as one continuous
chrome strip across the top. Mostly empty — carries the mobile hamburger on the left and
any admin TopBarNotice centered. */}
chrome strip across the top. Mostly empty — carries the mobile hamburger on the left,
any admin TopBarNotice centered, and the reconnecting chip on the right. */}
<div className="relative flex h-14 shrink-0 items-center justify-between border-b border-kumo-line bg-kumo-base px-3">
<button
type="button"
Expand All @@ -109,7 +112,13 @@ export default function AppShell({ children }: { children: React.ReactNode }) {
{mobileOpen ? <X size={16} /> : <List size={16} />}
</button>
<TopBarNotice />
<span aria-hidden="true" className="h-7 w-7 md:hidden" />
{/* `ml-auto` rather than the container's `justify-between`: on desktop the hamburger is
hidden, leaving this the only in-flow child, which `justify-between` would park on the
left. */}
<div className="ml-auto flex items-center gap-2">
{connectionLost && <ReconnectingChip />}
<span aria-hidden="true" className="h-7 w-7 md:hidden" />
</div>
</div>

{/* Routed content. Flat enterprise canvas — no texture. */}
Expand Down
16 changes: 16 additions & 0 deletions packages/workshop-frontend/src/components/ReconnectingChip.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
/**
* "Reconnecting…" pill for a fixed-height chrome strip (the workspace editor's top bar, the app
* shell's top bar). Deliberately an inline chip rather than a full-width banner: a banner inserted
* above the page reflows everything below it, so a blip that recovers on its own visibly jolts the
* layout twice.
*/
export default function ReconnectingChip() {
return (
<span
role="status"
className="text-xs text-kumo-warning px-2 py-0.5 rounded-full bg-kumo-warning-tint border border-kumo-warning/20"
>
Reconnecting…
</span>
)
}
16 changes: 2 additions & 14 deletions packages/workshop-frontend/src/routes/__root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,6 @@ export const Route = createRootRoute({
component: RootComponent,
})

function ConnectionLostBanner() {
return (
<div className="sticky top-0 z-[100] bg-kumo-warning-tint border-b border-kumo-warning/30 px-4 py-2 text-center text-sm text-kumo-warning">
Connection lost — reconnecting…
</div>
)
}

function RootComponent() {
const rpcStub = useRpcStub()
const connectionLost = useConnectionLost()
Expand Down Expand Up @@ -62,7 +54,6 @@ function RootComponent() {
if (isLoading && !standalone) {
return (
<div className="min-h-screen flex items-center justify-center flex-col gap-4 bg-kumo-base">
{connectionLost && <ConnectionLostBanner />}
<div className="w-8 h-8 border-2 border-kumo-brand border-t-transparent rounded-full animate-spin" />
<p className="text-sm text-kumo-subtle">{connectionLost ? 'Waiting for server…' : 'Loading...'}</p>
</div>
Expand Down Expand Up @@ -123,7 +114,6 @@ function RootComponent() {
<Toasty>
<AuthenticatedShell
authenticatedApi={authenticatedApi}
connectionLost={connectionLost}
isWorkspaceEditor={isWorkspaceEditor}
/>
</Toasty>
Expand All @@ -140,11 +130,9 @@ function RootComponent() {
*/
function AuthenticatedShell({
authenticatedApi,
connectionLost,
isWorkspaceEditor,
}: {
authenticatedApi: RpcStub<AuthenticatedApi>
connectionLost: boolean
isWorkspaceEditor: boolean
}) {
// null = still checking, true = needs onboarding, false = onboarding done
Expand Down Expand Up @@ -177,11 +165,11 @@ function AuthenticatedShell({
}

// Normal app shell. The workspace editor is rendered fullscreen (no chrome); everything else
// gets the persistent left-rail AppShell.
// gets the persistent left-rail AppShell. Connection loss is surfaced by a chip in whichever of
// those two top bars is showing, never by a banner that reflows the page (see ReconnectingChip).
const fullscreen = isWorkspaceEditor
return (
<>
{connectionLost && <ConnectionLostBanner />}
<AccountSelectionModal />
{fullscreen ? (
<main>
Expand Down
Loading