@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/dist/index.js ADDED
@@ -0,0 +1,2268 @@
1
+ // src/uuid.ts
2
+ function pickRandomBytes() {
3
+ const g = typeof globalThis !== "undefined" ? globalThis : {};
4
+ const crypto = g.crypto;
5
+ const getRandomValues = crypto?.getRandomValues;
6
+ if (typeof getRandomValues === "function") {
7
+ return (arr) => getRandomValues.call(crypto, arr);
8
+ }
9
+ return (arr) => {
10
+ for (let i = 0; i < arr.length; i++) {
11
+ arr[i] = Math.floor(Math.random() * 256);
12
+ }
13
+ return arr;
14
+ };
15
+ }
16
+ function uuidv4() {
17
+ const fill = pickRandomBytes();
18
+ const b = new Uint8Array(16);
19
+ fill(b);
20
+ b[6] = b[6] & 15 | 64;
21
+ b[8] = b[8] & 63 | 128;
22
+ const h = Array.from(b, (n) => n.toString(16).padStart(2, "0"));
23
+ return h.slice(0, 4).join("") + "-" + h.slice(4, 6).join("") + "-" + h.slice(6, 8).join("") + "-" + h.slice(8, 10).join("") + "-" + h.slice(10, 16).join("");
24
+ }
25
+
26
+ // src/guest-id.ts
27
+ var GUEST_KEY = "feedback-kit:guest-id";
28
+ async function getOrCreateGuestId(storage) {
29
+ const existing = await storage.get(GUEST_KEY);
30
+ if (existing && existing.length > 0) {
31
+ return existing;
32
+ }
33
+ const id = `guest_${uuidv4()}`;
34
+ await storage.set(GUEST_KEY, id);
35
+ return id;
36
+ }
37
+ function guestUser(id) {
38
+ return { id, isGuest: true };
39
+ }
40
+
41
+ // src/context.ts
42
+ async function resolveContextUser(getUser, storage) {
43
+ if (typeof getUser === "function") {
44
+ try {
45
+ const u = await Promise.resolve(getUser());
46
+ if (u && typeof u.id === "string" && u.id.length > 0) {
47
+ return normalizeUser(u);
48
+ }
49
+ } catch {
50
+ }
51
+ }
52
+ try {
53
+ const id = await getOrCreateGuestId(storage);
54
+ return { id, email: null, isGuest: true };
55
+ } catch {
56
+ return null;
57
+ }
58
+ }
59
+ function normalizeUser(u) {
60
+ const out = {
61
+ id: u.id,
62
+ email: typeof u.email === "string" ? u.email : null,
63
+ // isGuest 생략 = 로그인 사용자.
64
+ isGuest: u.isGuest === true
65
+ };
66
+ if (typeof u.name === "string") out.name = u.name;
67
+ if (typeof u.role === "string") out.role = u.role;
68
+ return out;
69
+ }
70
+ async function buildContext(opts) {
71
+ const now = opts.now ?? (() => Date.now());
72
+ const iso = opts.iso ?? ((n) => new Date(n).toISOString());
73
+ const user = await resolveContextUser(opts.getUser, opts.storage);
74
+ const screen = safeCall(opts.getCurrentScreen, null);
75
+ return {
76
+ app: opts.app,
77
+ screen: typeof screen === "string" ? screen : null,
78
+ url: nullableString(safeCall(opts.getUrl, null)),
79
+ sessionId: opts.sessionId,
80
+ user,
81
+ source: opts.source ?? { screenId: null, sourceFile: null },
82
+ platform: opts.platform,
83
+ timezone: resolveTimezone(opts.getTimezone),
84
+ clientTimestamp: iso(now()),
85
+ native: buildNative(opts, typeof screen === "string" ? screen : null),
86
+ web: buildWeb(opts),
87
+ // 공급자가 없으면 null — 빈 배열을 상수로 보내지 않는다.
88
+ diagnostics: opts.getDiagnostics ? normalizeDiagnostics(safeCall(opts.getDiagnostics, null)) : null,
89
+ extra: opts.extra ?? {}
90
+ };
91
+ }
92
+ function hasNativeProvider(opts) {
93
+ return Boolean(
94
+ opts.getAppInfo || opts.getDevice || opts.getDisplay || opts.getNavPath || opts.getRouteParams || opts.screenSourceMap
95
+ );
96
+ }
97
+ function buildNative(opts, screen) {
98
+ if (opts.platform !== "native" || !hasNativeProvider(opts)) return null;
99
+ const navPathRaw = safeCall(opts.getNavPath, null);
100
+ const navPath = Array.isArray(navPathRaw) ? navPathRaw.filter((s) => typeof s === "string") : (
101
+ // 스택을 못 구했으면 최소한 현재 화면 한 칸이라도 남긴다.
102
+ screen ? [screen] : []
103
+ );
104
+ const app = safeCall(opts.getAppInfo, null) ?? {};
105
+ const device = safeCall(opts.getDevice, null) ?? {};
106
+ const display = safeCall(opts.getDisplay, null) ?? {};
107
+ return {
108
+ // 매핑이 없으면 여기만 null 이고 나머지 컨텍스트는 그대로 수집된다.
109
+ screenPath: lookupScreenPath(opts.screenSourceMap, screen),
110
+ navPath,
111
+ routeParams: toPlainRecord(safeCall(opts.getRouteParams, void 0)),
112
+ appInfo: {
113
+ version: nullableString(app.version),
114
+ channel: nullableString(app.channel),
115
+ updateId: nullableString(app.updateId),
116
+ runtimeVersion: nullableString(app.runtimeVersion)
117
+ },
118
+ device: {
119
+ model: nullableString(device.model),
120
+ osName: nullableString(device.osName),
121
+ osVersion: nullableString(device.osVersion),
122
+ deviceType: nullableString(device.deviceType)
123
+ },
124
+ display: {
125
+ width: nullableNumber(display.width),
126
+ height: nullableNumber(display.height),
127
+ pixelRatio: nullableNumber(display.pixelRatio),
128
+ fontScale: nullableNumber(display.fontScale)
129
+ }
130
+ };
131
+ }
132
+ function buildWeb(opts) {
133
+ if (opts.platform !== "web" || !opts.getWebContext) return null;
134
+ const w = safeCall(opts.getWebContext, null) ?? {};
135
+ const vp = w.viewport ?? {};
136
+ return {
137
+ viewport: {
138
+ width: nullableNumber(vp.width),
139
+ height: nullableNumber(vp.height),
140
+ devicePixelRatio: nullableNumber(vp.devicePixelRatio)
141
+ },
142
+ userAgent: nullableString(w.userAgent)
143
+ };
144
+ }
145
+ function lookupScreenPath(map, screen) {
146
+ if (!map || !screen) return null;
147
+ const hit = map[screen];
148
+ return typeof hit === "string" && hit.length > 0 ? hit : null;
149
+ }
150
+ function normalizeDiagnostics(snap) {
151
+ if (!snap || !Array.isArray(snap.network) || !Array.isArray(snap.logs)) {
152
+ return null;
153
+ }
154
+ return snap;
155
+ }
156
+ function safeCall(fn, fallback) {
157
+ if (typeof fn !== "function") return fallback;
158
+ try {
159
+ return fn();
160
+ } catch {
161
+ return fallback;
162
+ }
163
+ }
164
+ function nullableString(v) {
165
+ return typeof v === "string" && v.length > 0 ? v : null;
166
+ }
167
+ function nullableNumber(v) {
168
+ return typeof v === "number" && Number.isFinite(v) ? v : null;
169
+ }
170
+ function resolveTimezone(getTimezone) {
171
+ const injected = safeCall(getTimezone, null);
172
+ if (typeof injected === "string" && injected.length > 0) return injected;
173
+ try {
174
+ const tz = new Intl.DateTimeFormat().resolvedOptions().timeZone;
175
+ return typeof tz === "string" && tz.length > 0 ? tz : null;
176
+ } catch {
177
+ return null;
178
+ }
179
+ }
180
+ function toPlainRecord(value) {
181
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
182
+ const seen = /* @__PURE__ */ new WeakSet();
183
+ let json2;
184
+ try {
185
+ json2 = JSON.stringify(value, (_key, v) => {
186
+ if (typeof v === "function") return void 0;
187
+ if (v && typeof v === "object") {
188
+ if (seen.has(v)) return "[Circular]";
189
+ seen.add(v);
190
+ }
191
+ return v;
192
+ });
193
+ } catch {
194
+ return null;
195
+ }
196
+ if (typeof json2 !== "string") return null;
197
+ try {
198
+ const parsed = JSON.parse(json2);
199
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
200
+ } catch {
201
+ return null;
202
+ }
203
+ }
204
+
205
+ // src/report.ts
206
+ async function buildReport(parts, opts) {
207
+ const now = opts.now ?? (() => Date.now());
208
+ const iso = opts.iso ?? ((n) => new Date(n).toISOString());
209
+ const context = await buildContext(opts);
210
+ return {
211
+ clientSubmissionId: uuidv4(),
212
+ kind: parts.kind,
213
+ comment: parts.comment,
214
+ priority: parts.priority,
215
+ screenshot: parts.screenshot,
216
+ pin: parts.pin,
217
+ element: parts.element,
218
+ context,
219
+ createdAt: iso(now())
220
+ };
221
+ }
222
+
223
+ // src/queue.ts
224
+ var QUEUE_KEY = "feedback-kit:queue";
225
+ var MAX_QUEUE_ITEMS = 50;
226
+ var MAX_QUEUE_AGE_MS = 7 * 24 * 60 * 60 * 1e3;
227
+ var BACKOFF_BASE_MS = 5 * 1e3;
228
+ var BACKOFF_MAX_MS = 5 * 60 * 1e3;
229
+ var RATE_LIMIT_MIN_DELAY_MS = 10 * 60 * 1e3;
230
+ function defaultBackoff(attempts, retryAfterMs, rateLimited = false) {
231
+ const serverHint = typeof retryAfterMs === "number" && retryAfterMs >= 0 ? retryAfterMs : null;
232
+ if (rateLimited) {
233
+ return Math.max(RATE_LIMIT_MIN_DELAY_MS, serverHint ?? 0);
234
+ }
235
+ if (serverHint !== null) {
236
+ return Math.min(serverHint, BACKOFF_MAX_MS);
237
+ }
238
+ const v = BACKOFF_BASE_MS * Math.pow(2, Math.max(0, attempts - 1));
239
+ return Math.min(v, BACKOFF_MAX_MS);
240
+ }
241
+ var FeedbackQueue = class {
242
+ constructor(opts) {
243
+ /**
244
+ * 저장소 접근 직렬화 체인.
245
+ *
246
+ * 왜 필요한가: `load → 수정 → save` 사이에 다른 경로가 끼어들면 그 사이 쓰기가 통째로
247
+ * 사라진다(flush 가 [A]를 읽는 동안 enqueue 가 [A,B]를 저장 → flush 가 []를 저장 →
248
+ * B 유실). 단순한 `flushing` 불리언은 재진입을 "무시"할 뿐이라 이 경합을 못 막고,
249
+ * 무시된 flush 를 기다리던 `submit()` 이 자기 전송을 await 할 수도 없다.
250
+ */
251
+ this.chain = Promise.resolve();
252
+ /** 아직 시작하지 않은 flush 요청(있으면 재사용해서 타이머 연타를 합친다). */
253
+ this.queuedFlush = null;
254
+ this.timer = null;
255
+ this.onlineHandler = null;
256
+ this.pending = 0;
257
+ this.sending = false;
258
+ this.lastError = null;
259
+ this.listeners = /* @__PURE__ */ new Set();
260
+ /** 제출 단위 결과 캐시. `submit()` 이 자기 건의 결말을 알아내는 데 쓴다. */
261
+ this.outcomes = /* @__PURE__ */ new Map();
262
+ this.storage = opts.storage;
263
+ this.adapter = opts.adapter;
264
+ this.flushIntervalMs = opts.flushIntervalMs ?? 3e4;
265
+ this.now = opts.now ?? (() => Date.now());
266
+ this.backoff = opts.backoff ?? defaultBackoff;
267
+ this.warn = opts.warn ?? defaultWarn;
268
+ }
269
+ // ── 공개 API ──────────────────────────────────────────────
270
+ /**
271
+ * 제보를 큐에 넣고 전송을 시도한 뒤, 그 한 건의 결말을 돌려준다.
272
+ * 실패해도 예외를 던지지 않는다 — 제보는 큐에 남고 `queued: true` 로 알린다.
273
+ */
274
+ async submit(report) {
275
+ this.outcomes.delete(report.clientSubmissionId);
276
+ await this.serialize(() => this.enqueueInner(report));
277
+ await this.flush();
278
+ const outcome = this.outcomes.get(report.clientSubmissionId);
279
+ if (outcome) {
280
+ return { delivered: outcome.delivered, id: outcome.id, queued: false };
281
+ }
282
+ return { delivered: false, id: null, queued: true };
283
+ }
284
+ /** 큐에 제보를 추가하고, 전송을 백그라운드로 시도한다. */
285
+ async enqueue(report) {
286
+ await this.serialize(() => this.enqueueInner(report));
287
+ void this.flush();
288
+ }
289
+ /**
290
+ * 만기된 큐 항목들을 한 건씩 순서대로 전송한다.
291
+ * 이미 진행 중이면 그 뒤에 한 번만 예약해 붙는다(타이머 연타로 flush 가 쌓이지 않는다).
292
+ */
293
+ flush() {
294
+ if (this.queuedFlush) return this.queuedFlush;
295
+ const p = this.serialize(async () => {
296
+ this.queuedFlush = null;
297
+ await this.doFlush();
298
+ });
299
+ this.queuedFlush = p;
300
+ return p;
301
+ }
302
+ /**
303
+ * 지금 즉시 재시도한다. 네트워크 복귀·앱 포그라운드 복귀·[다시 보내기] 용.
304
+ * 백오프 대기를 앞당기되, **429 로 묶인 항목은 앞당기지 않는다**(서버가 명시한 한도라
305
+ * 무시하면 다시 429 만 받는다).
306
+ */
307
+ async retryNow() {
308
+ await this.serialize(async () => {
309
+ const queue = await this.load();
310
+ let changed = false;
311
+ for (const item of queue) {
312
+ if (item.rateLimited === true) continue;
313
+ if (item.nextAttemptAt !== null) {
314
+ item.nextAttemptAt = null;
315
+ changed = true;
316
+ }
317
+ }
318
+ if (changed) await this.persist(queue);
319
+ });
320
+ await this.flush();
321
+ }
322
+ /** 현재 큐 길이(대기 중 제보 수). 검증/진단용. */
323
+ async size() {
324
+ const queue = await this.serialize(() => this.load());
325
+ this.setPending(queue.length);
326
+ return queue.length;
327
+ }
328
+ /** 현재 전송 상태 스냅샷. */
329
+ getStatus() {
330
+ return {
331
+ state: this.sending ? "sending" : this.pending > 0 ? "pending" : "idle",
332
+ pending: this.pending,
333
+ lastError: this.lastError
334
+ };
335
+ }
336
+ /** 특정 clientSubmissionId의 마지막 확정 결말. 큐에 있거나 아직 시도 전이면 null이다. */
337
+ getOutcome(clientSubmissionId) {
338
+ return this.outcomes.get(clientSubmissionId) ?? null;
339
+ }
340
+ /** 전송 상태 구독. 반환값을 호출하면 해지된다. 구독 즉시 현재 상태를 1회 통지한다. */
341
+ subscribe(listener) {
342
+ this.listeners.add(listener);
343
+ try {
344
+ listener(this.getStatus());
345
+ } catch {
346
+ }
347
+ return () => {
348
+ this.listeners.delete(listener);
349
+ };
350
+ }
351
+ /**
352
+ * 마운트 시 호출. 만기 항목 정리 + 즉시 재시도 + 주기 flush + `online` 재시도를 건다.
353
+ * 앱(RN)에는 `online` 이벤트가 없으므로, 호스트가 포그라운드 복귀 시 `retryNow()` 를
354
+ * 직접 부른다(코어는 플랫폼 API 를 모른다).
355
+ */
356
+ start() {
357
+ this.stop();
358
+ const g = globalThis;
359
+ if (typeof g.addEventListener === "function") {
360
+ const handler = () => {
361
+ void this.retryNow();
362
+ };
363
+ g.addEventListener("online", handler);
364
+ this.onlineHandler = handler;
365
+ }
366
+ if (this.flushIntervalMs > 0 && typeof g.setInterval === "function") {
367
+ this.timer = g.setInterval(() => {
368
+ void this.flush();
369
+ }, this.flushIntervalMs);
370
+ }
371
+ void this.retryNow();
372
+ }
373
+ /** 타이머와 이벤트 구독을 해제한다. 큐 내용은 그대로 남는다. */
374
+ stop() {
375
+ const g = globalThis;
376
+ if (this.timer !== null) {
377
+ if (typeof g.clearInterval === "function") g.clearInterval(this.timer);
378
+ this.timer = null;
379
+ }
380
+ if (this.onlineHandler !== null) {
381
+ if (typeof g.removeEventListener === "function") {
382
+ g.removeEventListener("online", this.onlineHandler);
383
+ }
384
+ this.onlineHandler = null;
385
+ }
386
+ }
387
+ // ── 내부: 적재 / 전송 ─────────────────────────────────────
388
+ async enqueueInner(report) {
389
+ const queue = this.purgeExpired(await this.load());
390
+ queue.push({
391
+ report,
392
+ attempts: 0,
393
+ nextAttemptAt: null,
394
+ enqueuedAt: this.now()
395
+ });
396
+ while (queue.length > MAX_QUEUE_ITEMS) {
397
+ const dropped = queue.shift();
398
+ this.warn(
399
+ `feedback-kit: \uB300\uAE30 \uD050\uAC00 ${MAX_QUEUE_ITEMS}\uAC74\uC744 \uB118\uC5B4 \uAC00\uC7A5 \uC624\uB798\uB41C \uC81C\uBCF4\uB97C \uBC84\uB838\uB2E4`,
400
+ dropped?.report.clientSubmissionId
401
+ );
402
+ }
403
+ await this.persist(queue);
404
+ }
405
+ async doFlush() {
406
+ let queue = await this.load();
407
+ const kept = this.purgeExpired(queue);
408
+ if (kept.length !== queue.length) {
409
+ queue = kept;
410
+ await this.persist(queue);
411
+ }
412
+ this.setPending(queue.length);
413
+ if (queue.length === 0) {
414
+ this.emit();
415
+ return;
416
+ }
417
+ const now = this.now();
418
+ const due = queue.filter(
419
+ (item) => item.nextAttemptAt === null || now >= item.nextAttemptAt
420
+ );
421
+ if (due.length === 0) {
422
+ this.emit();
423
+ return;
424
+ }
425
+ this.sending = true;
426
+ this.emit();
427
+ try {
428
+ for (const item of due) {
429
+ if (queue.indexOf(item) === -1) continue;
430
+ const result = await this.submitOne(item.report);
431
+ const at = this.now();
432
+ const index = queue.indexOf(item);
433
+ if (result.ok) {
434
+ if (index !== -1) queue.splice(index, 1);
435
+ this.recordOutcome(item.report.clientSubmissionId, true, result.id);
436
+ this.lastError = null;
437
+ } else if (!result.retryable) {
438
+ if (index !== -1) queue.splice(index, 1);
439
+ this.recordOutcome(item.report.clientSubmissionId, false, null);
440
+ this.lastError = "non-retryable";
441
+ this.warn(
442
+ "feedback-kit: \uC7AC\uC2DC\uB3C4 \uBD88\uAC00 \uC2E4\uD328\uB85C \uC81C\uBCF4\uB97C \uBC84\uB838\uB2E4",
443
+ item.report.clientSubmissionId
444
+ );
445
+ } else {
446
+ item.attempts += 1;
447
+ item.rateLimited = result.rateLimited === true;
448
+ item.nextAttemptAt = at + this.backoff(item.attempts, result.retryAfterMs, item.rateLimited);
449
+ this.lastError = item.rateLimited ? "rate-limited" : "retryable";
450
+ }
451
+ await this.persist(queue);
452
+ this.setPending(queue.length);
453
+ this.emit();
454
+ }
455
+ } finally {
456
+ this.sending = false;
457
+ this.emit();
458
+ }
459
+ }
460
+ /** 어댑터 호출. 어댑터가 예외를 던져도 결과값으로 흡수한다(전송 보장 우선). */
461
+ async submitOne(report) {
462
+ try {
463
+ return await this.adapter.submit(report);
464
+ } catch (err) {
465
+ this.warn("feedback-kit: \uC5B4\uB311\uD130\uAC00 \uC608\uC678\uB97C \uB358\uC84C\uB2E4(\uC77C\uC2DC \uC2E4\uD328\uB85C \uCC98\uB9AC)", err);
466
+ return { ok: false, id: null, retryable: true, retryAfterMs: null };
467
+ }
468
+ }
469
+ // ── 내부: 저장소 ──────────────────────────────────────────
470
+ async load() {
471
+ let raw = null;
472
+ try {
473
+ raw = await this.storage.get(QUEUE_KEY);
474
+ } catch (err) {
475
+ this.warn("feedback-kit: \uD050\uB97C \uC77D\uC9C0 \uBABB\uD588\uB2E4", err);
476
+ return [];
477
+ }
478
+ if (!raw) return [];
479
+ try {
480
+ const parsed = JSON.parse(raw);
481
+ if (!Array.isArray(parsed)) return [];
482
+ return parsed.filter(isQueuedReport).map(normalizeEntry(this.now()));
483
+ } catch {
484
+ this.warn("feedback-kit: \uD050\uAC00 \uC190\uC0C1\uB3FC \uCD08\uAE30\uD654\uD588\uB2E4");
485
+ return [];
486
+ }
487
+ }
488
+ /**
489
+ * 큐를 저장한다. 저장소 한도(쿼터)에 걸리면 **스크린샷부터** 떼어내고, 그래도 안 되면
490
+ * 가장 오래된 제보를 버린다. 사용자가 쓴 글이 스크린샷보다 우선이다.
491
+ * 인자로 받은 배열을 제자리에서 수정한다(호출부가 들고 있는 항목 참조는 유지된다).
492
+ */
493
+ async persist(queue) {
494
+ for (; ; ) {
495
+ try {
496
+ await this.storage.set(QUEUE_KEY, JSON.stringify(queue));
497
+ this.setPending(queue.length);
498
+ return;
499
+ } catch (err) {
500
+ if (queue.length === 0) {
501
+ this.warn("feedback-kit: \uD050\uB97C \uC800\uC7A5\uD558\uC9C0 \uBABB\uD588\uB2E4", err);
502
+ return;
503
+ }
504
+ const shotIndex = queue.findIndex(
505
+ (item) => item.report.screenshot !== null
506
+ );
507
+ if (shotIndex !== -1) {
508
+ const entry = queue[shotIndex];
509
+ entry.report = { ...entry.report, screenshot: null };
510
+ entry.screenshotDropped = true;
511
+ this.warn(
512
+ "feedback-kit: \uC800\uC7A5 \uD55C\uB3C4\uB97C \uB118\uC5B4 \uC2A4\uD06C\uB9B0\uC0F7\uC744 \uB5BC\uC5B4\uB0B4\uACE0 \uC81C\uBCF4\uB9CC \uBCF4\uAD00\uD55C\uB2E4",
513
+ entry.report.clientSubmissionId
514
+ );
515
+ continue;
516
+ }
517
+ const evicted = queue.shift();
518
+ this.warn(
519
+ "feedback-kit: \uC800\uC7A5 \uD55C\uB3C4\uB97C \uB118\uC5B4 \uAC00\uC7A5 \uC624\uB798\uB41C \uC81C\uBCF4\uB97C \uBC84\uB838\uB2E4",
520
+ evicted?.report.clientSubmissionId
521
+ );
522
+ }
523
+ }
524
+ }
525
+ /** 7일이 지난 항목을 걸러낸다. 만기 항목은 전송을 시도하지 않는다. */
526
+ purgeExpired(queue) {
527
+ const now = this.now();
528
+ const kept = queue.filter((item) => now - item.enqueuedAt <= MAX_QUEUE_AGE_MS);
529
+ if (kept.length !== queue.length) {
530
+ this.warn(
531
+ `feedback-kit: \uBCF4\uAD00 \uAE30\uAC04(7\uC77C)\uC774 \uC9C0\uB09C \uC81C\uBCF4 ${queue.length - kept.length}\uAC74\uC744 \uC815\uB9AC\uD588\uB2E4`
532
+ );
533
+ }
534
+ return kept;
535
+ }
536
+ // ── 내부: 상태 통지 ───────────────────────────────────────
537
+ recordOutcome(id, delivered, serverId) {
538
+ if (this.outcomes.size >= 100) {
539
+ const oldest = this.outcomes.keys().next();
540
+ if (!oldest.done) this.outcomes.delete(oldest.value);
541
+ }
542
+ this.outcomes.set(id, { delivered, id: serverId });
543
+ }
544
+ setPending(n) {
545
+ if (this.pending === n) return;
546
+ this.pending = n;
547
+ }
548
+ emit() {
549
+ if (this.listeners.size === 0) return;
550
+ const status = this.getStatus();
551
+ for (const listener of this.listeners) {
552
+ try {
553
+ listener(status);
554
+ } catch {
555
+ }
556
+ }
557
+ }
558
+ serialize(fn) {
559
+ const next = this.chain.then(fn, fn);
560
+ this.chain = next.catch(() => void 0);
561
+ return next;
562
+ }
563
+ };
564
+ function defaultWarn(...a) {
565
+ const g = globalThis;
566
+ g.console?.warn?.(...a);
567
+ }
568
+ function isQueuedReport(v) {
569
+ if (typeof v !== "object" || v === null) return false;
570
+ const report = v.report;
571
+ if (typeof report !== "object" || report === null) return false;
572
+ return typeof report.clientSubmissionId === "string";
573
+ }
574
+ function normalizeEntry(now) {
575
+ return (v) => ({
576
+ ...v,
577
+ attempts: typeof v.attempts === "number" && v.attempts >= 0 ? v.attempts : 0,
578
+ nextAttemptAt: typeof v.nextAttemptAt === "number" ? v.nextAttemptAt : null,
579
+ // enqueuedAt 이 없던 시절의 항목은 "방금 들어온 것"으로 봐서 즉시 버리지 않는다.
580
+ enqueuedAt: typeof v.enqueuedAt === "number" ? v.enqueuedAt : now
581
+ });
582
+ }
583
+
584
+ // src/keepalive.ts
585
+ var KEEPALIVE_BODY_LIMIT_BYTES = 64 * 1024;
586
+ function canUseKeepalive(bodyByteLength) {
587
+ if (!Number.isFinite(bodyByteLength) || bodyByteLength < 0) return false;
588
+ return bodyByteLength <= KEEPALIVE_BODY_LIMIT_BYTES;
589
+ }
590
+ function byteLengthOf(body) {
591
+ const g = globalThis;
592
+ if (typeof g.TextEncoder === "function") {
593
+ return new g.TextEncoder().encode(body).length;
594
+ }
595
+ return body.length * 3;
596
+ }
597
+
598
+ // src/ring-buffer.ts
599
+ var RingBuffer = class {
600
+ constructor(capacity) {
601
+ this.items = [];
602
+ this.capacity = Number.isFinite(capacity) && capacity > 0 ? Math.floor(capacity) : 0;
603
+ }
604
+ push(item) {
605
+ if (this.capacity === 0) {
606
+ return;
607
+ }
608
+ this.items.push(item);
609
+ const overflow = this.items.length - this.capacity;
610
+ if (overflow > 0) {
611
+ this.items.splice(0, overflow);
612
+ }
613
+ }
614
+ /** 현재 내용의 복사본. 호출자가 들고 있어도 이후 push 에 영향받지 않는다. */
615
+ toArray() {
616
+ return this.items.slice();
617
+ }
618
+ get size() {
619
+ return this.items.length;
620
+ }
621
+ clear() {
622
+ this.items = [];
623
+ }
624
+ };
625
+
626
+ // src/diagnostics.ts
627
+ var NETWORK_BUFFER_LIMIT = 30;
628
+ var LOG_BUFFER_LIMIT = 50;
629
+ var MAX_LOG_MESSAGE_CHARS = 2e3;
630
+ var DiagnosticsCollector = class {
631
+ constructor(opts = {}) {
632
+ this.installed = false;
633
+ // 원본 참조(원복용 + 위젯 우회용).
634
+ this.originalFetch = null;
635
+ this.originalXhrOpen = null;
636
+ this.originalXhrSend = null;
637
+ this.originalWarn = null;
638
+ this.originalError = null;
639
+ this.excludeMatcher = null;
640
+ /**
641
+ * 패치된 fetch 안에서 동기적으로 XHR 이 열리는 깊이.
642
+ *
643
+ * React Native 의 `fetch` 는 whatwg-fetch 폴리필이라 내부에서 XMLHttpRequest 를 쓴다.
644
+ * 둘 다 패치해두면 요청 1건이 2건으로 기록된다. fetch 폴리필은 Promise 실행자 안에서
645
+ * **동기적으로** `send()` 를 부르므로, 그 구간을 세어 XHR 쪽 기록만 건너뛴다.
646
+ */
647
+ this.fetchDepth = 0;
648
+ /** 위젯이 자신의 로그를 캡처 없이 찍을 때 쓰는 원본 console. */
649
+ this.originalConsole = {
650
+ warn: (...a) => {
651
+ (this.originalWarn ?? consoleMethod("warn"))(...a);
652
+ },
653
+ error: (...a) => {
654
+ (this.originalError ?? consoleMethod("error"))(...a);
655
+ },
656
+ log: (...a) => {
657
+ consoleMethod("log")(...a);
658
+ }
659
+ };
660
+ this.now = opts.now ?? (() => Date.now());
661
+ this.iso = opts.iso ?? ((n) => new Date(n).toISOString());
662
+ this.network = new RingBuffer(
663
+ opts.networkLimit ?? NETWORK_BUFFER_LIMIT
664
+ );
665
+ this.logs = new RingBuffer(opts.logLimit ?? LOG_BUFFER_LIMIT);
666
+ }
667
+ /** 글로벌 fetch/XHR/console 을 패치한다. 이미 설치했으면 no-op. */
668
+ install(opts = {}) {
669
+ if (this.installed) return;
670
+ this.installed = true;
671
+ this.excludeMatcher = opts.excludeMatcher ?? null;
672
+ if (opts.now) this.now = opts.now;
673
+ if (opts.iso) this.iso = opts.iso;
674
+ this.patchFetch();
675
+ this.patchXhr();
676
+ this.patchConsole();
677
+ }
678
+ /** 글로벌 패치를 모두 원복한다. 버퍼 내용은 그대로 남는다. */
679
+ uninstall() {
680
+ if (!this.installed) return;
681
+ this.installed = false;
682
+ const g = globalThis;
683
+ if (this.originalFetch) {
684
+ g.fetch = this.originalFetch;
685
+ this.originalFetch = null;
686
+ }
687
+ const XHR = g.XMLHttpRequest;
688
+ if (XHR?.prototype && this.originalXhrOpen && this.originalXhrSend) {
689
+ XHR.prototype.open = this.originalXhrOpen;
690
+ XHR.prototype.send = this.originalXhrSend;
691
+ }
692
+ this.originalXhrOpen = null;
693
+ this.originalXhrSend = null;
694
+ const c = g.console;
695
+ if (c && this.originalWarn) c.warn = this.originalWarn;
696
+ if (c && this.originalError) c.error = this.originalError;
697
+ this.originalWarn = null;
698
+ this.originalError = null;
699
+ this.excludeMatcher = null;
700
+ this.fetchDepth = 0;
701
+ }
702
+ /**
703
+ * 지금까지 모인 것을 그대로 스냅샷으로 낸다.
704
+ * 반환 배열은 사본이라 이후 수집이 제보 페이로드를 바꾸지 않는다.
705
+ */
706
+ snapshot() {
707
+ return { network: this.network.toArray(), logs: this.logs.toArray() };
708
+ }
709
+ /** 두 버퍼를 비운다. */
710
+ clear() {
711
+ this.network.clear();
712
+ this.logs.clear();
713
+ }
714
+ // ── 내부: 기록 ────────────────────────────────────────────
715
+ recordNetwork(method, url, status, startedAt) {
716
+ this.network.push({
717
+ method,
718
+ url,
719
+ status,
720
+ durationMs: Math.max(0, Math.round(this.now() - startedAt)),
721
+ at: this.iso(startedAt)
722
+ });
723
+ }
724
+ recordLog(level, args) {
725
+ this.logs.push({
726
+ level,
727
+ message: safeStringify(args).slice(0, MAX_LOG_MESSAGE_CHARS),
728
+ at: this.iso(this.now())
729
+ });
730
+ }
731
+ isExcluded(url, method) {
732
+ if (!this.excludeMatcher) return false;
733
+ try {
734
+ return this.excludeMatcher({ url, method }) === true;
735
+ } catch {
736
+ return false;
737
+ }
738
+ }
739
+ // ── 내부: 패치 ────────────────────────────────────────────
740
+ patchFetch() {
741
+ const g = globalThis;
742
+ if (typeof g.fetch !== "function") return;
743
+ this.originalFetch = g.fetch;
744
+ const originalFetch = this.originalFetch;
745
+ const self = this;
746
+ g.fetch = function patchedFetch(input, init) {
747
+ const { url, method } = describeRequest(input, init);
748
+ const excluded = self.isExcluded(url, method);
749
+ const start = self.now();
750
+ let pending;
751
+ self.fetchDepth += 1;
752
+ try {
753
+ pending = Promise.resolve(originalFetch(input, init));
754
+ } catch (err) {
755
+ self.fetchDepth -= 1;
756
+ if (!excluded) self.recordNetwork(method, url, null, start);
757
+ throw err;
758
+ }
759
+ self.fetchDepth -= 1;
760
+ return pending.then(
761
+ (raw) => {
762
+ if (!excluded) {
763
+ const status = raw?.status;
764
+ self.recordNetwork(
765
+ method,
766
+ url,
767
+ typeof status === "number" ? status : null,
768
+ start
769
+ );
770
+ }
771
+ return raw;
772
+ },
773
+ (err) => {
774
+ if (!excluded) self.recordNetwork(method, url, null, start);
775
+ throw err;
776
+ }
777
+ );
778
+ };
779
+ }
780
+ patchXhr() {
781
+ const g = globalThis;
782
+ const XHR = g.XMLHttpRequest;
783
+ if (typeof g.XMLHttpRequest !== "function" || !XHR?.prototype) return;
784
+ const proto = XHR.prototype;
785
+ if (typeof proto.open !== "function" || typeof proto.send !== "function") return;
786
+ this.originalXhrOpen = proto.open;
787
+ this.originalXhrSend = proto.send;
788
+ const oOpen = this.originalXhrOpen;
789
+ const oSend = this.originalXhrSend;
790
+ const self = this;
791
+ proto.open = function patchedOpen(method, url, ...rest) {
792
+ this.__fk_meta = {
793
+ method: String(method ?? "GET").toUpperCase(),
794
+ url: sanitizeUrl(String(url ?? ""))
795
+ };
796
+ return oOpen.call(this, method, url, ...rest);
797
+ };
798
+ proto.send = function patchedSend(...args) {
799
+ const meta = this.__fk_meta;
800
+ const viaFetch = self.fetchDepth > 0;
801
+ const addEv = this.addEventListener;
802
+ if (meta && !viaFetch && typeof addEv === "function") {
803
+ const excluded = self.isExcluded(meta.url, meta.method);
804
+ const start = self.now();
805
+ let recorded = false;
806
+ addEv.call(this, "loadend", () => {
807
+ if (recorded || excluded) return;
808
+ recorded = true;
809
+ const status = this.status;
810
+ self.recordNetwork(
811
+ meta.method,
812
+ meta.url,
813
+ typeof status === "number" && status > 0 ? status : null,
814
+ start
815
+ );
816
+ });
817
+ }
818
+ return oSend.apply(this, args);
819
+ };
820
+ }
821
+ patchConsole() {
822
+ const c = globalThis.console;
823
+ if (!c) return;
824
+ const self = this;
825
+ if (typeof c.warn === "function") {
826
+ const orig = c.warn.bind(c);
827
+ this.originalWarn = orig;
828
+ c.warn = function patchedWarn(...args) {
829
+ self.recordLog("warn", args);
830
+ orig(...args);
831
+ };
832
+ }
833
+ if (typeof c.error === "function") {
834
+ const orig = c.error.bind(c);
835
+ this.originalError = orig;
836
+ c.error = function patchedError(...args) {
837
+ self.recordLog("error", args);
838
+ orig(...args);
839
+ };
840
+ }
841
+ }
842
+ };
843
+ var sharedDiagnostics = new DiagnosticsCollector();
844
+ function consoleMethod(name) {
845
+ return (...a) => {
846
+ const c = globalThis.console;
847
+ c?.[name]?.(...a);
848
+ };
849
+ }
850
+ function safeStringify(args) {
851
+ return args.map((a) => {
852
+ if (typeof a === "string") return a;
853
+ if (a instanceof Error) return `${a.name}: ${a.message}`;
854
+ try {
855
+ return JSON.stringify(a) ?? String(a);
856
+ } catch {
857
+ try {
858
+ return String(a);
859
+ } catch {
860
+ return "[unserializable]";
861
+ }
862
+ }
863
+ }).join(" ");
864
+ }
865
+ function describeRequest(input, init) {
866
+ let url = "";
867
+ let method = "GET";
868
+ if (typeof input === "string") {
869
+ url = input;
870
+ } else if (input && typeof input === "object") {
871
+ const maybeUrl = input.url;
872
+ const maybeHref = input.href;
873
+ if (typeof maybeUrl === "string") url = maybeUrl;
874
+ else if (typeof maybeHref === "string") url = maybeHref;
875
+ else {
876
+ try {
877
+ url = String(input);
878
+ } catch {
879
+ url = "";
880
+ }
881
+ }
882
+ const maybeMethod = input.method;
883
+ if (typeof maybeMethod === "string") method = maybeMethod;
884
+ }
885
+ if (init && typeof init.method === "string") method = init.method;
886
+ return { url: sanitizeUrl(url), method: method.toUpperCase() };
887
+ }
888
+ var CREDENTIAL_PARAMS = /* @__PURE__ */ new Set([
889
+ "accesskey",
890
+ "accesstoken",
891
+ "apikey",
892
+ "apisecret",
893
+ "auth",
894
+ "authorization",
895
+ "authtoken",
896
+ "clientsecret",
897
+ "code",
898
+ "credential",
899
+ "credentials",
900
+ "idtoken",
901
+ "jwt",
902
+ "key",
903
+ "password",
904
+ "passwd",
905
+ "privatekey",
906
+ "pwd",
907
+ "refreshtoken",
908
+ "secret",
909
+ "secretkey",
910
+ "session",
911
+ "sessionid",
912
+ "sessiontoken",
913
+ "sid",
914
+ "sig",
915
+ "signature",
916
+ "token"
917
+ ]);
918
+ var REDACTED = "REDACTED";
919
+ function isCredentialParam(rawName) {
920
+ const name = rawName.toLowerCase().replace(/[-_.]/g, "");
921
+ if (CREDENTIAL_PARAMS.has(name)) return true;
922
+ return name.endsWith("token") || name.endsWith("secret") || name.endsWith("password") || name.endsWith("apikey");
923
+ }
924
+ function sanitizeUrl(raw) {
925
+ if (!raw) return "";
926
+ const hashAt = raw.indexOf("#");
927
+ const withoutHash = hashAt >= 0 ? raw.slice(0, hashAt) : raw;
928
+ const queryAt = withoutHash.indexOf("?");
929
+ const base = stripUserInfo(
930
+ queryAt >= 0 ? withoutHash.slice(0, queryAt) : withoutHash
931
+ );
932
+ if (queryAt < 0) return base;
933
+ const query = withoutHash.slice(queryAt + 1);
934
+ if (query.length === 0) return base;
935
+ const parts = query.split("&").map((pair) => {
936
+ const eq = pair.indexOf("=");
937
+ if (eq < 0) return pair;
938
+ const name = pair.slice(0, eq);
939
+ return isCredentialParam(name) ? `${name}=${REDACTED}` : pair;
940
+ });
941
+ return `${base}?${parts.join("&")}`;
942
+ }
943
+ function stripUserInfo(url) {
944
+ const schemeAt = url.indexOf("://");
945
+ if (schemeAt < 0) return url;
946
+ const authorityStart = schemeAt + 3;
947
+ const pathAt = url.indexOf("/", authorityStart);
948
+ const authority = pathAt < 0 ? url.slice(authorityStart) : url.slice(authorityStart, pathAt);
949
+ const at = authority.lastIndexOf("@");
950
+ if (at < 0) return url;
951
+ const cleaned = authority.slice(at + 1);
952
+ return url.slice(0, authorityStart) + cleaned + (pathAt < 0 ? "" : url.slice(pathAt));
953
+ }
954
+
955
+ // src/config.ts
956
+ var VISIBILITY_KEYWORDS = ["all", "internal", "dev-only"];
957
+ var DEFAULT_INTERNAL_ROLES = ["internal", "admin", "staff"];
958
+ var CORNERS = [
959
+ "bottom-right",
960
+ "bottom-left",
961
+ "top-right",
962
+ "top-left"
963
+ ];
964
+ var DEFAULT_POSITION = {
965
+ corner: "bottom-right",
966
+ offsetX: 16,
967
+ offsetY: 16
968
+ };
969
+ var ADAPTER_NAMES = ["lasso", "linear", "notion"];
970
+ var DEFAULT_ADAPTER = "lasso";
971
+ var ENDPOINT_FROM_ADAPTER = null;
972
+ function looksUnsubstituted(token) {
973
+ const t = token.trim();
974
+ if (t === "undefined" || t === "null" || t === "NaN") return true;
975
+ if (t.includes("process.env.") || t.includes("import.meta.env")) return true;
976
+ if (/^\$\{.*\}$/.test(t) || /^%.*%$/.test(t)) return true;
977
+ return false;
978
+ }
979
+ function detectDevBuild() {
980
+ const g = globalThis;
981
+ if (typeof g.__DEV__ === "boolean") return g.__DEV__;
982
+ const proc = g.process;
983
+ const nodeEnv = proc?.env?.NODE_ENV;
984
+ if (typeof nodeEnv === "string") return nodeEnv !== "production";
985
+ return false;
986
+ }
987
+ function normalizePosition(input, warn) {
988
+ if (input === void 0 || input === null) return { ...DEFAULT_POSITION };
989
+ if (typeof input === "string") {
990
+ if (CORNERS.includes(input)) {
991
+ return { ...DEFAULT_POSITION, corner: input };
992
+ }
993
+ warn("unknown-position", `\uBAA8\uB974\uB294 position "${input}" \u2014 \uC6B0\uD558\uB2E8\uC73C\uB85C \uB418\uB3CC\uB9B0\uB2E4.`);
994
+ return { ...DEFAULT_POSITION };
995
+ }
996
+ const corner = input.corner !== void 0 && CORNERS.includes(input.corner) ? input.corner : DEFAULT_POSITION.corner;
997
+ if (input.corner !== void 0 && !CORNERS.includes(input.corner)) {
998
+ warn("unknown-position", `\uBAA8\uB974\uB294 position.corner "${String(input.corner)}" \u2014 \uC6B0\uD558\uB2E8\uC73C\uB85C \uB418\uB3CC\uB9B0\uB2E4.`);
999
+ }
1000
+ return {
1001
+ corner,
1002
+ offsetX: numberOr(input.offsetX, DEFAULT_POSITION.offsetX),
1003
+ offsetY: numberOr(input.offsetY, DEFAULT_POSITION.offsetY)
1004
+ };
1005
+ }
1006
+ function numberOr(value, fallback) {
1007
+ return typeof value === "number" && Number.isFinite(value) ? value : fallback;
1008
+ }
1009
+ function normalizeAdapter(input, warn) {
1010
+ if (input === void 0 || input === null) return DEFAULT_ADAPTER;
1011
+ if (typeof input === "object") return input;
1012
+ if (ADAPTER_NAMES.includes(input)) return input;
1013
+ warn("unknown-adapter", `\uBAA8\uB974\uB294 \uC5B4\uB311\uD130 "${String(input)}" \u2014 "${DEFAULT_ADAPTER}" \uB85C \uB418\uB3CC\uB9B0\uB2E4.`);
1014
+ return DEFAULT_ADAPTER;
1015
+ }
1016
+ function normalizeVisibility(input, warn) {
1017
+ if (input === void 0 || input === null) return "all";
1018
+ if (typeof input === "function") return input;
1019
+ if (VISIBILITY_KEYWORDS.includes(input)) return input;
1020
+ warn("unknown-visibility", `\uBAA8\uB974\uB294 visibility "${String(input)}" \u2014 "all" \uB85C \uB418\uB3CC\uB9B0\uB2E4.`);
1021
+ return "all";
1022
+ }
1023
+ function normalizeToken(input, warn) {
1024
+ const token = typeof input === "string" ? input.trim() : "";
1025
+ if (token === "") {
1026
+ warn(
1027
+ "missing-token",
1028
+ "\uC218\uC9D1 \uD1A0\uD070\uC774 \uC5C6\uB2E4 \u2014 \uC81C\uBCF4\uB294 \uADF8\uB300\uB85C \uC804\uC1A1\uB418\uC9C0\uB9CC \uD504\uB85C\uC81D\uD2B8\uC5D0 \uADC0\uC18D\uB418\uC9C0 \uC54A\uB294\uB2E4."
1029
+ );
1030
+ return null;
1031
+ }
1032
+ if (looksUnsubstituted(token)) {
1033
+ warn(
1034
+ "unsubstituted-token",
1035
+ `\uD1A0\uD070\uC774 \uD658\uACBD\uBCC0\uC218 \uCE58\uD658 \uC804 \uAC12\uC73C\uB85C \uBCF4\uC778\uB2E4("${token}"). \uD658\uACBD\uBCC0\uC218\uB294 \uBE4C\uB4DC \uD0C0\uC784\uC5D0 \uC778\uB77C\uC778\uB418\uBBC0\uB85C \uAC12\uC744 \uB123\uC740 \uB4A4 \uB2E4\uC2DC \uBE4C\uB4DC\uD574\uC57C \uD558\uACE0, \uC571\uC740 OTA \uB85C \uBC18\uC601\uB418\uC9C0 \uC54A\uB294\uB2E4.`
1036
+ );
1037
+ return null;
1038
+ }
1039
+ return token;
1040
+ }
1041
+ function resolveConfig(config = {}) {
1042
+ const warnings = [];
1043
+ const warn = (code, message) => {
1044
+ const warning = { code, message };
1045
+ warnings.push(warning);
1046
+ try {
1047
+ config.onWarn?.(warning);
1048
+ } catch {
1049
+ }
1050
+ };
1051
+ const endpoint = typeof config.endpoint === "string" && config.endpoint.trim() !== "" ? config.endpoint.trim() : ENDPOINT_FROM_ADAPTER;
1052
+ return {
1053
+ token: normalizeToken(config.token, warn),
1054
+ endpoint,
1055
+ adapter: normalizeAdapter(config.adapter, warn),
1056
+ visibility: normalizeVisibility(config.visibility, warn),
1057
+ captureScreenshot: config.captureScreenshot ?? true,
1058
+ captureDiagnostics: config.captureDiagnostics ?? true,
1059
+ position: normalizePosition(config.position, warn),
1060
+ isInternal: typeof config.isInternal === "function" ? config.isInternal : null,
1061
+ internalRoles: config.internalRoles ?? DEFAULT_INTERNAL_ROLES,
1062
+ isDev: config.isDev ?? detectDevBuild(),
1063
+ warnings
1064
+ };
1065
+ }
1066
+ function isInternalUser(resolved, env) {
1067
+ if (resolved.isInternal) return resolved.isInternal(env) === true;
1068
+ const user = env.user;
1069
+ if (!user) return false;
1070
+ if (user.isGuest === true) return false;
1071
+ const role = typeof user.role === "string" ? user.role.toLowerCase() : null;
1072
+ if (role === null) return false;
1073
+ return resolved.internalRoles.some((r) => r.toLowerCase() === role);
1074
+ }
1075
+ function shouldShowWidget(resolved, input = {}) {
1076
+ const env = {
1077
+ user: input.user ?? null,
1078
+ isDev: input.isDev ?? resolved.isDev,
1079
+ platform: input.platform ?? "unknown"
1080
+ };
1081
+ const visibility = resolved.visibility;
1082
+ try {
1083
+ if (typeof visibility === "function") return visibility(env) === true;
1084
+ switch (visibility) {
1085
+ case "all":
1086
+ return true;
1087
+ case "dev-only":
1088
+ return env.isDev;
1089
+ case "internal":
1090
+ return isInternalUser(resolved, env);
1091
+ default:
1092
+ return true;
1093
+ }
1094
+ } catch (err) {
1095
+ const message = err instanceof Error ? err.message : String(err);
1096
+ resolved.warnings.push({
1097
+ code: "visibility-threw",
1098
+ message: `visibility \uD310\uC815 \uD568\uC218\uAC00 \uC2E4\uD328\uD574 \uC704\uC82F\uC744 \uC228\uAE34\uB2E4: ${message}`
1099
+ });
1100
+ return false;
1101
+ }
1102
+ }
1103
+ function diagnosticsProviderFor(resolved, collector) {
1104
+ if (!resolved.captureDiagnostics) return void 0;
1105
+ collector.install();
1106
+ return () => collector.snapshot();
1107
+ }
1108
+
1109
+ // src/source-attr.ts
1110
+ var SOURCE_ATTR = "data-fk-source";
1111
+ function parseSourceAttr(value) {
1112
+ if (typeof value !== "string") return null;
1113
+ const raw = value.trim();
1114
+ if (raw === "") return null;
1115
+ let rest = raw;
1116
+ let line = null;
1117
+ let column = null;
1118
+ for (let i = 0; i < 2; i += 1) {
1119
+ const at = rest.lastIndexOf(":");
1120
+ if (at <= 0) break;
1121
+ const tail = rest.slice(at + 1);
1122
+ if (!/^\d+$/.test(tail)) break;
1123
+ const n = Number.parseInt(tail, 10);
1124
+ if (!Number.isFinite(n) || n <= 0) break;
1125
+ rest = rest.slice(0, at);
1126
+ if (line === null) line = n;
1127
+ else {
1128
+ column = line;
1129
+ line = n;
1130
+ }
1131
+ }
1132
+ if (line === null || rest === "" || rest.startsWith(":")) return null;
1133
+ return { file: rest, line, column };
1134
+ }
1135
+ function sourceFromElement(element, base) {
1136
+ const fallback = {
1137
+ screenId: base?.screenId ?? null,
1138
+ sourceFile: base?.sourceFile ?? null,
1139
+ sourceLine: base?.sourceLine ?? null
1140
+ };
1141
+ const attrs = element?.attributes;
1142
+ if (!attrs) return fallback;
1143
+ const loc = parseSourceAttr(attrs[SOURCE_ATTR]);
1144
+ if (!loc) return fallback;
1145
+ return { screenId: fallback.screenId, sourceFile: loc.file, sourceLine: loc.line };
1146
+ }
1147
+
1148
+ // src/widget/screenshot.ts
1149
+ var SCREENSHOT_MAX_BASE64_BYTES = 8 * 1024 * 1024;
1150
+ var SCREENSHOT_FAILED_MESSAGE = "\uCEA1\uCC98 \uC2E4\uD328 \u2014 \uD30C\uC77C\uB85C \uCCA8\uBD80\uD560 \uC218 \uC788\uC2B5\uB2C8\uB2E4";
1151
+ var SCREENSHOT_QUALITY_STEPS = [0.7, 0.5, 0.3];
1152
+ function screenshotBytes(shot) {
1153
+ if (!shot || typeof shot.base64 !== "string") return 0;
1154
+ return shot.base64.length;
1155
+ }
1156
+ function failure() {
1157
+ return {
1158
+ screenshot: null,
1159
+ failed: true,
1160
+ message: SCREENSHOT_FAILED_MESSAGE,
1161
+ reencoded: false
1162
+ };
1163
+ }
1164
+ function usable(shot) {
1165
+ return !!shot && typeof shot.base64 === "string" && shot.base64.length > 0;
1166
+ }
1167
+ async function captureWithinLimit(opts) {
1168
+ const limit = opts.limitBytes ?? SCREENSHOT_MAX_BASE64_BYTES;
1169
+ let shot = null;
1170
+ try {
1171
+ shot = await opts.capture() ?? null;
1172
+ } catch {
1173
+ shot = null;
1174
+ }
1175
+ if (!usable(shot)) return failure();
1176
+ if (screenshotBytes(shot) <= limit) {
1177
+ return { screenshot: shot, failed: false, message: null, reencoded: false };
1178
+ }
1179
+ const steps = opts.qualitySteps ?? SCREENSHOT_QUALITY_STEPS;
1180
+ if (opts.reencode) {
1181
+ for (const quality of steps) {
1182
+ let next = null;
1183
+ try {
1184
+ next = await opts.reencode(shot, quality) ?? null;
1185
+ } catch {
1186
+ next = null;
1187
+ }
1188
+ if (usable(next) && screenshotBytes(next) <= limit) {
1189
+ return { screenshot: next, failed: false, message: null, reencoded: true };
1190
+ }
1191
+ }
1192
+ }
1193
+ return failure();
1194
+ }
1195
+
1196
+ // src/widget/pin.ts
1197
+ function clamp01(value) {
1198
+ if (!Number.isFinite(value)) return 0;
1199
+ if (value < 0) return 0;
1200
+ if (value > 1) return 1;
1201
+ return value;
1202
+ }
1203
+ function normalizePin(point, size) {
1204
+ const x = size.width > 0 ? point.x / size.width : 0;
1205
+ const y = size.height > 0 ? point.y / size.height : 0;
1206
+ return { x: clamp01(x), y: clamp01(y) };
1207
+ }
1208
+ function denormalizePin(pin, size) {
1209
+ return { x: pin.x * size.width, y: pin.y * size.height };
1210
+ }
1211
+ var PinController = class {
1212
+ constructor(initial = null) {
1213
+ this.opened = false;
1214
+ this.confirmedPin = initial;
1215
+ this.draftPin = initial;
1216
+ }
1217
+ get isOpen() {
1218
+ return this.opened;
1219
+ }
1220
+ get draft() {
1221
+ return this.draftPin;
1222
+ }
1223
+ get confirmed() {
1224
+ return this.confirmedPin;
1225
+ }
1226
+ /** 화면에 떠 있는 핀 개수. 단일 핀 규칙이라 0 아니면 1이다. */
1227
+ get count() {
1228
+ return this.draftPin ? 1 : 0;
1229
+ }
1230
+ open() {
1231
+ this.draftPin = this.confirmedPin;
1232
+ this.opened = true;
1233
+ }
1234
+ /** 탭한 자리에 핀을 찍는다. 기존 핀이 있으면 그 자리로 옮긴다(추가가 아니다). */
1235
+ place(point, size) {
1236
+ const pin = normalizePin(point, size);
1237
+ this.draftPin = pin;
1238
+ return pin;
1239
+ }
1240
+ /** 이미 상대 좌표를 갖고 있을 때 쓰는 경로. */
1241
+ placeRelative(pin) {
1242
+ const next = { x: clamp01(pin.x), y: clamp01(pin.y) };
1243
+ this.draftPin = next;
1244
+ return next;
1245
+ }
1246
+ confirm() {
1247
+ this.confirmedPin = this.draftPin;
1248
+ this.opened = false;
1249
+ return this.confirmedPin;
1250
+ }
1251
+ /** 취소 — 이전에 확정한 좌표로 되돌린다. */
1252
+ cancel() {
1253
+ this.draftPin = this.confirmedPin;
1254
+ this.opened = false;
1255
+ return this.confirmedPin;
1256
+ }
1257
+ reset() {
1258
+ this.confirmedPin = null;
1259
+ this.draftPin = null;
1260
+ this.opened = false;
1261
+ }
1262
+ };
1263
+
1264
+ // src/widget/focus.ts
1265
+ var FLOATING_BUTTON_ID = "feedback-kit-floating-button";
1266
+ var FocusRing = class {
1267
+ constructor(items = []) {
1268
+ this.items = [...items];
1269
+ this.index = this.items.length > 0 ? 0 : -1;
1270
+ }
1271
+ get order() {
1272
+ return this.items;
1273
+ }
1274
+ get current() {
1275
+ return this.index >= 0 && this.index < this.items.length ? this.items[this.index] : null;
1276
+ }
1277
+ /**
1278
+ * 항목 목록을 바꾼다. 지금 포커스된 항목이 새 목록에도 있으면 그 자리를 유지한다
1279
+ * (스크린샷을 지웠다고 포커스가 처음으로 튀면 키보드 사용자가 길을 잃는다).
1280
+ */
1281
+ setItems(items) {
1282
+ const focused = this.current;
1283
+ this.items = [...items];
1284
+ if (this.items.length === 0) {
1285
+ this.index = -1;
1286
+ return;
1287
+ }
1288
+ const keep = focused === null ? -1 : this.items.indexOf(focused);
1289
+ this.index = keep >= 0 ? keep : 0;
1290
+ }
1291
+ focus(id) {
1292
+ const at = this.items.indexOf(id);
1293
+ if (at < 0) return false;
1294
+ this.index = at;
1295
+ return true;
1296
+ }
1297
+ next() {
1298
+ if (this.items.length === 0) return null;
1299
+ this.index = (this.index + 1) % this.items.length;
1300
+ return this.current;
1301
+ }
1302
+ prev() {
1303
+ if (this.items.length === 0) return null;
1304
+ this.index = (this.index - 1 + this.items.length) % this.items.length;
1305
+ return this.current;
1306
+ }
1307
+ contains(id) {
1308
+ return this.items.includes(id);
1309
+ }
1310
+ };
1311
+
1312
+ // src/widget/modal.ts
1313
+ var COMMENT_MAX_CHARS = 4e3;
1314
+ var COMMENT_REQUIRED_MESSAGE = "\uC758\uACAC\uC744 \uC785\uB825\uD574\uC8FC\uC138\uC694";
1315
+ var COMMENT_TOO_LONG_MESSAGE = "4,000\uC790 \uC774\uD558\uB85C \uC785\uB825\uD574\uC8FC\uC138\uC694";
1316
+ var SUBMIT_DONE_MESSAGE = "\uBCF4\uB0C8\uC2B5\uB2C8\uB2E4";
1317
+ var SUBMIT_PENDING_MESSAGE = "\uB300\uAE30 \uC911";
1318
+ var SUBMIT_FAILED_MESSAGE = "\uBCF4\uB0B4\uC9C0 \uBABB\uD588\uC2B5\uB2C8\uB2E4";
1319
+ var MODAL_FIELD_COMMENT = "comment";
1320
+ var MODAL_FIELD_PRIORITY = "priority";
1321
+ var MODAL_ACTION_ATTACH = "attach";
1322
+ var MODAL_ACTION_REMOVE_SCREENSHOT = "remove-screenshot";
1323
+ var MODAL_ACTION_PIN = "pin";
1324
+ var MODAL_ACTION_PICK = "pick";
1325
+ var MODAL_ACTION_RETRY = "retry";
1326
+ var MODAL_ACTION_CANCEL = "cancel";
1327
+ var MODAL_ACTION_SEND = "send";
1328
+ var ReportModalController = class {
1329
+ constructor(opts) {
1330
+ this.listeners = /* @__PURE__ */ new Set();
1331
+ this.ring = new FocusRing();
1332
+ /** 사용자가 입력란을 건드렸는가. 열자마자 빨간 문구가 뜨는 걸 막으려고 둔다. */
1333
+ this.touched = false;
1334
+ this.submitAttempted = false;
1335
+ this.lastReport = null;
1336
+ /** 이전 open/recapture가 늦게 끝나 최신 상태를 덮지 못하게 하는 세대 번호. */
1337
+ this.captureGeneration = 0;
1338
+ this.state = {
1339
+ open: false,
1340
+ comment: "",
1341
+ priority: "unset",
1342
+ screenshot: null,
1343
+ screenshotStatus: "none",
1344
+ screenshotMessage: null,
1345
+ pin: null,
1346
+ submitStatus: "idle",
1347
+ submitMessage: null,
1348
+ commentError: null,
1349
+ canSubmit: false,
1350
+ canPin: false,
1351
+ showRetry: false,
1352
+ pending: 0,
1353
+ closeConfirmVisible: false,
1354
+ actions: [],
1355
+ focusOrder: [],
1356
+ focused: null,
1357
+ restoreFocusTo: null
1358
+ };
1359
+ this.queue = opts.queue;
1360
+ this.createReport = opts.createReport;
1361
+ this.platform = opts.platform;
1362
+ this.capture = opts.capture ?? null;
1363
+ this.reencode = opts.reencode ?? null;
1364
+ this.screenshotLimitBytes = opts.screenshotLimitBytes;
1365
+ this.unsubscribeQueue = this.queue.subscribe((status) => {
1366
+ this.patch({ pending: status.pending });
1367
+ if (this.state.submitStatus !== "pending" || !this.lastReport) return;
1368
+ const outcome = this.queue.getOutcome?.(this.lastReport.clientSubmissionId) ?? null;
1369
+ const delivered = outcome?.delivered === true;
1370
+ const rejected = outcome?.delivered === false;
1371
+ const legacyDrained = !this.queue.getOutcome && status.pending === 0;
1372
+ if (delivered || legacyDrained) {
1373
+ this.patch({ submitStatus: "sent", submitMessage: SUBMIT_DONE_MESSAGE });
1374
+ this.close({ keepResult: true });
1375
+ } else if (rejected) {
1376
+ this.patch({ submitStatus: "failed", submitMessage: SUBMIT_FAILED_MESSAGE });
1377
+ }
1378
+ });
1379
+ this.recompute();
1380
+ }
1381
+ // ── 조회 ──────────────────────────────────────────────────────────────────
1382
+ getState() {
1383
+ return this.state;
1384
+ }
1385
+ /** 마지막으로 조립한 제보. 재시도가 같은 멱등키를 쓰는지 확인할 때 쓴다. */
1386
+ getLastReport() {
1387
+ return this.lastReport;
1388
+ }
1389
+ subscribe(listener) {
1390
+ this.listeners.add(listener);
1391
+ listener(this.state);
1392
+ return () => {
1393
+ this.listeners.delete(listener);
1394
+ };
1395
+ }
1396
+ dispose() {
1397
+ this.unsubscribeQueue();
1398
+ this.listeners.clear();
1399
+ }
1400
+ /** WidgetController가 소유한 큐 lifecycle을 함께 정리한다. */
1401
+ stopQueue() {
1402
+ this.queue.stop?.();
1403
+ }
1404
+ // ── 열기/닫기 ─────────────────────────────────────────────────────────────
1405
+ async open() {
1406
+ if (this.state.open) return;
1407
+ const wasPending = this.state.submitStatus === "pending" && this.lastReport !== null;
1408
+ const pendingReport = wasPending ? this.lastReport : null;
1409
+ this.touched = false;
1410
+ this.submitAttempted = false;
1411
+ this.patch({
1412
+ open: true,
1413
+ comment: pendingReport?.comment ?? "",
1414
+ priority: pendingReport?.priority ?? "unset",
1415
+ screenshot: pendingReport?.screenshot ?? null,
1416
+ screenshotStatus: "none",
1417
+ screenshotMessage: null,
1418
+ pin: pendingReport?.pin ?? null,
1419
+ submitStatus: wasPending ? "pending" : "idle",
1420
+ submitMessage: wasPending ? SUBMIT_PENDING_MESSAGE : null,
1421
+ closeConfirmVisible: false,
1422
+ restoreFocusTo: null
1423
+ });
1424
+ this.ring.focus(MODAL_FIELD_COMMENT);
1425
+ this.patch({ focused: this.ring.current });
1426
+ if (this.capture && !wasPending) await this.runCapture();
1427
+ }
1428
+ /**
1429
+ * 닫기 요청. 쓰던 내용이 있으면 곧장 닫지 않고 확인을 받는다.
1430
+ * @returns 실제로 닫혔으면 `"closed"`, 확인이 필요하면 `"confirm"`.
1431
+ */
1432
+ requestClose() {
1433
+ if (this.state.comment.length >= 1) {
1434
+ this.patch({ closeConfirmVisible: true });
1435
+ return "confirm";
1436
+ }
1437
+ this.close();
1438
+ return "closed";
1439
+ }
1440
+ /** 확인 대화에서 "닫기"를 고른 경우. */
1441
+ confirmClose() {
1442
+ this.close();
1443
+ }
1444
+ /** 확인 대화에서 "계속 쓰기"를 고른 경우. 입력은 그대로 남는다. */
1445
+ cancelClose() {
1446
+ this.patch({ closeConfirmVisible: false });
1447
+ }
1448
+ /** 확인 없이 닫는다(요소 지목 모드로 넘어갈 때처럼 흐름이 이어지는 경우). */
1449
+ dismiss() {
1450
+ this.close();
1451
+ }
1452
+ close(opts) {
1453
+ this.captureGeneration += 1;
1454
+ const keepDraft = opts?.keepResult || this.state.submitStatus === "pending";
1455
+ const cleared = keepDraft ? {} : {
1456
+ comment: "",
1457
+ priority: "unset",
1458
+ screenshot: null,
1459
+ screenshotStatus: "none",
1460
+ screenshotMessage: null,
1461
+ pin: null,
1462
+ submitStatus: "idle",
1463
+ submitMessage: null
1464
+ };
1465
+ this.touched = false;
1466
+ this.submitAttempted = false;
1467
+ this.patch({
1468
+ open: false,
1469
+ closeConfirmVisible: false,
1470
+ focused: null,
1471
+ restoreFocusTo: FLOATING_BUTTON_ID,
1472
+ ...cleared
1473
+ });
1474
+ }
1475
+ // ── 입력 ──────────────────────────────────────────────────────────────────
1476
+ /** 상한을 넘겨도 값을 자르지 않는다. 거부는 하되 사용자가 쓴 글은 보존한다. */
1477
+ setComment(value) {
1478
+ if (this.isDraftLocked()) return;
1479
+ this.touched = true;
1480
+ this.patch({ comment: value });
1481
+ }
1482
+ setPriority(priority) {
1483
+ if (this.isDraftLocked()) return;
1484
+ this.patch({ priority });
1485
+ }
1486
+ setPin(pin) {
1487
+ if (this.isDraftLocked()) return;
1488
+ this.patch({ pin });
1489
+ }
1490
+ /** 스크린샷 제거 — 핀은 스크린샷 위 좌표라 함께 사라진다. */
1491
+ removeScreenshot() {
1492
+ if (this.isDraftLocked()) return;
1493
+ this.captureGeneration += 1;
1494
+ this.patch({
1495
+ screenshot: null,
1496
+ screenshotStatus: "none",
1497
+ screenshotMessage: null,
1498
+ pin: null
1499
+ });
1500
+ }
1501
+ async recapture() {
1502
+ if (!this.capture || this.isDraftLocked()) return;
1503
+ await this.runCapture();
1504
+ }
1505
+ /** 캡처가 실패했을 때의 대체 경로 — 사용자가 직접 고른 파일. */
1506
+ async attachFile(shot) {
1507
+ if (this.isDraftLocked()) return;
1508
+ const generation = ++this.captureGeneration;
1509
+ const outcome = await captureWithinLimit({
1510
+ capture: () => shot,
1511
+ reencode: this.reencode,
1512
+ limitBytes: this.screenshotLimitBytes
1513
+ });
1514
+ if (generation === this.captureGeneration && this.state.open && !this.isDraftLocked()) {
1515
+ this.applyScreenshot(outcome.screenshot, outcome.failed);
1516
+ }
1517
+ }
1518
+ async runCapture() {
1519
+ const generation = ++this.captureGeneration;
1520
+ this.patch({ screenshotStatus: "capturing", screenshotMessage: null });
1521
+ const outcome = await captureWithinLimit({
1522
+ capture: this.capture,
1523
+ reencode: this.reencode,
1524
+ limitBytes: this.screenshotLimitBytes
1525
+ });
1526
+ if (generation === this.captureGeneration && this.state.open) {
1527
+ this.applyScreenshot(outcome.screenshot, outcome.failed);
1528
+ }
1529
+ }
1530
+ applyScreenshot(shot, failed) {
1531
+ if (shot && !failed) {
1532
+ this.patch({ screenshot: shot, screenshotStatus: "ready", screenshotMessage: null, pin: null });
1533
+ return;
1534
+ }
1535
+ this.patch({
1536
+ screenshot: null,
1537
+ screenshotStatus: "failed",
1538
+ screenshotMessage: SCREENSHOT_FAILED_MESSAGE,
1539
+ pin: null
1540
+ });
1541
+ }
1542
+ // ── 포커스 ────────────────────────────────────────────────────────────────
1543
+ tabNext() {
1544
+ const next = this.ring.next();
1545
+ this.patch({ focused: next });
1546
+ return next;
1547
+ }
1548
+ tabPrev() {
1549
+ const prev = this.ring.prev();
1550
+ this.patch({ focused: prev });
1551
+ return prev;
1552
+ }
1553
+ focus(id) {
1554
+ const ok = this.ring.focus(id);
1555
+ if (ok) this.patch({ focused: this.ring.current });
1556
+ return ok;
1557
+ }
1558
+ // ── 전송 ──────────────────────────────────────────────────────────────────
1559
+ /**
1560
+ * 제출. 실패해도 예외를 던지지 않는다 — 대기 큐에 남기고 입력을 보존한다.
1561
+ * @returns 검증에 걸려 아무것도 보내지 않았으면 `null`.
1562
+ */
1563
+ async submit() {
1564
+ this.submitAttempted = true;
1565
+ if (this.state.submitStatus === "sending" || this.state.submitStatus === "pending") return null;
1566
+ if (!this.isCommentValid()) {
1567
+ this.recompute();
1568
+ this.emit();
1569
+ return null;
1570
+ }
1571
+ this.patch({ submitStatus: "sending", submitMessage: null });
1572
+ const parts = {
1573
+ kind: "report",
1574
+ comment: this.state.comment,
1575
+ priority: this.state.priority,
1576
+ screenshot: this.state.screenshot,
1577
+ pin: this.state.pin,
1578
+ element: null
1579
+ };
1580
+ let outcome;
1581
+ try {
1582
+ const report = await this.createReport(parts);
1583
+ this.lastReport = report;
1584
+ outcome = await this.queue.submit(report);
1585
+ } catch {
1586
+ outcome = { delivered: false, id: null, queued: false };
1587
+ }
1588
+ if (outcome.delivered) {
1589
+ this.patch({ submitStatus: "sent", submitMessage: SUBMIT_DONE_MESSAGE });
1590
+ this.close({ keepResult: true });
1591
+ } else if (outcome.queued) {
1592
+ this.patch({ submitStatus: "pending", submitMessage: SUBMIT_PENDING_MESSAGE });
1593
+ } else {
1594
+ this.patch({ submitStatus: "failed", submitMessage: SUBMIT_FAILED_MESSAGE });
1595
+ }
1596
+ return outcome;
1597
+ }
1598
+ /** [다시 보내기]. 큐가 비면 완료로 넘어간다. */
1599
+ async retry() {
1600
+ if (this.state.submitStatus !== "pending" && this.state.pending === 0) return;
1601
+ const retryingCurrent = this.state.submitStatus === "pending";
1602
+ if (retryingCurrent) this.patch({ submitStatus: "sending", submitMessage: null });
1603
+ try {
1604
+ await this.queue.retryNow();
1605
+ } catch {
1606
+ }
1607
+ const remaining = await this.queue.size();
1608
+ if (!retryingCurrent) {
1609
+ this.patch({ pending: remaining });
1610
+ } else {
1611
+ const outcome = this.lastReport ? this.queue.getOutcome?.(this.lastReport.clientSubmissionId) ?? null : null;
1612
+ const delivered = outcome?.delivered === true;
1613
+ const rejected = outcome?.delivered === false;
1614
+ const legacyDrained = !this.queue.getOutcome && remaining === 0;
1615
+ if (delivered || legacyDrained) {
1616
+ this.patch({ submitStatus: "sent", submitMessage: SUBMIT_DONE_MESSAGE });
1617
+ this.close({ keepResult: true });
1618
+ } else if (rejected) {
1619
+ this.patch({ submitStatus: "failed", submitMessage: SUBMIT_FAILED_MESSAGE });
1620
+ } else {
1621
+ this.patch({ submitStatus: "pending", submitMessage: SUBMIT_PENDING_MESSAGE });
1622
+ }
1623
+ }
1624
+ }
1625
+ // ── 내부 ──────────────────────────────────────────────────────────────────
1626
+ isCommentValid() {
1627
+ const value = this.state.comment;
1628
+ return value.trim().length > 0 && value.length <= COMMENT_MAX_CHARS;
1629
+ }
1630
+ isDraftLocked() {
1631
+ return this.state.submitStatus === "sending" || this.state.submitStatus === "pending";
1632
+ }
1633
+ commentErrorFor() {
1634
+ const value = this.state.comment;
1635
+ if (value.length > COMMENT_MAX_CHARS) return COMMENT_TOO_LONG_MESSAGE;
1636
+ if (value.trim().length === 0) {
1637
+ return this.touched || this.submitAttempted ? COMMENT_REQUIRED_MESSAGE : null;
1638
+ }
1639
+ return null;
1640
+ }
1641
+ actionsFor() {
1642
+ return this.platform === "web" ? [MODAL_ACTION_PICK] : [MODAL_ACTION_PIN];
1643
+ }
1644
+ focusOrderFor() {
1645
+ const order = [MODAL_FIELD_COMMENT, MODAL_FIELD_PRIORITY];
1646
+ if (this.state.screenshot) order.push(MODAL_ACTION_REMOVE_SCREENSHOT);
1647
+ else order.push(MODAL_ACTION_ATTACH);
1648
+ order.push(...this.actionsFor());
1649
+ if (this.state.submitStatus === "pending") order.push(MODAL_ACTION_RETRY);
1650
+ order.push(MODAL_ACTION_CANCEL, MODAL_ACTION_SEND);
1651
+ return order;
1652
+ }
1653
+ recompute() {
1654
+ const commentError = this.commentErrorFor();
1655
+ const canSubmit = this.isCommentValid() && this.state.screenshotStatus !== "capturing" && (this.state.submitStatus === "idle" || this.state.submitStatus === "failed");
1656
+ const canPin = this.platform === "native" && this.state.screenshot !== null && this.state.screenshotStatus !== "capturing" && !this.isDraftLocked() && this.state.open;
1657
+ const actions = this.actionsFor();
1658
+ const focusOrder = this.state.open ? this.focusOrderFor() : [];
1659
+ this.ring.setItems(focusOrder);
1660
+ this.state = {
1661
+ ...this.state,
1662
+ commentError,
1663
+ canSubmit,
1664
+ canPin,
1665
+ showRetry: this.state.submitStatus === "pending" || this.state.pending > 0,
1666
+ actions,
1667
+ focusOrder,
1668
+ focused: this.state.open ? this.ring.current : null
1669
+ };
1670
+ }
1671
+ patch(partial) {
1672
+ this.state = { ...this.state, ...partial };
1673
+ this.recompute();
1674
+ this.emit();
1675
+ }
1676
+ emit() {
1677
+ for (const listener of this.listeners) listener(this.state);
1678
+ }
1679
+ };
1680
+
1681
+ // src/widget/controller.ts
1682
+ var WidgetController = class {
1683
+ constructor(opts) {
1684
+ this.listeners = /* @__PURE__ */ new Set();
1685
+ this.platform = opts.platform;
1686
+ this.onPickingChange = opts.onPickingChange ?? null;
1687
+ this.modal = new ReportModalController(opts);
1688
+ this.pin = new PinController(null);
1689
+ opts.queue.start?.();
1690
+ this.picking = opts.initialPicking ?? false;
1691
+ this.screen = this.picking ? "picking" : "button";
1692
+ this.unsubscribeModal = this.modal.subscribe((state) => {
1693
+ if (state.pin === null && this.pin.confirmed !== null) this.pin.reset();
1694
+ if (!state.open && (this.screen === "modal" || this.screen === "pin")) {
1695
+ this.screen = this.picking ? "picking" : "button";
1696
+ }
1697
+ this.emit();
1698
+ });
1699
+ }
1700
+ // ── 조회 ──────────────────────────────────────────────────────────────────
1701
+ getScreen() {
1702
+ return this.screen;
1703
+ }
1704
+ getState() {
1705
+ return {
1706
+ screen: this.screen,
1707
+ modal: this.modal.getState(),
1708
+ pinDraft: this.pin.draft,
1709
+ pinConfirmed: this.pin.confirmed,
1710
+ pickingActive: this.picking
1711
+ };
1712
+ }
1713
+ get isPicking() {
1714
+ return this.picking;
1715
+ }
1716
+ subscribe(listener) {
1717
+ this.listeners.add(listener);
1718
+ listener(this.getState());
1719
+ return () => {
1720
+ this.listeners.delete(listener);
1721
+ };
1722
+ }
1723
+ dispose() {
1724
+ this.unsubscribeModal();
1725
+ this.modal.dispose();
1726
+ this.modal.stopQueue();
1727
+ this.listeners.clear();
1728
+ }
1729
+ // ── 플로팅 버튼 ↔ 모달 ────────────────────────────────────────────────────
1730
+ async openReport() {
1731
+ this.screen = "modal";
1732
+ await this.modal.open();
1733
+ this.emit();
1734
+ }
1735
+ /** 모달 닫기 요청. 쓰던 내용이 있으면 확인부터 받는다(화면은 그대로 모달). */
1736
+ closeReport() {
1737
+ const result = this.modal.requestClose();
1738
+ if (result === "closed") {
1739
+ this.pin.reset();
1740
+ this.screen = this.picking ? "picking" : "button";
1741
+ }
1742
+ this.emit();
1743
+ return result;
1744
+ }
1745
+ confirmCloseReport() {
1746
+ this.modal.confirmClose();
1747
+ this.pin.reset();
1748
+ this.screen = this.picking ? "picking" : "button";
1749
+ this.emit();
1750
+ }
1751
+ cancelCloseReport() {
1752
+ this.modal.cancelClose();
1753
+ this.emit();
1754
+ }
1755
+ async submitReport() {
1756
+ const outcome = await this.modal.submit();
1757
+ if (outcome?.delivered) this.pin.reset();
1758
+ this.emit();
1759
+ return outcome;
1760
+ }
1761
+ async retryReport() {
1762
+ await this.modal.retry();
1763
+ this.emit();
1764
+ }
1765
+ // ── 핀 지정 ───────────────────────────────────────────────────────────────
1766
+ /**
1767
+ * 핀 지정 화면 열기. 스크린샷이 없으면 좌표가 가리킬 대상이 없으므로 열지 않는다.
1768
+ * @returns 열렸으면 true.
1769
+ */
1770
+ openPinScreen() {
1771
+ if (!this.modal.getState().canPin) return false;
1772
+ this.pin.open();
1773
+ this.screen = "pin";
1774
+ this.emit();
1775
+ return true;
1776
+ }
1777
+ /** 핀 지정 화면에서 탭. 기존 핀이 있으면 그 자리로 옮긴다. */
1778
+ placePin(point, size) {
1779
+ if (this.screen !== "pin") return null;
1780
+ const placed = this.pin.place(point, size);
1781
+ this.emit();
1782
+ return placed;
1783
+ }
1784
+ /** 확정 — 모달로 돌아가고 좌표가 제출에 실린다. */
1785
+ confirmPin() {
1786
+ if (this.screen !== "pin") return this.pin.confirmed;
1787
+ const confirmed = this.pin.confirm();
1788
+ this.modal.setPin(confirmed);
1789
+ this.screen = "modal";
1790
+ this.emit();
1791
+ return confirmed;
1792
+ }
1793
+ /** 취소 — 이전 확정 좌표를 그대로 두고 모달로 돌아간다. */
1794
+ cancelPin() {
1795
+ if (this.screen !== "pin") return this.pin.confirmed;
1796
+ const previous = this.pin.cancel();
1797
+ this.modal.setPin(previous);
1798
+ this.screen = "modal";
1799
+ this.emit();
1800
+ return previous;
1801
+ }
1802
+ // ── 요소 지목 (웹 전용) ───────────────────────────────────────────────────
1803
+ /**
1804
+ * 요소 지목 모드 시작. 모달에서 넘어오는 흐름이라 닫기 확인을 묻지 않는다
1805
+ * (사용자가 이미 "지목하러 가겠다"고 밝힌 상태다).
1806
+ */
1807
+ startPicking() {
1808
+ if (this.platform !== "web") return false;
1809
+ this.modal.dismiss();
1810
+ this.setPicking(true);
1811
+ this.screen = "picking";
1812
+ this.emit();
1813
+ return true;
1814
+ }
1815
+ /** [지목 종료]. 플로팅 버튼 화면으로 돌아가고 저장된 모드 표시도 지운다. */
1816
+ stopPicking() {
1817
+ this.setPicking(false);
1818
+ if (this.screen === "picking") this.screen = "button";
1819
+ this.emit();
1820
+ }
1821
+ setPicking(active) {
1822
+ if (this.picking === active) return;
1823
+ this.picking = active;
1824
+ this.onPickingChange?.(active);
1825
+ }
1826
+ emit() {
1827
+ const state = this.getState();
1828
+ for (const listener of this.listeners) listener(state);
1829
+ }
1830
+ };
1831
+
1832
+ // src/adapters/body.ts
1833
+ var DETAILS_OPEN = "<details>";
1834
+ var DETAILS_SUMMARY = "<summary>\uC9C4\uB2E8 \uC815\uBCF4</summary>";
1835
+ var DETAILS_CLOSE = "</details>";
1836
+ var TITLE_MAX_CHARS = 80;
1837
+ var KIND_LABEL = {
1838
+ report: "\uC81C\uBCF4",
1839
+ annotation: "\uC694\uC18C \uC8FC\uC11D"
1840
+ };
1841
+ var PRIORITY_LABEL = {
1842
+ unset: "\uBBF8\uC9C0\uC815",
1843
+ normal: "\uBCF4\uD1B5",
1844
+ high: "\uB192\uC74C",
1845
+ urgent: "\uAE34\uAE09"
1846
+ };
1847
+ function inline(value) {
1848
+ return value.replace(/</g, "&lt;").replace(/[\r\n\t]+/g, " ").trim();
1849
+ }
1850
+ function kv(label, value) {
1851
+ if (value === null || value === void 0) return null;
1852
+ const text = inline(value);
1853
+ return text.length > 0 ? `- ${label}: ${text}` : null;
1854
+ }
1855
+ function json(value) {
1856
+ try {
1857
+ const text = JSON.stringify(value);
1858
+ return text === void 0 || text === "{}" ? null : text;
1859
+ } catch {
1860
+ return "(\uC9C1\uB82C\uD654 \uBD88\uAC00)";
1861
+ }
1862
+ }
1863
+ function approxBytesOfBase64(b64) {
1864
+ const padding = b64.endsWith("==") ? 2 : b64.endsWith("=") ? 1 : 0;
1865
+ return Math.max(0, Math.floor(b64.length * 3 / 4) - padding);
1866
+ }
1867
+ function humanBytes(n) {
1868
+ return n < 1024 ? `${n} B` : `${(n / 1024).toFixed(1)} KB`;
1869
+ }
1870
+ function formatSource(s) {
1871
+ const file = s.sourceFile;
1872
+ const line = typeof s.sourceLine === "number" ? s.sourceLine : null;
1873
+ const filePart = file ? line === null ? file : `${file}:${line}` : null;
1874
+ if (filePart && s.screenId) return `${filePart} (\uD654\uBA74 ${s.screenId})`;
1875
+ if (filePart) return filePart;
1876
+ return s.screenId ? `\uD654\uBA74 ${s.screenId}` : null;
1877
+ }
1878
+ function formatUser(u) {
1879
+ if (!u) return null;
1880
+ const bits = [u.id ?? "(\uBBF8\uC0C1)"];
1881
+ if (u.name) bits.push(u.name);
1882
+ if (u.email) bits.push(u.email);
1883
+ if (u.role) bits.push(`\uC5ED\uD560 ${u.role}`);
1884
+ if (u.isGuest) bits.push("\uAC8C\uC2A4\uD2B8");
1885
+ return bits.join(" \xB7 ");
1886
+ }
1887
+ function elementSelector(el) {
1888
+ if (el.selector) return el.selector;
1889
+ let out = el.tag;
1890
+ if (el.id) out += `#${el.id}`;
1891
+ if (el.className) {
1892
+ for (const cls of el.className.split(/\s+/)) {
1893
+ if (cls) out += `.${cls}`;
1894
+ }
1895
+ }
1896
+ return out;
1897
+ }
1898
+ function formatAppInfo(a) {
1899
+ const bits = [];
1900
+ if (a.version) bits.push(a.version);
1901
+ if (a.channel) bits.push(`\uCC44\uB110 ${a.channel}`);
1902
+ if (a.runtimeVersion) bits.push(`\uB7F0\uD0C0\uC784 ${a.runtimeVersion}`);
1903
+ if (a.updateId) bits.push(`\uC5C5\uB370\uC774\uD2B8 ${a.updateId}`);
1904
+ return bits.length > 0 ? bits.join(" \xB7 ") : null;
1905
+ }
1906
+ function formatDevice(d) {
1907
+ const bits = [];
1908
+ if (d.model) bits.push(d.model);
1909
+ if (d.osName || d.osVersion) bits.push([d.osName, d.osVersion].filter(Boolean).join(" "));
1910
+ if (d.deviceType) bits.push(d.deviceType);
1911
+ return bits.length > 0 ? bits.join(" \xB7 ") : null;
1912
+ }
1913
+ function formatDisplay(d) {
1914
+ const bits = [];
1915
+ if (d.width !== null && d.height !== null) bits.push(`${d.width}\xD7${d.height}`);
1916
+ if (d.pixelRatio !== null) bits.push(`@${d.pixelRatio}x`);
1917
+ if (d.fontScale !== null) bits.push(`\uAE00\uAF34 \uBC30\uC728 ${d.fontScale}`);
1918
+ return bits.length > 0 ? bits.join(" \xB7 ") : null;
1919
+ }
1920
+ function formatViewport(v) {
1921
+ const bits = [];
1922
+ if (v.width !== null && v.height !== null) bits.push(`${v.width}\xD7${v.height}`);
1923
+ if (v.devicePixelRatio !== null) bits.push(`@${v.devicePixelRatio}x`);
1924
+ return bits.length > 0 ? bits.join(" \xB7 ") : null;
1925
+ }
1926
+ function detailLines(report) {
1927
+ const c = report.context;
1928
+ const out = [];
1929
+ out.push(kv("\uC885\uB958", KIND_LABEL[report.kind]));
1930
+ out.push(kv("\uC6B0\uC120\uC21C\uC704", PRIORITY_LABEL[report.priority]));
1931
+ out.push(kv("\uC81C\uCD9C ID", report.clientSubmissionId));
1932
+ out.push(kv("\uC791\uC131 \uC2DC\uAC01", report.createdAt));
1933
+ out.push(kv("\uC571", c.app));
1934
+ out.push(kv("\uD50C\uB7AB\uD3FC", c.platform));
1935
+ out.push(kv("\uD654\uBA74", c.screen));
1936
+ out.push(kv("URL", c.url));
1937
+ out.push(kv("\uC18C\uC2A4", formatSource(c.source)));
1938
+ out.push(kv("\uC0AC\uC6A9\uC790", formatUser(c.user)));
1939
+ out.push(kv("\uC138\uC158", c.sessionId));
1940
+ out.push(
1941
+ kv("\uC218\uC9D1 \uC2DC\uAC01", c.timezone ? `${c.clientTimestamp} (${c.timezone})` : c.clientTimestamp)
1942
+ );
1943
+ if (report.element) {
1944
+ const el = report.element;
1945
+ out.push(kv("\uC694\uC18C", elementSelector(el)));
1946
+ out.push(kv("\uC694\uC18C CSS \uACBD\uB85C", el.selector ?? null));
1947
+ out.push(kv("\uC694\uC18C \uC18C\uC2A4", formatSource(sourceFromElement(el))));
1948
+ out.push(kv("\uC694\uC18C \uD14D\uC2A4\uD2B8", el.text));
1949
+ if (el.boundingBox) {
1950
+ const b = el.boundingBox;
1951
+ out.push(
1952
+ kv(
1953
+ "\uC694\uC18C \uC704\uCE58",
1954
+ `x ${Math.round(b.x)} \xB7 y ${Math.round(b.y)} \xB7 ${Math.round(b.width)}\xD7${Math.round(
1955
+ b.height
1956
+ )}`
1957
+ )
1958
+ );
1959
+ }
1960
+ out.push(kv("\uC694\uC18C \uC18D\uC131", json(el.attributes)));
1961
+ }
1962
+ if (report.pin) {
1963
+ out.push(kv("\uD540", `x ${report.pin.x.toFixed(3)} \xB7 y ${report.pin.y.toFixed(3)} (0~1 \uC815\uADDC\uD654)`));
1964
+ }
1965
+ if (report.screenshot) {
1966
+ const bytes = approxBytesOfBase64(report.screenshot.base64);
1967
+ out.push(kv("\uC2A4\uD06C\uB9B0\uC0F7", `${report.screenshot.contentType} \xB7 \uC57D ${humanBytes(bytes)}`));
1968
+ }
1969
+ if (c.native) {
1970
+ const n = c.native;
1971
+ out.push(kv("\uD654\uBA74 \uACBD\uB85C", n.screenPath));
1972
+ out.push(kv("\uB0B4\uBE44 \uC2A4\uD0DD", n.navPath.length > 0 ? n.navPath.join(" \u203A ") : null));
1973
+ out.push(kv("\uB77C\uC6B0\uD2B8 \uD30C\uB77C\uBBF8\uD130", n.routeParams ? json(n.routeParams) : null));
1974
+ out.push(kv("\uC571 \uBC84\uC804", formatAppInfo(n.appInfo)));
1975
+ out.push(kv("\uAE30\uAE30", formatDevice(n.device)));
1976
+ out.push(kv("\uB514\uC2A4\uD50C\uB808\uC774", formatDisplay(n.display)));
1977
+ }
1978
+ if (c.web) {
1979
+ out.push(kv("\uBDF0\uD3EC\uD2B8", formatViewport(c.web.viewport)));
1980
+ out.push(kv("UA", c.web.userAgent));
1981
+ }
1982
+ const extra = json(c.extra);
1983
+ out.push(kv("\uCD94\uAC00", extra));
1984
+ if (c.diagnostics === null) {
1985
+ out.push("- \uC9C4\uB2E8: \uC218\uC9D1\uD558\uC9C0 \uC54A\uC74C");
1986
+ } else {
1987
+ const { network, logs } = c.diagnostics;
1988
+ out.push(`- \uC9C4\uB2E8 \uB124\uD2B8\uC6CC\uD06C (${network.length}\uAC74)`);
1989
+ for (const e of network) {
1990
+ const status = e.status === null ? "\uC2E4\uD328" : String(e.status);
1991
+ out.push(` - ${inline(`${e.method} ${e.url}`)} \u2192 ${status} \xB7 ${e.durationMs}ms \xB7 ${e.at}`);
1992
+ }
1993
+ out.push(`- \uC9C4\uB2E8 \uB85C\uADF8 (${logs.length}\uAC74)`);
1994
+ for (const e of logs) {
1995
+ out.push(` - [${e.level}] ${inline(e.message)} \xB7 ${e.at}`);
1996
+ }
1997
+ }
1998
+ return out.filter((line) => line !== null);
1999
+ }
2000
+ function renderReportBody(report) {
2001
+ const comment = report.comment.trim().length > 0 ? report.comment.trimEnd() : "(\uCF54\uBA58\uD2B8 \uC5C6\uC74C)";
2002
+ return [
2003
+ comment,
2004
+ "",
2005
+ DETAILS_OPEN,
2006
+ DETAILS_SUMMARY,
2007
+ "",
2008
+ ...detailLines(report),
2009
+ "",
2010
+ DETAILS_CLOSE
2011
+ ].join("\n");
2012
+ }
2013
+ function buildTitle(report) {
2014
+ const firstLine = report.comment.split("\n").find((l) => l.trim().length > 0);
2015
+ const fallback = `${KIND_LABEL[report.kind]} \u2014 ${report.context.screen ?? report.context.url ?? report.context.app}`;
2016
+ const base = inline(firstLine ?? fallback) || fallback;
2017
+ return base.length > TITLE_MAX_CHARS ? `${base.slice(0, TITLE_MAX_CHARS - 1)}\u2026` : base;
2018
+ }
2019
+
2020
+ // src/adapters/linear.ts
2021
+ function buildLinearIssue(report) {
2022
+ return {
2023
+ title: buildTitle(report),
2024
+ description: renderReportBody(report)
2025
+ };
2026
+ }
2027
+
2028
+ // src/adapters/notion.ts
2029
+ function buildNotionPage(report) {
2030
+ return {
2031
+ title: buildTitle(report),
2032
+ content: renderReportBody(report)
2033
+ };
2034
+ }
2035
+
2036
+ // src/adapters/lasso.ts
2037
+ var LASSO_DEFAULT_ENDPOINT = "/api/feedback";
2038
+ var RETRYABLE_STATUS = /* @__PURE__ */ new Set([408, 425, 429, 500, 502, 503, 504]);
2039
+ function resolveFetch(injected) {
2040
+ if (injected) return injected;
2041
+ const g = globalThis;
2042
+ return typeof g.fetch === "function" ? g.fetch : null;
2043
+ }
2044
+ function absoluteEndpoint(endpoint) {
2045
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(endpoint)) return endpoint;
2046
+ const origin = globalThis.location?.origin;
2047
+ if (typeof origin !== "string" || origin.length === 0) return null;
2048
+ return endpoint.startsWith("/") ? `${origin}${endpoint}` : `${origin}/${endpoint}`;
2049
+ }
2050
+ function parseRetryAfterMs(res) {
2051
+ const raw = res.headers?.get("Retry-After") ?? null;
2052
+ if (raw === null) return null;
2053
+ const seconds = Number(raw.trim());
2054
+ return Number.isFinite(seconds) && seconds >= 0 ? Math.round(seconds * 1e3) : null;
2055
+ }
2056
+ function fail(retryable, extra) {
2057
+ return { ok: false, id: null, retryable, retryAfterMs: null, ...extra };
2058
+ }
2059
+ function idFrom(parsed) {
2060
+ const annotationId = parsed.annotationId;
2061
+ if (typeof annotationId === "string" && annotationId.length > 0) return annotationId;
2062
+ const direct = parsed.id;
2063
+ if (typeof direct === "string" && direct.length > 0) return direct;
2064
+ const data = parsed.data;
2065
+ if (data && typeof data === "object") {
2066
+ const nested = data.id;
2067
+ if (typeof nested === "string" && nested.length > 0) return nested;
2068
+ }
2069
+ return null;
2070
+ }
2071
+ function buildLassoEnvelope(report) {
2072
+ const c = report.context;
2073
+ const clientType = c.platform === "native" ? "app" : "web";
2074
+ const parsed = Date.parse(report.createdAt);
2075
+ const timestamp = Number.isFinite(parsed) ? parsed : 0;
2076
+ const el = report.element;
2077
+ if (report.kind === "annotation") {
2078
+ return {
2079
+ clientSubmissionId: report.clientSubmissionId,
2080
+ clientType,
2081
+ event: "annotation.add",
2082
+ timestamp,
2083
+ url: c.url ?? "",
2084
+ priority: report.priority,
2085
+ annotation: {
2086
+ id: report.clientSubmissionId,
2087
+ comment: renderReportBody(report),
2088
+ x: report.pin?.x ?? null,
2089
+ y: report.pin?.y ?? null,
2090
+ element: el ? el.tag : null,
2091
+ elementPath: el?.attributes["data-feedback-source"] ?? null,
2092
+ selectedText: el?.text ?? null,
2093
+ boundingBox: el?.boundingBox ?? null,
2094
+ timestamp
2095
+ }
2096
+ };
2097
+ }
2098
+ return {
2099
+ clientSubmissionId: report.clientSubmissionId,
2100
+ clientType,
2101
+ event: "report.create",
2102
+ timestamp,
2103
+ // 앱에는 URL 이 없다 — 화면 이름을 app:// 경로로 싣는다(서버가 그대로 저장한다).
2104
+ url: c.url ?? (clientType === "app" ? `app://${c.screen ?? "unknown"}` : ""),
2105
+ priority: report.priority,
2106
+ report: {
2107
+ comment: renderReportBody(report),
2108
+ pin: report.pin ? { x: report.pin.x, y: report.pin.y } : null,
2109
+ sourceFile: el?.attributes["data-feedback-source"] ?? null,
2110
+ screenshotBase64: report.screenshot?.base64 ?? null,
2111
+ screenshotContentType: report.screenshot?.contentType ?? null,
2112
+ // 서버가 없으면 빈 배열로 취급하지만, 미연동과 "없었음"을 구분해 보낸다.
2113
+ network: report.context.diagnostics?.network ?? [],
2114
+ logs: report.context.diagnostics?.logs ?? []
2115
+ }
2116
+ };
2117
+ }
2118
+ function createLassoAdapter(opts = {}) {
2119
+ const token = opts.token ?? null;
2120
+ const endpoint = opts.endpoint && opts.endpoint.trim().length > 0 ? opts.endpoint.trim() : LASSO_DEFAULT_ENDPOINT;
2121
+ const warn = opts.warn ?? null;
2122
+ const emit = (message) => {
2123
+ if (!warn) return;
2124
+ try {
2125
+ warn(message);
2126
+ } catch {
2127
+ }
2128
+ };
2129
+ return {
2130
+ async submit(report) {
2131
+ const url = absoluteEndpoint(endpoint);
2132
+ if (url === null) {
2133
+ emit(
2134
+ `feedback-kit: endpoint "${endpoint}" \uB97C \uC808\uB300 URL \uB85C \uB9CC\uB4E4 \uC218 \uC5C6\uB2E4(\uC624\uB9AC\uC9C4 \uC5C6\uC74C). \uC571\uC5D0\uC11C\uB294 endpoint \uB97C \uC808\uB300 URL \uB85C \uC9C0\uC815\uD574\uC57C \uD55C\uB2E4.`
2135
+ );
2136
+ return fail(false);
2137
+ }
2138
+ const sender = resolveFetch(opts.fetch);
2139
+ if (sender === null) {
2140
+ emit("feedback-kit: fetch \uB97C \uCC3E\uC9C0 \uBABB\uD588\uB2E4(\uC804\uC1A1\uC744 \uBBF8\uB8EC\uB2E4).");
2141
+ return fail(true);
2142
+ }
2143
+ const body = JSON.stringify(buildLassoEnvelope(report));
2144
+ const headers = {
2145
+ "Content-Type": "application/json",
2146
+ // 재시도가 중복 제보가 되지 않게. 코어가 같은 항목을 다시 보내도 키는 그대로다.
2147
+ "Idempotency-Key": report.clientSubmissionId
2148
+ };
2149
+ if (token !== null) headers.Authorization = `Bearer ${token}`;
2150
+ let res;
2151
+ try {
2152
+ res = await sender(url, {
2153
+ method: "POST",
2154
+ headers,
2155
+ body,
2156
+ // 페이지를 즉시 이탈해도 전송이 끝나게. 본문이 한도를 넘으면 브라우저가
2157
+ // 요청 자체를 거절하므로 크기를 보고 켠다.
2158
+ keepalive: canUseKeepalive(byteLengthOf(body))
2159
+ });
2160
+ } catch {
2161
+ emit("feedback-kit: \uC804\uC1A1\uC5D0 \uC2E4\uD328\uD588\uB2E4(\uB124\uD2B8\uC6CC\uD06C). \uD050\uC5D0 \uB0A8\uACA8 \uB2E4\uC2DC \uC2DC\uB3C4\uD55C\uB2E4.");
2162
+ return fail(true);
2163
+ }
2164
+ const status = res.status;
2165
+ if (status < 200 || status >= 300) {
2166
+ if (status === 429) {
2167
+ emit("feedback-kit: \uC218\uC9D1 \uD55C\uB3C4\uC5D0 \uAC78\uB838\uB2E4(429). \uC7A0\uC2DC \uB4A4 \uB2E4\uC2DC \uC2DC\uB3C4\uD55C\uB2E4.");
2168
+ return fail(true, { retryAfterMs: parseRetryAfterMs(res), rateLimited: true });
2169
+ }
2170
+ const retryable = RETRYABLE_STATUS.has(status);
2171
+ emit(
2172
+ `feedback-kit: \uC218\uC9D1 \uC751\uB2F5 ${status}${retryable ? " (\uB2E4\uC2DC \uC2DC\uB3C4\uD55C\uB2E4)" : " (\uC7AC\uC2DC\uB3C4\uD558\uC9C0 \uC54A\uB294\uB2E4)"}`
2173
+ );
2174
+ return fail(retryable, { retryAfterMs: retryable ? parseRetryAfterMs(res) : null });
2175
+ }
2176
+ let parsed;
2177
+ try {
2178
+ const text = await res.text();
2179
+ const value = JSON.parse(text);
2180
+ if (value === null || typeof value !== "object") throw new Error("\uAC1D\uCCB4\uAC00 \uC544\uB2D8");
2181
+ parsed = value;
2182
+ } catch {
2183
+ emit(`feedback-kit: \uC218\uC9D1 \uC751\uB2F5 ${status} \uC758 \uBCF8\uBB38\uC744 \uC77D\uC9C0 \uBABB\uD588\uB2E4. \uB2E4\uC2DC \uC2DC\uB3C4\uD55C\uB2E4.`);
2184
+ return fail(true);
2185
+ }
2186
+ if (parsed.ok !== true) {
2187
+ emit(`feedback-kit: \uC218\uC9D1 \uC11C\uBC84\uAC00 \uC81C\uBCF4\uB97C \uAC70\uC808\uD588\uB2E4(${status}). \uC7AC\uC2DC\uB3C4\uD558\uC9C0 \uC54A\uB294\uB2E4.`);
2188
+ return fail(false);
2189
+ }
2190
+ return { ok: true, id: idFrom(parsed), retryable: false, retryAfterMs: null };
2191
+ }
2192
+ };
2193
+ }
2194
+ export {
2195
+ BACKOFF_BASE_MS,
2196
+ BACKOFF_MAX_MS,
2197
+ COMMENT_MAX_CHARS,
2198
+ COMMENT_REQUIRED_MESSAGE,
2199
+ COMMENT_TOO_LONG_MESSAGE,
2200
+ DEFAULT_ADAPTER,
2201
+ DEFAULT_INTERNAL_ROLES,
2202
+ DEFAULT_POSITION,
2203
+ DiagnosticsCollector,
2204
+ ENDPOINT_FROM_ADAPTER,
2205
+ FLOATING_BUTTON_ID,
2206
+ FeedbackQueue,
2207
+ FocusRing,
2208
+ KEEPALIVE_BODY_LIMIT_BYTES,
2209
+ LASSO_DEFAULT_ENDPOINT,
2210
+ LOG_BUFFER_LIMIT,
2211
+ MAX_LOG_MESSAGE_CHARS,
2212
+ MAX_QUEUE_AGE_MS,
2213
+ MAX_QUEUE_ITEMS,
2214
+ MODAL_ACTION_ATTACH,
2215
+ MODAL_ACTION_CANCEL,
2216
+ MODAL_ACTION_PICK,
2217
+ MODAL_ACTION_PIN,
2218
+ MODAL_ACTION_REMOVE_SCREENSHOT,
2219
+ MODAL_ACTION_RETRY,
2220
+ MODAL_ACTION_SEND,
2221
+ MODAL_FIELD_COMMENT,
2222
+ MODAL_FIELD_PRIORITY,
2223
+ NETWORK_BUFFER_LIMIT,
2224
+ PinController,
2225
+ RATE_LIMIT_MIN_DELAY_MS,
2226
+ REDACTED,
2227
+ ReportModalController,
2228
+ RingBuffer,
2229
+ SCREENSHOT_FAILED_MESSAGE,
2230
+ SCREENSHOT_MAX_BASE64_BYTES,
2231
+ SCREENSHOT_QUALITY_STEPS,
2232
+ SOURCE_ATTR,
2233
+ SUBMIT_DONE_MESSAGE,
2234
+ SUBMIT_FAILED_MESSAGE,
2235
+ SUBMIT_PENDING_MESSAGE,
2236
+ TITLE_MAX_CHARS,
2237
+ WidgetController,
2238
+ buildContext,
2239
+ buildLassoEnvelope,
2240
+ buildLinearIssue,
2241
+ buildNotionPage,
2242
+ buildReport,
2243
+ buildTitle,
2244
+ byteLengthOf,
2245
+ canUseKeepalive,
2246
+ captureWithinLimit,
2247
+ createLassoAdapter,
2248
+ defaultBackoff,
2249
+ denormalizePin,
2250
+ detectDevBuild,
2251
+ diagnosticsProviderFor,
2252
+ getOrCreateGuestId,
2253
+ guestUser,
2254
+ isInternalUser,
2255
+ normalizePin,
2256
+ normalizeUser,
2257
+ parseSourceAttr,
2258
+ renderReportBody,
2259
+ resolveConfig,
2260
+ resolveContextUser,
2261
+ sanitizeUrl,
2262
+ screenshotBytes,
2263
+ sharedDiagnostics,
2264
+ shouldShowWidget,
2265
+ sourceFromElement,
2266
+ uuidv4
2267
+ };
2268
+ //# sourceMappingURL=index.js.map