From 68a5f77a1e8c542d4501d0407def072999961c26 Mon Sep 17 00:00:00 2001 From: rossirpaulo Date: Fri, 10 Jul 2026 11:59:36 -0700 Subject: [PATCH 1/2] refactor(playground): remove session token --- baml_language/Cargo.lock | 1 - .../keyword_docs/baml_keywords.yaml | 4 - ...mand_tests__render_keyword_playground.snap | 4 - .../crates/baml_lsp_server/Cargo.toml | 1 - .../crates/baml_lsp_server/src/lib.rs | 45 ++-------- .../baml_lsp_server/src/playground_sender.rs | 12 +-- .../baml_lsp_server/src/playground_server.rs | 83 ++----------------- typescript2/app-vscode-webview/src/App.tsx | 29 +------ .../src/RemoteEditorView.tsx | 11 +-- typescript2/app-vscode-webview/vite.config.ts | 4 - typescript2/pkg-editor/package.json | 4 - typescript2/pkg-editor/src/index.ts | 3 - .../pkg-editor/src/playground-token.ts | 21 ----- typescript2/pkg-editor/src/remote-backend.ts | 5 +- typescript2/pkg-playground/src/index.ts | 1 - .../src/ports/WebSocketRuntimePort.ts | 32 ------- 16 files changed, 20 insertions(+), 240 deletions(-) delete mode 100644 typescript2/pkg-editor/src/playground-token.ts diff --git a/baml_language/Cargo.lock b/baml_language/Cargo.lock index f6336e8e2d8..7af41d61f4d 100644 --- a/baml_language/Cargo.lock +++ b/baml_language/Cargo.lock @@ -1048,7 +1048,6 @@ dependencies = [ "tower-http", "tracing", "tracing-subscriber", - "uuid", "vfs", "webbrowser", ] diff --git a/baml_language/crates/baml_builtins2/keyword_docs/baml_keywords.yaml b/baml_language/crates/baml_builtins2/keyword_docs/baml_keywords.yaml index daed2af6575..9e86ff85346 100644 --- a/baml_language/crates/baml_builtins2/keyword_docs/baml_keywords.yaml +++ b/baml_language/crates/baml_builtins2/keyword_docs/baml_keywords.yaml @@ -701,10 +701,6 @@ playground: ssh -L 4265:localhost:4265 user@host - The printed URL carries a `?token=...` query parameter that authorizes the - session — open it verbatim and treat it as a per-session secret. API requests - without the token are rejected. - See also: `baml describe check`. spawn: diff --git a/baml_language/crates/baml_cli/src/snapshots/baml_cli__describe_command_tests__render_keyword_playground.snap b/baml_language/crates/baml_cli/src/snapshots/baml_cli__describe_command_tests__render_keyword_playground.snap index b135d379c46..3dd6a727900 100644 --- a/baml_language/crates/baml_cli/src/snapshots/baml_cli__describe_command_tests__render_keyword_playground.snap +++ b/baml_language/crates/baml_cli/src/snapshots/baml_cli__describe_command_tests__render_keyword_playground.snap @@ -21,8 +21,4 @@ On a remote machine, forward the port and open the printed URL locally: ssh -L 4265:localhost:4265 user@host -The printed URL carries a `?token=...` query parameter that authorizes the -session — open it verbatim and treat it as a per-session secret. API requests -without the token are rejected. - See also: `baml describe check`. diff --git a/baml_language/crates/baml_lsp_server/Cargo.toml b/baml_language/crates/baml_lsp_server/Cargo.toml index 49356935b55..88948d8efd9 100644 --- a/baml_language/crates/baml_lsp_server/Cargo.toml +++ b/baml_language/crates/baml_lsp_server/Cargo.toml @@ -59,7 +59,6 @@ tokio-tungstenite = { workspace = true } tower-http = { workspace = true } tracing = { workspace = true } tracing-subscriber = { workspace = true } -uuid = { workspace = true } vfs = { workspace = true } webbrowser = { workspace = true } diff --git a/baml_language/crates/baml_lsp_server/src/lib.rs b/baml_language/crates/baml_lsp_server/src/lib.rs index a318b93da65..4ce7d154dec 100644 --- a/baml_language/crates/baml_lsp_server/src/lib.rs +++ b/baml_language/crates/baml_lsp_server/src/lib.rs @@ -234,12 +234,6 @@ fn run_server_inner( let doc_mirror: playground_server::DocMirror = Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())); - // Per-session bearer token for /api, browser mode only. In LSP/editor - // mode no token is minted and the guard behaves exactly as before (D6). - let session_token: Option> = - matches!(playground_open_target, PlaygroundOpenTarget::Browser) - .then(|| uuid::Uuid::new_v4().simple().to_string().into()); - // Pick the playground port early so we can pass it to the sender. let (playground_listener, playground_port): (Option, u16) = { let bind_result = match options.port { @@ -261,7 +255,7 @@ fn run_server_inner( }; if matches!(playground_open_target, PlaygroundOpenTarget::Browser) { - print_playground_banner(playground_port, &workspace_roots, session_token.as_deref()); + print_playground_banner(playground_port, &workspace_roots); } // Playground sender (needs port + lsp_sender for OpenPlayground) @@ -271,7 +265,6 @@ fn run_server_inner( lsp_sender.clone(), playground_port, matches!(playground_open_target, PlaygroundOpenTarget::Browser), - session_token.clone(), )); // Create the BexLsp (multi-project LSP) @@ -362,7 +355,6 @@ fn run_server_inner( lsp_out_tx, doc_mirror, workspace_roots.clone(), - session_token, )); } @@ -379,7 +371,6 @@ fn run_server_inner( lsp_out_tx, doc_mirror, workspace_roots_for_server, - session_token, ) .await { @@ -451,22 +442,17 @@ fn run_server_inner( } #[allow(clippy::print_stdout)] // user-facing banner; browser mode has no stdio LSP client -fn print_playground_banner(port: u16, roots: &[PathBuf], token: Option<&str>) { - println!("{}", format_playground_banner(port, roots, token)); +fn print_playground_banner(port: u16, roots: &[PathBuf]) { + println!("{}", format_playground_banner(port, roots)); } -fn format_playground_banner(port: u16, roots: &[PathBuf], token: Option<&str>) -> String { +fn format_playground_banner(port: u16, roots: &[PathBuf]) -> String { let root = roots .first() .map(|r| r.display().to_string()) .unwrap_or_else(|| "(no workspace roots)".to_string()); - // The token rides the URL, not the tunnel, so the ssh hint doesn't carry it. - let url = match token { - Some(token) => format!("http://localhost:{port}/?token={token}"), - None => format!("http://localhost:{port}/"), - }; format!( - "\n Playground: {url}\n Project: {root}\n\n \ + "\n Playground: http://localhost:{port}/\n Project: {root}\n\n \ Remote machine? Forward the port, then open the URL locally:\n \ ssh -L {port}:localhost:{port} \n\n Press Ctrl-C to stop.\n" ) @@ -620,9 +606,8 @@ mod tests { #[test] fn playground_banner_shows_url_project_root_and_ssh_hint() { - let banner = format_playground_banner(4265, &[PathBuf::from("/home/dev/my-app")], None); + let banner = format_playground_banner(4265, &[PathBuf::from("/home/dev/my-app")]); assert!(banner.contains("http://localhost:4265/"), "{banner}"); - assert!(!banner.contains("?token="), "{banner}"); assert!(banner.contains("/home/dev/my-app"), "{banner}"); assert!( banner.contains("ssh -L 4265:localhost:4265 "), @@ -630,27 +615,11 @@ mod tests { ); assert!(banner.contains("Press Ctrl-C to stop."), "{banner}"); - let no_roots = format_playground_banner(4270, &[], None); + let no_roots = format_playground_banner(4270, &[]); assert!(no_roots.contains("(no workspace roots)"), "{no_roots}"); assert!(no_roots.contains("http://localhost:4270/"), "{no_roots}"); } - #[test] - fn playground_banner_carries_the_session_token_on_the_url_only() { - let banner = - format_playground_banner(4265, &[PathBuf::from("/home/dev/my-app")], Some("abc123")); - assert!( - banner.contains("http://localhost:4265/?token=abc123"), - "{banner}" - ); - // The token rides the URL, not the tunnel: the ssh hint stays bare. - assert!( - banner.contains("ssh -L 4265:localhost:4265 "), - "{banner}" - ); - assert_eq!(banner.matches("token").count(), 1, "{banner}"); - } - #[test] fn absolutize_workspace_roots_makes_relative_paths_absolute() { let cwd = std::env::current_dir().expect("cwd should be available"); diff --git a/baml_language/crates/baml_lsp_server/src/playground_sender.rs b/baml_language/crates/baml_lsp_server/src/playground_sender.rs index c3148c21e26..05422351040 100644 --- a/baml_language/crates/baml_lsp_server/src/playground_sender.rs +++ b/baml_language/crates/baml_lsp_server/src/playground_sender.rs @@ -19,9 +19,6 @@ pub struct NativePlaygroundSender { lsp_sender: Arc, playground_port: u16, open_in_browser: bool, - /// Browser-mode session token; carried on the opened URL so the page can - /// authorize its /api requests. - session_token: Option>, } impl NativePlaygroundSender { @@ -30,14 +27,12 @@ impl NativePlaygroundSender { lsp_sender: Arc, playground_port: u16, open_in_browser: bool, - session_token: Option>, ) -> Self { Self { broadcast_tx, lsp_sender, playground_port, open_in_browser, - session_token, } } } @@ -52,12 +47,7 @@ impl bex_project::PlaygroundSender for NativePlaygroundSender { } = notification { if self.open_in_browser { - let url = match &self.session_token { - Some(token) => { - format!("http://localhost:{}/?token={token}", self.playground_port) - } - None => format!("http://localhost:{}", self.playground_port), - }; + let url = format!("http://localhost:{}", self.playground_port); // `webbrowser::open` can block until a text-mode browser (lynx/w3m) // exits on headless hosts; never hold up server startup on it. std::thread::spawn(move || { diff --git a/baml_language/crates/baml_lsp_server/src/playground_server.rs b/baml_language/crates/baml_lsp_server/src/playground_server.rs index d2102bc8116..1fc74ba3379 100644 --- a/baml_language/crates/baml_lsp_server/src/playground_server.rs +++ b/baml_language/crates/baml_lsp_server/src/playground_server.rs @@ -828,26 +828,11 @@ fn capture_loss_message(capture_kind: &str, skipped: u64) -> String { } #[derive(Clone, Debug)] -struct PlaygroundAccessGuard { - /// When set (browser mode), every /api request must present it as `?token=`. - session_token: Option>, -} +struct PlaygroundAccessGuard {} impl PlaygroundAccessGuard { - fn new(session_token: Option>) -> Self { - Self { session_token } - } - - /// True when the request may use the API: either no session token is - /// required (LSP/editor mode — origin-only, as before the token existed) - /// or the request's `?token=` matches. Covers Origin-less clients too - /// (curl/websocat), which the origin check alone deliberately allows. - fn is_authorized(&self, uri: &Uri) -> bool { - let Some(expected) = &self.session_token else { - return true; - }; - query_param(uri.query(), "token") - .is_some_and(|got| constant_time_eq(got.as_bytes(), expected.as_bytes())) + fn new() -> Self { + Self {} } fn is_allowed_origin(&self, origin: Option<&HeaderValue>) -> bool { @@ -870,21 +855,6 @@ impl PlaygroundAccessGuard { } } -/// Extract a raw query parameter value (no percent-decoding — the session -/// token is plain `[0-9a-f]{32}`, so none is needed). -fn query_param<'a>(query: Option<&'a str>, key: &str) -> Option<&'a str> { - query?.split('&').find_map(|pair| { - let (k, v) = pair.split_once('=')?; - (k == key).then_some(v) - }) -} - -/// Length-safe constant-time comparison; the token is low-value enough that a -/// dependency like `subtle` isn't warranted for a loopback timing channel. -fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { - a.len() == b.len() && a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0 -} - /// True when `origin` is an http(s) origin whose host is loopback /// (`localhost`, a 127.0.0.0/8 address, or `[::1]`), on any port. /// @@ -968,10 +938,9 @@ pub async fn run( lsp_out_tx: broadcast::Sender, doc_mirror: DocMirror, workspace_roots: Vec, - session_token: Option>, ) -> anyhow::Result<()> { let local_addr = listener.local_addr()?; - let access_guard = PlaygroundAccessGuard::new(session_token); + let access_guard = PlaygroundAccessGuard::new(); let app = build_router( bex, broadcast_tx, @@ -2443,16 +2412,6 @@ async fn api_guard_middleware( return response; } - // After the OPTIONS branch: CORS preflights carry no credentials by design. - if !access_guard.is_authorized(req.uri()) { - return text_response( - StatusCode::UNAUTHORIZED, - "Missing or invalid playground session token. Open the exact URL \ - (including ?token=...) printed by `baml playground`." - .to_string(), - ); - } - let mut resp = next.run(req).await; apply_api_response_headers(&access_guard, origin.as_ref(), &mut resp); resp @@ -3011,7 +2970,7 @@ mod tests { #[test] fn playground_access_guard_accepts_any_loopback_port_and_vscode_origins() { - let guard = PlaygroundAccessGuard::new(None); + let guard = PlaygroundAccessGuard::new(); assert!(guard.is_allowed_origin(None)); for allowed in [ "http://localhost:4265", @@ -3042,38 +3001,6 @@ mod tests { } } - #[test] - fn session_token_required_when_set() { - let token = "a3f9c2e8b1d04567a3f9c2e8b1d04567"; - let guard = PlaygroundAccessGuard::new(Some(token.into())); - let uri = |s: &str| s.parse::().unwrap(); - - assert!(guard.is_authorized(&uri(&format!("/api/ws?token={token}")))); - assert!(guard.is_authorized(&uri(&format!("/api/source-files?project=x&token={token}")))); - assert!(!guard.is_authorized(&uri("/api/ws"))); - assert!(!guard.is_authorized(&uri("/api/ws?token=wrong"))); - assert!(!guard.is_authorized(&uri("/api/ws?token="))); - assert!(!guard.is_authorized(&uri("/api/ws?project=x"))); - - // No token minted (LSP/editor mode): every URI is authorized. - let open_guard = PlaygroundAccessGuard::new(None); - assert!(open_guard.is_authorized(&uri("/api/ws"))); - assert!(open_guard.is_authorized(&uri("/api/ws?token=whatever"))); - } - - #[test] - fn query_param_extracts_token() { - assert_eq!(query_param(Some("token=abc"), "token"), Some("abc")); - assert_eq!(query_param(Some("token=abc&x=1"), "token"), Some("abc")); - assert_eq!( - query_param(Some("project=p&token=abc"), "token"), - Some("abc") - ); - assert_eq!(query_param(Some("project=p"), "token"), None); - assert_eq!(query_param(Some("token"), "token"), None); - assert_eq!(query_param(None, "token"), None); - } - #[test] fn collect_referenced_env_vars_filters_unset() { let names = vec![ diff --git a/typescript2/app-vscode-webview/src/App.tsx b/typescript2/app-vscode-webview/src/App.tsx index 9695fe6be71..b1ab9da4413 100644 --- a/typescript2/app-vscode-webview/src/App.tsx +++ b/typescript2/app-vscode-webview/src/App.tsx @@ -1,13 +1,5 @@ import { useState, useEffect, useRef, lazy, Suspense } from 'react'; -import { - ExecutionPanel, - WebSocketRuntimePort, - type WebSocketRuntimePortStatus, - type SourceNavigationTarget, -} from '@b/pkg-playground'; -// Subpath import: the package index re-exports MonacoEditor, which must stay -// out of this bundle (see the lazy() note below). -import { withPlaygroundToken } from '@b/pkg-editor/token'; +import { ExecutionPanel, WebSocketRuntimePort, type SourceNavigationTarget } from '@b/pkg-playground'; // Monaco workbench is heavy (monaco-vscode-api) and only needed when the user // opens the editor view; lazy-load it so the plain playground (and the VS Code @@ -62,8 +54,6 @@ function getVsCodeApi() { const App: React.FC = () => { const [port, setPort] = useState(null); - const [connectionStatus, setConnectionStatus] = - useState('connecting'); const [activeProject, setActiveProject] = useState(null); const [showEditor, setShowEditor] = useState(false); const [editorHasUnsaved, setEditorHasUnsaved] = useState(false); @@ -114,22 +104,15 @@ const App: React.FC = () => { useEffect(() => { // When loaded directly in a VS Code webview (no iframe), the extension - // injects __PLAYGROUND_WS_URL (no session token in that mode). Fall back - // to a location-based URL — carrying the page's ?token= — for + // injects __PLAYGROUND_WS_URL. Fall back to location-based URL for // standalone / iframe / dev scenarios. const wsUrl = - window.__PLAYGROUND_WS_URL ?? - withPlaygroundToken(`ws://${window.location.host}/api/ws`); + window.__PLAYGROUND_WS_URL ?? `ws://${window.location.host}/api/ws`; const runtimePort = new WebSocketRuntimePort(wsUrl); setPort(runtimePort); return () => runtimePort.dispose(); }, []); - useEffect(() => { - if (!port) return; - return port.onStatusChange(setConnectionStatus); - }, [port]); - // Track the active project from playground notifications so the editor view // can be rooted at (and load source files for) the real project. useEffect(() => { @@ -227,12 +210,6 @@ const App: React.FC = () => { return (
- {connectionStatus === 'retrying' && ( -
- Can't reach the playground server — open the exact URL printed by{' '} - baml playground, including the token. -
- )} {(inVsCode || canShowEditor) && (
{editorActive && ( diff --git a/typescript2/app-vscode-webview/src/RemoteEditorView.tsx b/typescript2/app-vscode-webview/src/RemoteEditorView.tsx index 7e0f1f3c440..2c51df6c411 100644 --- a/typescript2/app-vscode-webview/src/RemoteEditorView.tsx +++ b/typescript2/app-vscode-webview/src/RemoteEditorView.tsx @@ -17,12 +17,7 @@ */ import { useEffect, useMemo, useState } from 'react'; -import { - MonacoEditor, - createRemoteBackend, - remoteUrlsFromLocation, - withPlaygroundToken, -} from '@b/pkg-editor'; +import { MonacoEditor, createRemoteBackend, remoteUrlsFromLocation } from '@b/pkg-editor'; import '@b/pkg-editor/views-workbench.css'; interface SourceFile { @@ -59,9 +54,7 @@ const RemoteEditorView: React.FC = ({ project, onUnsavedC setError(null); void (async () => { try { - const res = await fetch( - withPlaygroundToken(`/api/source-files?project=${encodeURIComponent(project)}`), - ); + const res = await fetch(`/api/source-files?project=${encodeURIComponent(project)}`); if (!res.ok) throw new Error(`Failed to load source files (${res.status})`); const data = (await res.json()) as SourceFilesResponse; if (cancelled) return; diff --git a/typescript2/app-vscode-webview/vite.config.ts b/typescript2/app-vscode-webview/vite.config.ts index dbc66da01a0..10deefd7851 100644 --- a/typescript2/app-vscode-webview/vite.config.ts +++ b/typescript2/app-vscode-webview/vite.config.ts @@ -83,10 +83,6 @@ export default defineConfig({ // path so every importer shares a single instance. react: resolve(projectRoot, 'node_modules/react'), 'react-dom': resolve(projectRoot, 'node_modules/react-dom'), - // Must precede the bare '@b/pkg-editor' alias: source-dir aliasing - // bypasses the package's `exports` map, so subpaths need explicit - // entries (with extension — alias results skip extension resolution). - '@b/pkg-editor/token': resolve(projectRoot, '../pkg-editor/src/playground-token.ts'), '@b/pkg-editor': resolve(projectRoot, '../pkg-editor/src'), '@b/pkg-playground': resolve(projectRoot, '../pkg-playground/src'), '@b/pkg-proto': resolve(projectRoot, '../pkg-proto/src'), diff --git a/typescript2/pkg-editor/package.json b/typescript2/pkg-editor/package.json index 1cd4c6e00ae..a6c3d72bbec 100644 --- a/typescript2/pkg-editor/package.json +++ b/typescript2/pkg-editor/package.json @@ -13,10 +13,6 @@ "types": "./src/remote-backend.ts", "default": "./src/remote-backend.ts" }, - "./token": { - "types": "./src/playground-token.ts", - "default": "./src/playground-token.ts" - }, "./views-workbench.css": "./src/views-workbench.css" }, "files": [ diff --git a/typescript2/pkg-editor/src/index.ts b/typescript2/pkg-editor/src/index.ts index 6e49ebe329b..8ce442318f2 100644 --- a/typescript2/pkg-editor/src/index.ts +++ b/typescript2/pkg-editor/src/index.ts @@ -19,9 +19,6 @@ export { } from './remote-backend'; export type { RemoteBackendOptions } from './remote-backend'; -// Session token for `baml playground` /api access. -export { getPlaygroundToken, withPlaygroundToken } from './playground-token'; - // Media helpers (text vs data-URL files). export { isMediaPath, diff --git a/typescript2/pkg-editor/src/playground-token.ts b/typescript2/pkg-editor/src/playground-token.ts deleted file mode 100644 index f711a436d72..00000000000 --- a/typescript2/pkg-editor/src/playground-token.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** - * Per-session token minted by `baml playground` (browser mode) and carried on - * the page URL as `?token=...`. Every same-origin /api request — fetch and - * WebSocket alike — must present it back to the server; browsers can't set - * custom WS headers, so it rides the query string. - * - * In VS Code webviews (no token minted) `getPlaygroundToken` returns null and - * `withPlaygroundToken` is a no-op. - */ - -/** Session token from the current page URL, or null when absent. */ -export function getPlaygroundToken(): string | null { - return new URLSearchParams(window.location.search).get('token'); -} - -/** Append the session token (if any) to a same-origin API/WS URL. */ -export function withPlaygroundToken(url: string): string { - const token = getPlaygroundToken(); - if (!token) return url; - return `${url}${url.includes('?') ? '&' : '?'}token=${encodeURIComponent(token)}`; -} diff --git a/typescript2/pkg-editor/src/remote-backend.ts b/typescript2/pkg-editor/src/remote-backend.ts index 5eb86b76c77..1f62602c495 100644 --- a/typescript2/pkg-editor/src/remote-backend.ts +++ b/typescript2/pkg-editor/src/remote-backend.ts @@ -15,7 +15,6 @@ import { WebSocketRuntimePort } from '@b/pkg-playground'; import type { EditorBackend, EditorConnection, WorkbenchHandle } from './backend'; -import { withPlaygroundToken } from './playground-token'; export interface RemoteBackendOptions { /** WebSocket URL for the LSP JSON-RPC endpoint, e.g. `ws://localhost:4265/api/lsp`. */ @@ -32,8 +31,8 @@ export function remoteUrlsFromLocation( ): { lspUrl: string; runtimeUrl: string } { const scheme = loc.protocol === 'https:' ? 'wss' : 'ws'; return { - lspUrl: withPlaygroundToken(`${scheme}://${loc.host}/api/lsp`), - runtimeUrl: withPlaygroundToken(`${scheme}://${loc.host}/api/ws`), + lspUrl: `${scheme}://${loc.host}/api/lsp`, + runtimeUrl: `${scheme}://${loc.host}/api/ws`, }; } diff --git a/typescript2/pkg-playground/src/index.ts b/typescript2/pkg-playground/src/index.ts index 37e515953e5..86fbf93ebec 100644 --- a/typescript2/pkg-playground/src/index.ts +++ b/typescript2/pkg-playground/src/index.ts @@ -6,7 +6,6 @@ export type { ExecutionPanelProps } from './ExecutionPanel'; export type { RuntimePort } from './runtime-port'; export { WorkerRuntimePort } from './ports/WorkerRuntimePort'; export { WebSocketRuntimePort } from './ports/WebSocketRuntimePort'; -export type { WebSocketRuntimePortStatus } from './ports/WebSocketRuntimePort'; export { createRunStoreClient } from './run-store-client'; export { applyRunPatch, createExecutionStore } from './execution-store'; export { decodeRunResultValue } from './run-store-projections'; diff --git a/typescript2/pkg-playground/src/ports/WebSocketRuntimePort.ts b/typescript2/pkg-playground/src/ports/WebSocketRuntimePort.ts index 7880967d8e0..6a831f630f4 100644 --- a/typescript2/pkg-playground/src/ports/WebSocketRuntimePort.ts +++ b/typescript2/pkg-playground/src/ports/WebSocketRuntimePort.ts @@ -22,15 +22,6 @@ import { isPlaygroundProtocolCompatible } from '../protocol'; const MAX_RECONNECT_DELAY = 5000; -/** - * Connection lifecycle as observable from the browser: `connecting` until the - * first handshake resolves, `open` while connected, `retrying` once a - * handshake has failed or the socket closed and the backoff loop is running. - * (Browsers hide the HTTP status of a failed WS upgrade, so a 401 for a - * missing session token is indistinguishable from the server being down.) - */ -export type WebSocketRuntimePortStatus = 'connecting' | 'open' | 'retrying'; - export class WebSocketRuntimePort implements RuntimePort { private url: string; private ws: WebSocket | null = null; @@ -41,8 +32,6 @@ export class WebSocketRuntimePort implements RuntimePort { private reconnectDelay = 500; private reconnectTimer: ReturnType | null = null; private playgroundCompatible = true; - private status: WebSocketRuntimePortStatus = 'connecting'; - private statusHandlers = new Set<(status: WebSocketRuntimePortStatus) => void>(); constructor(url: string) { this.url = url; @@ -60,7 +49,6 @@ export class WebSocketRuntimePort implements RuntimePort { } this.ws.onopen = () => { - this.setStatus('open'); this.reconnectDelay = 500; // reset backoff // Flush queued outgoing messages. for (const msg of this.outQueue) { @@ -100,7 +88,6 @@ export class WebSocketRuntimePort implements RuntimePort { private scheduleReconnect(): void { if (this.disposed || this.reconnectTimer) return; - this.setStatus('retrying'); this.reconnectTimer = setTimeout(() => { this.reconnectTimer = null; this.connect(); @@ -126,24 +113,6 @@ export class WebSocketRuntimePort implements RuntimePort { } } - private setStatus(status: WebSocketRuntimePortStatus): void { - if (this.status === status) return; - this.status = status; - for (const h of this.statusHandlers) h(status); - } - - /** - * Observe connection status changes. The handler is invoked immediately - * with the current status, then on every transition. Returns unsubscribe. - */ - onStatusChange(handler: (status: WebSocketRuntimePortStatus) => void): () => void { - this.statusHandlers.add(handler); - handler(this.status); - return () => { - this.statusHandlers.delete(handler); - }; - } - onMessage(handler: (msg: WorkerOutMessage) => void): () => void { this.handlers.add(handler); @@ -176,7 +145,6 @@ export class WebSocketRuntimePort implements RuntimePort { this.ws = null; } this.handlers.clear(); - this.statusHandlers.clear(); this.outQueue = []; this.inBuffer = []; } From fd92a8f638e062acf2e1e64b7937b54671c1ce65 Mon Sep 17 00:00:00 2001 From: rossirpaulo Date: Fri, 10 Jul 2026 13:29:02 -0700 Subject: [PATCH 2/2] fix(playground): use secure websocket on https --- typescript2/app-vscode-webview/src/App.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/typescript2/app-vscode-webview/src/App.tsx b/typescript2/app-vscode-webview/src/App.tsx index b1ab9da4413..3a67a0c9858 100644 --- a/typescript2/app-vscode-webview/src/App.tsx +++ b/typescript2/app-vscode-webview/src/App.tsx @@ -106,8 +106,9 @@ const App: React.FC = () => { // When loaded directly in a VS Code webview (no iframe), the extension // injects __PLAYGROUND_WS_URL. Fall back to location-based URL for // standalone / iframe / dev scenarios. + const scheme = window.location.protocol === 'https:' ? 'wss' : 'ws'; const wsUrl = - window.__PLAYGROUND_WS_URL ?? `ws://${window.location.host}/api/ws`; + window.__PLAYGROUND_WS_URL ?? `${scheme}://${window.location.host}/api/ws`; const runtimePort = new WebSocketRuntimePort(wsUrl); setPort(runtimePort); return () => runtimePort.dispose();