// ---------------------------------------------------------------------------// openOAuthPopup — browser popup opener for OAuth flows.//// Opens a centered popup window pointed at an authorization URL, listens// for the result via `postMessage` and `BroadcastChannel` (Safari fallback),// and settles exactly once. Has NO React-specific imports so it can be used// from any browser context, but lives under the `/react` entry to signal// it is browser-only and should not be imported from Node / worker code.// ---------------------------------------------------------------------------import { isOAuthPopupResult as sharedIsOAuthPopupResult, type OAuthPopupResult,} from "@executor-js/sdk/shared";import { getExecutorServerAuthorizationHeader } from "./server-connection";export { OAUTH_POPUP_MESSAGE_TYPE } from "@executor-js/sdk/shared";export type { OAuthPopupResult } from "@executor-js/sdk/shared";export const isOAuthPopupResult = sharedIsOAuthPopupResult;// ---------------------------------------------------------------------------// openOAuthPopup// ---------------------------------------------------------------------------export type OpenOAuthPopupInput<TAuth> = { readonly url: string; readonly onResult: (data: OAuthPopupResult<TAuth>) => void; readonly reservedPopup?: ReservedOAuthPopup; /** Ignore popup messages for any other in-flight OAuth session. */ readonly expectedSessionId?: string; /** `window.open` target name — also used to focus an existing popup. */ readonly popupName: string; /** BroadcastChannel name, must match the server-side `popupDocument` channel. */ readonly channelName: string; readonly onOpenFailed?: () => void; /** * Called if the user closes the popup window without completing the * flow (detected via a `popup.closed` poll). NOT called when the popup * closes itself after a successful result post — `onResult` handles * that path. Also not called if the caller invokes the teardown * function returned from this function. */ readonly onClosed?: () => void; readonly width?: number; readonly height?: number; /** How often to poll `popup.closed`. Default 500ms. Set to null to disable. */ readonly closedPollMs?: number | null;};export type ReservedOAuthPopup = { readonly popup: Window;};const isHttpPopupUrl = (value: string): boolean => { if (!URL.canParse(value)) return false; const url = new URL(value); return url.protocol === "http:" || url.protocol === "https:";};const oauthPopupFeatures = (input: { readonly width?: number; readonly height?: number }) => { const w = input.width ?? 640; const h = input.height ?? 760; const left = window.screenX + (window.outerWidth - w) / 2; const top = window.screenY + (window.outerHeight - h) / 2; return `width=${w},height=${h},left=${left},top=${top},popup=1`;};export const reserveOAuthPopup = (input: { readonly popupName: string; readonly width?: number; readonly height?: number;}): ReservedOAuthPopup | null => { const popup = window.open("about:blank", input.popupName, oauthPopupFeatures(input)); if (!popup) return null; // Keep opener available for the same-origin callback page. Browser // BroadcastChannel delivery can be partitioned in popup flows, so the // callback's postMessage path is the primary completion signal. return { popup };};/** * Open a centered popup window at `url` and resolve when the popup posts * an `OAuthPopupResult` back to the opener. Returns a teardown function * that removes the listeners, stops polling, and closes the popup window. * * Settles exactly once via one of three paths: * 1. `onResult` — popup posted a message back (success or error) * 2. `onClosed` — user closed the popup without completing the flow, * unless `closedPollMs` is null * 3. teardown called — caller cancelled programmatically * * If the popup is blocked (`window.open` returns null), invokes * `onOpenFailed` on the next microtask and returns a no-op teardown. */export const openOAuthPopup = <TAuth>(input: OpenOAuthPopupInput<TAuth>): (() => void) => { if (!isHttpPopupUrl(input.url)) { queueMicrotask(() => input.onOpenFailed?.()); return () => {}; } let settled = false; let pollHandle: ReturnType<typeof setInterval> | null = null; const channel = typeof BroadcastChannel !== "undefined" ? new BroadcastChannel(input.channelName) : null; const onMessage = (event: MessageEvent) => { if (event.origin !== window.location.origin) return; handleResult(event.data); }; // localStorage `storage` events are the reliable same-origin completion path: // they fire on the opener when the (same-origin) callback page writes, survive // the provider's COOP severing `window.opener`, and aren't lost to the popup's // auto-close (unlike a raced BroadcastChannel). The callback writes the result // under `channelName`; we read it, clean up, and settle. const onStorage = (event: StorageEvent) => { if (event.key !== input.channelName || event.newValue === null) return; // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: JSON.parse of a foreign storage value can throw try { // oxlint-disable-next-line executor/no-json-parse -- boundary: browser-only helper, no Effect runtime; parsing a same-origin localStorage signal const data: unknown = JSON.parse(event.newValue); // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: localStorage can throw (private mode / disabled) try { window.localStorage.removeItem(input.channelName); } catch { // best-effort cleanup } handleResult(data); } catch { // Malformed value — ignore; another channel may still deliver. } }; const stopPolling = () => { if (pollHandle !== null) { clearInterval(pollHandle); pollHandle = null; } }; /** Close the popup window if it's still open. Swallows cross-origin errors. */ const closePopup = (popup: Window | null) => { if (!popup) return; // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: cross-origin popup state can throw and cleanup is best-effort try { if (!popup.closed) popup.close(); } catch { // Cross-origin access can throw; safe to ignore. } }; const settle = () => { if (settled) return; settled = true; window.removeEventListener("message", onMessage); window.removeEventListener("storage", onStorage); channel?.close(); stopPolling(); }; const handleResult = (data: unknown) => { if (!isOAuthPopupResult<TAuth>(data) || settled) return; if (input.expectedSessionId && data.sessionId !== input.expectedSessionId) return; settle(); input.onResult(data); }; window.addEventListener("message", onMessage); window.addEventListener("storage", onStorage); if (channel) channel.onmessage = (event) => handleResult(event.data); const popup = input.reservedPopup?.popup ?? reserveOAuthPopup({ popupName: input.popupName, width: input.width, height: input.height, })?.popup ?? null; if (!popup) { if (!settled) { settle(); queueMicrotask(() => input.onOpenFailed?.()); } return () => {}; } // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: popup navigation can fail if the browser has invalidated the handle try { popup.location.href = input.url; } catch { if (!settled) { settle(); queueMicrotask(() => input.onOpenFailed?.()); } return () => {}; } // Some providers use COOP headers that can make a live cross-origin // popup look closed to the opener. Callers can disable polling and rely // on the explicit cancel path plus BroadcastChannel completion. const pollMs = input.closedPollMs === undefined ? 500 : input.closedPollMs; if (pollMs !== null) { pollHandle = setInterval(() => { let isClosed = false; // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: browser popup.closed can throw while navigating cross-origin try { isClosed = popup.closed; } catch { // Cross-origin access can throw during navigation; treat as open. } if (isClosed && !settled) { settle(); input.onClosed?.(); } }, pollMs); } return () => { if (settled) return; settle(); closePopup(popup); };};// ---------------------------------------------------------------------------// System-browser flow — used when a desktop host (Electron) opens the auth// URL in the user's real browser. There's no shared origin, so the// renderer polls `/api/oauth/await/:sessionId` for the result. The local// server publishes there via `setOAuthCompletionListener` (see// apps/local/src/serve.ts).// ---------------------------------------------------------------------------export type OpenOAuthSystemBrowserInput<TAuth> = { readonly url: string; readonly sessionId: string; readonly openExternal: (url: string) => Promise<void>; readonly onResult: (data: OAuthPopupResult<TAuth>) => void; /** Called once if the external open itself fails (URL rejected, IPC error). */ readonly onOpenFailed?: (cause: unknown) => void; /** Poll cadence. Default 1000ms. */ readonly pollMs?: number; /** Stop polling after this many ms with no result. Default 10 minutes. */ readonly timeoutMs?: number; readonly onTimeout?: () => void;};const OAUTH_AWAIT_DEFAULT_POLL_MS = 1000;const OAUTH_AWAIT_DEFAULT_TIMEOUT_MS = 10 * 60 * 1000;export const openOAuthSystemBrowser = <TAuth>( input: OpenOAuthSystemBrowserInput<TAuth>,): (() => void) => { let settled = false; let pollHandle: ReturnType<typeof setInterval> | null = null; let timeoutHandle: ReturnType<typeof setTimeout> | null = null; const controller = new AbortController(); const settle = () => { if (settled) return; settled = true; if (pollHandle !== null) clearInterval(pollHandle); if (timeoutHandle !== null) clearTimeout(timeoutHandle); controller.abort(); }; const poll = async () => { if (settled) return; // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: fetch can reject for transient network errors during polling try { // The await poll is now gated like the rest of /api — carry the bearer // (standalone web). On desktop the connection has no client-side auth and // the main process injects the header instead. const authorization = getExecutorServerAuthorizationHeader(); const response = await fetch(`/api/oauth/await/${encodeURIComponent(input.sessionId)}`, { signal: controller.signal, cache: "no-store", ...(authorization ? { headers: { authorization } } : {}), }); if (!response.ok) return; const body = (await response.json()) as unknown; if (body === null || settled) return; if (!isOAuthPopupResult<TAuth>(body)) return; settle(); input.onResult(body); } catch { // Transient — next tick will retry. AbortError after settle is also caught here. } }; void (async () => { // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: openExternal is host-provided IPC, no Effect runtime in this browser-only helper try { await input.openExternal(input.url); } catch (cause: unknown) { if (settled) return; settle(); input.onOpenFailed?.(cause); } })(); pollHandle = setInterval(() => void poll(), input.pollMs ?? OAUTH_AWAIT_DEFAULT_POLL_MS); void poll(); timeoutHandle = setTimeout(() => { if (settled) return; settle(); input.onTimeout?.(); }, input.timeoutMs ?? OAUTH_AWAIT_DEFAULT_TIMEOUT_MS); return () => settle();};
// ---------------------------------------------------------------------------
// openOAuthPopup — browser popup opener for OAuth flows.
//
// Opens a centered popup window pointed at an authorization URL, listens
// for the result via `postMessage` and `BroadcastChannel` (Safari fallback),
// and settles exactly once. Has NO React-specific imports so it can be used
// from any browser context, but lives under the `/react` entry to signal
// it is browser-only and should not be imported from Node / worker code.
// ---------------------------------------------------------------------------
import {
isOAuthPopupResult as sharedIsOAuthPopupResult,
type OAuthPopupResult,
} from "@executor-js/sdk/shared";
import { getExecutorServerAuthorizationHeader } from "./server-connection";
export { OAUTH_POPUP_MESSAGE_TYPE } from "@executor-js/sdk/shared";
export type { OAuthPopupResult } from "@executor-js/sdk/shared";
export const isOAuthPopupResult = sharedIsOAuthPopupResult;
// ---------------------------------------------------------------------------
// openOAuthPopup
// ---------------------------------------------------------------------------
export type OpenOAuthPopupInput<TAuth> = {
readonly url: string;
readonly onResult: (data: OAuthPopupResult<TAuth>) => void;
readonly reservedPopup?: ReservedOAuthPopup;
/** Ignore popup messages for any other in-flight OAuth session. */
readonly expectedSessionId?: string;
/** `window.open` target name — also used to focus an existing popup. */
readonly popupName: string;
/** BroadcastChannel name, must match the server-side `popupDocument` channel. */
readonly channelName: string;
readonly onOpenFailed?: () => void;
/**
* Called if the user closes the popup window without completing the
* flow (detected via a `popup.closed` poll). NOT called when the popup
* closes itself after a successful result post — `onResult` handles
* that path. Also not called if the caller invokes the teardown
* function returned from this function.
*/
readonly onClosed?: () => void;
readonly width?: number;
readonly height?: number;
/** How often to poll `popup.closed`. Default 500ms. Set to null to disable. */
readonly closedPollMs?: number | null;
};
export type ReservedOAuthPopup = {
readonly popup: Window;
};
const isHttpPopupUrl = (value: string): boolean => {
if (!URL.canParse(value)) return false;
const url = new URL(value);
return url.protocol === "http:" || url.protocol === "https:";
};
const oauthPopupFeatures = (input: { readonly width?: number; readonly height?: number }) => {
const w = input.width ?? 640;
const h = input.height ?? 760;
const left = window.screenX + (window.outerWidth - w) / 2;
const top = window.screenY + (window.outerHeight - h) / 2;
return `width=${w},height=${h},left=${left},top=${top},popup=1`;
};
export const reserveOAuthPopup = (input: {
readonly popupName: string;
readonly width?: number;
readonly height?: number;
}): ReservedOAuthPopup | null => {
const popup = window.open("about:blank", input.popupName, oauthPopupFeatures(input));
if (!popup) return null;
// Keep opener available for the same-origin callback page. Browser
// BroadcastChannel delivery can be partitioned in popup flows, so the
// callback's postMessage path is the primary completion signal.
return { popup };
};
/**
* Open a centered popup window at `url` and resolve when the popup posts
* an `OAuthPopupResult` back to the opener. Returns a teardown function
* that removes the listeners, stops polling, and closes the popup window.
*
* Settles exactly once via one of three paths:
* 1. `onResult` — popup posted a message back (success or error)
* 2. `onClosed` — user closed the popup without completing the flow,
* unless `closedPollMs` is null
* 3. teardown called — caller cancelled programmatically
*
* If the popup is blocked (`window.open` returns null), invokes
* `onOpenFailed` on the next microtask and returns a no-op teardown.
*/
export const openOAuthPopup = <TAuth>(input: OpenOAuthPopupInput<TAuth>): (() => void) => {
if (!isHttpPopupUrl(input.url)) {
queueMicrotask(() => input.onOpenFailed?.());
return () => {};
}
let settled = false;
let pollHandle: ReturnType<typeof setInterval> | null = null;
const channel =
typeof BroadcastChannel !== "undefined" ? new BroadcastChannel(input.channelName) : null;
const onMessage = (event: MessageEvent) => {
if (event.origin !== window.location.origin) return;
handleResult(event.data);
};
// localStorage `storage` events are the reliable same-origin completion path:
// they fire on the opener when the (same-origin) callback page writes, survive
// the provider's COOP severing `window.opener`, and aren't lost to the popup's
// auto-close (unlike a raced BroadcastChannel). The callback writes the result
// under `channelName`; we read it, clean up, and settle.
const onStorage = (event: StorageEvent) => {
if (event.key !== input.channelName || event.newValue === null) return;
// oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: JSON.parse of a foreign storage value can throw
try {
// oxlint-disable-next-line executor/no-json-parse -- boundary: browser-only helper, no Effect runtime; parsing a same-origin localStorage signal
const data: unknown = JSON.parse(event.newValue);
// oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: localStorage can throw (private mode / disabled)
try {
window.localStorage.removeItem(input.channelName);
} catch {
// best-effort cleanup
}
handleResult(data);
} catch {
// Malformed value — ignore; another channel may still deliver.
}
};
const stopPolling = () => {
if (pollHandle !== null) {
clearInterval(pollHandle);
pollHandle = null;
}
};
/** Close the popup window if it's still open. Swallows cross-origin errors. */
const closePopup = (popup: Window | null) => {
if (!popup) return;
// oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: cross-origin popup state can throw and cleanup is best-effort
try {
if (!popup.closed) popup.close();
} catch {
// Cross-origin access can throw; safe to ignore.
}
};
const settle = () => {
if (settled) return;
settled = true;
window.removeEventListener("message", onMessage);
window.removeEventListener("storage", onStorage);
channel?.close();
stopPolling();
};
const handleResult = (data: unknown) => {
if (!isOAuthPopupResult<TAuth>(data) || settled) return;
if (input.expectedSessionId && data.sessionId !== input.expectedSessionId) return;
settle();
input.onResult(data);
};
window.addEventListener("message", onMessage);
window.addEventListener("storage", onStorage);
if (channel) channel.onmessage = (event) => handleResult(event.data);
const popup =
input.reservedPopup?.popup ??
reserveOAuthPopup({
popupName: input.popupName,
width: input.width,
height: input.height,
})?.popup ??
null;
if (!popup) {
if (!settled) {
settle();
queueMicrotask(() => input.onOpenFailed?.());
}
return () => {};
}
// oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: popup navigation can fail if the browser has invalidated the handle
try {
popup.location.href = input.url;
} catch {
if (!settled) {
settle();
queueMicrotask(() => input.onOpenFailed?.());
}
return () => {};
}
// Some providers use COOP headers that can make a live cross-origin
// popup look closed to the opener. Callers can disable polling and rely
// on the explicit cancel path plus BroadcastChannel completion.
const pollMs = input.closedPollMs === undefined ? 500 : input.closedPollMs;
if (pollMs !== null) {
pollHandle = setInterval(() => {
let isClosed = false;
// oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: browser popup.closed can throw while navigating cross-origin
try {
isClosed = popup.closed;
} catch {
// Cross-origin access can throw during navigation; treat as open.
}
if (isClosed && !settled) {
settle();
input.onClosed?.();
}
}, pollMs);
}
return () => {
if (settled) return;
settle();
closePopup(popup);
};
};
// ---------------------------------------------------------------------------
// System-browser flow — used when a desktop host (Electron) opens the auth
// URL in the user's real browser. There's no shared origin, so the
// renderer polls `/api/oauth/await/:sessionId` for the result. The local
// server publishes there via `setOAuthCompletionListener` (see
// apps/local/src/serve.ts).
// ---------------------------------------------------------------------------
export type OpenOAuthSystemBrowserInput<TAuth> = {
readonly url: string;
readonly sessionId: string;
readonly openExternal: (url: string) => Promise<void>;
readonly onResult: (data: OAuthPopupResult<TAuth>) => void;
/** Called once if the external open itself fails (URL rejected, IPC error). */
readonly onOpenFailed?: (cause: unknown) => void;
/** Poll cadence. Default 1000ms. */
readonly pollMs?: number;
/** Stop polling after this many ms with no result. Default 10 minutes. */
readonly timeoutMs?: number;
readonly onTimeout?: () => void;
};
const OAUTH_AWAIT_DEFAULT_POLL_MS = 1000;
const OAUTH_AWAIT_DEFAULT_TIMEOUT_MS = 10 * 60 * 1000;
export const openOAuthSystemBrowser = <TAuth>(
input: OpenOAuthSystemBrowserInput<TAuth>,
): (() => void) => {
let settled = false;
let pollHandle: ReturnType<typeof setInterval> | null = null;
let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
const controller = new AbortController();
const settle = () => {
if (settled) return;
settled = true;
if (pollHandle !== null) clearInterval(pollHandle);
if (timeoutHandle !== null) clearTimeout(timeoutHandle);
controller.abort();
};
const poll = async () => {
if (settled) return;
// oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: fetch can reject for transient network errors during polling
try {
// The await poll is now gated like the rest of /api — carry the bearer
// (standalone web). On desktop the connection has no client-side auth and
// the main process injects the header instead.
const authorization = getExecutorServerAuthorizationHeader();
const response = await fetch(`/api/oauth/await/${encodeURIComponent(input.sessionId)}`, {
signal: controller.signal,
cache: "no-store",
...(authorization ? { headers: { authorization } } : {}),
});
if (!response.ok) return;
const body = (await response.json()) as unknown;
if (body === null || settled) return;
if (!isOAuthPopupResult<TAuth>(body)) return;
settle();
input.onResult(body);
} catch {
// Transient — next tick will retry. AbortError after settle is also caught here.
}
};
void (async () => {
// oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: openExternal is host-provided IPC, no Effect runtime in this browser-only helper
try {
await input.openExternal(input.url);
} catch (cause: unknown) {
if (settled) return;
settle();
input.onOpenFailed?.(cause);
}
})();
pollHandle = setInterval(() => void poll(), input.pollMs ?? OAUTH_AWAIT_DEFAULT_POLL_MS);
void poll();
timeoutHandle = setTimeout(() => {
if (settled) return;
settle();
input.onTimeout?.();
}, input.timeoutMs ?? OAUTH_AWAIT_DEFAULT_TIMEOUT_MS);
return () => settle();
};