@solhun/feedback-kit-core 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/dist/index.cjs +2366 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +1234 -0
- package/dist/index.d.ts +1234 -0
- package/dist/index.js +2268 -0
- package/dist/index.js.map +1 -0
- package/package.json +35 -0
- package/src/adapters/adapters.test.ts +730 -0
- package/src/adapters/body.ts +280 -0
- package/src/adapters/index.ts +19 -0
- package/src/adapters/lasso.live.test.ts +156 -0
- package/src/adapters/lasso.ts +316 -0
- package/src/adapters/linear.ts +39 -0
- package/src/adapters/notion.ts +30 -0
- package/src/config.test.ts +284 -0
- package/src/config.ts +396 -0
- package/src/context.test.ts +427 -0
- package/src/context.ts +326 -0
- package/src/diagnostics.test.ts +382 -0
- package/src/diagnostics.ts +502 -0
- package/src/guest-id.ts +36 -0
- package/src/index.ts +179 -0
- package/src/keepalive.test.ts +72 -0
- package/src/keepalive.ts +37 -0
- package/src/queue.test.ts +848 -0
- package/src/queue.ts +546 -0
- package/src/report.ts +58 -0
- package/src/ring-buffer.test.ts +62 -0
- package/src/ring-buffer.ts +42 -0
- package/src/source-attr.test.ts +77 -0
- package/src/source-attr.ts +86 -0
- package/src/types.ts +320 -0
- package/src/uuid.test.ts +116 -0
- package/src/uuid.ts +54 -0
- package/src/widget/controller.ts +224 -0
- package/src/widget/focus.ts +66 -0
- package/src/widget/index.ts +59 -0
- package/src/widget/modal.test.ts +472 -0
- package/src/widget/modal.ts +526 -0
- package/src/widget/pin.ts +110 -0
- package/src/widget/screenshot.ts +109 -0
package/src/context.ts
ADDED
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
// 제보 컨텍스트 조립 — "이 제보가 어디서, 누가, 어떤 상태에서 났는가"를 모은다.
|
|
2
|
+
//
|
|
3
|
+
// 왜 report.ts 에서 분리했나: 컨텍스트 수집은 플랫폼 공급자(앱 정보·기기·디스플레이·
|
|
4
|
+
// 진단 버퍼)를 여러 개 받는다. 제보 조립(report.ts)은 그 결과를 쓰기만 한다.
|
|
5
|
+
//
|
|
6
|
+
// 설계 원칙:
|
|
7
|
+
// - 코어는 여전히 플랫폼 무관이다. `expo-constants` 같은 걸 import 하지 않고,
|
|
8
|
+
// **함수 공급자를 주입받아** 호출만 한다. 앱/웹 패키지가 그 공급자를 채운다.
|
|
9
|
+
// - 공급자가 던지거나 이상한 값을 줘도 제보를 잃지 않는다. 각 항목은 독립적으로
|
|
10
|
+
// 실패하고 그 자리만 null 이 된다.
|
|
11
|
+
// - "값이 없다"와 "수집이 배선되지 않았다"를 구분한다. 공급자가 아예 없으면
|
|
12
|
+
// 해당 블록(`native`/`web`/`diagnostics`)이 통째로 null 이고, 공급자는 있는데
|
|
13
|
+
// 값을 못 구했으면 블록은 있고 그 안의 필드가 null 이다.
|
|
14
|
+
|
|
15
|
+
import { getOrCreateGuestId } from "./guest-id.js";
|
|
16
|
+
import type {
|
|
17
|
+
AppInfo,
|
|
18
|
+
ContextUser,
|
|
19
|
+
DeviceInfo,
|
|
20
|
+
DiagnosticsPayload,
|
|
21
|
+
DisplayInfo,
|
|
22
|
+
FeedbackContext,
|
|
23
|
+
FeedbackStorage,
|
|
24
|
+
FeedbackUser,
|
|
25
|
+
GetCurrentScreenFn,
|
|
26
|
+
GetUserFn,
|
|
27
|
+
NativeContext,
|
|
28
|
+
SourceMapping,
|
|
29
|
+
WebContext,
|
|
30
|
+
} from "./types.js";
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 웹 공급자가 돌려주는 원자료.
|
|
34
|
+
* 모든 칸이 선택이다 — 못 구한 값은 빼면 되고, 코어가 null 로 채운다.
|
|
35
|
+
*/
|
|
36
|
+
export interface WebContextInput {
|
|
37
|
+
viewport?: {
|
|
38
|
+
width?: number | null;
|
|
39
|
+
height?: number | null;
|
|
40
|
+
devicePixelRatio?: number | null;
|
|
41
|
+
};
|
|
42
|
+
userAgent?: string | null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** 앱/웹 컨텍스트를 채우는 공급자 묶음. 전부 선택이다. */
|
|
46
|
+
export interface ContextProviders {
|
|
47
|
+
/** 현재 URL(웹) 또는 딥링크(앱). */
|
|
48
|
+
getUrl?: () => string | null;
|
|
49
|
+
/** 라우트 스택. 바깥(루트)부터 안쪽(현재 화면) 순서. */
|
|
50
|
+
getNavPath?: () => string[] | null;
|
|
51
|
+
/** 현재 화면 파라미터 원본. JSON 왕복으로 정제해서 싣는다. */
|
|
52
|
+
getRouteParams?: () => unknown;
|
|
53
|
+
/** 앱 빌드 정보(version/channel/updateId/runtimeVersion). */
|
|
54
|
+
getAppInfo?: () => Partial<AppInfo> | null;
|
|
55
|
+
/** 기기 정보(model/osName/osVersion/deviceType). */
|
|
56
|
+
getDevice?: () => Partial<DeviceInfo> | null;
|
|
57
|
+
/** 화면 크기·배율(width/height/pixelRatio/fontScale). */
|
|
58
|
+
getDisplay?: () => Partial<DisplayInfo> | null;
|
|
59
|
+
/** 웹 뷰포트·userAgent. */
|
|
60
|
+
getWebContext?: () => WebContextInput | null;
|
|
61
|
+
/**
|
|
62
|
+
* 진단 스냅샷 공급자.
|
|
63
|
+
* 주지 않으면 `diagnostics` 는 **빈 배열이 아니라 null** 이다
|
|
64
|
+
* (수집이 배선되지 않았다는 뜻과 "그 세션에 아무 일도 없었다"는 뜻을 구분한다).
|
|
65
|
+
*/
|
|
66
|
+
getDiagnostics?: () => DiagnosticsPayload | null;
|
|
67
|
+
/** 화면 이름 → 소스 파일 경로. 빌드 플러그인이 생성한다. 없으면 screenPath: null. */
|
|
68
|
+
screenSourceMap?: Record<string, string> | null;
|
|
69
|
+
/** 타임존 결정(테스트 주입용). 없으면 Intl 로 알아낸다. */
|
|
70
|
+
getTimezone?: () => string | null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface BuildContextOpts extends ContextProviders {
|
|
74
|
+
app: string;
|
|
75
|
+
sessionId: string;
|
|
76
|
+
storage: FeedbackStorage;
|
|
77
|
+
platform: FeedbackContext["platform"];
|
|
78
|
+
/** 제출 시점 사용자 결정. 없으면 게스트. */
|
|
79
|
+
getUser?: GetUserFn;
|
|
80
|
+
/** 현재 화면 이름. 없거나 null 반환 → screen: null. */
|
|
81
|
+
getCurrentScreen?: GetCurrentScreenFn;
|
|
82
|
+
/** 빌드 플러그인 매핑. 없으면 screenId/sourceFile 이 null. */
|
|
83
|
+
source?: SourceMapping;
|
|
84
|
+
/** 자유 메타. 기본 {}. */
|
|
85
|
+
extra?: Record<string, unknown>;
|
|
86
|
+
/** 시간 주입(테스트용). */
|
|
87
|
+
now?: () => number;
|
|
88
|
+
iso?: (n: number) => string;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* getUser prop을 **제출 시점에** 호출해 사용자를 결정한다(캐싱하지 않는다).
|
|
93
|
+
* - getUser 없음 → 게스트(기기에 영구 저장된 id).
|
|
94
|
+
* - getUser 는 있지만 빈 결과/예외 → 게스트 폴백(제보 자체는 잃지 않는다).
|
|
95
|
+
* - getUser 결과에 id 있음 → 그대로 사용.
|
|
96
|
+
*
|
|
97
|
+
* 게스트도 `isGuest: true` 로 **객체를 채운다**. 반환이 null 인 경우는 저장소 접근이
|
|
98
|
+
* 실패해 게스트 식별자조차 만들지 못한 최초 실행 순간뿐이다.
|
|
99
|
+
*/
|
|
100
|
+
export async function resolveContextUser(
|
|
101
|
+
getUser: GetUserFn | undefined,
|
|
102
|
+
storage: FeedbackStorage
|
|
103
|
+
): Promise<ContextUser | null> {
|
|
104
|
+
if (typeof getUser === "function") {
|
|
105
|
+
try {
|
|
106
|
+
const u = await Promise.resolve(getUser());
|
|
107
|
+
if (u && typeof u.id === "string" && u.id.length > 0) {
|
|
108
|
+
return normalizeUser(u);
|
|
109
|
+
}
|
|
110
|
+
} catch {
|
|
111
|
+
// getUser 가 던지면 게스트로 폴백한다.
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
try {
|
|
115
|
+
const id = await getOrCreateGuestId(storage);
|
|
116
|
+
return { id, email: null, isGuest: true };
|
|
117
|
+
} catch {
|
|
118
|
+
// 저장소를 읽지도 쓰지도 못한 상태 — 신원을 만들 수 없다.
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** 호스트가 준 FeedbackUser 를 제보에 실리는 ContextUser 로 정규화한다. */
|
|
124
|
+
export function normalizeUser(u: FeedbackUser): ContextUser {
|
|
125
|
+
const out: ContextUser = {
|
|
126
|
+
id: u.id,
|
|
127
|
+
email: typeof u.email === "string" ? u.email : null,
|
|
128
|
+
// isGuest 생략 = 로그인 사용자.
|
|
129
|
+
isGuest: u.isGuest === true,
|
|
130
|
+
};
|
|
131
|
+
if (typeof u.name === "string") out.name = u.name;
|
|
132
|
+
if (typeof u.role === "string") out.role = u.role;
|
|
133
|
+
return out;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** FeedbackReport.context 를 조립한다. 사용자·화면·기기·진단 수집을 모두 포함. */
|
|
137
|
+
export async function buildContext(
|
|
138
|
+
opts: BuildContextOpts
|
|
139
|
+
): Promise<FeedbackContext> {
|
|
140
|
+
const now = opts.now ?? ((): number => Date.now());
|
|
141
|
+
const iso = opts.iso ?? ((n: number): string => new Date(n).toISOString());
|
|
142
|
+
|
|
143
|
+
const user = await resolveContextUser(opts.getUser, opts.storage);
|
|
144
|
+
const screen = safeCall(opts.getCurrentScreen, null);
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
app: opts.app,
|
|
148
|
+
screen: typeof screen === "string" ? screen : null,
|
|
149
|
+
url: nullableString(safeCall(opts.getUrl, null)),
|
|
150
|
+
sessionId: opts.sessionId,
|
|
151
|
+
user,
|
|
152
|
+
source: opts.source ?? { screenId: null, sourceFile: null },
|
|
153
|
+
platform: opts.platform,
|
|
154
|
+
timezone: resolveTimezone(opts.getTimezone),
|
|
155
|
+
clientTimestamp: iso(now()),
|
|
156
|
+
native: buildNative(opts, typeof screen === "string" ? screen : null),
|
|
157
|
+
web: buildWeb(opts),
|
|
158
|
+
// 공급자가 없으면 null — 빈 배열을 상수로 보내지 않는다.
|
|
159
|
+
diagnostics: opts.getDiagnostics
|
|
160
|
+
? normalizeDiagnostics(safeCall(opts.getDiagnostics, null))
|
|
161
|
+
: null,
|
|
162
|
+
extra: opts.extra ?? {},
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
167
|
+
// 블록 조립
|
|
168
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
169
|
+
|
|
170
|
+
function hasNativeProvider(opts: BuildContextOpts): boolean {
|
|
171
|
+
return Boolean(
|
|
172
|
+
opts.getAppInfo ||
|
|
173
|
+
opts.getDevice ||
|
|
174
|
+
opts.getDisplay ||
|
|
175
|
+
opts.getNavPath ||
|
|
176
|
+
opts.getRouteParams ||
|
|
177
|
+
opts.screenSourceMap
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function buildNative(
|
|
182
|
+
opts: BuildContextOpts,
|
|
183
|
+
screen: string | null
|
|
184
|
+
): NativeContext | null {
|
|
185
|
+
if (opts.platform !== "native" || !hasNativeProvider(opts)) return null;
|
|
186
|
+
|
|
187
|
+
const navPathRaw = safeCall(opts.getNavPath, null);
|
|
188
|
+
const navPath = Array.isArray(navPathRaw)
|
|
189
|
+
? navPathRaw.filter((s): s is string => typeof s === "string")
|
|
190
|
+
: // 스택을 못 구했으면 최소한 현재 화면 한 칸이라도 남긴다.
|
|
191
|
+
screen
|
|
192
|
+
? [screen]
|
|
193
|
+
: [];
|
|
194
|
+
|
|
195
|
+
const app = safeCall(opts.getAppInfo, null) ?? {};
|
|
196
|
+
const device = safeCall(opts.getDevice, null) ?? {};
|
|
197
|
+
const display = safeCall(opts.getDisplay, null) ?? {};
|
|
198
|
+
|
|
199
|
+
return {
|
|
200
|
+
// 매핑이 없으면 여기만 null 이고 나머지 컨텍스트는 그대로 수집된다.
|
|
201
|
+
screenPath: lookupScreenPath(opts.screenSourceMap, screen),
|
|
202
|
+
navPath,
|
|
203
|
+
routeParams: toPlainRecord(safeCall(opts.getRouteParams, undefined)),
|
|
204
|
+
appInfo: {
|
|
205
|
+
version: nullableString(app.version),
|
|
206
|
+
channel: nullableString(app.channel),
|
|
207
|
+
updateId: nullableString(app.updateId),
|
|
208
|
+
runtimeVersion: nullableString(app.runtimeVersion),
|
|
209
|
+
},
|
|
210
|
+
device: {
|
|
211
|
+
model: nullableString(device.model),
|
|
212
|
+
osName: nullableString(device.osName),
|
|
213
|
+
osVersion: nullableString(device.osVersion),
|
|
214
|
+
deviceType: nullableString(device.deviceType),
|
|
215
|
+
},
|
|
216
|
+
display: {
|
|
217
|
+
width: nullableNumber(display.width),
|
|
218
|
+
height: nullableNumber(display.height),
|
|
219
|
+
pixelRatio: nullableNumber(display.pixelRatio),
|
|
220
|
+
fontScale: nullableNumber(display.fontScale),
|
|
221
|
+
},
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function buildWeb(opts: BuildContextOpts): WebContext | null {
|
|
226
|
+
if (opts.platform !== "web" || !opts.getWebContext) return null;
|
|
227
|
+
const w = safeCall(opts.getWebContext, null) ?? {};
|
|
228
|
+
const vp = w.viewport ?? {};
|
|
229
|
+
return {
|
|
230
|
+
viewport: {
|
|
231
|
+
width: nullableNumber(vp.width),
|
|
232
|
+
height: nullableNumber(vp.height),
|
|
233
|
+
devicePixelRatio: nullableNumber(vp.devicePixelRatio),
|
|
234
|
+
},
|
|
235
|
+
userAgent: nullableString(w.userAgent),
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function lookupScreenPath(
|
|
240
|
+
map: Record<string, string> | null | undefined,
|
|
241
|
+
screen: string | null
|
|
242
|
+
): string | null {
|
|
243
|
+
if (!map || !screen) return null;
|
|
244
|
+
const hit = map[screen];
|
|
245
|
+
return typeof hit === "string" && hit.length > 0 ? hit : null;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* 공급자가 준 스냅샷을 그대로 통과시킨다(배열만 확인).
|
|
250
|
+
* 여기서 배열을 새로 만들어 채우지 않는다 — 이 값은 수집기가 모은 결과여야 한다.
|
|
251
|
+
*/
|
|
252
|
+
function normalizeDiagnostics(
|
|
253
|
+
snap: DiagnosticsPayload | null
|
|
254
|
+
): DiagnosticsPayload | null {
|
|
255
|
+
if (!snap || !Array.isArray(snap.network) || !Array.isArray(snap.logs)) {
|
|
256
|
+
return null;
|
|
257
|
+
}
|
|
258
|
+
return snap;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
262
|
+
// 헬퍼
|
|
263
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
264
|
+
|
|
265
|
+
/** 공급자를 호출하되, 없거나 던지면 fallback 을 쓴다. 한 항목의 실패가 제보를 막지 않는다. */
|
|
266
|
+
function safeCall<T>(fn: (() => T) | undefined, fallback: T): T {
|
|
267
|
+
if (typeof fn !== "function") return fallback;
|
|
268
|
+
try {
|
|
269
|
+
return fn();
|
|
270
|
+
} catch {
|
|
271
|
+
return fallback;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function nullableString(v: unknown): string | null {
|
|
276
|
+
return typeof v === "string" && v.length > 0 ? v : null;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function nullableNumber(v: unknown): number | null {
|
|
280
|
+
return typeof v === "number" && Number.isFinite(v) ? v : null;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function resolveTimezone(getTimezone?: () => string | null): string | null {
|
|
284
|
+
const injected = safeCall(getTimezone, null);
|
|
285
|
+
if (typeof injected === "string" && injected.length > 0) return injected;
|
|
286
|
+
try {
|
|
287
|
+
// Intl 은 ES 표준이라 웹/RN(Hermes intl 빌드) 모두에서 쓸 수 있다.
|
|
288
|
+
const tz = new Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
289
|
+
return typeof tz === "string" && tz.length > 0 ? tz : null;
|
|
290
|
+
} catch {
|
|
291
|
+
return null;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* 라우트 파라미터를 JSON 왕복으로 정제한다.
|
|
297
|
+
* 함수는 버리고, 순환참조는 `[Circular]` 로 바꾼다 — 그대로 두면 제보 직렬화가 통째로
|
|
298
|
+
* 실패한다. (같은 객체가 형제로 두 번 나오면 두 번째도 [Circular] 로 보이는데,
|
|
299
|
+
* 안전한 쪽으로 과하게 판단하는 것이라 그대로 둔다.)
|
|
300
|
+
*/
|
|
301
|
+
function toPlainRecord(value: unknown): Record<string, unknown> | null {
|
|
302
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
303
|
+
const seen = new WeakSet<object>();
|
|
304
|
+
let json: string | undefined;
|
|
305
|
+
try {
|
|
306
|
+
json = JSON.stringify(value, (_key: string, v: unknown) => {
|
|
307
|
+
if (typeof v === "function") return undefined;
|
|
308
|
+
if (v && typeof v === "object") {
|
|
309
|
+
if (seen.has(v as object)) return "[Circular]";
|
|
310
|
+
seen.add(v as object);
|
|
311
|
+
}
|
|
312
|
+
return v;
|
|
313
|
+
});
|
|
314
|
+
} catch {
|
|
315
|
+
return null;
|
|
316
|
+
}
|
|
317
|
+
if (typeof json !== "string") return null;
|
|
318
|
+
try {
|
|
319
|
+
const parsed: unknown = JSON.parse(json);
|
|
320
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
|
321
|
+
? (parsed as Record<string, unknown>)
|
|
322
|
+
: null;
|
|
323
|
+
} catch {
|
|
324
|
+
return null;
|
|
325
|
+
}
|
|
326
|
+
}
|