Repository navigation
feat(spike): Pace dev page as the third durable client (P3) #430
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,49 @@ | ||
| import type { BackendRpcEvent } from "@pace/backend"; | ||
| import { describe, expect, it, vi } from "vitest"; | ||
| import { backendRelay } from "./backend-relay"; | ||
|
|
||
| function envelope(type: string, payload: Record<string, unknown>): BackendRpcEvent { | ||
| return { type: "event", event: { id: "evt-1", seq: 0, sessionId: "", piSessionId: "", type, ts: "2026-10-02T00:00:00Z", payload } }; | ||
| } | ||
|
|
||
| describe("backendRelay", () => { | ||
| it("maps relay calls to backend commands", async () => { | ||
| const invoke = vi.fn(async () => null) as unknown as <T>(command: string, args?: Record<string, unknown>) => Promise<T>; | ||
| const relay = backendRelay({ invoke, onBackendEvent: () => () => {} }); | ||
|
|
||
| await relay.connect("c1"); | ||
| relay.send("c1", "frame"); | ||
| relay.disconnect("c1"); | ||
|
|
||
| expect(vi.mocked(invoke).mock.calls).toEqual([ | ||
| ["durable_spike_connect", { connectionId: "c1" }], | ||
| ["durable_spike_send", { connectionId: "c1", data: "frame" }], | ||
| ["durable_spike_disconnect", { connectionId: "c1" }], | ||
| ]); | ||
| }); | ||
|
|
||
| it("passes on frames and closes from backend events and drops everything else", () => { | ||
| let emit: (event: BackendRpcEvent) => void = () => {}; | ||
| const relay = backendRelay({ | ||
| invoke: vi.fn() as never, | ||
| onBackendEvent: (listener) => { | ||
| emit = listener; | ||
| return () => {}; | ||
| }, | ||
| }); | ||
| const received: unknown[] = []; | ||
| relay.subscribe((event) => received.push(event)); | ||
|
|
||
| emit(envelope("durable_spike.frame", { connectionId: "c1", data: "{}" })); | ||
| emit(envelope("terminal_output", { terminalId: "t", data: "x" })); | ||
| emit(envelope("durable_spike.frame", { data: "no connection id" })); | ||
| emit(envelope("durable_spike.closed", { connectionId: "c1", code: 4401, reason: "unauthorized" })); | ||
| emit(envelope("durable_spike.closed", { connectionId: "c2", code: 1006 })); | ||
|
|
||
| expect(received).toEqual([ | ||
| { kind: "frame", connectionId: "c1", data: "{}" }, | ||
| { kind: "closed", connectionId: "c1", code: 4401, reason: "unauthorized" }, | ||
| { kind: "closed", connectionId: "c2", code: 1006 }, | ||
| ]); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,40 @@ | ||
| import type { BackendRpcEvent } from "@pace/backend"; | ||
| import type { FrameRelay, RelayEvent } from "@pace/durable-spike/protocol/transport.ts"; | ||
|
|
||
| type Invoke = <T>(command: string, args?: Record<string, unknown>) => Promise<T>; | ||
| type SubscribeBackendEvent = (listener: (event: BackendRpcEvent) => void) => () => void; | ||
|
|
||
| /** | ||
| * The spike's relay over Pace's backend: the renderer cannot open a WebSocket | ||
| * under its CSP, so the backend holds it and frames cross the existing IPC. | ||
| */ | ||
| export function backendRelay({ invoke, onBackendEvent }: { invoke: Invoke; onBackendEvent: SubscribeBackendEvent }): FrameRelay { | ||
| return { | ||
| connect: (connectionId) => invoke<null>("durable_spike_connect", { connectionId }).then(() => {}), | ||
| send: (connectionId, data) => void invoke("durable_spike_send", { connectionId, data }), | ||
| disconnect: (connectionId) => void invoke("durable_spike_disconnect", { connectionId }), | ||
| subscribe: (listener) => | ||
| onBackendEvent((event) => { | ||
| const relayed = relayEventOf(event); | ||
| if (relayed !== undefined) listener(relayed); | ||
|
Comment on lines
+17
to
+19
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔴 Backend restart strands the Pace client When the backend restarts, Learn morePace's main process emits backend lifecycle envelopes on backend exit and restart through sendBackendLifecycleEvent. The spike page only listens for frame and closed envelopes, while its WebSocket lives in the backend process. If the backend exits, the socket dies without a relay close event. The new backend has an empty connection map, so the existing client keeps its connected state and the bridge silently discards its sends. Example: The page connects as Recommended fix: Translate backend-disconnected lifecycle envelopes into close notifications for all active relay connection IDs. Ensure the page reopens through the new backend generation rather than retaining the old connected transport. Was this helpful? React with 👍 or 👎 to provide feedback. |
||
| }), | ||
| }; | ||
| } | ||
|
|
||
| function relayEventOf(event: BackendRpcEvent): RelayEvent | undefined { | ||
| if (event.type !== "event") return undefined; | ||
| const { type, payload } = event.event; | ||
| if (typeof payload.connectionId !== "string") return undefined; | ||
| if (type === "durable_spike.frame" && typeof payload.data === "string") { | ||
| return { kind: "frame", connectionId: payload.connectionId, data: payload.data }; | ||
| } | ||
| if (type === "durable_spike.closed" && typeof payload.code === "number") { | ||
| return { | ||
| kind: "closed", | ||
| connectionId: payload.connectionId, | ||
| code: payload.code, | ||
| ...(typeof payload.reason === "string" ? { reason: payload.reason } : {}), | ||
| }; | ||
| } | ||
| return undefined; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| import { relayTransport } from "@pace/durable-spike/protocol/transport.ts"; | ||
| import { RemoteWorkbench } from "@pace/durable-spike/web/App.tsx"; | ||
| import { invoke, onBackendEvent } from "@/shared/runtime"; | ||
| import { backendRelay } from "./backend-relay"; | ||
|
|
||
| const label = "Pace backend → ws://127.0.0.1:7420"; | ||
| const transport = relayTransport(backendRelay({ invoke, onBackendEvent }), label); | ||
|
|
||
| /** | ||
| * Dev-only third client of the Durable multiview spike (spikes/durable-multiview): | ||
| * the web client's workbench, reached through Pace's backend instead of a | ||
| * browser WebSocket. Start the host first (`bun run host` in the spike). | ||
| */ | ||
| export function DurableSpikePage() { | ||
| return <RemoteWorkbench options={{ transport }} connectionKey="pace-backend" label={label} />; | ||
| } |
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟡 Preflight errors block the Durable page
If the preflight status request fails,
PreflightGaterenders its error before checking the new route exemption. The Durable page never opens, even with its host running.Learn more
The route exemption is calculated by PreflightGate, but that gate returns loading or error screens before allowing any exempt route through. A failed preflight status request blocks the new page even though the route is explicitly exempt. This already applies to other exempt routes, but the newly added page inherits the failure.
Example: With a working Durable host and a failed
get_environment_preflight_statusrequest, navigating to#/durable-spikeshows the preflight error instead of the workbench.Recommended fix: Let exempt paths render their children before the preflight loading and error early returns, while keeping the redirect and error gate for nonexempt paths.
Was this helpful? React with 👍 or 👎 to provide feedback.