@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
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
// 진단 링버퍼 — 호스트 앱의 네트워크 요청과 경고·에러 로그를 모아 제보에 첨부한다.
|
|
2
|
+
//
|
|
3
|
+
// 왜 링버퍼인가: 제보는 "문제가 난 뒤"에 작성된다. 그래서 수집은 항상 켜져 있어야 하고,
|
|
4
|
+
// 메모리는 유한해야 한다. 고정 길이 버퍼에 계속 밀어 넣으면 **직전 N건**이 언제나 남는다.
|
|
5
|
+
//
|
|
6
|
+
// 이 모듈이 지키는 약속(DP-253 진단 컨텍스트 수집):
|
|
7
|
+
// 1. 버퍼는 **모듈 스코프에 두 개**(network 30 / logs 50). 화면마다 상태를 연결할 필요가
|
|
8
|
+
// 없어야 한다 — 어느 화면에서 제보를 열든 같은 버퍼를 본다.
|
|
9
|
+
// 2. 기록 항목은 **메서드·URL·상태코드·소요시간**까지다. `Authorization` 헤더·쿠키·
|
|
10
|
+
// 요청 본문·응답 본문은 **애초에 읽지 않는다**(가리는 게 아니라 접근 자체를 안 한다).
|
|
11
|
+
// URL 쿼리의 자격증명형 파라미터(`token`, `api_key`, …)와 `user:pass@` 는 값을 가린다.
|
|
12
|
+
// 3. 스냅샷의 두 배열은 **실제로 모은 결과**다. 하드코딩된 빈 배열을 보내지 않는다.
|
|
13
|
+
// 수집이 배선되지 않은 경우는 빈 배열이 아니라 컨텍스트의 `diagnostics` 자체가 null 이다.
|
|
14
|
+
// 4. 원래 동작을 절대 바꾸지 않는다. 래핑된 fetch/XHR/console 은 원본을 그대로 호출하고
|
|
15
|
+
// 결과·예외를 손대지 않고 돌려준다.
|
|
16
|
+
// 5. 위젯 자신의 요청·로그는 캡처에서 제외한다(제보를 보내는 행위가 진단을 오염시키지 않게).
|
|
17
|
+
// - 요청: `excludeMatcher` 가 true 를 주면 제외(예: 수집 백엔드 URL).
|
|
18
|
+
// - 로그: 위젯은 `originalConsole` 로 직접 찍어 캡처를 우회한다.
|
|
19
|
+
// 6. 코어는 document/window/react-native 를 직접 참조하지 않는다. 글로벌은 globalThis 로만.
|
|
20
|
+
|
|
21
|
+
import { RingBuffer } from "./ring-buffer.js";
|
|
22
|
+
import type {
|
|
23
|
+
DiagLogEntry,
|
|
24
|
+
DiagNetworkEntry,
|
|
25
|
+
DiagnosticsExcludeMatcher,
|
|
26
|
+
DiagnosticsPayload,
|
|
27
|
+
} from "./types.js";
|
|
28
|
+
|
|
29
|
+
/** 네트워크 링버퍼 용량. 넘치면 오래된 것부터 밀려난다. */
|
|
30
|
+
export const NETWORK_BUFFER_LIMIT = 30;
|
|
31
|
+
|
|
32
|
+
/** 콘솔 로그(warn/error) 링버퍼 용량. 넘치면 오래된 것부터 밀려난다. */
|
|
33
|
+
export const LOG_BUFFER_LIMIT = 50;
|
|
34
|
+
|
|
35
|
+
/** 로그 한 건의 최대 길이. 스택 덤프 하나가 버퍼를 통째로 먹는 걸 막는다. */
|
|
36
|
+
export const MAX_LOG_MESSAGE_CHARS = 2000;
|
|
37
|
+
|
|
38
|
+
export interface DiagnosticsInstallOpts {
|
|
39
|
+
/** true 를 반환하면 그 요청은 기록하지 않는다(위젯 자신의 전송 등). */
|
|
40
|
+
excludeMatcher?: DiagnosticsExcludeMatcher;
|
|
41
|
+
/** 시각 주입(테스트용). */
|
|
42
|
+
now?: () => number;
|
|
43
|
+
iso?: (n: number) => string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** 최소한의 콘솔 형태. DOM 의 `Console` 타입에 의존하지 않기 위해 구조적으로 정의한다. */
|
|
47
|
+
interface ConsoleLike {
|
|
48
|
+
warn?: (...a: unknown[]) => void;
|
|
49
|
+
error?: (...a: unknown[]) => void;
|
|
50
|
+
log?: (...a: unknown[]) => void;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* 진단 수집기. `install()` 로 글로벌을 패치하고 `uninstall()` 로 원복한다.
|
|
55
|
+
* 생성 자체는 부작용이 없다 — `install()` 을 호출해야 패치가 일어난다.
|
|
56
|
+
*/
|
|
57
|
+
export class DiagnosticsCollector {
|
|
58
|
+
readonly network: RingBuffer<DiagNetworkEntry>;
|
|
59
|
+
readonly logs: RingBuffer<DiagLogEntry>;
|
|
60
|
+
|
|
61
|
+
private installed = false;
|
|
62
|
+
|
|
63
|
+
// 원본 참조(원복용 + 위젯 우회용).
|
|
64
|
+
private originalFetch: ((...a: unknown[]) => Promise<unknown>) | null = null;
|
|
65
|
+
private originalXhrOpen: ((...a: unknown[]) => unknown) | null = null;
|
|
66
|
+
private originalXhrSend: ((...a: unknown[]) => unknown) | null = null;
|
|
67
|
+
private originalWarn: ((...a: unknown[]) => void) | null = null;
|
|
68
|
+
private originalError: ((...a: unknown[]) => void) | null = null;
|
|
69
|
+
|
|
70
|
+
private excludeMatcher: DiagnosticsExcludeMatcher | null = null;
|
|
71
|
+
private now: () => number;
|
|
72
|
+
private iso: (n: number) => string;
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* 패치된 fetch 안에서 동기적으로 XHR 이 열리는 깊이.
|
|
76
|
+
*
|
|
77
|
+
* React Native 의 `fetch` 는 whatwg-fetch 폴리필이라 내부에서 XMLHttpRequest 를 쓴다.
|
|
78
|
+
* 둘 다 패치해두면 요청 1건이 2건으로 기록된다. fetch 폴리필은 Promise 실행자 안에서
|
|
79
|
+
* **동기적으로** `send()` 를 부르므로, 그 구간을 세어 XHR 쪽 기록만 건너뛴다.
|
|
80
|
+
*/
|
|
81
|
+
private fetchDepth = 0;
|
|
82
|
+
|
|
83
|
+
constructor(
|
|
84
|
+
opts: {
|
|
85
|
+
now?: () => number;
|
|
86
|
+
iso?: (n: number) => string;
|
|
87
|
+
networkLimit?: number;
|
|
88
|
+
logLimit?: number;
|
|
89
|
+
} = {}
|
|
90
|
+
) {
|
|
91
|
+
this.now = opts.now ?? ((): number => Date.now());
|
|
92
|
+
this.iso = opts.iso ?? ((n): string => new Date(n).toISOString());
|
|
93
|
+
this.network = new RingBuffer<DiagNetworkEntry>(
|
|
94
|
+
opts.networkLimit ?? NETWORK_BUFFER_LIMIT
|
|
95
|
+
);
|
|
96
|
+
this.logs = new RingBuffer<DiagLogEntry>(opts.logLimit ?? LOG_BUFFER_LIMIT);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** 위젯이 자신의 로그를 캡처 없이 찍을 때 쓰는 원본 console. */
|
|
100
|
+
readonly originalConsole = {
|
|
101
|
+
warn: (...a: unknown[]): void => {
|
|
102
|
+
(this.originalWarn ?? consoleMethod("warn"))(...a);
|
|
103
|
+
},
|
|
104
|
+
error: (...a: unknown[]): void => {
|
|
105
|
+
(this.originalError ?? consoleMethod("error"))(...a);
|
|
106
|
+
},
|
|
107
|
+
log: (...a: unknown[]): void => {
|
|
108
|
+
consoleMethod("log")(...a);
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
/** 글로벌 fetch/XHR/console 을 패치한다. 이미 설치했으면 no-op. */
|
|
113
|
+
install(opts: DiagnosticsInstallOpts = {}): void {
|
|
114
|
+
if (this.installed) return;
|
|
115
|
+
this.installed = true;
|
|
116
|
+
this.excludeMatcher = opts.excludeMatcher ?? null;
|
|
117
|
+
if (opts.now) this.now = opts.now;
|
|
118
|
+
if (opts.iso) this.iso = opts.iso;
|
|
119
|
+
|
|
120
|
+
this.patchFetch();
|
|
121
|
+
this.patchXhr();
|
|
122
|
+
this.patchConsole();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** 글로벌 패치를 모두 원복한다. 버퍼 내용은 그대로 남는다. */
|
|
126
|
+
uninstall(): void {
|
|
127
|
+
if (!this.installed) return;
|
|
128
|
+
this.installed = false;
|
|
129
|
+
|
|
130
|
+
const g = globalThis as Record<string, unknown>;
|
|
131
|
+
|
|
132
|
+
if (this.originalFetch) {
|
|
133
|
+
g.fetch = this.originalFetch;
|
|
134
|
+
this.originalFetch = null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const XHR = g.XMLHttpRequest as { prototype?: Record<string, unknown> } | undefined;
|
|
138
|
+
if (XHR?.prototype && this.originalXhrOpen && this.originalXhrSend) {
|
|
139
|
+
XHR.prototype.open = this.originalXhrOpen;
|
|
140
|
+
XHR.prototype.send = this.originalXhrSend;
|
|
141
|
+
}
|
|
142
|
+
this.originalXhrOpen = null;
|
|
143
|
+
this.originalXhrSend = null;
|
|
144
|
+
|
|
145
|
+
const c = g.console as ConsoleLike | undefined;
|
|
146
|
+
if (c && this.originalWarn) c.warn = this.originalWarn;
|
|
147
|
+
if (c && this.originalError) c.error = this.originalError;
|
|
148
|
+
this.originalWarn = null;
|
|
149
|
+
this.originalError = null;
|
|
150
|
+
|
|
151
|
+
this.excludeMatcher = null;
|
|
152
|
+
this.fetchDepth = 0;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* 지금까지 모인 것을 그대로 스냅샷으로 낸다.
|
|
157
|
+
* 반환 배열은 사본이라 이후 수집이 제보 페이로드를 바꾸지 않는다.
|
|
158
|
+
*/
|
|
159
|
+
snapshot(): DiagnosticsPayload {
|
|
160
|
+
return { network: this.network.toArray(), logs: this.logs.toArray() };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** 두 버퍼를 비운다. */
|
|
164
|
+
clear(): void {
|
|
165
|
+
this.network.clear();
|
|
166
|
+
this.logs.clear();
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ── 내부: 기록 ────────────────────────────────────────────
|
|
170
|
+
|
|
171
|
+
private recordNetwork(
|
|
172
|
+
method: string,
|
|
173
|
+
url: string,
|
|
174
|
+
status: number | null,
|
|
175
|
+
startedAt: number
|
|
176
|
+
): void {
|
|
177
|
+
this.network.push({
|
|
178
|
+
method,
|
|
179
|
+
url,
|
|
180
|
+
status,
|
|
181
|
+
durationMs: Math.max(0, Math.round(this.now() - startedAt)),
|
|
182
|
+
at: this.iso(startedAt),
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
private recordLog(level: "warn" | "error", args: unknown[]): void {
|
|
187
|
+
this.logs.push({
|
|
188
|
+
level,
|
|
189
|
+
message: safeStringify(args).slice(0, MAX_LOG_MESSAGE_CHARS),
|
|
190
|
+
at: this.iso(this.now()),
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
private isExcluded(url: string, method: string): boolean {
|
|
195
|
+
if (!this.excludeMatcher) return false;
|
|
196
|
+
try {
|
|
197
|
+
return this.excludeMatcher({ url, method }) === true;
|
|
198
|
+
} catch {
|
|
199
|
+
// 호스트가 준 매처가 던져도 수집을 멈추지 않는다(기록하는 쪽으로 판단).
|
|
200
|
+
return false;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ── 내부: 패치 ────────────────────────────────────────────
|
|
205
|
+
|
|
206
|
+
private patchFetch(): void {
|
|
207
|
+
const g = globalThis as Record<string, unknown>;
|
|
208
|
+
if (typeof g.fetch !== "function") return;
|
|
209
|
+
|
|
210
|
+
this.originalFetch = g.fetch as (...a: unknown[]) => Promise<unknown>;
|
|
211
|
+
const originalFetch = this.originalFetch;
|
|
212
|
+
const self = this;
|
|
213
|
+
|
|
214
|
+
g.fetch = function patchedFetch(
|
|
215
|
+
input: unknown,
|
|
216
|
+
// init 은 `method` 만 읽는다. headers/body 는 **의도적으로 건드리지 않는다**.
|
|
217
|
+
init?: { method?: string }
|
|
218
|
+
): Promise<unknown> {
|
|
219
|
+
const { url, method } = describeRequest(input, init);
|
|
220
|
+
const excluded = self.isExcluded(url, method);
|
|
221
|
+
const start = self.now();
|
|
222
|
+
|
|
223
|
+
let pending: Promise<unknown>;
|
|
224
|
+
self.fetchDepth += 1;
|
|
225
|
+
try {
|
|
226
|
+
pending = Promise.resolve(originalFetch(input, init));
|
|
227
|
+
} catch (err) {
|
|
228
|
+
// 동기 throw 도 실패 1건으로 남기고 그대로 전파한다.
|
|
229
|
+
self.fetchDepth -= 1;
|
|
230
|
+
if (!excluded) self.recordNetwork(method, url, null, start);
|
|
231
|
+
throw err;
|
|
232
|
+
}
|
|
233
|
+
self.fetchDepth -= 1;
|
|
234
|
+
|
|
235
|
+
return pending.then(
|
|
236
|
+
(raw: unknown) => {
|
|
237
|
+
if (!excluded) {
|
|
238
|
+
const status = (raw as { status?: unknown } | null)?.status;
|
|
239
|
+
self.recordNetwork(
|
|
240
|
+
method,
|
|
241
|
+
url,
|
|
242
|
+
typeof status === "number" ? status : null,
|
|
243
|
+
start
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
return raw;
|
|
247
|
+
},
|
|
248
|
+
(err: unknown) => {
|
|
249
|
+
if (!excluded) self.recordNetwork(method, url, null, start);
|
|
250
|
+
throw err;
|
|
251
|
+
}
|
|
252
|
+
);
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
private patchXhr(): void {
|
|
257
|
+
const g = globalThis as Record<string, unknown>;
|
|
258
|
+
const XHR = g.XMLHttpRequest as
|
|
259
|
+
| { prototype?: Record<string, unknown> }
|
|
260
|
+
| undefined;
|
|
261
|
+
if (typeof g.XMLHttpRequest !== "function" || !XHR?.prototype) return;
|
|
262
|
+
|
|
263
|
+
const proto = XHR.prototype;
|
|
264
|
+
if (typeof proto.open !== "function" || typeof proto.send !== "function") return;
|
|
265
|
+
|
|
266
|
+
this.originalXhrOpen = proto.open as (...a: unknown[]) => unknown;
|
|
267
|
+
this.originalXhrSend = proto.send as (...a: unknown[]) => unknown;
|
|
268
|
+
const oOpen = this.originalXhrOpen;
|
|
269
|
+
const oSend = this.originalXhrSend;
|
|
270
|
+
const self = this;
|
|
271
|
+
|
|
272
|
+
proto.open = function patchedOpen(
|
|
273
|
+
method: unknown,
|
|
274
|
+
url: unknown,
|
|
275
|
+
...rest: unknown[]
|
|
276
|
+
): unknown {
|
|
277
|
+
// 인스턴스에 메타를 저장해 send/loadend 에서 쓴다.
|
|
278
|
+
(this as Record<string, unknown>).__fk_meta = {
|
|
279
|
+
method: String(method ?? "GET").toUpperCase(),
|
|
280
|
+
url: sanitizeUrl(String(url ?? "")),
|
|
281
|
+
};
|
|
282
|
+
return oOpen.call(this, method, url, ...rest);
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
// send 의 인자는 요청 본문이다 — 읽지 않고 원본에 그대로 넘기기만 한다.
|
|
286
|
+
proto.send = function patchedSend(...args: unknown[]): unknown {
|
|
287
|
+
const meta = (this as Record<string, unknown>).__fk_meta as
|
|
288
|
+
| { method: string; url: string }
|
|
289
|
+
| undefined;
|
|
290
|
+
const viaFetch = self.fetchDepth > 0;
|
|
291
|
+
const addEv = (this as {
|
|
292
|
+
addEventListener?: (t: string, cb: () => void) => void;
|
|
293
|
+
}).addEventListener;
|
|
294
|
+
|
|
295
|
+
if (meta && !viaFetch && typeof addEv === "function") {
|
|
296
|
+
const excluded = self.isExcluded(meta.url, meta.method);
|
|
297
|
+
const start = self.now();
|
|
298
|
+
let recorded = false;
|
|
299
|
+
// loadend 는 성공·실패·중단 모두에서 발생한다. 한 번만 기록한다.
|
|
300
|
+
addEv.call(this, "loadend", () => {
|
|
301
|
+
if (recorded || excluded) return;
|
|
302
|
+
recorded = true;
|
|
303
|
+
const status = (this as { status?: unknown }).status;
|
|
304
|
+
// XHR 은 실패 시 status 0 을 준다 — "응답 못 받음"이므로 null 로 남긴다.
|
|
305
|
+
self.recordNetwork(
|
|
306
|
+
meta.method,
|
|
307
|
+
meta.url,
|
|
308
|
+
typeof status === "number" && status > 0 ? status : null,
|
|
309
|
+
start
|
|
310
|
+
);
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
return oSend.apply(this, args);
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
private patchConsole(): void {
|
|
319
|
+
const c = (globalThis as { console?: ConsoleLike }).console;
|
|
320
|
+
if (!c) return;
|
|
321
|
+
const self = this;
|
|
322
|
+
|
|
323
|
+
if (typeof c.warn === "function") {
|
|
324
|
+
const orig = c.warn.bind(c) as (...a: unknown[]) => void;
|
|
325
|
+
this.originalWarn = orig;
|
|
326
|
+
c.warn = function patchedWarn(...args: unknown[]): void {
|
|
327
|
+
self.recordLog("warn", args);
|
|
328
|
+
orig(...args);
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
if (typeof c.error === "function") {
|
|
332
|
+
const orig = c.error.bind(c) as (...a: unknown[]) => void;
|
|
333
|
+
this.originalError = orig;
|
|
334
|
+
c.error = function patchedError(...args: unknown[]): void {
|
|
335
|
+
self.recordLog("error", args);
|
|
336
|
+
orig(...args);
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* 모듈 스코프 공유 수집기.
|
|
344
|
+
*
|
|
345
|
+
* 화면·컴포넌트마다 수집기를 연결할 필요가 없어야 한다는 요구사항 때문에 여기 하나를 둔다.
|
|
346
|
+
* 위젯이 어느 화면에서 열리든 이 인스턴스의 스냅샷을 싣는다.
|
|
347
|
+
*/
|
|
348
|
+
export const sharedDiagnostics = new DiagnosticsCollector();
|
|
349
|
+
|
|
350
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
351
|
+
// 헬퍼
|
|
352
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
353
|
+
|
|
354
|
+
function consoleMethod(name: "warn" | "error" | "log"): (...a: unknown[]) => void {
|
|
355
|
+
return (...a: unknown[]): void => {
|
|
356
|
+
const c = (globalThis as { console?: ConsoleLike }).console;
|
|
357
|
+
c?.[name]?.(...a);
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function safeStringify(args: unknown[]): string {
|
|
362
|
+
// 인자들을 사람이 읽을 수 있는 한 줄 문자열로. 직렬화 실패는 toString 으로.
|
|
363
|
+
return args
|
|
364
|
+
.map((a) => {
|
|
365
|
+
if (typeof a === "string") return a;
|
|
366
|
+
if (a instanceof Error) return `${a.name}: ${a.message}`;
|
|
367
|
+
try {
|
|
368
|
+
return JSON.stringify(a) ?? String(a);
|
|
369
|
+
} catch {
|
|
370
|
+
try {
|
|
371
|
+
return String(a);
|
|
372
|
+
} catch {
|
|
373
|
+
return "[unserializable]";
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
})
|
|
377
|
+
.join(" ");
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* 요청에서 **메서드와 URL만** 뽑는다.
|
|
382
|
+
*
|
|
383
|
+
* `init.headers` / `init.body` 는 읽지 않는다. Request 객체가 와도 `url`·`method` 만
|
|
384
|
+
* 본다 — 그래야 `Authorization` 헤더나 본문이 실수로도 버퍼에 닿지 않는다.
|
|
385
|
+
*/
|
|
386
|
+
function describeRequest(
|
|
387
|
+
input: unknown,
|
|
388
|
+
init?: { method?: string }
|
|
389
|
+
): { url: string; method: string } {
|
|
390
|
+
let url = "";
|
|
391
|
+
let method = "GET";
|
|
392
|
+
|
|
393
|
+
if (typeof input === "string") {
|
|
394
|
+
url = input;
|
|
395
|
+
} else if (input && typeof input === "object") {
|
|
396
|
+
const maybeUrl = (input as { url?: unknown }).url;
|
|
397
|
+
const maybeHref = (input as { href?: unknown }).href;
|
|
398
|
+
if (typeof maybeUrl === "string") url = maybeUrl;
|
|
399
|
+
else if (typeof maybeHref === "string") url = maybeHref;
|
|
400
|
+
else {
|
|
401
|
+
try {
|
|
402
|
+
url = String(input);
|
|
403
|
+
} catch {
|
|
404
|
+
url = "";
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
const maybeMethod = (input as { method?: unknown }).method;
|
|
408
|
+
if (typeof maybeMethod === "string") method = maybeMethod;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
if (init && typeof init.method === "string") method = init.method;
|
|
412
|
+
|
|
413
|
+
return { url: sanitizeUrl(url), method: method.toUpperCase() };
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** 값을 가려야 하는 쿼리 파라미터 이름(정규화 후 비교). */
|
|
417
|
+
const CREDENTIAL_PARAMS = new Set([
|
|
418
|
+
"accesskey",
|
|
419
|
+
"accesstoken",
|
|
420
|
+
"apikey",
|
|
421
|
+
"apisecret",
|
|
422
|
+
"auth",
|
|
423
|
+
"authorization",
|
|
424
|
+
"authtoken",
|
|
425
|
+
"clientsecret",
|
|
426
|
+
"code",
|
|
427
|
+
"credential",
|
|
428
|
+
"credentials",
|
|
429
|
+
"idtoken",
|
|
430
|
+
"jwt",
|
|
431
|
+
"key",
|
|
432
|
+
"password",
|
|
433
|
+
"passwd",
|
|
434
|
+
"privatekey",
|
|
435
|
+
"pwd",
|
|
436
|
+
"refreshtoken",
|
|
437
|
+
"secret",
|
|
438
|
+
"secretkey",
|
|
439
|
+
"session",
|
|
440
|
+
"sessionid",
|
|
441
|
+
"sessiontoken",
|
|
442
|
+
"sid",
|
|
443
|
+
"sig",
|
|
444
|
+
"signature",
|
|
445
|
+
"token",
|
|
446
|
+
]);
|
|
447
|
+
|
|
448
|
+
/** 가려진 값 표시. 파라미터가 "있었다"는 사실은 남기고 값만 지운다. */
|
|
449
|
+
export const REDACTED = "REDACTED";
|
|
450
|
+
|
|
451
|
+
function isCredentialParam(rawName: string): boolean {
|
|
452
|
+
const name = rawName.toLowerCase().replace(/[-_.]/g, "");
|
|
453
|
+
if (CREDENTIAL_PARAMS.has(name)) return true;
|
|
454
|
+
return (
|
|
455
|
+
name.endsWith("token") ||
|
|
456
|
+
name.endsWith("secret") ||
|
|
457
|
+
name.endsWith("password") ||
|
|
458
|
+
name.endsWith("apikey")
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* URL 에서 자격증명을 걷어낸다.
|
|
464
|
+
* - `#fragment` 는 통째로 버린다(진단 가치는 없고 토큰이 실리는 경우가 있다).
|
|
465
|
+
* - `scheme://user:pass@host` 의 userinfo 를 지운다.
|
|
466
|
+
* - 자격증명형 쿼리 파라미터의 **값만** `REDACTED` 로 바꾼다.
|
|
467
|
+
*/
|
|
468
|
+
export function sanitizeUrl(raw: string): string {
|
|
469
|
+
if (!raw) return "";
|
|
470
|
+
|
|
471
|
+
const hashAt = raw.indexOf("#");
|
|
472
|
+
const withoutHash = hashAt >= 0 ? raw.slice(0, hashAt) : raw;
|
|
473
|
+
|
|
474
|
+
const queryAt = withoutHash.indexOf("?");
|
|
475
|
+
const base = stripUserInfo(
|
|
476
|
+
queryAt >= 0 ? withoutHash.slice(0, queryAt) : withoutHash
|
|
477
|
+
);
|
|
478
|
+
if (queryAt < 0) return base;
|
|
479
|
+
|
|
480
|
+
const query = withoutHash.slice(queryAt + 1);
|
|
481
|
+
if (query.length === 0) return base;
|
|
482
|
+
|
|
483
|
+
const parts = query.split("&").map((pair) => {
|
|
484
|
+
const eq = pair.indexOf("=");
|
|
485
|
+
if (eq < 0) return pair;
|
|
486
|
+
const name = pair.slice(0, eq);
|
|
487
|
+
return isCredentialParam(name) ? `${name}=${REDACTED}` : pair;
|
|
488
|
+
});
|
|
489
|
+
return `${base}?${parts.join("&")}`;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function stripUserInfo(url: string): string {
|
|
493
|
+
const schemeAt = url.indexOf("://");
|
|
494
|
+
if (schemeAt < 0) return url;
|
|
495
|
+
const authorityStart = schemeAt + 3;
|
|
496
|
+
const pathAt = url.indexOf("/", authorityStart);
|
|
497
|
+
const authority = pathAt < 0 ? url.slice(authorityStart) : url.slice(authorityStart, pathAt);
|
|
498
|
+
const at = authority.lastIndexOf("@");
|
|
499
|
+
if (at < 0) return url;
|
|
500
|
+
const cleaned = authority.slice(at + 1);
|
|
501
|
+
return url.slice(0, authorityStart) + cleaned + (pathAt < 0 ? "" : url.slice(pathAt));
|
|
502
|
+
}
|
package/src/guest-id.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// 게스트 영구 식별자 — getUser prop이 없을 때 사용.
|
|
2
|
+
//
|
|
3
|
+
// 요구사항(TC3): getUser가 없으면 게스트로 취급하되, 식별자는 영구적이어야 한다.
|
|
4
|
+
// 즉 같은 저장소(localStorage/AsyncStorage)를 쓰는 한, 위젯을 다시 띄워도 같은 게스트 ID.
|
|
5
|
+
// 그래서 최초 1회 생성해 저장소에 넣고, 이후엔 그 값을 재사용한다.
|
|
6
|
+
|
|
7
|
+
import type { FeedbackStorage, FeedbackUser } from "./types.js";
|
|
8
|
+
import { uuidv4 } from "./uuid.js";
|
|
9
|
+
|
|
10
|
+
const GUEST_KEY = "feedback-kit:guest-id";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* 저장소에서 게스트 id를 찾아 반환하고, 없으면 새로 만들어 저장한다.
|
|
14
|
+
* 반환값은 사용자가 "이미 알고 있던" 식별자이므로 멱등(같은 저장소 → 같은 id).
|
|
15
|
+
*/
|
|
16
|
+
export async function getOrCreateGuestId(
|
|
17
|
+
storage: FeedbackStorage
|
|
18
|
+
): Promise<string> {
|
|
19
|
+
const existing = await storage.get(GUEST_KEY);
|
|
20
|
+
if (existing && existing.length > 0) {
|
|
21
|
+
return existing;
|
|
22
|
+
}
|
|
23
|
+
const id = `guest_${uuidv4()}`;
|
|
24
|
+
await storage.set(GUEST_KEY, id);
|
|
25
|
+
return id;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* 게스트 id로 FeedbackUser 객체를 만든다.
|
|
30
|
+
*
|
|
31
|
+
* `isGuest: true` 를 반드시 붙인다. 게스트를 `user: null` 로 보내면 받는 쪽에서
|
|
32
|
+
* "신원 수집이 안 된 제보"와 "게스트가 남긴 제보"를 구분할 수 없다.
|
|
33
|
+
*/
|
|
34
|
+
export function guestUser(id: string): FeedbackUser {
|
|
35
|
+
return { id, isGuest: true };
|
|
36
|
+
}
|