@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/index.ts
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// @solhun/feedback-kit-core 공개 진입점.
|
|
2
|
+
//
|
|
3
|
+
// 코어는 플랫폼에 의존하지 않는다(DOM·React Native API를 참조하지 않는다).
|
|
4
|
+
// 웹/앱 패키지가 이 위에 각자의 UI를 얹고, 어댑터가 전송 대상을 결정한다.
|
|
5
|
+
|
|
6
|
+
export type {
|
|
7
|
+
AppInfo,
|
|
8
|
+
ContextUser,
|
|
9
|
+
DeviceInfo,
|
|
10
|
+
DiagLogEntry,
|
|
11
|
+
DiagNetworkEntry,
|
|
12
|
+
DiagRequestRef,
|
|
13
|
+
DiagnosticsExcludeMatcher,
|
|
14
|
+
DiagnosticsPayload,
|
|
15
|
+
DisplayInfo,
|
|
16
|
+
ElementInfo,
|
|
17
|
+
FeedbackAdapter,
|
|
18
|
+
FeedbackContext,
|
|
19
|
+
FeedbackKind,
|
|
20
|
+
FeedbackPin,
|
|
21
|
+
FeedbackPriority,
|
|
22
|
+
FeedbackReport,
|
|
23
|
+
FeedbackScreenshot,
|
|
24
|
+
FeedbackStorage,
|
|
25
|
+
FeedbackUser,
|
|
26
|
+
GetCurrentScreenFn,
|
|
27
|
+
GetUserFn,
|
|
28
|
+
NativeContext,
|
|
29
|
+
SourceMapping,
|
|
30
|
+
SubmitResult,
|
|
31
|
+
WebContext,
|
|
32
|
+
} from "./types.js";
|
|
33
|
+
|
|
34
|
+
export type {
|
|
35
|
+
BuildContextOpts,
|
|
36
|
+
ContextProviders,
|
|
37
|
+
WebContextInput,
|
|
38
|
+
} from "./context.js";
|
|
39
|
+
export { buildContext, normalizeUser, resolveContextUser } from "./context.js";
|
|
40
|
+
|
|
41
|
+
export type { BuildReportOpts, ReportParts } from "./report.js";
|
|
42
|
+
export { buildReport } from "./report.js";
|
|
43
|
+
|
|
44
|
+
export type {
|
|
45
|
+
FeedbackQueueOpts,
|
|
46
|
+
QueueStatus,
|
|
47
|
+
QueueStatusListener,
|
|
48
|
+
SubmitOutcome,
|
|
49
|
+
} from "./queue.js";
|
|
50
|
+
export {
|
|
51
|
+
BACKOFF_BASE_MS,
|
|
52
|
+
BACKOFF_MAX_MS,
|
|
53
|
+
defaultBackoff,
|
|
54
|
+
FeedbackQueue,
|
|
55
|
+
MAX_QUEUE_AGE_MS,
|
|
56
|
+
MAX_QUEUE_ITEMS,
|
|
57
|
+
RATE_LIMIT_MIN_DELAY_MS,
|
|
58
|
+
} from "./queue.js";
|
|
59
|
+
|
|
60
|
+
export {
|
|
61
|
+
byteLengthOf,
|
|
62
|
+
canUseKeepalive,
|
|
63
|
+
KEEPALIVE_BODY_LIMIT_BYTES,
|
|
64
|
+
} from "./keepalive.js";
|
|
65
|
+
|
|
66
|
+
export type { DiagnosticsInstallOpts } from "./diagnostics.js";
|
|
67
|
+
export {
|
|
68
|
+
DiagnosticsCollector,
|
|
69
|
+
LOG_BUFFER_LIMIT,
|
|
70
|
+
MAX_LOG_MESSAGE_CHARS,
|
|
71
|
+
NETWORK_BUFFER_LIMIT,
|
|
72
|
+
REDACTED,
|
|
73
|
+
sanitizeUrl,
|
|
74
|
+
sharedDiagnostics,
|
|
75
|
+
} from "./diagnostics.js";
|
|
76
|
+
export { RingBuffer } from "./ring-buffer.js";
|
|
77
|
+
|
|
78
|
+
export type {
|
|
79
|
+
AdapterName,
|
|
80
|
+
ConfigWarning,
|
|
81
|
+
ConfigWarningCode,
|
|
82
|
+
DiagnosticsSource,
|
|
83
|
+
FeedbackConfig,
|
|
84
|
+
ResolvedConfig,
|
|
85
|
+
Visibility,
|
|
86
|
+
VisibilityEnv,
|
|
87
|
+
VisibilityFn,
|
|
88
|
+
WidgetCorner,
|
|
89
|
+
WidgetPosition,
|
|
90
|
+
} from "./config.js";
|
|
91
|
+
export {
|
|
92
|
+
DEFAULT_ADAPTER,
|
|
93
|
+
DEFAULT_INTERNAL_ROLES,
|
|
94
|
+
DEFAULT_POSITION,
|
|
95
|
+
detectDevBuild,
|
|
96
|
+
diagnosticsProviderFor,
|
|
97
|
+
ENDPOINT_FROM_ADAPTER,
|
|
98
|
+
isInternalUser,
|
|
99
|
+
resolveConfig,
|
|
100
|
+
shouldShowWidget,
|
|
101
|
+
} from "./config.js";
|
|
102
|
+
|
|
103
|
+
export type { SourceLocation } from "./source-attr.js";
|
|
104
|
+
export { parseSourceAttr, SOURCE_ATTR, sourceFromElement } from "./source-attr.js";
|
|
105
|
+
|
|
106
|
+
// 위젯 헤드리스 계층. 화면 지도·리포트 모달·핀 지정·스크린샷 정책·포커스 순서.
|
|
107
|
+
export type {
|
|
108
|
+
CaptureWithinLimitOpts,
|
|
109
|
+
ModalQueueLike,
|
|
110
|
+
ModalSubmitStatus,
|
|
111
|
+
PixelPoint,
|
|
112
|
+
PixelSize,
|
|
113
|
+
ReportModalListener,
|
|
114
|
+
ReportModalOpts,
|
|
115
|
+
ReportModalState,
|
|
116
|
+
ScreenshotCapture,
|
|
117
|
+
ScreenshotOutcome,
|
|
118
|
+
ScreenshotReencode,
|
|
119
|
+
ScreenshotStatus,
|
|
120
|
+
WidgetControllerOpts,
|
|
121
|
+
WidgetListener,
|
|
122
|
+
WidgetPlatform,
|
|
123
|
+
WidgetScreen,
|
|
124
|
+
WidgetState,
|
|
125
|
+
} from "./widget/index.js";
|
|
126
|
+
export {
|
|
127
|
+
captureWithinLimit,
|
|
128
|
+
COMMENT_MAX_CHARS,
|
|
129
|
+
COMMENT_REQUIRED_MESSAGE,
|
|
130
|
+
COMMENT_TOO_LONG_MESSAGE,
|
|
131
|
+
denormalizePin,
|
|
132
|
+
FLOATING_BUTTON_ID,
|
|
133
|
+
FocusRing,
|
|
134
|
+
MODAL_ACTION_ATTACH,
|
|
135
|
+
MODAL_ACTION_CANCEL,
|
|
136
|
+
MODAL_ACTION_PICK,
|
|
137
|
+
MODAL_ACTION_PIN,
|
|
138
|
+
MODAL_ACTION_REMOVE_SCREENSHOT,
|
|
139
|
+
MODAL_ACTION_RETRY,
|
|
140
|
+
MODAL_ACTION_SEND,
|
|
141
|
+
MODAL_FIELD_COMMENT,
|
|
142
|
+
MODAL_FIELD_PRIORITY,
|
|
143
|
+
normalizePin,
|
|
144
|
+
PinController,
|
|
145
|
+
ReportModalController,
|
|
146
|
+
SCREENSHOT_FAILED_MESSAGE,
|
|
147
|
+
SCREENSHOT_MAX_BASE64_BYTES,
|
|
148
|
+
SCREENSHOT_QUALITY_STEPS,
|
|
149
|
+
screenshotBytes,
|
|
150
|
+
SUBMIT_DONE_MESSAGE,
|
|
151
|
+
SUBMIT_FAILED_MESSAGE,
|
|
152
|
+
SUBMIT_PENDING_MESSAGE,
|
|
153
|
+
WidgetController,
|
|
154
|
+
} from "./widget/index.js";
|
|
155
|
+
|
|
156
|
+
// 어댑터. 코어의 다른 모듈은 이 아래를 import 하지 않는다 — 의존 방향은
|
|
157
|
+
// "어댑터 → 코어" 한쪽뿐이고, 되짚어 참조하는 순간 어댑터 교체가 거짓이 된다.
|
|
158
|
+
export type {
|
|
159
|
+
LassoAdapterOpts,
|
|
160
|
+
LassoEnvelope,
|
|
161
|
+
LassoFetch,
|
|
162
|
+
LassoRequestInit,
|
|
163
|
+
LassoResponseLike,
|
|
164
|
+
LinearIssueInput,
|
|
165
|
+
NotionPageInput,
|
|
166
|
+
} from "./adapters/index.js";
|
|
167
|
+
export {
|
|
168
|
+
buildLassoEnvelope,
|
|
169
|
+
buildLinearIssue,
|
|
170
|
+
buildNotionPage,
|
|
171
|
+
buildTitle,
|
|
172
|
+
createLassoAdapter,
|
|
173
|
+
LASSO_DEFAULT_ENDPOINT,
|
|
174
|
+
renderReportBody,
|
|
175
|
+
TITLE_MAX_CHARS,
|
|
176
|
+
} from "./adapters/index.js";
|
|
177
|
+
|
|
178
|
+
export { getOrCreateGuestId, guestUser } from "./guest-id.js";
|
|
179
|
+
export { uuidv4 } from "./uuid.js";
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// keepalive 판단 유닛 테스트 (DP-252 TC6 — 이탈 대비의 경계 조건).
|
|
2
|
+
//
|
|
3
|
+
// 이 모듈이 틀리면 증상이 "가끔 전송이 통째로 실패"로 나타난다.
|
|
4
|
+
// keepalive 한도를 넘긴 fetch 는 요청조차 나가지 않고 TypeError 로 죽기 때문에,
|
|
5
|
+
// 경계값(64 KiB 정확히 / +1 바이트)을 테스트로 못 박아 둔다.
|
|
6
|
+
|
|
7
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
8
|
+
|
|
9
|
+
import { byteLengthOf, canUseKeepalive, KEEPALIVE_BODY_LIMIT_BYTES } from "./keepalive.js";
|
|
10
|
+
|
|
11
|
+
describe("canUseKeepalive", () => {
|
|
12
|
+
it("한도(64 KiB) 이하면 keepalive 를 쓴다", () => {
|
|
13
|
+
expect(KEEPALIVE_BODY_LIMIT_BYTES).toBe(65_536);
|
|
14
|
+
expect(canUseKeepalive(0)).toBe(true);
|
|
15
|
+
expect(canUseKeepalive(1_024)).toBe(true);
|
|
16
|
+
// 경계값 자체는 허용(표준의 quota 는 "초과 시 실패")
|
|
17
|
+
expect(canUseKeepalive(KEEPALIVE_BODY_LIMIT_BYTES)).toBe(true);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("한도를 1바이트라도 넘기면 keepalive 를 끈다", () => {
|
|
21
|
+
expect(canUseKeepalive(KEEPALIVE_BODY_LIMIT_BYTES + 1)).toBe(false);
|
|
22
|
+
// 스크린샷이 붙은 제보의 현실적인 크기대
|
|
23
|
+
expect(canUseKeepalive(2 * 1024 * 1024)).toBe(false);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("측정에 실패한 값(NaN·Infinity·음수)은 안전한 쪽(false)으로 떨어진다", () => {
|
|
27
|
+
expect(canUseKeepalive(Number.NaN)).toBe(false);
|
|
28
|
+
expect(canUseKeepalive(Number.POSITIVE_INFINITY)).toBe(false);
|
|
29
|
+
expect(canUseKeepalive(-1)).toBe(false);
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe("byteLengthOf", () => {
|
|
34
|
+
it("ASCII 는 길이와 바이트 수가 같다", () => {
|
|
35
|
+
expect(byteLengthOf("")).toBe(0);
|
|
36
|
+
expect(byteLengthOf("abc")).toBe(3);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("한글·이모지는 코드 유닛이 아니라 UTF-8 바이트로 센다", () => {
|
|
40
|
+
// "한" = 3바이트, "가나다" = 9바이트
|
|
41
|
+
expect(byteLengthOf("가나다")).toBe(9);
|
|
42
|
+
// 이모지는 서로게이트 쌍(코드 유닛 2개)이지만 UTF-8 로는 4바이트
|
|
43
|
+
expect("🙂".length).toBe(2);
|
|
44
|
+
expect(byteLengthOf("🙂")).toBe(4);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("한도 판정에 그대로 물린다 — 한글 본문이 길이로는 통과해도 바이트로는 막힌다", () => {
|
|
48
|
+
// 코드 유닛 30,000개(<65,536)지만 UTF-8 로는 90,000바이트(>65,536)
|
|
49
|
+
const body = "가".repeat(30_000);
|
|
50
|
+
expect(body.length).toBeLessThan(KEEPALIVE_BODY_LIMIT_BYTES);
|
|
51
|
+
expect(byteLengthOf(body)).toBe(90_000);
|
|
52
|
+
expect(canUseKeepalive(byteLengthOf(body))).toBe(false);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe("TextEncoder 가 없는 런타임(구형 RN 등)", () => {
|
|
56
|
+
const original = (globalThis as { TextEncoder?: unknown }).TextEncoder;
|
|
57
|
+
|
|
58
|
+
afterEach(() => {
|
|
59
|
+
(globalThis as { TextEncoder?: unknown }).TextEncoder = original;
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("폴백은 실제 바이트 수보다 작지 않다 — 한도를 넘겨 켜는 일이 없다", () => {
|
|
63
|
+
delete (globalThis as { TextEncoder?: unknown }).TextEncoder;
|
|
64
|
+
|
|
65
|
+
for (const body of ["abc", "가나다", "🙂", "mixed 한글 🙂 text"]) {
|
|
66
|
+
const encoded = new (original as new () => { encode(s: string): { length: number } })()
|
|
67
|
+
.encode(body).length;
|
|
68
|
+
expect(byteLengthOf(body)).toBeGreaterThanOrEqual(encoded);
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
});
|
package/src/keepalive.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// 이탈(탭 닫기 / 화면 전환) 중에도 전송을 마치기 위한 keepalive 판단.
|
|
2
|
+
//
|
|
3
|
+
// 왜 별도 모듈인가:
|
|
4
|
+
// `fetch(..., { keepalive: true })` 는 브라우저가 **요청 본문 64 KiB** 로 제한한다.
|
|
5
|
+
// 한도를 넘기면 요청 자체가 TypeError 로 즉시 실패한다 — 즉 "일단 keepalive 를 켜두면
|
|
6
|
+
// 안전하다"가 성립하지 않는다. 스크린샷이 붙은 제보는 수 MiB 라서 대부분 한도를 넘는다.
|
|
7
|
+
//
|
|
8
|
+
// 그래서 전송 계층은 본문 크기를 재고 이 함수로 갈라야 한다.
|
|
9
|
+
// - 한도 이하(대부분의 텍스트 제보): keepalive 로 보내 이탈해도 전송이 끝난다.
|
|
10
|
+
// - 한도 초과(스크린샷 동반): keepalive 없이 보낸다. 이탈로 중단되더라도 제보는 큐에
|
|
11
|
+
// 남아 있으므로(2xx + ok=true 전에는 제거하지 않는다) 다음 마운트에서 자동 재전송된다.
|
|
12
|
+
// 유실이 아니라 지연으로 떨어지는 것이 이 설계의 의도다.
|
|
13
|
+
|
|
14
|
+
/** `fetch` keepalive 요청 본문 한도(바이트). Fetch 표준의 inflight keepalive quota. */
|
|
15
|
+
export const KEEPALIVE_BODY_LIMIT_BYTES = 64 * 1024;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* 이 본문을 keepalive 로 보내도 되는지.
|
|
19
|
+
* @param bodyByteLength 직렬화된 요청 본문의 바이트 수.
|
|
20
|
+
*/
|
|
21
|
+
export function canUseKeepalive(bodyByteLength: number): boolean {
|
|
22
|
+
if (!Number.isFinite(bodyByteLength) || bodyByteLength < 0) return false;
|
|
23
|
+
return bodyByteLength <= KEEPALIVE_BODY_LIMIT_BYTES;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 문자열 본문의 UTF-8 바이트 수. `TextEncoder` 가 없으면(구형 RN 등) 보수적으로
|
|
28
|
+
* 근사한다 — 근사는 항상 실제보다 크거나 같게 잡아 한도를 넘겨 켜는 일이 없게 한다.
|
|
29
|
+
*/
|
|
30
|
+
export function byteLengthOf(body: string): number {
|
|
31
|
+
const g = globalThis as { TextEncoder?: new () => { encode(s: string): { length: number } } };
|
|
32
|
+
if (typeof g.TextEncoder === "function") {
|
|
33
|
+
return new g.TextEncoder().encode(body).length;
|
|
34
|
+
}
|
|
35
|
+
// 폴백: 코드 유닛당 최대 3바이트(서로게이트 쌍은 2유닛 → 4바이트라 여전히 상한).
|
|
36
|
+
return body.length * 3;
|
|
37
|
+
}
|