2단계 인증은 7.0.6 에서 서버측이 갖춰졌지만 인증번호를 입력할 화면이 어느 버전에도
없었다. 그래서 그 설정을 켠 사이트는 관리자를 포함한 전원이 로그인할 수 없었다.
원인은 `POST /api/auth/login` 이 조건에 따라 **다른 형태의 200** 을 돌려준다는 것이다.
평소에는 `{token, user}` 지만 2단계 인증이 켜져 있으면 `{two_factor_required,
challenge_id, ...}` 를 돌려준다. 프론트는 앞의 형태만 선언하고 `response.data.user.language`
를 바로 읽었으므로 그 자리에서 TypeError 가 났고, 영문 원문이 로그인 화면에 그대로 노출됐다.
서버는 정상 응답했으므로 서버 로그에는 아무 흔적도 남지 않는다.
이어서 `setToken(undefined)` 가 `localStorage` 에 문자열 `"undefined"` 를 남겼다.
이 값은 truthy 라 이후 모든 요청이 `Bearer undefined` 로 나가 401 이 되고, 사용자에게는
「세션이 만료되었습니다」로 보인다. 관리자 로그인은 한발 더 나가 `null->isAdmin` 으로
500 이 되어, 설정을 되돌릴 수단까지 함께 사라졌다.
## 구현
- 로그인 응답을 판별 유니온(`LoginResult`)으로 표현하고, 형태를 판별한 뒤에 읽는다.
`ApiClient.setToken` 은 비어 있지 않은 문자열만 저장한다.
- 사용자·관리자 로그인 화면에 인증번호 입력 단계를 추가했다. 같은 카드 안에서 넘어가며
「인증번호 다시 받기」와 「처음부터」를 제공한다. 관리자 판정은 코드 확인에 성공한 뒤에
수행하고, 거부할 때는 그 직전에 발급된 토큰을 회수한다.
- 재발송(`login/two-factor/resend`)은 기존 challenge 를 취소하고 새로 발행한다. 유효한
코드를 여러 개 살려 두면 대입 시도의 표적이 넓어진다.
- 인증번호를 보내지 못하면 401 이 아니라 503 으로 답한다. 자격 증명은 올바른데 401 로
뭉개면 사용자는 비밀번호를 의심하며 같은 시도를 반복하고, 운영자는 메일 설정이 깨진
사실을 알 방법이 없다.
- 공개 본인인증 경로(`identity/verify`·`cancel`)가 로그인 목적의 challenge 를 소진하지
못하도록 403 게이트를 세웠다. 소진되면 그 challenge 로 영영 로그인할 수 없다.
- 로그인 시도 제한 429 응답이 다국어 문구를 싣도록 했다(종전에는 프레임워크 기본 영문).
- 다국어 파라미터에서 파이프 표현식이 평가되지 않아 「유효시간 까지」처럼 값이 빠지던
문제를 함께 고쳤다. 같은 결함이 문의 목록 화면에도 있었다.
## 이번 점검에서 함께 고친 것
- 계정 잠금(423)·발송 실패(503) 응답이 사용자·관리자 컨트롤러에 동일하게 복제돼 있었고
그 주석 자신은 "단일 지점에서 만든다" 고 적혀 있었다. 페이로드에 필드가 하나 추가되면
한쪽만 따라가 같은 실패를 두 화면이 다르게 안내하게 된다 — 트레이트로 통합했다.
- 테스트가 개발자 자신의 사이트 설정을 읽고 있었다. 2단계 인증을 켜 둔 환경에서는 로그인
성공을 전제한 테스트가 503 으로 깨지는데 실패 메시지가 원인을 가리키지도 않는다.
같은 결함군을 위해 이미 존재하던 단일 지점에 그 축을 추가했다.
## 버전
코어 7.0.11 · sirsoft-basic 1.1.4 · sirsoft-admin_basic 1.0.9 ·
번들 일본어팩 3종 · 템플릿 엔진 engine-v1.65.0.
476 lines
17 KiB
TypeScript
476 lines
17 KiB
TypeScript
/**
|
|
* 네트워크 복원력 유틸리티
|
|
*
|
|
* 페이지 로드에 필요한 요청 1건이 일시적으로 실패했다고 앱 전체가 죽지 않도록,
|
|
* **네트워크 레벨 실패에만** 지수 백오프 재시도를 거는 fetch/script 로더를 제공한다.
|
|
*
|
|
* 판별의 핵심: `fetch` 는 4xx/5xx 로 reject 하지 않는다(`Response.ok=false` 로 resolve).
|
|
* 따라서 `TypeError: Failed to fetch` 는 **응답 자체가 없었다**는 뜻이다 — 요청 취소 또는
|
|
* 커넥션 유실. 서버가 에러를 응답한 것과는 근본이 다르며, 전자만 재시도 가치가 있다.
|
|
* HTTP 응답은 그대로 호출부에 넘겨 기존 상태코드 분기(401 재시도 등)를 보존한다.
|
|
*
|
|
* @since engine-v1.53.0
|
|
*/
|
|
|
|
import { createLogger } from '../utils/Logger';
|
|
|
|
const logger = createLogger('networkResilience');
|
|
|
|
/**
|
|
* 재시도 옵션
|
|
*
|
|
* @since engine-v1.53.0
|
|
*/
|
|
export interface RetryOptions {
|
|
/** 재시도 횟수 (기본 2 = 총 3시도) */
|
|
retries?: number;
|
|
/** 첫 백오프 대기 (기본 300ms, 시도마다 2배 + ±25% jitter) */
|
|
baseDelayMs?: number;
|
|
/** 백오프 상한 (기본 2000ms) */
|
|
maxDelayMs?: number;
|
|
/** 시도별 타임아웃 (기본 15000ms, 0 이면 비활성) */
|
|
timeoutMs?: number;
|
|
/** 로그 식별용 라벨 */
|
|
label?: string;
|
|
}
|
|
|
|
const DEFAULT_RETRIES = 2;
|
|
const DEFAULT_BASE_DELAY_MS = 300;
|
|
const DEFAULT_MAX_DELAY_MS = 2000;
|
|
const DEFAULT_TIMEOUT_MS = 15000;
|
|
|
|
/**
|
|
* 취소(AbortError) 여부를 판별합니다.
|
|
*
|
|
* `instanceof DOMException` 을 쓰지 않는 이유: jsdom 등 테스트 환경에서 DOMException 이
|
|
* 없거나 다른 realm 의 것일 수 있어 판정이 어긋난다. name 비교가 환경 독립적이다.
|
|
*
|
|
* @param error 검사할 에러
|
|
* @return bool 취소로 인한 에러이면 true
|
|
* @since engine-v1.53.0
|
|
*/
|
|
export function isAbortError(error: unknown): boolean {
|
|
return (error as { name?: string } | null)?.name === 'AbortError';
|
|
}
|
|
|
|
/**
|
|
* 네트워크 레벨 실패(응답 없음) 여부를 판별합니다.
|
|
*
|
|
* fetch 스펙상 네트워크 오류는 TypeError 로 reject 된다. 취소(AbortError)는
|
|
* 호출부의 의도이므로 재시도 대상이 아니다.
|
|
*
|
|
* axios 경로(로그인 등 ApiClient 를 쓰는 요청)는 TypeError 가 아니라 `AxiosError` 로
|
|
* reject 되며 종류는 `code` 에만 남는다. TypeError 만 보면 그 경로의 네트워크 실패가
|
|
* 판정에서 빠져 영문 원문(`Network Error`)이 그대로 화면에 노출된다.
|
|
*
|
|
* @param error 검사할 에러
|
|
* @return bool 재시도할 가치가 있는 네트워크 실패이면 true
|
|
* @since engine-v1.53.0
|
|
* @since engine-v1.65.0 axios 네트워크 오류 코드(ERR_NETWORK/ECONNABORTED) 인식
|
|
*/
|
|
export function isNetworkFailure(error: unknown): boolean {
|
|
if (isAbortError(error)) return false;
|
|
if (error instanceof TypeError) return true;
|
|
|
|
const code = (error as { code?: string } | null)?.code;
|
|
|
|
return code === 'ERR_NETWORK' || code === 'ECONNABORTED';
|
|
}
|
|
|
|
let unloading = false;
|
|
let unloadGuardInstalled = false;
|
|
|
|
/**
|
|
* 현재 문서가 이탈(새로고침/이동) 중인지 반환합니다.
|
|
*
|
|
* @return bool 이탈 중이면 true
|
|
* @since engine-v1.53.0
|
|
*/
|
|
export function isDocumentUnloading(): boolean {
|
|
return unloading;
|
|
}
|
|
|
|
/**
|
|
* 문서 이탈 감지 가드를 설치합니다 (중복 설치 무해).
|
|
*
|
|
* 이탈 중 발생한 요청 실패는 "버려지는 문서" 의 실패이므로 에러 화면을 띄우면 안 된다.
|
|
* 사용자가 이미 떠난 화면에 에러를 그리는 것은 그 자체가 결함이다.
|
|
*
|
|
* `visibilitychange`/`hidden` 은 의도적으로 쓰지 않는다 — 탭 전환·앱 백그라운드로도
|
|
* 발화하므로, 백그라운드에서 재시도가 모두 실패한 **정상적 초기화 실패**까지 삼키면
|
|
* 사용자가 돌아왔을 때 영구 빈 화면이 된다(현행보다 나쁨). 새로고침·이탈은 `pagehide`
|
|
* 가 반드시 발화한다.
|
|
*
|
|
* @return void
|
|
* @since engine-v1.53.0
|
|
*/
|
|
export function installUnloadGuard(): void {
|
|
if (typeof window === 'undefined' || unloadGuardInstalled) return;
|
|
unloadGuardInstalled = true;
|
|
|
|
window.addEventListener('pagehide', () => {
|
|
unloading = true;
|
|
});
|
|
|
|
// bfcache 복귀 시 해제 — 없으면 뒤로가기로 복귀한 문서가 영구히 "이탈 중" 으로 오판된다.
|
|
window.addEventListener('pageshow', (event) => {
|
|
if ((event as PageTransitionEvent).persisted) {
|
|
unloading = false;
|
|
}
|
|
});
|
|
|
|
// Safari 보강 (pagehide 미발화 케이스 대비)
|
|
window.addEventListener('beforeunload', () => {
|
|
unloading = true;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 테스트 전용 — unload 가드 상태를 초기화합니다.
|
|
*
|
|
* @return void
|
|
* @since engine-v1.53.0
|
|
*/
|
|
export function resetUnloadGuardForTesting(): void {
|
|
unloading = false;
|
|
unloadGuardInstalled = false;
|
|
}
|
|
|
|
/**
|
|
* 지수 백오프 대기 시간을 계산합니다 (2배 증가 + ±25% jitter).
|
|
*
|
|
* jitter 를 두는 이유: 병렬 요청이 동시에 실패하면(커넥션 유실은 대개 그렇다)
|
|
* 재시도가 한 시점에 몰려 스탬피드가 된다.
|
|
*
|
|
* @param attempt 완료된 시도 횟수 (0부터)
|
|
* @param baseDelayMs 기준 대기 시간
|
|
* @param maxDelayMs 상한
|
|
* @return number 대기할 밀리초
|
|
* @since engine-v1.53.0
|
|
*/
|
|
function computeBackoffDelay(attempt: number, baseDelayMs: number, maxDelayMs: number): number {
|
|
const exponential = Math.min(baseDelayMs * Math.pow(2, attempt), maxDelayMs);
|
|
const jitter = exponential * 0.25 * (Math.random() * 2 - 1);
|
|
return Math.max(0, Math.round(exponential + jitter));
|
|
}
|
|
|
|
/**
|
|
* 지정 시간만큼 대기합니다.
|
|
*
|
|
* @param ms 대기할 밀리초
|
|
* @return Promise<void>
|
|
* @since engine-v1.53.0
|
|
*/
|
|
function delay(ms: number): Promise<void> {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|
|
|
|
/**
|
|
* 네트워크 레벨 실패에만 재시도하는 fetch 래퍼입니다.
|
|
*
|
|
* HTTP 응답(2xx/4xx/5xx 무관)은 **재시도하지 않고 그대로 반환**한다. `!response.ok`
|
|
* 검사와 throw 는 호출부 책임으로 남긴다 — 유틸이 `.ok` 를 보면 호출부의 상태코드
|
|
* 분기(예: LayoutLoader 의 401 재시도)와 이중 재시도가 겹친다.
|
|
*
|
|
* 외부 signal 에 의한 AbortError 는 즉시 rethrow 한다(호출부의 명시적 취소를 유틸이
|
|
* 되살리면 안 된다). 내부 timeout 이 발화시킨 abort 만 재시도 대상이다.
|
|
*
|
|
* @param url 요청 URL
|
|
* @param options 재시도 옵션 + fetch init
|
|
* @return Promise<Response> HTTP 응답 (4xx/5xx 포함)
|
|
* @throws TypeError 모든 시도가 네트워크 실패로 끝난 경우
|
|
* @since engine-v1.53.0
|
|
*/
|
|
export async function fetchWithRetry(
|
|
url: string,
|
|
options: RetryOptions & { init?: RequestInit } = {}
|
|
): Promise<Response> {
|
|
const {
|
|
retries = DEFAULT_RETRIES,
|
|
baseDelayMs = DEFAULT_BASE_DELAY_MS,
|
|
maxDelayMs = DEFAULT_MAX_DELAY_MS,
|
|
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
label = url,
|
|
init,
|
|
} = options;
|
|
|
|
const maxAttempts = retries + 1;
|
|
let lastError: unknown;
|
|
|
|
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
// 내부 timeout 이 발화시킨 abort 와 외부 signal 의 abort 를 구분하기 위한 플래그.
|
|
// 이 구분이 없으면 호출부가 명시적으로 취소한 요청을 유틸이 되살려 재발사한다.
|
|
let timedOut = false;
|
|
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
|
const controller = timeoutMs > 0 ? new AbortController() : null;
|
|
|
|
try {
|
|
const requestInit: RequestInit = { ...init };
|
|
|
|
if (controller) {
|
|
// 외부 signal 이 있으면 그 취소를 내부 controller 로 전파한다.
|
|
const externalSignal = init?.signal;
|
|
if (externalSignal) {
|
|
if (externalSignal.aborted) {
|
|
controller.abort();
|
|
} else {
|
|
externalSignal.addEventListener('abort', () => controller.abort(), { once: true });
|
|
}
|
|
}
|
|
requestInit.signal = controller.signal;
|
|
timeoutId = setTimeout(() => {
|
|
timedOut = true;
|
|
controller.abort();
|
|
}, timeoutMs);
|
|
}
|
|
|
|
const response = await fetch(url, requestInit);
|
|
|
|
// HTTP 응답이 왔다 = 재시도 대상 아님. 상태코드 판단은 호출부 몫.
|
|
return response;
|
|
} catch (error) {
|
|
lastError = error;
|
|
|
|
// 외부 취소는 즉시 rethrow (유틸이 되살리지 않는다)
|
|
if (isAbortError(error) && !timedOut) {
|
|
throw error;
|
|
}
|
|
|
|
// 내부 timeout 이 발화시킨 abort 는 네트워크 실패와 동급으로 취급해 재시도한다.
|
|
const retryable = timedOut || isNetworkFailure(error);
|
|
if (!retryable) {
|
|
throw error;
|
|
}
|
|
|
|
// 이탈 중인 문서는 남은 재시도를 포기한다 (버려질 문서에 대한 낭비)
|
|
if (isDocumentUnloading()) {
|
|
logger.warn(`Document unloading, aborting retries: ${label}`);
|
|
throw error;
|
|
}
|
|
|
|
const isLastAttempt = attempt === maxAttempts - 1;
|
|
if (isLastAttempt) {
|
|
logger.warn(
|
|
`All ${maxAttempts} attempts failed: ${label}`,
|
|
error
|
|
);
|
|
throw error;
|
|
}
|
|
|
|
const waitMs = computeBackoffDelay(attempt, baseDelayMs, maxDelayMs);
|
|
logger.warn(
|
|
`Network failure (attempt ${attempt + 1}/${maxAttempts}), retrying in ${waitMs}ms: ${label}`
|
|
);
|
|
await delay(waitMs);
|
|
} finally {
|
|
if (timeoutId !== undefined) {
|
|
clearTimeout(timeoutId);
|
|
}
|
|
}
|
|
}
|
|
|
|
// 도달 불가 (루프가 반드시 return 또는 throw 한다) — 타입 완결용
|
|
throw lastError;
|
|
}
|
|
|
|
/**
|
|
* `<script src>` 로드를 재시도하는 로더입니다.
|
|
*
|
|
* 최종 실패 시 **반드시 reject** 한다. `onerror` 에서 `resolve()` 하면 실패가 성공으로
|
|
* 위장되어 상위 try/catch 가 무력화되고, 앱은 한참 뒤 엉뚱한 곳(미등록 핸들러 등)에서
|
|
* 죽는다 — 그것이 이 유틸이 도입된 계기다.
|
|
*
|
|
* `<script>` 의 `onerror` 는 실패 사유를 주지 않으므로(404 인지 네트워크 유실인지 구분
|
|
* 불가) **모든 실패를 재시도 대상**으로 본다. 404 라면 3회 시도 후 reject 되어 명시적
|
|
* 에러로 끝나므로 안전하다.
|
|
*
|
|
* 재시도 전 기존 element 를 제거하고 새로 만든다 — 남겨두면 IIFE 번들이 두 번 실행되어
|
|
* 핸들러가 중복 등록된다.
|
|
*
|
|
* @param url 스크립트 URL
|
|
* @param attrs script element 에 부여할 속성 (id 등)
|
|
* @param options 재시도 옵션
|
|
* @return Promise<void> 로드 성공 시 resolve
|
|
* @throws Error 모든 시도가 실패한 경우
|
|
* @since engine-v1.53.0
|
|
*/
|
|
export async function loadScriptWithRetry(
|
|
url: string,
|
|
attrs: Record<string, string> = {},
|
|
options: RetryOptions = {}
|
|
): Promise<void> {
|
|
const {
|
|
retries = DEFAULT_RETRIES,
|
|
baseDelayMs = DEFAULT_BASE_DELAY_MS,
|
|
maxDelayMs = DEFAULT_MAX_DELAY_MS,
|
|
label = url,
|
|
} = options;
|
|
|
|
const maxAttempts = retries + 1;
|
|
|
|
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
try {
|
|
await loadScriptOnce(url, attrs);
|
|
return;
|
|
} catch (error) {
|
|
// 실패한 element 를 제거한다 (IIFE 중복 실행 방지 + 다음 시도의 id 충돌 방지)
|
|
if (attrs.id) {
|
|
document.getElementById(attrs.id)?.remove();
|
|
}
|
|
|
|
if (isDocumentUnloading()) {
|
|
logger.warn(`Document unloading, aborting script retries: ${label}`);
|
|
throw error;
|
|
}
|
|
|
|
const isLastAttempt = attempt === maxAttempts - 1;
|
|
if (isLastAttempt) {
|
|
logger.warn(`All ${maxAttempts} script attempts failed: ${label}`);
|
|
throw error;
|
|
}
|
|
|
|
const waitMs = computeBackoffDelay(attempt, baseDelayMs, maxDelayMs);
|
|
logger.warn(
|
|
`Script load failed (attempt ${attempt + 1}/${maxAttempts}), retrying in ${waitMs}ms: ${label}`
|
|
);
|
|
await delay(waitMs);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* `<script>` 1회 로드를 시도합니다 (재시도 없음).
|
|
*
|
|
* @param url 스크립트 URL
|
|
* @param attrs script element 속성
|
|
* @return Promise<void> 로드 성공 시 resolve, 실패 시 reject
|
|
* @since engine-v1.53.0
|
|
*/
|
|
function loadScriptOnce(url: string, attrs: Record<string, string>): Promise<void> {
|
|
return new Promise<void>((resolve, reject) => {
|
|
const script = document.createElement('script');
|
|
script.src = url;
|
|
script.async = false; // 번들 내부 물리 순서로 실행 보장
|
|
|
|
for (const [key, value] of Object.entries(attrs)) {
|
|
if (key === 'id') {
|
|
script.id = value;
|
|
} else {
|
|
script.setAttribute(key, value);
|
|
}
|
|
}
|
|
|
|
script.onload = () => resolve();
|
|
script.onerror = () => reject(new Error(`Failed to load script: ${url}`));
|
|
|
|
document.head.appendChild(script);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* `<link rel="stylesheet">` 로드를 재시도하는 로더입니다.
|
|
*
|
|
* `loadScriptWithRetry` 와 동형이다 — 3시도(기본) · 지수 백오프 · **최종 실패 시 reject**.
|
|
* CSS 경로만 이 계층이 없어 실패가 통째로 무음이었다: `onerror` 를 아예 걸지 않거나
|
|
* `resolve()` 로 삼켜, 스타일이 붙지 않은 화면(아이콘 소실·본문 서식 붕괴)이 오류 없이
|
|
* 남았다. 아이콘만으로 조작하는 버튼이 있는 화면에서는 그것이 곧 조작 불능이다.
|
|
*
|
|
* `<link>` 의 `onerror` 도 사유를 주지 않으므로 모든 실패를 재시도 대상으로 본다.
|
|
* 재시도 전 기존 element 를 제거한다 — 남겨두면 실패한 `<link>` 가 누적되고, id 를
|
|
* 지정한 경우 다음 시도와 충돌한다.
|
|
*
|
|
* @param url 스타일시트 URL
|
|
* @param attrs link element 에 부여할 속성 (id, media, crossorigin 등)
|
|
* @param options 재시도 옵션
|
|
* @return Promise<void> 로드 성공 시 resolve
|
|
* @throws Error 모든 시도가 실패한 경우
|
|
* @since engine-v1.62.0
|
|
*/
|
|
export async function loadStylesheetWithRetry(
|
|
url: string,
|
|
attrs: Record<string, string> = {},
|
|
options: RetryOptions = {}
|
|
): Promise<void> {
|
|
const {
|
|
retries = DEFAULT_RETRIES,
|
|
baseDelayMs = DEFAULT_BASE_DELAY_MS,
|
|
maxDelayMs = DEFAULT_MAX_DELAY_MS,
|
|
label = url,
|
|
} = options;
|
|
|
|
const maxAttempts = retries + 1;
|
|
|
|
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
let link: HTMLLinkElement | null = null;
|
|
|
|
try {
|
|
link = createStylesheetElement(url, attrs);
|
|
await loadStylesheetOnce(link);
|
|
return;
|
|
} catch (error) {
|
|
// 실패한 element 를 제거한다 (누적 방지 + 다음 시도의 id 충돌 방지)
|
|
link?.remove();
|
|
if (attrs.id) {
|
|
document.getElementById(attrs.id)?.remove();
|
|
}
|
|
|
|
if (isDocumentUnloading()) {
|
|
logger.warn(`Document unloading, aborting stylesheet retries: ${label}`);
|
|
throw error;
|
|
}
|
|
|
|
const isLastAttempt = attempt === maxAttempts - 1;
|
|
if (isLastAttempt) {
|
|
logger.warn(`All ${maxAttempts} stylesheet attempts failed: ${label}`);
|
|
throw error;
|
|
}
|
|
|
|
const waitMs = computeBackoffDelay(attempt, baseDelayMs, maxDelayMs);
|
|
logger.warn(
|
|
`Stylesheet load failed (attempt ${attempt + 1}/${maxAttempts}), retrying in ${waitMs}ms: ${label}`
|
|
);
|
|
await delay(waitMs);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* `<link rel="stylesheet">` element 를 생성해 head 에 추가합니다.
|
|
*
|
|
* @param url 스타일시트 URL
|
|
* @param attrs link element 속성
|
|
* @return HTMLLinkElement 생성된 element
|
|
* @since engine-v1.62.0
|
|
*/
|
|
function createStylesheetElement(url: string, attrs: Record<string, string>): HTMLLinkElement {
|
|
const link = document.createElement('link');
|
|
link.rel = 'stylesheet';
|
|
link.href = url;
|
|
|
|
for (const [key, value] of Object.entries(attrs)) {
|
|
if (key === 'id') {
|
|
link.id = value;
|
|
} else {
|
|
link.setAttribute(key, value);
|
|
}
|
|
}
|
|
|
|
document.head.appendChild(link);
|
|
|
|
return link;
|
|
}
|
|
|
|
/**
|
|
* `<link>` 1회 로드를 시도합니다 (재시도 없음).
|
|
*
|
|
* @param link head 에 이미 추가된 link element
|
|
* @return Promise<void> 로드 성공 시 resolve, 실패 시 reject
|
|
* @since engine-v1.62.0
|
|
*/
|
|
function loadStylesheetOnce(link: HTMLLinkElement): Promise<void> {
|
|
return new Promise<void>((resolve, reject) => {
|
|
link.onload = () => resolve();
|
|
link.onerror = () => reject(new Error(`Failed to load stylesheet: ${link.href}`));
|
|
});
|
|
}
|