fix(files): refresh URL auth token proactively for asset previews
The oc_url_token has a ~50s effective lifetime and was only fetched once at preview mount, so HTML/image/PDF previews cycled to 'authentication required' when it expired and nothing forced a re-render with a fresh token. Add a consumer-gated proactive refresh in runtime-auth: while at least one url-token consumer is active, a single scheduler mints a fresh token just before the skew window and swaps it in atomically (the previous token stays valid until the new one lands — no empty-token window for other consumers). acquire/release manage the consumer count; subscribe fires only on a real token replacement. FilesView consumes this via a shared useAssetAuthRefresh hook (replacing three near-duplicate effects) and remounts the iframe/img only when the token actually changes, not on a blind interval.
This commit is contained in:
@@ -4,6 +4,7 @@ All notable changes to this project will be documented in this file.
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
- Files: HTML, image, and PDF previews no longer cycle to "authentication required" every ~50 seconds. The short-lived URL auth token is now refreshed proactively before it expires (centrally, only while a preview is open), and previews remount only when the token actually changes.
|
||||||
- Chat: adjacent paragraphs in assistant messages now render with a visible gap instead of collapsing into a single visual line. Reasoning and tool-card markdown stay compact, and messages don't gain trailing space at the bottom.
|
- Chat: adjacent paragraphs in assistant messages now render with a visible gap instead of collapsing into a single visual line. Reasoning and tool-card markdown stay compact, and messages don't gain trailing space at the bottom.
|
||||||
|
|
||||||
## [1.13.1] - 2026-06-17
|
## [1.13.1] - 2026-06-17
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ import { useDeviceInfo } from '@/lib/device';
|
|||||||
import { cn, getModifierLabel, getRevealLabelKey, hasModifier } from '@/lib/utils';
|
import { cn, getModifierLabel, getRevealLabelKey, hasModifier } from '@/lib/utils';
|
||||||
import { getLanguageFromExtension, getImageMimeType, isDrawioFile, isImageFile, isPdfFile } from '@/lib/toolHelpers';
|
import { getLanguageFromExtension, getImageMimeType, isDrawioFile, isImageFile, isPdfFile } from '@/lib/toolHelpers';
|
||||||
import { getRuntimeUrlResolver } from '@/lib/runtime-url';
|
import { getRuntimeUrlResolver } from '@/lib/runtime-url';
|
||||||
import { refreshRuntimeUrlAuthToken } from '@/lib/runtime-auth';
|
import { acquireRuntimeUrlAuthToken, refreshRuntimeUrlAuthToken, subscribeRuntimeUrlAuthToken } from '@/lib/runtime-auth';
|
||||||
import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch';
|
import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch';
|
||||||
import { getOutsideFileGrant } from '@/lib/outsideFileGrants';
|
import { getOutsideFileGrant } from '@/lib/outsideFileGrants';
|
||||||
import { DiagramEditor } from '@/components/diagram';
|
import { DiagramEditor } from '@/components/diagram';
|
||||||
@@ -670,6 +670,62 @@ interface FilesViewProps {
|
|||||||
mode?: 'full' | 'editor-only';
|
mode?: 'full' | 'editor-only';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keeps a token-bearing asset preview (image/HTML/PDF) authenticated. While
|
||||||
|
* `assetKey` is set this registers an active url-token consumer (so runtime-auth
|
||||||
|
* proactively refreshes the shared token before it expires) and subscribes to
|
||||||
|
* token replacements, bumping `nonce` so the iframe/img remounts with the fresh
|
||||||
|
* token — but only when the token actually changed, not on every interval.
|
||||||
|
*/
|
||||||
|
const useAssetAuthRefresh = (
|
||||||
|
assetKey: string,
|
||||||
|
setFileError: React.Dispatch<React.SetStateAction<string | null>>,
|
||||||
|
errorFallback: string,
|
||||||
|
): { readyKey: string; nonce: number } => {
|
||||||
|
const [readyKey, setReadyKey] = React.useState('');
|
||||||
|
const [nonce, setNonce] = React.useState(0);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!assetKey) {
|
||||||
|
setReadyKey('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
setReadyKey('');
|
||||||
|
const apiBaseUrl = getRuntimeApiBaseUrl();
|
||||||
|
const release = acquireRuntimeUrlAuthToken(apiBaseUrl);
|
||||||
|
|
||||||
|
void refreshRuntimeUrlAuthToken(apiBaseUrl)
|
||||||
|
.then((token) => {
|
||||||
|
if (cancelled || !token) return;
|
||||||
|
setReadyKey(assetKey);
|
||||||
|
setFileError(null);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setFileError(error instanceof Error ? error.message : errorFallback);
|
||||||
|
setReadyKey(assetKey);
|
||||||
|
});
|
||||||
|
|
||||||
|
const unsubscribe = subscribeRuntimeUrlAuthToken(() => {
|
||||||
|
if (cancelled) return;
|
||||||
|
// Token was refreshed underneath us — remount the asset with the fresh URL.
|
||||||
|
setReadyKey(assetKey);
|
||||||
|
setNonce((n) => n + 1);
|
||||||
|
setFileError(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
release();
|
||||||
|
unsubscribe();
|
||||||
|
};
|
||||||
|
}, [assetKey, setFileError, errorFallback]);
|
||||||
|
|
||||||
|
return { readyKey, nonce };
|
||||||
|
};
|
||||||
|
|
||||||
export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const { files, runtime } = useRuntimeAPIs();
|
const { files, runtime } = useRuntimeAPIs();
|
||||||
@@ -850,9 +906,6 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
|||||||
const [fileError, setFileError] = React.useState<string | null>(null);
|
const [fileError, setFileError] = React.useState<string | null>(null);
|
||||||
const [desktopImageSrc, setDesktopImageSrc] = React.useState<string>('');
|
const [desktopImageSrc, setDesktopImageSrc] = React.useState<string>('');
|
||||||
const desktopImageBlobUrlRef = React.useRef<string>('');
|
const desktopImageBlobUrlRef = React.useRef<string>('');
|
||||||
const [imageAssetAuthReadyKey, setImageAssetAuthReadyKey] = React.useState('');
|
|
||||||
const [htmlAssetAuthReadyKey, setHtmlAssetAuthReadyKey] = React.useState('');
|
|
||||||
const [pdfAssetAuthReadyKey, setPdfAssetAuthReadyKey] = React.useState('');
|
|
||||||
|
|
||||||
const [loadedFilePath, setLoadedFilePath] = React.useState<string | null>(null);
|
const [loadedFilePath, setLoadedFilePath] = React.useState<string | null>(null);
|
||||||
|
|
||||||
@@ -2880,78 +2933,16 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
|||||||
? selectedFile.path
|
? selectedFile.path
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
React.useEffect(() => {
|
const assetAuthErrorFallback = t('filesView.error.readFileFailed');
|
||||||
if (!imageAssetAuthKey) {
|
const { readyKey: imageAssetAuthReadyKey, nonce: imagePreviewNonce } =
|
||||||
setImageAssetAuthReadyKey('');
|
useAssetAuthRefresh(imageAssetAuthKey, setFileError, assetAuthErrorFallback);
|
||||||
return;
|
const { readyKey: htmlAssetAuthReadyKey, nonce: htmlPreviewNonce } =
|
||||||
}
|
useAssetAuthRefresh(htmlAssetAuthKey, setFileError, assetAuthErrorFallback);
|
||||||
|
const { readyKey: pdfAssetAuthReadyKey, nonce: pdfPreviewNonce } =
|
||||||
let cancelled = false;
|
useAssetAuthRefresh(pdfAssetAuthKey, setFileError, assetAuthErrorFallback);
|
||||||
setImageAssetAuthReadyKey('');
|
|
||||||
void refreshRuntimeUrlAuthToken(getRuntimeApiBaseUrl())
|
|
||||||
.then((token) => {
|
|
||||||
if (!cancelled && token) setImageAssetAuthReadyKey(imageAssetAuthKey);
|
|
||||||
})
|
|
||||||
.catch(() => {});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [imageAssetAuthKey]);
|
|
||||||
|
|
||||||
const isImageAssetAuthLoading = Boolean(imageAssetAuthKey && imageAssetAuthReadyKey !== imageAssetAuthKey);
|
const isImageAssetAuthLoading = Boolean(imageAssetAuthKey && imageAssetAuthReadyKey !== imageAssetAuthKey);
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (!htmlAssetAuthKey) {
|
|
||||||
setHtmlAssetAuthReadyKey('');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let cancelled = false;
|
|
||||||
setHtmlAssetAuthReadyKey('');
|
|
||||||
void refreshRuntimeUrlAuthToken(getRuntimeApiBaseUrl())
|
|
||||||
.then((token) => {
|
|
||||||
if (!cancelled && token) {
|
|
||||||
setHtmlAssetAuthReadyKey(htmlAssetAuthKey);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
if (!cancelled) {
|
|
||||||
setFileError(error instanceof Error ? error.message : t('filesView.error.readFileFailed'));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [htmlAssetAuthKey, t]);
|
|
||||||
|
|
||||||
const isHtmlAssetAuthLoading = Boolean(htmlAssetAuthKey && htmlAssetAuthReadyKey !== htmlAssetAuthKey);
|
const isHtmlAssetAuthLoading = Boolean(htmlAssetAuthKey && htmlAssetAuthReadyKey !== htmlAssetAuthKey);
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (!pdfAssetAuthKey) {
|
|
||||||
setPdfAssetAuthReadyKey('');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let cancelled = false;
|
|
||||||
setPdfAssetAuthReadyKey('');
|
|
||||||
void refreshRuntimeUrlAuthToken(getRuntimeApiBaseUrl())
|
|
||||||
.then((token) => {
|
|
||||||
if (!cancelled && token) setPdfAssetAuthReadyKey(pdfAssetAuthKey);
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
if (!cancelled) {
|
|
||||||
setFileError(error instanceof Error ? error.message : t('filesView.error.readFileFailed'));
|
|
||||||
setPdfAssetAuthReadyKey(pdfAssetAuthKey);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [pdfAssetAuthKey, t]);
|
|
||||||
|
|
||||||
const isPdfAssetAuthLoading = Boolean(pdfAssetAuthKey && pdfAssetAuthReadyKey !== pdfAssetAuthKey);
|
const isPdfAssetAuthLoading = Boolean(pdfAssetAuthKey && pdfAssetAuthReadyKey !== pdfAssetAuthKey);
|
||||||
|
|
||||||
const imageSrc = selectedFile?.path && isSelectedImage
|
const imageSrc = selectedFile?.path && isSelectedImage
|
||||||
@@ -2981,12 +2972,13 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
|||||||
const renderPdfPreview = React.useCallback((file: FileNode) => (
|
const renderPdfPreview = React.useCallback((file: FileNode) => (
|
||||||
<div className="h-full overflow-hidden bg-[var(--surface-background)]">
|
<div className="h-full overflow-hidden bg-[var(--surface-background)]">
|
||||||
<iframe
|
<iframe
|
||||||
|
key={pdfPreviewNonce}
|
||||||
src={pdfSrc}
|
src={pdfSrc}
|
||||||
className="h-full w-full border-0"
|
className="h-full w-full border-0"
|
||||||
title={file.name}
|
title={file.name}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
), [pdfSrc]);
|
), [pdfSrc, pdfPreviewNonce]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -3757,6 +3749,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
|||||||
) : isSelectedImage ? (
|
) : isSelectedImage ? (
|
||||||
<div className="flex h-full items-center justify-center p-3">
|
<div className="flex h-full items-center justify-center p-3">
|
||||||
<img
|
<img
|
||||||
|
key={imagePreviewNonce}
|
||||||
src={imageSrc}
|
src={imageSrc}
|
||||||
alt={selectedFile?.name ?? t('filesView.editor.imageAltFallback')}
|
alt={selectedFile?.name ?? t('filesView.editor.imageAltFallback')}
|
||||||
className="max-w-full max-h-[70vh] object-contain rounded-md border border-border/30 bg-primary/10"
|
className="max-w-full max-h-[70vh] object-contain rounded-md border border-border/30 bg-primary/10"
|
||||||
@@ -3825,6 +3818,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
|||||||
) : (
|
) : (
|
||||||
<div className="h-full overflow-hidden">
|
<div className="h-full overflow-hidden">
|
||||||
<iframe
|
<iframe
|
||||||
|
key={htmlPreviewNonce}
|
||||||
src={!runtime.isVSCode && htmlAssetAuthReadyKey === htmlAssetAuthKey ? (() => {
|
src={!runtime.isVSCode && htmlAssetAuthReadyKey === htmlAssetAuthKey ? (() => {
|
||||||
const encoded = selectedFile.path.split('/').map((segment) => encodeURIComponent(segment)).join('/');
|
const encoded = selectedFile.path.split('/').map((segment) => encodeURIComponent(segment)).join('/');
|
||||||
return getRuntimeUrlResolver().authenticatedAsset(`/api/fs/serve${encoded.startsWith('/') ? encoded : `/${encoded}`}`);
|
return getRuntimeUrlResolver().authenticatedAsset(`/api/fs/serve${encoded.startsWith('/') ? encoded : `/${encoded}`}`);
|
||||||
@@ -4126,6 +4120,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
|||||||
) : isSelectedImage ? (
|
) : isSelectedImage ? (
|
||||||
<div className="flex h-full items-center justify-center p-4">
|
<div className="flex h-full items-center justify-center p-4">
|
||||||
<img
|
<img
|
||||||
|
key={imagePreviewNonce}
|
||||||
src={imageSrc}
|
src={imageSrc}
|
||||||
alt={selectedFile.name}
|
alt={selectedFile.name}
|
||||||
className="max-w-full max-h-full object-contain rounded-md border border-border/30 bg-primary/10"
|
className="max-w-full max-h-full object-contain rounded-md border border-border/30 bg-primary/10"
|
||||||
|
|||||||
@@ -51,6 +51,8 @@ const resetRuntimeAuthGeneration = (): void => {
|
|||||||
runtimeAuthGeneration += 1;
|
runtimeAuthGeneration += 1;
|
||||||
runtimeUrlAuthRefreshPromise = null;
|
runtimeUrlAuthRefreshPromise = null;
|
||||||
clearRuntimeUrlAuthToken();
|
clearRuntimeUrlAuthToken();
|
||||||
|
// Credentials changed: if a consumer is active, re-mint promptly.
|
||||||
|
scheduleUrlAuthRefresh();
|
||||||
};
|
};
|
||||||
|
|
||||||
export const setRuntimeAuthCredentialProvider = (provider: RuntimeAuthCredentialProvider): void => {
|
export const setRuntimeAuthCredentialProvider = (provider: RuntimeAuthCredentialProvider): void => {
|
||||||
@@ -80,8 +82,15 @@ export const setRuntimeUrlAuthToken = (token: string | null | undefined, expires
|
|||||||
clearRuntimeUrlAuthToken();
|
clearRuntimeUrlAuthToken();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const previous = runtimeUrlAuthToken;
|
||||||
runtimeUrlAuthToken = normalized;
|
runtimeUrlAuthToken = normalized;
|
||||||
runtimeUrlAuthTokenExpiresAt = expiresAt;
|
runtimeUrlAuthTokenExpiresAt = expiresAt;
|
||||||
|
// Notify only on a real replacement (existing token swapped for a fresh one),
|
||||||
|
// not on the initial mint, so consumers remount token-bearing assets only
|
||||||
|
// when the URL token actually changed underneath them.
|
||||||
|
if (previous && previous !== normalized) {
|
||||||
|
notifyRuntimeUrlAuthListeners();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const readValidRuntimeUrlAuthTokenSync = (): string => {
|
const readValidRuntimeUrlAuthTokenSync = (): string => {
|
||||||
@@ -108,9 +117,10 @@ export const getRuntimeAuthCredential = async (): Promise<RuntimeAuthCredential>
|
|||||||
return token ? { type: 'bearer', token } : null;
|
return token ? { type: 'bearer', token } : null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const refreshRuntimeUrlAuthToken = async (apiBaseUrl?: string | null): Promise<string> => {
|
// Performs the actual network mint and swaps the new token in atomically (the
|
||||||
const existing = readValidRuntimeUrlAuthTokenSync();
|
// previous token stays valid until `setRuntimeUrlAuthToken` replaces it — no
|
||||||
if (existing) return existing;
|
// empty-token window). Concurrent callers share one in-flight request.
|
||||||
|
const mintRuntimeUrlAuthToken = (apiBaseUrl?: string | null): Promise<string> => {
|
||||||
if (runtimeUrlAuthRefreshPromise) return runtimeUrlAuthRefreshPromise;
|
if (runtimeUrlAuthRefreshPromise) return runtimeUrlAuthRefreshPromise;
|
||||||
const generation = runtimeAuthGeneration;
|
const generation = runtimeAuthGeneration;
|
||||||
|
|
||||||
@@ -153,6 +163,98 @@ export const refreshRuntimeUrlAuthToken = async (apiBaseUrl?: string | null): Pr
|
|||||||
return runtimeUrlAuthRefreshPromise;
|
return runtimeUrlAuthRefreshPromise;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Returns a valid token without a network call, minting only when the current
|
||||||
|
// token is missing or already inside the skew window.
|
||||||
|
export const refreshRuntimeUrlAuthToken = async (apiBaseUrl?: string | null): Promise<string> => {
|
||||||
|
const existing = readValidRuntimeUrlAuthTokenSync();
|
||||||
|
if (existing) return existing;
|
||||||
|
return mintRuntimeUrlAuthToken(apiBaseUrl);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Proactive URL auth token refresh ──────────────────────────────────────
|
||||||
|
// The url token has a short server TTL. Instead of each consumer minting on its
|
||||||
|
// own timer (and clearing the shared token, which 401s other consumers during
|
||||||
|
// the refetch), a single scheduler refreshes it just before the skew window —
|
||||||
|
// but only while at least one consumer is active, so we never poll
|
||||||
|
// /auth/url-token in the background when nothing needs the token.
|
||||||
|
let urlAuthConsumerCount = 0;
|
||||||
|
let urlAuthRefreshTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let urlAuthApiBaseUrl: string | null = null;
|
||||||
|
const urlAuthListeners = new Set<() => void>();
|
||||||
|
const URL_AUTH_PROACTIVE_BUFFER_MS = 5_000;
|
||||||
|
|
||||||
|
const notifyRuntimeUrlAuthListeners = (): void => {
|
||||||
|
for (const listener of urlAuthListeners) {
|
||||||
|
try {
|
||||||
|
listener();
|
||||||
|
} catch {
|
||||||
|
// A listener throwing must not break the refresh loop.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearUrlAuthRefreshTimer = (): void => {
|
||||||
|
if (urlAuthRefreshTimer !== null) {
|
||||||
|
clearTimeout(urlAuthRefreshTimer);
|
||||||
|
urlAuthRefreshTimer = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const scheduleUrlAuthRefresh = (): void => {
|
||||||
|
clearUrlAuthRefreshTimer();
|
||||||
|
if (urlAuthConsumerCount <= 0 || typeof window === 'undefined') return;
|
||||||
|
|
||||||
|
// Refresh before the skew window so the old token is still valid when the new
|
||||||
|
// one swaps in. With no token yet (expiry 0), refresh immediately.
|
||||||
|
const refreshAt = runtimeUrlAuthTokenExpiresAt - URL_AUTH_REFRESH_SKEW_MS - URL_AUTH_PROACTIVE_BUFFER_MS;
|
||||||
|
const delay = runtimeUrlAuthTokenExpiresAt > 0 ? Math.max(0, refreshAt - Date.now()) : 0;
|
||||||
|
|
||||||
|
urlAuthRefreshTimer = setTimeout(() => {
|
||||||
|
urlAuthRefreshTimer = null;
|
||||||
|
if (urlAuthConsumerCount <= 0) return;
|
||||||
|
void mintRuntimeUrlAuthToken(urlAuthApiBaseUrl)
|
||||||
|
.catch(() => {
|
||||||
|
// Transient — the reschedule below retries (token is cleared on
|
||||||
|
// failure → expiry 0 → delay 0 → prompt retry).
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
scheduleUrlAuthRefresh();
|
||||||
|
});
|
||||||
|
}, delay);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Register an active url-token consumer. While any consumer is held, the token
|
||||||
|
// is proactively refreshed before it expires. Returns a release function;
|
||||||
|
// the proactive loop stops once the last consumer releases.
|
||||||
|
export const acquireRuntimeUrlAuthToken = (apiBaseUrl?: string | null): (() => void) => {
|
||||||
|
if (typeof apiBaseUrl === 'string' && apiBaseUrl.trim()) {
|
||||||
|
urlAuthApiBaseUrl = apiBaseUrl.trim();
|
||||||
|
}
|
||||||
|
urlAuthConsumerCount += 1;
|
||||||
|
scheduleUrlAuthRefresh();
|
||||||
|
|
||||||
|
let released = false;
|
||||||
|
return () => {
|
||||||
|
if (released) return;
|
||||||
|
released = true;
|
||||||
|
urlAuthConsumerCount = Math.max(0, urlAuthConsumerCount - 1);
|
||||||
|
if (urlAuthConsumerCount === 0) {
|
||||||
|
clearUrlAuthRefreshTimer();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Subscribe to url-token *replacements* (an existing token swapped for a fresh
|
||||||
|
// one). Fires only on a real change — not the initial mint — so consumers can
|
||||||
|
// remount token-bearing assets without churning on first load. Returns an
|
||||||
|
// unsubscribe function.
|
||||||
|
export const subscribeRuntimeUrlAuthToken = (listener: () => void): (() => void) => {
|
||||||
|
urlAuthListeners.add(listener);
|
||||||
|
return () => {
|
||||||
|
urlAuthListeners.delete(listener);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export const buildRuntimeAuthHeaders = async (headers?: HeadersInit): Promise<Headers> => {
|
export const buildRuntimeAuthHeaders = async (headers?: HeadersInit): Promise<Headers> => {
|
||||||
const next = new Headers(headers);
|
const next = new Headers(headers);
|
||||||
if (next.has('Authorization')) {
|
if (next.has('Authorization')) {
|
||||||
|
|||||||
Reference in New Issue
Block a user