A tool for people curious about the React Server Components protocol rscexplorer.dev/
rsc react
35
fork

Configure Feed

Select the types of activity you want to include in your feed.

tighten up worker sessions

+374 -442
+1 -8
src/client/runtime/index.ts
··· 5 5 type Thenable, 6 6 type CallServerCallback, 7 7 } from "./steppable-stream.ts"; 8 - export { 9 - Timeline, 10 - type TimelineEntry, 11 - type RenderEntry, 12 - type ActionEntry, 13 - type TimelineSnapshot, 14 - type TimelinePosition, 15 - } from "./timeline.ts"; 8 + export { Timeline, type EntryView, type TimelineSnapshot } from "./timeline.ts";
+87 -112
src/client/runtime/timeline.ts
··· 1 - import type { SteppableStream } from "./steppable-stream.ts"; 1 + import type { SteppableStream, Thenable } from "./steppable-stream.ts"; 2 2 3 - /** 4 - * Timeline - manages a sequence of Flight responses for debugging. 5 - * 6 - * Each entry owns its SteppableStream(s). The cursor controls playback. 7 - * Stepping releases data to streams; I/O is handled externally. 8 - * 9 - * Entry types: 10 - * - render: { type, stream } - initial render 11 - * - action: { type, name, args, stream } - action invoked from client or added manually 12 - */ 13 - 14 - export interface RenderEntry { 15 - type: "render"; 16 - stream: SteppableStream; 17 - } 18 - 19 - export interface ActionEntry { 20 - type: "action"; 21 - name: string; 22 - args: string; 23 - stream: SteppableStream; 24 - } 3 + type InternalEntry = 4 + | { type: "render"; stream: SteppableStream } 5 + | { type: "action"; name: string; args: string; stream: SteppableStream }; 25 6 26 - export type TimelineEntry = RenderEntry | ActionEntry; 7 + export type EntryView = { 8 + type: "render" | "action"; 9 + name?: string; 10 + args?: string; 11 + rows: string[]; 12 + flightPromise: Thenable<unknown> | undefined; 13 + chunkStart: number; 14 + chunkCount: number; 15 + canDelete: boolean; 16 + isActive: boolean; 17 + isDone: boolean; 18 + }; 27 19 28 20 export interface TimelineSnapshot { 29 - entries: TimelineEntry[]; 21 + entries: EntryView[]; 30 22 cursor: number; 31 23 totalChunks: number; 32 24 isAtStart: boolean; 33 25 isAtEnd: boolean; 34 26 } 35 27 36 - export interface TimelinePosition { 37 - entryIndex: number; 38 - localChunk: number; 39 - } 40 - 41 - type TimelineListener = () => void; 28 + type Listener = () => void; 42 29 43 30 export class Timeline { 44 - entries: TimelineEntry[] = []; 45 - cursor = 0; 46 - private listeners: Set<TimelineListener> = new Set(); 47 - private snapshot: TimelineSnapshot | null = null; 48 - 49 - subscribe = (listener: TimelineListener): (() => void) => { 50 - this.listeners.add(listener); 51 - return () => { 52 - this.listeners.delete(listener); 53 - }; 54 - }; 31 + private entries: InternalEntry[] = []; 32 + private cursor = 0; 33 + private listeners = new Set<Listener>(); 34 + private cachedSnapshot: TimelineSnapshot | null = null; 55 35 56 36 private notify(): void { 57 - this.snapshot = null; // Invalidate cache 58 - this.listeners.forEach((fn) => fn()); 59 - } 60 - 61 - getChunkCount(entry: TimelineEntry): number { 62 - return entry.stream.rows.length; 37 + this.cachedSnapshot = null; 38 + for (const fn of this.listeners) { 39 + fn(); 40 + } 63 41 } 64 42 65 - getTotalChunks(): number { 66 - return this.entries.reduce((sum, e) => sum + this.getChunkCount(e), 0); 67 - } 43 + subscribe = (listener: Listener): (() => void) => { 44 + this.listeners.add(listener); 45 + return () => this.listeners.delete(listener); 46 + }; 68 47 69 - getPosition(globalChunk: number): TimelinePosition | null { 70 - let remaining = globalChunk; 71 - for (let i = 0; i < this.entries.length; i++) { 72 - const entry = this.entries[i]; 73 - if (!entry) continue; 74 - const count = this.getChunkCount(entry); 75 - if (remaining < count) { 76 - return { entryIndex: i, localChunk: remaining }; 77 - } 78 - remaining -= count; 48 + getSnapshot = (): TimelineSnapshot => { 49 + if (this.cachedSnapshot) { 50 + return this.cachedSnapshot; 79 51 } 80 - return null; 81 - } 82 52 83 - getEntryStart(entryIndex: number): number { 84 - let start = 0; 85 - for (let i = 0; i < entryIndex; i++) { 86 - const entry = this.entries[i]; 87 - if (entry) { 88 - start += this.getChunkCount(entry); 53 + let chunkStart = 0; 54 + const entries: EntryView[] = this.entries.map((entry) => { 55 + const chunkCount = entry.stream.rows.length; 56 + const chunkEnd = chunkStart + chunkCount; 57 + const base = { 58 + rows: entry.stream.rows, 59 + flightPromise: entry.stream.flightPromise, 60 + chunkStart, 61 + chunkCount, 62 + canDelete: this.cursor <= chunkStart, 63 + isActive: this.cursor >= chunkStart && this.cursor < chunkEnd, 64 + isDone: this.cursor >= chunkEnd, 65 + }; 66 + chunkStart = chunkEnd; 67 + if (entry.type === "action") { 68 + return { type: "action" as const, name: entry.name, args: entry.args, ...base }; 89 69 } 90 - } 91 - return start; 92 - } 93 - 94 - canDeleteEntry(entryIndex: number): boolean { 95 - if (entryIndex < 0 || entryIndex >= this.entries.length) return false; 96 - return this.cursor <= this.getEntryStart(entryIndex); 97 - } 70 + return { type: "render" as const, ...base }; 71 + }); 98 72 99 - // For useSyncExternalStore compatibility - must return cached object 100 - getSnapshot = (): TimelineSnapshot => { 101 - if (this.snapshot) return this.snapshot; 102 - 103 - const totalChunks = this.getTotalChunks(); 104 - this.snapshot = { 105 - entries: this.entries, 73 + this.cachedSnapshot = { 74 + entries, 106 75 cursor: this.cursor, 107 - totalChunks, 76 + totalChunks: chunkStart, 108 77 isAtStart: this.cursor === 0, 109 - isAtEnd: this.cursor >= totalChunks, 78 + isAtEnd: this.cursor >= chunkStart, 110 79 }; 111 - return this.snapshot; 80 + return this.cachedSnapshot; 112 81 }; 113 82 114 83 setRender(stream: SteppableStream): void { ··· 122 91 this.notify(); 123 92 } 124 93 125 - deleteEntry(entryIndex: number): boolean { 126 - if (!this.canDeleteEntry(entryIndex)) return false; 94 + deleteEntry(entryIndex: number): void { 95 + let chunkStart = 0; 96 + for (let i = 0; i < entryIndex; i++) { 97 + chunkStart += this.entries[i]!.stream.rows.length; 98 + } 99 + if (this.cursor > chunkStart) { 100 + return; 101 + } 127 102 this.entries = this.entries.filter((_, i) => i !== entryIndex); 128 103 this.notify(); 129 - return true; 130 104 } 131 105 132 106 stepForward(): void { 133 - const total = this.getTotalChunks(); 134 - if (this.cursor >= total) return; 135 - 136 - const pos = this.getPosition(this.cursor); 137 - if (!pos) return; 138 - 139 - const entry = this.entries[pos.entryIndex]; 140 - if (!entry) return; 141 - 142 - this.cursor++; 143 - entry.stream.release(pos.localChunk + 1); 144 - 145 - this.notify(); 107 + let remaining = this.cursor; 108 + for (const entry of this.entries) { 109 + const count = entry.stream.rows.length; 110 + if (remaining < count) { 111 + entry.stream.release(remaining + 1); 112 + this.cursor++; 113 + this.notify(); 114 + return; 115 + } 116 + remaining -= count; 117 + } 146 118 } 147 119 148 120 skipToEntryEnd(): void { 149 - const pos = this.getPosition(this.cursor); 150 - if (!pos) return; 151 - 152 - const entry = this.entries[pos.entryIndex]; 153 - if (!entry) return; 154 - 155 - const entryEnd = this.getEntryStart(pos.entryIndex) + this.getChunkCount(entry); 156 - while (this.cursor < entryEnd) { 157 - this.stepForward(); 121 + let remaining = this.cursor; 122 + for (const entry of this.entries) { 123 + const count = entry.stream.rows.length; 124 + if (remaining < count) { 125 + for (let local = remaining; local < count; local++) { 126 + entry.stream.release(local + 1); 127 + } 128 + this.cursor += count - remaining; 129 + this.notify(); 130 + return; 131 + } 132 + remaining -= count; 158 133 } 159 134 } 160 135
+20 -4
src/client/styles/workspace.css
··· 40 40 display: grid; 41 41 grid-template-columns: 50% 50%; 42 42 grid-template-rows: 50% 50%; 43 + grid-template-areas: 44 + "server flight" 45 + "client preview"; 43 46 overflow: hidden; 44 47 } 48 + /* Grid area assignments */ 49 + .pane.editor-server { 50 + grid-area: server; 51 + } 52 + .pane.editor-client { 53 + grid-area: client; 54 + } 55 + .pane.flight-pane { 56 + grid-area: flight; 57 + } 58 + .pane.preview-pane { 59 + grid-area: preview; 60 + } 45 61 .pane { 46 62 display: flex; 47 63 flex-direction: column; 48 64 overflow: hidden; 49 65 } 50 66 /* Left column border */ 51 - .pane:nth-child(1), 52 - .pane:nth-child(3) { 67 + .pane.editor-server, 68 + .pane.editor-client { 53 69 border-right: 1px solid var(--border); 54 70 } 55 71 /* Top row border */ 56 - .pane:nth-child(1), 57 - .pane:nth-child(2) { 72 + .pane.editor-server, 73 + .pane.flight-pane { 58 74 border-bottom: 1px solid var(--border); 59 75 } 60 76 .pane-header {
+8 -2
src/client/ui/CodeEditor.tsx
··· 43 43 defaultValue: string; 44 44 onChange: (code: string) => void; 45 45 label: string; 46 + className?: string; 46 47 }; 47 48 48 - export function CodeEditor({ defaultValue, onChange, label }: CodeEditorProps): React.ReactElement { 49 + export function CodeEditor({ 50 + defaultValue, 51 + onChange, 52 + label, 53 + className, 54 + }: CodeEditorProps): React.ReactElement { 49 55 const [initialDefaultValue] = useState(defaultValue); 50 56 const containerRef = useRef<HTMLDivElement>(null); 51 57 ··· 80 86 }, [initialDefaultValue]); 81 87 82 88 return ( 83 - <div className="pane"> 89 + <div className={`pane${className ? ` ${className}` : ""}`}> 84 90 <div className="pane-header">{label}</div> 85 91 <div className="editor-container" ref={containerRef} /> 86 92 </div>
+37 -123
src/client/ui/FlightLog.tsx
··· 1 1 import React, { useState, useRef, useEffect } from "react"; 2 2 import { FlightTreeView } from "./TreeView.tsx"; 3 - import type { Timeline, TimelineEntry, Thenable } from "../runtime/index.ts"; 3 + import type { EntryView } from "../runtime/index.ts"; 4 4 5 5 function escapeHtml(str: string): string { 6 6 return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;"); 7 7 } 8 8 9 9 type RenderLogViewProps = { 10 - lines: string[]; 11 - chunkStart: number; 10 + entry: EntryView; 12 11 cursor: number; 13 - flightPromise: Thenable<unknown> | undefined; 14 12 }; 15 13 16 - function RenderLogView({ 17 - lines, 18 - chunkStart, 19 - cursor, 20 - flightPromise, 21 - }: RenderLogViewProps): React.ReactElement | null { 14 + function RenderLogView({ entry, cursor }: RenderLogViewProps): React.ReactElement | null { 22 15 const activeRef = useRef<HTMLSpanElement>(null); 16 + const { rows, chunkStart, flightPromise } = entry; 17 + 23 18 const nextLineIndex = 24 - cursor >= chunkStart && cursor < chunkStart + lines.length ? cursor - chunkStart : -1; 19 + cursor >= chunkStart && cursor < chunkStart + rows.length ? cursor - chunkStart : -1; 25 20 26 21 useEffect(() => { 27 22 if (activeRef.current && document.hasFocus()) { ··· 29 24 } 30 25 }, [nextLineIndex]); 31 26 32 - if (lines.length === 0) return null; 27 + if (rows.length === 0) return null; 33 28 34 29 const getLineClass = (i: number): string => { 35 30 const globalChunk = chunkStart + i; ··· 45 40 <div className="log-entry-split"> 46 41 <div className="log-entry-flight-lines-wrapper"> 47 42 <pre className="log-entry-flight-lines"> 48 - {lines.map((line, i) => ( 43 + {rows.map((line, i) => ( 49 44 <span 50 45 key={i} 51 46 ref={i === nextLineIndex ? activeRef : null} ··· 65 60 } 66 61 67 62 type FlightLogEntryProps = { 68 - entry: TimelineEntry; 69 - entryIndex: number; 70 - chunkStart: number; 63 + entry: EntryView; 64 + index: number; 71 65 cursor: number; 72 - canDelete: boolean; 73 66 onDelete: (index: number) => void; 74 - getChunkCount: (entry: TimelineEntry) => number; 75 67 }; 76 68 77 69 function FlightLogEntry({ 78 70 entry, 79 - entryIndex, 80 - chunkStart, 71 + index, 81 72 cursor, 82 - canDelete, 83 73 onDelete, 84 - getChunkCount, 85 - }: FlightLogEntryProps): React.ReactElement | null { 86 - const chunkCount = getChunkCount(entry); 87 - const entryEnd = chunkStart + chunkCount; 88 - const isEntryActive = cursor >= chunkStart && cursor < entryEnd; 89 - const isEntryDone = cursor >= entryEnd; 90 - 91 - const entryClass = isEntryActive ? "active" : isEntryDone ? "done-entry" : "pending-entry"; 74 + }: FlightLogEntryProps): React.ReactElement { 75 + const entryClass = entry.isActive ? "active" : entry.isDone ? "done-entry" : "pending-entry"; 92 76 93 - if (entry.type === "render") { 94 - const lines = entry.stream.rows; 95 - return ( 96 - <div className={`log-entry ${entryClass}`}> 97 - <div className="log-entry-header"> 98 - <span className="log-entry-label">Render</span> 99 - <span className="log-entry-header-right"> 100 - {canDelete && ( 101 - <button 102 - className="delete-entry-btn" 103 - onClick={() => onDelete(entryIndex)} 104 - title="Delete" 105 - > 106 - × 107 - </button> 108 - )} 109 - </span> 110 - </div> 111 - <RenderLogView 112 - lines={lines} 113 - chunkStart={chunkStart} 114 - cursor={cursor} 115 - flightPromise={entry.stream.flightPromise} 116 - /> 77 + return ( 78 + <div className={`log-entry ${entryClass}`}> 79 + <div className="log-entry-header"> 80 + <span className="log-entry-label"> 81 + {entry.type === "render" ? "Render" : `Action: ${entry.name}`} 82 + </span> 83 + <span className="log-entry-header-right"> 84 + {entry.canDelete && ( 85 + <button className="delete-entry-btn" onClick={() => onDelete(index)} title="Delete"> 86 + × 87 + </button> 88 + )} 89 + </span> 117 90 </div> 118 - ); 119 - } 120 - 121 - if (entry.type === "action") { 122 - const responseLines = entry.stream.rows; 123 - 124 - return ( 125 - <div className={`log-entry ${entryClass}`}> 126 - <div className="log-entry-header"> 127 - <span className="log-entry-label">Action: {entry.name}</span> 128 - <span className="log-entry-header-right"> 129 - {canDelete && ( 130 - <button 131 - className="delete-entry-btn" 132 - onClick={() => onDelete(entryIndex)} 133 - title="Delete" 134 - > 135 - × 136 - </button> 137 - )} 138 - </span> 91 + {entry.type === "action" && entry.args && ( 92 + <div className="log-entry-request"> 93 + <pre className="log-entry-request-args">{entry.args}</pre> 139 94 </div> 140 - {entry.args && ( 141 - <div className="log-entry-request"> 142 - <pre className="log-entry-request-args">{entry.args}</pre> 143 - </div> 144 - )} 145 - <RenderLogView 146 - lines={responseLines} 147 - chunkStart={chunkStart} 148 - cursor={cursor} 149 - flightPromise={entry.stream.flightPromise} 150 - /> 151 - </div> 152 - ); 153 - } 154 - 155 - return null; 95 + )} 96 + <RenderLogView entry={entry} cursor={cursor} /> 97 + </div> 98 + ); 156 99 } 157 100 158 101 type FlightLogProps = { 159 - timeline: Timeline; 160 - entries: TimelineEntry[]; 102 + entries: EntryView[]; 161 103 cursor: number; 162 - error: string | null; 163 104 availableActions: string[]; 164 105 onAddRawAction: (actionName: string, rawPayload: string) => void; 165 106 onDeleteEntry: (index: number) => void; 166 107 }; 167 108 168 109 export function FlightLog({ 169 - timeline, 170 110 entries, 171 111 cursor, 172 - error, 173 112 availableActions, 174 113 onAddRawAction, 175 114 onDeleteEntry, ··· 193 132 setShowRawInput(true); 194 133 }; 195 134 196 - if (error) { 197 - return <pre className="flight-output error">{error}</pre>; 198 - } 199 - 200 135 if (entries.length === 0) { 201 136 return ( 202 137 <div className="flight-output"> ··· 205 140 ); 206 141 } 207 142 208 - const getChunkCount = (entry: TimelineEntry): number => timeline.getChunkCount(entry); 209 - 210 - const entryElements: React.ReactElement[] = []; 211 - let chunkOffset = 0; 212 - for (let i = 0; i < entries.length; i++) { 213 - const entry = entries[i]; 214 - if (!entry) continue; 215 - const chunkStart = chunkOffset; 216 - chunkOffset += getChunkCount(entry); 217 - entryElements.push( 218 - <FlightLogEntry 219 - key={i} 220 - entry={entry} 221 - entryIndex={i} 222 - chunkStart={chunkStart} 223 - cursor={cursor} 224 - canDelete={timeline.canDeleteEntry(i)} 225 - onDelete={onDeleteEntry} 226 - getChunkCount={getChunkCount} 227 - />, 228 - ); 229 - } 230 - 231 143 return ( 232 144 <div className="flight-log" ref={logRef}> 233 - {entryElements} 145 + {entries.map((entry, i) => ( 146 + <FlightLogEntry key={i} entry={entry} index={i} cursor={cursor} onDelete={onDeleteEntry} /> 147 + ))} 234 148 {availableActions.length > 0 && 235 149 (showRawInput ? ( 236 150 <div className="raw-input-form">
+8 -19
src/client/ui/LivePreview.tsx
··· 1 - import React, { 2 - Suspense, 3 - Component, 4 - useState, 5 - useEffect, 6 - useSyncExternalStore, 7 - type ReactNode, 8 - } from "react"; 9 - import type { Timeline, Thenable } from "../runtime/index.ts"; 1 + import React, { Suspense, Component, useState, useEffect, type ReactNode } from "react"; 2 + import type { EntryView, Thenable } from "../runtime/index.ts"; 10 3 11 4 type PreviewErrorBoundaryProps = { 12 5 children: ReactNode; ··· 47 40 } 48 41 49 42 type LivePreviewProps = { 50 - timeline: Timeline; 51 - clientModuleReady: boolean; 43 + entries: EntryView[]; 44 + cursor: number; 52 45 totalChunks: number; 53 - cursor: number; 54 46 isAtStart: boolean; 55 47 isAtEnd: boolean; 56 48 onStep: () => void; ··· 59 51 }; 60 52 61 53 export function LivePreview({ 62 - timeline, 63 - clientModuleReady, 54 + entries, 55 + cursor, 64 56 totalChunks, 65 - cursor, 66 57 isAtStart, 67 58 isAtEnd, 68 59 onStep, 69 60 onSkip, 70 61 onReset, 71 62 }: LivePreviewProps): React.ReactElement { 72 - const snapshot = useSyncExternalStore(timeline.subscribe, timeline.getSnapshot); 73 - const { entries } = snapshot; 74 63 const renderEntry = entries[0]; 75 - const flightPromise = renderEntry?.stream.flightPromise; 64 + const flightPromise = renderEntry?.flightPromise; 76 65 77 66 const [isPlaying, setIsPlaying] = useState(false); 78 67 ··· 91 80 setIsPlaying(false); 92 81 }, [totalChunks]); 93 82 94 - const showPlaceholder = !clientModuleReady || cursor === 0; 83 + const showPlaceholder = entries.length === 0 || cursor === 0; 95 84 96 85 const handlePlayPause = (): void => setIsPlaying(!isPlaying); 97 86 const handleStep = (): void => {
+96 -150
src/client/ui/Workspace.tsx
··· 1 - import React, { useState, useEffect, useRef, useCallback, useSyncExternalStore } from "react"; 2 - import { encodeReply } from "react-server-dom-webpack/client"; 3 - import { 4 - Timeline, 5 - SteppableStream, 6 - registerClientModule, 7 - evaluateClientModule, 8 - type CallServerCallback, 9 - } from "../runtime/index.ts"; 10 - import { WorkerClient, encodeArgs } from "../worker-client.ts"; 11 - import { 12 - parseClientModule, 13 - parseServerActions, 14 - compileToCommonJS, 15 - buildManifest, 16 - } from "../../shared/compiler.ts"; 1 + import React, { useState, useEffect, useSyncExternalStore, startTransition } from "react"; 2 + import { WorkspaceSession } from "../workspace-session.ts"; 17 3 import { CodeEditor } from "./CodeEditor.tsx"; 18 4 import { FlightLog } from "./FlightLog.tsx"; 19 5 import { LivePreview } from "./LivePreview.tsx"; ··· 24 10 onCodeChange?: (server: string, client: string) => void; 25 11 }; 26 12 27 - type CallServerRef = { 28 - current: ((actionId: string, args: unknown[]) => Promise<unknown>) | null; 29 - }; 30 - 31 13 export function Workspace({ 32 14 initialServerCode, 33 15 initialClientCode, ··· 35 17 }: WorkspaceProps): React.ReactElement { 36 18 const [serverCode, setServerCode] = useState(initialServerCode); 37 19 const [clientCode, setClientCode] = useState(initialClientCode); 38 - const [timeline] = useState(() => new Timeline()); 39 - const [workerClient, setWorkerClient] = useState<WorkerClient | null>(null); 40 - const [callServerRef] = useState<CallServerRef>({ current: null }); 20 + const [resetKey, setResetKey] = useState(0); 21 + const [session, setSession] = useState<WorkspaceSession | null>(null); 41 22 42 - const snapshot = useSyncExternalStore(timeline.subscribe, timeline.getSnapshot); 43 - const { entries, cursor, totalChunks, isAtStart, isAtEnd } = snapshot; 44 - 45 - const [clientModuleReady, setClientModuleReady] = useState(false); 46 - const [error, setError] = useState<string | null>(null); 47 - const [availableActions, setAvailableActions] = useState<string[]>([]); 48 - const compileTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); 23 + useEffect(() => { 24 + const abort = new AbortController(); 25 + WorkspaceSession.create(serverCode, clientCode, abort.signal).then((nextSession) => { 26 + if (!abort.signal.aborted) { 27 + startTransition(() => { 28 + setSession(nextSession); 29 + }); 30 + } 31 + }); 32 + return () => abort.abort(); 33 + }, [serverCode, clientCode, resetKey]); 49 34 50 - const handleServerChange = (code: string): void => { 35 + function handleServerChange(code: string) { 51 36 setServerCode(code); 52 37 onCodeChange?.(code, clientCode); 53 - }; 38 + } 54 39 55 - const handleClientChange = (code: string): void => { 40 + function handleClientChange(code: string) { 56 41 setClientCode(code); 57 42 onCodeChange?.(serverCode, code); 58 - }; 43 + } 59 44 60 - const handleStep = useCallback(() => { 61 - timeline.stepForward(); 62 - }, [timeline]); 45 + function reset() { 46 + setResetKey((k) => k + 1); 47 + } 63 48 64 - const handleSkip = useCallback(() => { 65 - timeline.skipToEntryEnd(); 66 - }, [timeline]); 67 - 68 - const handleAddRawAction = useCallback( 69 - async (actionName: string, rawPayload: string) => { 70 - if (!workerClient) throw new Error("Worker not initialized"); 71 - try { 72 - const responseRaw = await workerClient.callAction(actionName, { 73 - type: "formdata", 74 - data: rawPayload, 75 - }); 76 - const streamOptions = callServerRef.current ? { callServer: callServerRef.current } : {}; 77 - const stream = new SteppableStream(responseRaw, streamOptions); 78 - await stream.waitForBuffer(); 79 - timeline.addAction(actionName, rawPayload, stream); 80 - } catch (err) { 81 - console.error("[raw action] Failed:", err); 82 - } 83 - }, 84 - [workerClient, timeline, callServerRef], 49 + return ( 50 + <main> 51 + <CodeEditor 52 + label="server" 53 + defaultValue={serverCode} 54 + onChange={handleServerChange} 55 + className="editor-server" 56 + /> 57 + <CodeEditor 58 + label="client" 59 + defaultValue={clientCode} 60 + onChange={handleClientChange} 61 + className="editor-client" 62 + /> 63 + {session ? ( 64 + <WorkspaceContent session={session} onReset={reset} key={session.id} /> 65 + ) : ( 66 + <WorkspaceLoading /> 67 + )} 68 + </main> 85 69 ); 86 - 87 - const compile = useCallback( 88 - async (sCode: string, cCode: string) => { 89 - if (!workerClient) throw new Error("Worker not initialized"); 90 - try { 91 - setError(null); 92 - timeline.clear(); 93 - 94 - const clientExports = parseClientModule(cCode); 95 - const manifest = buildManifest("client", clientExports); 96 - const compiledClient = compileToCommonJS(cCode); 97 - const clientModule = evaluateClientModule(compiledClient); 98 - registerClientModule("client", clientModule); 99 - 100 - const actionNames = parseServerActions(sCode); 101 - const compiledServer = compileToCommonJS(sCode); 102 - setAvailableActions(actionNames); 103 - 104 - await workerClient.deploy(compiledServer, manifest, actionNames); 105 - 106 - const callServer: CallServerCallback | null = 107 - actionNames.length > 0 108 - ? async (actionId: string, args: unknown[]): Promise<unknown> => { 109 - const actionName = actionId.split("#")[0] ?? actionId; 110 - const encodedArgs = await encodeReply(args); 111 - const argsDisplay = 112 - typeof encodedArgs === "string" 113 - ? `0=${encodedArgs}` 114 - : new URLSearchParams( 115 - encodedArgs as unknown as Record<string, string>, 116 - ).toString(); 70 + } 117 71 118 - const responseRaw = await workerClient.callAction( 119 - actionName, 120 - encodeArgs(encodedArgs), 121 - ); 122 - const stream = new SteppableStream(responseRaw, { 123 - callServer: callServer as CallServerCallback, 124 - }); 125 - await stream.waitForBuffer(); 126 - timeline.addAction(actionName, argsDisplay, stream); 127 - return stream.flightPromise; 128 - } 129 - : null; 130 - 131 - callServerRef.current = callServer; 72 + function WorkspaceLoading(): React.ReactElement { 73 + return ( 74 + <> 75 + <div className="pane flight-pane"> 76 + <div className="pane-header">flight</div> 77 + <div className="flight-output"> 78 + <span className="empty waiting-dots">Compiling</span> 79 + </div> 80 + </div> 81 + <div className="pane preview-pane"> 82 + <div className="pane-header">preview</div> 83 + <div className="preview-container"> 84 + <span className="empty waiting-dots">Compiling</span> 85 + </div> 86 + </div> 87 + </> 88 + ); 89 + } 132 90 133 - const renderRaw = await workerClient.render(); 134 - const renderStreamOptions = callServer ? { callServer } : {}; 135 - const renderStream = new SteppableStream(renderRaw, renderStreamOptions); 136 - await renderStream.waitForBuffer(); 91 + type WorkspaceContentProps = { 92 + session: WorkspaceSession; 93 + onReset: () => void; 94 + }; 137 95 138 - timeline.setRender(renderStream); 139 - setClientModuleReady(true); 140 - } catch (err) { 141 - console.error("[compile] Error:", err); 142 - setError(err instanceof Error ? err.message : String(err)); 143 - timeline.clear(); 144 - setClientModuleReady(false); 145 - } 146 - }, 147 - [timeline, workerClient, callServerRef], 96 + function WorkspaceContent({ session, onReset }: WorkspaceContentProps): React.ReactElement { 97 + const { entries, cursor, totalChunks, isAtStart, isAtEnd } = useSyncExternalStore( 98 + session.timeline.subscribe, 99 + session.timeline.getSnapshot, 148 100 ); 149 101 150 - const handleReset = useCallback(() => { 151 - compile(serverCode, clientCode); 152 - }, [compile, serverCode, clientCode]); 102 + if (session.state.status === "error") { 103 + return ( 104 + <> 105 + <div className="pane flight-pane"> 106 + <div className="pane-header">flight</div> 107 + <pre className="flight-output error">{session.state.message}</pre> 108 + </div> 109 + <div className="pane preview-pane"> 110 + <div className="pane-header">preview</div> 111 + <div className="preview-container"> 112 + <span className="empty error">Compilation error</span> 113 + </div> 114 + </div> 115 + </> 116 + ); 117 + } 153 118 154 - useEffect(() => { 155 - if (compileTimeoutRef.current) { 156 - clearTimeout(compileTimeoutRef.current); 157 - } 158 - compileTimeoutRef.current = setTimeout(() => { 159 - compile(serverCode, clientCode); 160 - }, 300); 161 - }, [serverCode, clientCode, compile]); 162 - 163 - useEffect(() => { 164 - const client = new WorkerClient(); 165 - // eslint-disable-next-line react-hooks/set-state-in-effect 166 - setWorkerClient(client); 167 - return () => client.terminate(); 168 - }, []); 119 + const { availableActions } = session.state; 169 120 170 121 return ( 171 - <main> 172 - <CodeEditor label="server" defaultValue={serverCode} onChange={handleServerChange} /> 173 - <div className="pane"> 122 + <> 123 + <div className="pane flight-pane"> 174 124 <div className="pane-header">flight</div> 175 125 <FlightLog 176 - timeline={timeline} 177 126 entries={entries} 178 127 cursor={cursor} 179 - error={error} 180 128 availableActions={availableActions} 181 - onAddRawAction={handleAddRawAction} 182 - onDeleteEntry={(idx) => timeline.deleteEntry(idx)} 129 + onAddRawAction={(name, payload) => session.addRawAction(name, payload)} 130 + onDeleteEntry={(idx) => session.timeline.deleteEntry(idx)} 183 131 /> 184 132 </div> 185 - <CodeEditor label="client" defaultValue={clientCode} onChange={handleClientChange} /> 186 133 <LivePreview 187 - timeline={timeline} 188 - clientModuleReady={clientModuleReady} 189 - totalChunks={totalChunks} 134 + entries={entries} 190 135 cursor={cursor} 136 + totalChunks={totalChunks} 191 137 isAtStart={isAtStart} 192 138 isAtEnd={isAtEnd} 193 - onStep={handleStep} 194 - onSkip={handleSkip} 195 - onReset={handleReset} 139 + onStep={() => session.timeline.stepForward()} 140 + onSkip={() => session.timeline.skipToEntryEnd()} 141 + onReset={onReset} 196 142 /> 197 - </main> 143 + </> 198 144 ); 199 145 }
+20 -24
src/client/worker-client.ts
··· 20 20 private readyPromise: Promise<void>; 21 21 private readyResolve!: () => void; 22 22 23 - constructor() { 23 + constructor(signal: AbortSignal) { 24 24 this.worker = new Worker(workerUrl); 25 - this.readyPromise = new Promise((resolve) => { 26 - this.readyResolve = resolve; 27 - }); 28 - this.worker.onmessage = this.handleMessage.bind(this); 29 - this.worker.onerror = (e) => { 30 - const err = new Error(e.message || "Worker error"); 25 + 26 + const dispose = (reason: unknown) => { 31 27 for (const controller of this.requests.values()) { 32 - controller.error(err); 28 + controller.error(reason); 33 29 } 30 + this.worker.terminate(); 34 31 this.requests.clear(); 35 32 }; 33 + 34 + this.readyPromise = new Promise((resolve, reject) => { 35 + this.readyResolve = resolve; 36 + signal.addEventListener("abort", () => { 37 + reject(signal.reason); 38 + dispose(signal.reason); 39 + }); 40 + }); 41 + this.worker.onmessage = (msg) => this.handleMessage(msg); 42 + this.worker.onerror = (e) => dispose(e.error); 36 43 } 37 44 38 45 private handleMessage(event: MessageEvent<Response>): void { ··· 70 77 71 78 private nextRequestId = 0; 72 79 73 - private request(body: Record<string, unknown>): ReadableStream<Uint8Array> { 80 + private async request(body: Record<string, unknown>): Promise<ReadableStream<Uint8Array>> { 81 + await this.readyPromise; 74 82 const requestId = String(this.nextRequestId++); 75 83 let controller!: ReadableStreamDefaultController<Uint8Array>; 76 84 const stream = new ReadableStream<Uint8Array>({ ··· 83 91 return stream; 84 92 } 85 93 86 - terminate(): void { 87 - this.worker.terminate(); 88 - const err = new Error("Worker terminated"); 89 - for (const controller of this.requests.values()) { 90 - controller.error(err); 91 - } 92 - this.requests.clear(); 93 - } 94 - 95 - async deploy(...args: Parameters<Deploy>): Promise<ReturnType<Deploy>> { 96 - await this.readyPromise; 94 + deploy(...args: Parameters<Deploy>): Promise<ReturnType<Deploy>> { 97 95 return this.request({ method: "deploy", args }); 98 96 } 99 97 100 - async render(...args: Parameters<Render>): Promise<ReturnType<Render>> { 101 - await this.readyPromise; 98 + render(...args: Parameters<Render>): Promise<ReturnType<Render>> { 102 99 return this.request({ method: "render", args }); 103 100 } 104 101 105 - async callAction(...args: Parameters<CallAction>): ReturnType<CallAction> { 106 - await this.readyPromise; 102 + callAction(...args: Parameters<CallAction>): ReturnType<CallAction> { 107 103 return this.request({ method: "action", args }); 108 104 } 109 105 }
+97
src/client/workspace-session.ts
··· 1 + import { encodeReply } from "react-server-dom-webpack/client"; 2 + import { Timeline } from "./runtime/timeline.ts"; 3 + import { SteppableStream, registerClientModule, evaluateClientModule } from "./runtime/index.ts"; 4 + import { WorkerClient, encodeArgs, type EncodedArgs } from "./worker-client.ts"; 5 + import { 6 + parseClientModule, 7 + parseServerActions, 8 + compileToCommonJS, 9 + buildManifest, 10 + } from "../shared/compiler.ts"; 11 + 12 + export type SessionState = 13 + | { status: "ready"; availableActions: string[] } 14 + | { status: "error"; message: string }; 15 + 16 + let lastId = 0; 17 + 18 + export class WorkspaceSession { 19 + readonly timeline = new Timeline(); 20 + readonly state: SessionState; 21 + readonly id: number = lastId++; 22 + private worker: WorkerClient; 23 + 24 + private constructor(worker: WorkerClient, state: SessionState) { 25 + this.worker = worker; 26 + this.state = state; 27 + } 28 + 29 + static async create( 30 + serverCode: string, 31 + clientCode: string, 32 + signal: AbortSignal, 33 + ): Promise<WorkspaceSession> { 34 + const worker = new WorkerClient(signal); 35 + 36 + try { 37 + const clientExports = parseClientModule(clientCode); 38 + const manifest = buildManifest("client", clientExports); 39 + const compiledClient = compileToCommonJS(clientCode); 40 + const clientModule = evaluateClientModule(compiledClient); 41 + registerClientModule("client", clientModule); 42 + 43 + const actionNames = parseServerActions(serverCode); 44 + const compiledServer = compileToCommonJS(serverCode); 45 + 46 + await worker.deploy(compiledServer, manifest, actionNames); 47 + const renderRaw = await worker.render(); 48 + 49 + const session = new WorkspaceSession(worker, { 50 + status: "ready", 51 + availableActions: actionNames, 52 + }); 53 + 54 + const renderStream = new SteppableStream(renderRaw, { 55 + callServer: session.callServer.bind(session), 56 + }); 57 + await renderStream.waitForBuffer(); 58 + session.timeline.setRender(renderStream); 59 + 60 + return session; 61 + } catch (err) { 62 + return new WorkspaceSession(worker, { 63 + status: "error", 64 + message: err instanceof Error ? err.message : String(err), 65 + }); 66 + } 67 + } 68 + 69 + private async runAction( 70 + actionName: string, 71 + args: EncodedArgs, 72 + argsDisplay: string, 73 + ): Promise<SteppableStream> { 74 + const responseRaw = await this.worker.callAction(actionName, args); 75 + const stream = new SteppableStream(responseRaw, { 76 + callServer: this.callServer.bind(this), 77 + }); 78 + await stream.waitForBuffer(); 79 + this.timeline.addAction(actionName, argsDisplay, stream); 80 + return stream; 81 + } 82 + 83 + private async callServer(actionId: string, args: unknown[]): Promise<unknown> { 84 + const actionName = actionId.split("#")[0] ?? actionId; 85 + const encodedArgs = await encodeReply(args); 86 + const argsDisplay = 87 + typeof encodedArgs === "string" 88 + ? `0=${encodedArgs}` 89 + : new URLSearchParams(encodedArgs as unknown as Record<string, string>).toString(); 90 + const stream = await this.runAction(actionName, encodeArgs(encodedArgs), argsDisplay); 91 + return stream.flightPromise; 92 + } 93 + 94 + async addRawAction(actionName: string, rawPayload: string): Promise<void> { 95 + await this.runAction(actionName, { type: "formdata", data: rawPayload }, rawPayload); 96 + } 97 + }