@solhun/feedback-kit-core 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/dist/index.cjs +2366 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +1234 -0
- package/dist/index.d.ts +1234 -0
- package/dist/index.js +2268 -0
- package/dist/index.js.map +1 -0
- package/package.json +35 -0
- package/src/adapters/adapters.test.ts +730 -0
- package/src/adapters/body.ts +280 -0
- package/src/adapters/index.ts +19 -0
- package/src/adapters/lasso.live.test.ts +156 -0
- package/src/adapters/lasso.ts +316 -0
- package/src/adapters/linear.ts +39 -0
- package/src/adapters/notion.ts +30 -0
- package/src/config.test.ts +284 -0
- package/src/config.ts +396 -0
- package/src/context.test.ts +427 -0
- package/src/context.ts +326 -0
- package/src/diagnostics.test.ts +382 -0
- package/src/diagnostics.ts +502 -0
- package/src/guest-id.ts +36 -0
- package/src/index.ts +179 -0
- package/src/keepalive.test.ts +72 -0
- package/src/keepalive.ts +37 -0
- package/src/queue.test.ts +848 -0
- package/src/queue.ts +546 -0
- package/src/report.ts +58 -0
- package/src/ring-buffer.test.ts +62 -0
- package/src/ring-buffer.ts +42 -0
- package/src/source-attr.test.ts +77 -0
- package/src/source-attr.ts +86 -0
- package/src/types.ts +320 -0
- package/src/uuid.test.ts +116 -0
- package/src/uuid.ts +54 -0
- package/src/widget/controller.ts +224 -0
- package/src/widget/focus.ts +66 -0
- package/src/widget/index.ts +59 -0
- package/src/widget/modal.test.ts +472 -0
- package/src/widget/modal.ts +526 -0
- package/src/widget/pin.ts +110 -0
- package/src/widget/screenshot.ts +109 -0
package/src/config.ts
ADDED
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
// 설정과 토큰 — 붙이는 데 필요한 것을 한 줄로 줄인다.
|
|
2
|
+
//
|
|
3
|
+
// <FeedbackKit token={TOKEN} />
|
|
4
|
+
//
|
|
5
|
+
// 위 한 줄이 성립하려면 나머지 항목이 **전부 기본값을 갖고 있어야** 한다. 그래서 이
|
|
6
|
+
// 모듈의 본체는 `resolveConfig` 하나이고, 나머지는 그 결과를 읽는 판정 함수들이다.
|
|
7
|
+
//
|
|
8
|
+
// ## 수집 토큰은 비밀이 아니다
|
|
9
|
+
//
|
|
10
|
+
// 토큰은 클라이언트 번들에 **상수로 인라인**된다(웹 `VITE_*`/`NEXT_PUBLIC_*`, 앱
|
|
11
|
+
// `EXPO_PUBLIC_*`). 누구나 번들을 열어 꺼낼 수 있으므로 토큰 자체는 접근 제어가 아니다.
|
|
12
|
+
// 실제 방어선은 서버에 있다 — 웹은 등록된 Origin 에서 온 요청만 받고, 앱은 Origin 검사가
|
|
13
|
+
// 불가능하므로 시간당 한도가 방어선이다.
|
|
14
|
+
//
|
|
15
|
+
// 이 사실을 코드 주석과 문서에 남기는 이유는, 토큰이 노출됐을 때 "유출 사고"로 오인해
|
|
16
|
+
// 전면 회전 같은 과잉 대응을 하는 걸 막기 위해서다. 저장소에 커밋하지 않는 것은
|
|
17
|
+
// **위생 문제이지 보안 경계가 아니다.**
|
|
18
|
+
//
|
|
19
|
+
// 또 하나: 환경변수는 빌드 타임에 인라인되므로 값을 바꾸면 **다시 빌드해야** 반영된다.
|
|
20
|
+
// 앱은 OTA 로 반영되지 않는다. 그래서 아래 `resolveConfig` 는 치환되지 않은 환경변수
|
|
21
|
+
// 문자열(`undefined`, `import.meta.env.VITE_TOKEN` 등)을 토큰으로 받으면 경고를 남긴다.
|
|
22
|
+
// 이건 실제로 가장 자주 나는 사고이고, 조용히 미귀속으로 흘러가면 알아채기 어렵다.
|
|
23
|
+
|
|
24
|
+
import type { ContextUser, DiagnosticsPayload, FeedbackAdapter, FeedbackUser } from "./types.js";
|
|
25
|
+
|
|
26
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
27
|
+
// 노출 범위
|
|
28
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
/** 노출 판정에 쓰이는 현재 상황. 함수형 `visibility` 가 그대로 받는다. */
|
|
31
|
+
export interface VisibilityEnv {
|
|
32
|
+
/** 현재 사용자. 로그인 전이면 게스트이거나 null. */
|
|
33
|
+
user: ContextUser | FeedbackUser | null;
|
|
34
|
+
/** 개발 빌드 여부. */
|
|
35
|
+
isDev: boolean;
|
|
36
|
+
platform: "web" | "native" | "unknown";
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** 프로젝트가 직접 판정하는 형태. true 면 노출. */
|
|
40
|
+
export type VisibilityFn = (env: VisibilityEnv) => boolean;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* 누구에게 위젯을 노출할지.
|
|
44
|
+
*
|
|
45
|
+
* 기본값은 `all` 이다. 실사용자 제보가 실제로 가치를 냈기 때문에 패키지가 먼저 막지
|
|
46
|
+
* 않는다. 좁혀야 하는 프로젝트가 `internal`·`dev-only`·함수로 좁힌다.
|
|
47
|
+
*/
|
|
48
|
+
export type Visibility = "all" | "internal" | "dev-only" | VisibilityFn;
|
|
49
|
+
|
|
50
|
+
const VISIBILITY_KEYWORDS = ["all", "internal", "dev-only"] as const;
|
|
51
|
+
|
|
52
|
+
/** `internal` 판정의 기본 근거. 프로젝트가 `internalRoles` 로 갈아끼운다. */
|
|
53
|
+
export const DEFAULT_INTERNAL_ROLES: readonly string[] = ["internal", "admin", "staff"];
|
|
54
|
+
|
|
55
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
56
|
+
// 위치 / 어댑터
|
|
57
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
export type WidgetCorner = "bottom-right" | "bottom-left" | "top-right" | "top-left";
|
|
60
|
+
|
|
61
|
+
const CORNERS: readonly WidgetCorner[] = [
|
|
62
|
+
"bottom-right",
|
|
63
|
+
"bottom-left",
|
|
64
|
+
"top-right",
|
|
65
|
+
"top-left",
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
export interface WidgetPosition {
|
|
69
|
+
corner: WidgetCorner;
|
|
70
|
+
/** 모서리로부터의 가로 여백(px). */
|
|
71
|
+
offsetX: number;
|
|
72
|
+
/** 모서리로부터의 세로 여백(px). */
|
|
73
|
+
offsetY: number;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export const DEFAULT_POSITION: WidgetPosition = {
|
|
77
|
+
corner: "bottom-right",
|
|
78
|
+
offsetX: 16,
|
|
79
|
+
offsetY: 16,
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/** 내장 어댑터 이름. 실제 구현과 전송 규약은 어댑터 섹션이 갖는다. */
|
|
83
|
+
export type AdapterName = "lasso" | "linear" | "notion";
|
|
84
|
+
|
|
85
|
+
const ADAPTER_NAMES: readonly AdapterName[] = ["lasso", "linear", "notion"];
|
|
86
|
+
|
|
87
|
+
export const DEFAULT_ADAPTER: AdapterName = "lasso";
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* 수집 엔드포인트 기본값은 **어댑터가 갖는다.**
|
|
91
|
+
*
|
|
92
|
+
* 코어가 특정 백엔드 URL 을 상수로 들고 있으면 어댑터 교체가 무의미해지고, 코어가
|
|
93
|
+
* 어댑터별 사정을 아는 셈이 된다. 그래서 여기서는 "지정 안 함"을 `null` 로 표현하고,
|
|
94
|
+
* 어댑터가 자기 내장 기본값을 쓰도록 넘긴다.
|
|
95
|
+
*/
|
|
96
|
+
export const ENDPOINT_FROM_ADAPTER = null;
|
|
97
|
+
|
|
98
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
99
|
+
// 경고
|
|
100
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
export type ConfigWarningCode =
|
|
103
|
+
/** 토큰이 없다 — 전송은 되지만 미귀속이다. */
|
|
104
|
+
| "missing-token"
|
|
105
|
+
/** 환경변수가 치환되지 않은 채로 들어왔다(빌드 설정 사고). */
|
|
106
|
+
| "unsubstituted-token"
|
|
107
|
+
/** 모르는 어댑터 이름 — 기본 어댑터로 되돌린다. */
|
|
108
|
+
| "unknown-adapter"
|
|
109
|
+
/** 모르는 visibility 값 — `all` 로 되돌린다. */
|
|
110
|
+
| "unknown-visibility"
|
|
111
|
+
/** 모르는 위치 값 — 우하단으로 되돌린다. */
|
|
112
|
+
| "unknown-position"
|
|
113
|
+
/** 사용자가 준 판정 함수가 예외를 던졌다 — 숨김으로 처리한다. */
|
|
114
|
+
| "visibility-threw";
|
|
115
|
+
|
|
116
|
+
export interface ConfigWarning {
|
|
117
|
+
code: ConfigWarningCode;
|
|
118
|
+
message: string;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* 치환되지 않은 환경변수처럼 보이는 값.
|
|
123
|
+
*
|
|
124
|
+
* `EXPO_PUBLIC_` 접두사를 빠뜨리거나 `.env` 를 안 만들면 번들에 `undefined` 라는
|
|
125
|
+
* **문자열**이 그대로 박히는 일이 흔하다. 눈으로는 토큰이 있는 것처럼 보이는데 서버는
|
|
126
|
+
* 미귀속으로 받는다.
|
|
127
|
+
*/
|
|
128
|
+
function looksUnsubstituted(token: string): boolean {
|
|
129
|
+
const t = token.trim();
|
|
130
|
+
if (t === "undefined" || t === "null" || t === "NaN") return true;
|
|
131
|
+
if (t.includes("process.env.") || t.includes("import.meta.env")) return true;
|
|
132
|
+
// `${VITE_TOKEN}` / `%NEXT_PUBLIC_TOKEN%` 같은 미치환 플레이스홀더.
|
|
133
|
+
if (/^\$\{.*\}$/.test(t) || /^%.*%$/.test(t)) return true;
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
138
|
+
// 설정
|
|
139
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
140
|
+
|
|
141
|
+
export interface FeedbackConfig {
|
|
142
|
+
/** 수집 토큰. 없으면 미귀속으로 전송된다(막지 않는다). */
|
|
143
|
+
token?: string | null;
|
|
144
|
+
/** 수집 엔드포인트. 없으면 어댑터 내장 기본값. */
|
|
145
|
+
endpoint?: string | null;
|
|
146
|
+
/** 어댑터 이름 또는 어댑터 구현체. */
|
|
147
|
+
adapter?: AdapterName | FeedbackAdapter;
|
|
148
|
+
/** 노출 범위. 기본 `all`. */
|
|
149
|
+
visibility?: Visibility;
|
|
150
|
+
/** 모달을 열 때 화면을 자동 캡처할지. */
|
|
151
|
+
captureScreenshot?: boolean;
|
|
152
|
+
/** network·logs 링버퍼를 수집할지. false 면 아예 설치하지 않는다. */
|
|
153
|
+
captureDiagnostics?: boolean;
|
|
154
|
+
/** 플로팅 버튼 위치. 모서리만 주면 여백은 기본값. */
|
|
155
|
+
position?: WidgetCorner | Partial<WidgetPosition>;
|
|
156
|
+
/** `internal` 판정을 프로젝트가 직접 할 때. 주면 `internalRoles` 보다 우선한다. */
|
|
157
|
+
isInternal?: VisibilityFn;
|
|
158
|
+
/** `internal` 로 볼 역할 목록. 기본 {@link DEFAULT_INTERNAL_ROLES}. */
|
|
159
|
+
internalRoles?: readonly string[];
|
|
160
|
+
/** 개발 빌드 여부. 안 주면 런타임 전역에서 추론한다. */
|
|
161
|
+
isDev?: boolean;
|
|
162
|
+
/** 경고 출구. 안 주면 `resolveConfig` 결과의 `warnings` 로만 남는다. */
|
|
163
|
+
onWarn?: (warning: ConfigWarning) => void;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** 기본값이 모두 채워진 설정. 위젯은 이것만 본다. */
|
|
167
|
+
export interface ResolvedConfig {
|
|
168
|
+
token: string | null;
|
|
169
|
+
endpoint: string | null;
|
|
170
|
+
adapter: AdapterName | FeedbackAdapter;
|
|
171
|
+
visibility: Visibility;
|
|
172
|
+
captureScreenshot: boolean;
|
|
173
|
+
captureDiagnostics: boolean;
|
|
174
|
+
position: WidgetPosition;
|
|
175
|
+
isInternal: VisibilityFn | null;
|
|
176
|
+
internalRoles: readonly string[];
|
|
177
|
+
isDev: boolean;
|
|
178
|
+
warnings: ConfigWarning[];
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* 개발 빌드 여부 추론.
|
|
183
|
+
*
|
|
184
|
+
* 코어는 플랫폼 무관이라 `__DEV__`(RN)·`process.env.NODE_ENV`(웹/노드)를 **globalThis
|
|
185
|
+
* 를 통해서만** 본다. 어느 쪽도 없으면 프로덕션으로 본다 — 모르는 상태에서 개발 빌드로
|
|
186
|
+
* 단정하면 `dev-only` 가 실사용자에게 새기 때문이다.
|
|
187
|
+
*/
|
|
188
|
+
export function detectDevBuild(): boolean {
|
|
189
|
+
const g = globalThis as Record<string, unknown>;
|
|
190
|
+
if (typeof g.__DEV__ === "boolean") return g.__DEV__;
|
|
191
|
+
const proc = g.process as { env?: Record<string, string | undefined> } | undefined;
|
|
192
|
+
const nodeEnv = proc?.env?.NODE_ENV;
|
|
193
|
+
if (typeof nodeEnv === "string") return nodeEnv !== "production";
|
|
194
|
+
return false;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function normalizePosition(
|
|
198
|
+
input: FeedbackConfig["position"],
|
|
199
|
+
warn: (code: ConfigWarningCode, message: string) => void
|
|
200
|
+
): WidgetPosition {
|
|
201
|
+
if (input === undefined || input === null) return { ...DEFAULT_POSITION };
|
|
202
|
+
|
|
203
|
+
if (typeof input === "string") {
|
|
204
|
+
if (CORNERS.includes(input)) {
|
|
205
|
+
return { ...DEFAULT_POSITION, corner: input };
|
|
206
|
+
}
|
|
207
|
+
warn("unknown-position", `모르는 position "${input}" — 우하단으로 되돌린다.`);
|
|
208
|
+
return { ...DEFAULT_POSITION };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const corner =
|
|
212
|
+
input.corner !== undefined && CORNERS.includes(input.corner)
|
|
213
|
+
? input.corner
|
|
214
|
+
: DEFAULT_POSITION.corner;
|
|
215
|
+
if (input.corner !== undefined && !CORNERS.includes(input.corner)) {
|
|
216
|
+
warn("unknown-position", `모르는 position.corner "${String(input.corner)}" — 우하단으로 되돌린다.`);
|
|
217
|
+
}
|
|
218
|
+
return {
|
|
219
|
+
corner,
|
|
220
|
+
offsetX: numberOr(input.offsetX, DEFAULT_POSITION.offsetX),
|
|
221
|
+
offsetY: numberOr(input.offsetY, DEFAULT_POSITION.offsetY),
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function numberOr(value: unknown, fallback: number): number {
|
|
226
|
+
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function normalizeAdapter(
|
|
230
|
+
input: FeedbackConfig["adapter"],
|
|
231
|
+
warn: (code: ConfigWarningCode, message: string) => void
|
|
232
|
+
): AdapterName | FeedbackAdapter {
|
|
233
|
+
if (input === undefined || input === null) return DEFAULT_ADAPTER;
|
|
234
|
+
// 구현체를 직접 준 경우 — 이름 검사 대상이 아니다.
|
|
235
|
+
if (typeof input === "object") return input;
|
|
236
|
+
if (ADAPTER_NAMES.includes(input)) return input;
|
|
237
|
+
warn("unknown-adapter", `모르는 어댑터 "${String(input)}" — "${DEFAULT_ADAPTER}" 로 되돌린다.`);
|
|
238
|
+
return DEFAULT_ADAPTER;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function normalizeVisibility(
|
|
242
|
+
input: FeedbackConfig["visibility"],
|
|
243
|
+
warn: (code: ConfigWarningCode, message: string) => void
|
|
244
|
+
): Visibility {
|
|
245
|
+
if (input === undefined || input === null) return "all";
|
|
246
|
+
if (typeof input === "function") return input;
|
|
247
|
+
if ((VISIBILITY_KEYWORDS as readonly string[]).includes(input)) return input;
|
|
248
|
+
warn("unknown-visibility", `모르는 visibility "${String(input)}" — "all" 로 되돌린다.`);
|
|
249
|
+
return "all";
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function normalizeToken(
|
|
253
|
+
input: FeedbackConfig["token"],
|
|
254
|
+
warn: (code: ConfigWarningCode, message: string) => void
|
|
255
|
+
): string | null {
|
|
256
|
+
const token = typeof input === "string" ? input.trim() : "";
|
|
257
|
+
if (token === "") {
|
|
258
|
+
warn(
|
|
259
|
+
"missing-token",
|
|
260
|
+
"수집 토큰이 없다 — 제보는 그대로 전송되지만 프로젝트에 귀속되지 않는다."
|
|
261
|
+
);
|
|
262
|
+
return null;
|
|
263
|
+
}
|
|
264
|
+
if (looksUnsubstituted(token)) {
|
|
265
|
+
warn(
|
|
266
|
+
"unsubstituted-token",
|
|
267
|
+
`토큰이 환경변수 치환 전 값으로 보인다("${token}"). 환경변수는 빌드 타임에 인라인되므로 ` +
|
|
268
|
+
"값을 넣은 뒤 다시 빌드해야 하고, 앱은 OTA 로 반영되지 않는다."
|
|
269
|
+
);
|
|
270
|
+
return null;
|
|
271
|
+
}
|
|
272
|
+
return token;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* 부분 설정을 기본값으로 채운다.
|
|
277
|
+
*
|
|
278
|
+
* **던지지 않는다.** 설정이 틀렸다고 위젯이 호스트 앱을 깨거나 사용자 화면에 오류를
|
|
279
|
+
* 띄우면, 붙이는 비용이 한 줄이라는 전제가 무너진다. 모르는 값은 기본값으로 되돌리고
|
|
280
|
+
* 경고만 남긴다.
|
|
281
|
+
*/
|
|
282
|
+
export function resolveConfig(config: FeedbackConfig = {}): ResolvedConfig {
|
|
283
|
+
const warnings: ConfigWarning[] = [];
|
|
284
|
+
const warn = (code: ConfigWarningCode, message: string): void => {
|
|
285
|
+
const warning: ConfigWarning = { code, message };
|
|
286
|
+
warnings.push(warning);
|
|
287
|
+
// 호스트가 준 출구도 깨질 수 있다 — 경고 때문에 설정 해석이 실패하면 안 된다.
|
|
288
|
+
try {
|
|
289
|
+
config.onWarn?.(warning);
|
|
290
|
+
} catch {
|
|
291
|
+
/* 무시 */
|
|
292
|
+
}
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
const endpoint = typeof config.endpoint === "string" && config.endpoint.trim() !== ""
|
|
296
|
+
? config.endpoint.trim()
|
|
297
|
+
: ENDPOINT_FROM_ADAPTER;
|
|
298
|
+
|
|
299
|
+
return {
|
|
300
|
+
token: normalizeToken(config.token, warn),
|
|
301
|
+
endpoint,
|
|
302
|
+
adapter: normalizeAdapter(config.adapter, warn),
|
|
303
|
+
visibility: normalizeVisibility(config.visibility, warn),
|
|
304
|
+
captureScreenshot: config.captureScreenshot ?? true,
|
|
305
|
+
captureDiagnostics: config.captureDiagnostics ?? true,
|
|
306
|
+
position: normalizePosition(config.position, warn),
|
|
307
|
+
isInternal: typeof config.isInternal === "function" ? config.isInternal : null,
|
|
308
|
+
internalRoles: config.internalRoles ?? DEFAULT_INTERNAL_ROLES,
|
|
309
|
+
isDev: config.isDev ?? detectDevBuild(),
|
|
310
|
+
warnings,
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
315
|
+
// 노출 판정
|
|
316
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
317
|
+
|
|
318
|
+
export function isInternalUser(resolved: ResolvedConfig, env: VisibilityEnv): boolean {
|
|
319
|
+
if (resolved.isInternal) return resolved.isInternal(env) === true;
|
|
320
|
+
|
|
321
|
+
const user = env.user;
|
|
322
|
+
if (!user) return false;
|
|
323
|
+
// 게스트는 내부 계정일 수 없다.
|
|
324
|
+
if (user.isGuest === true) return false;
|
|
325
|
+
const role = typeof user.role === "string" ? user.role.toLowerCase() : null;
|
|
326
|
+
if (role === null) return false;
|
|
327
|
+
return resolved.internalRoles.some((r) => r.toLowerCase() === role);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* 지금 이 사용자에게 플로팅 버튼을 보여줄지.
|
|
332
|
+
*
|
|
333
|
+
* 판정에 실패하면(사용자가 준 함수가 던지면) **숨긴다.** 노출 범위를 좁힌 프로젝트가
|
|
334
|
+
* 있다는 건 보이면 안 되는 이유가 있다는 뜻이므로, 애매할 때 새는 쪽보다 안 보이는
|
|
335
|
+
* 쪽이 안전하다.
|
|
336
|
+
*/
|
|
337
|
+
export function shouldShowWidget(
|
|
338
|
+
resolved: ResolvedConfig,
|
|
339
|
+
input: Partial<VisibilityEnv> = {}
|
|
340
|
+
): boolean {
|
|
341
|
+
const env: VisibilityEnv = {
|
|
342
|
+
user: input.user ?? null,
|
|
343
|
+
isDev: input.isDev ?? resolved.isDev,
|
|
344
|
+
platform: input.platform ?? "unknown",
|
|
345
|
+
};
|
|
346
|
+
|
|
347
|
+
const visibility = resolved.visibility;
|
|
348
|
+
// `isInternal` 도 사용자가 준 함수라, 판정 전체를 한 번에 감싼다.
|
|
349
|
+
try {
|
|
350
|
+
if (typeof visibility === "function") return visibility(env) === true;
|
|
351
|
+
|
|
352
|
+
switch (visibility) {
|
|
353
|
+
case "all":
|
|
354
|
+
return true;
|
|
355
|
+
case "dev-only":
|
|
356
|
+
return env.isDev;
|
|
357
|
+
case "internal":
|
|
358
|
+
return isInternalUser(resolved, env);
|
|
359
|
+
default:
|
|
360
|
+
return true;
|
|
361
|
+
}
|
|
362
|
+
} catch (err) {
|
|
363
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
364
|
+
resolved.warnings.push({
|
|
365
|
+
code: "visibility-threw",
|
|
366
|
+
message: `visibility 판정 함수가 실패해 위젯을 숨긴다: ${message}`,
|
|
367
|
+
});
|
|
368
|
+
return false;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
373
|
+
// 진단 수집 배선
|
|
374
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
375
|
+
|
|
376
|
+
/** `DiagnosticsCollector` 중 설정 배선에 필요한 최소 형태. */
|
|
377
|
+
export interface DiagnosticsSource {
|
|
378
|
+
install(): void;
|
|
379
|
+
snapshot(): DiagnosticsPayload;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* `captureDiagnostics` 를 실제 배선으로 옮긴다.
|
|
384
|
+
*
|
|
385
|
+
* 꺼져 있으면 **공급자를 만들지 않고 수집기도 설치하지 않는다.** 빈 배열을 상수로
|
|
386
|
+
* 돌려주는 공급자를 만들면 "수집했는데 아무 일도 없었다"와 "애초에 수집하지 않았다"가
|
|
387
|
+
* 구분되지 않는다. 공급자가 없으면 `buildContext` 가 `diagnostics: null` 을 쓴다.
|
|
388
|
+
*/
|
|
389
|
+
export function diagnosticsProviderFor(
|
|
390
|
+
resolved: Pick<ResolvedConfig, "captureDiagnostics">,
|
|
391
|
+
collector: DiagnosticsSource
|
|
392
|
+
): (() => DiagnosticsPayload) | undefined {
|
|
393
|
+
if (!resolved.captureDiagnostics) return undefined;
|
|
394
|
+
collector.install();
|
|
395
|
+
return () => collector.snapshot();
|
|
396
|
+
}
|