react-linear-feedback 0.2.0 → 0.4.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/README.md +151 -35
- package/dist/embed/index.d.ts +48 -0
- package/dist/embed/index.js +2742 -0
- package/dist/embed/linear-feedback.js +130 -0
- package/dist/react/index.cjs +144 -91
- package/dist/react/index.d.cts +7 -1
- package/dist/react/index.d.ts +7 -1
- package/dist/react/index.js +144 -91
- package/dist/server/index.cjs +94 -49
- package/dist/server/index.d.cts +32 -12
- package/dist/server/index.d.ts +32 -12
- package/dist/server/index.js +92 -49
- package/dist/vite/index.cjs +93 -28
- package/dist/vite/index.d.cts +19 -1
- package/dist/vite/index.d.ts +19 -1
- package/dist/vite/index.js +93 -28
- package/package.json +18 -4
package/dist/react/index.d.ts
CHANGED
|
@@ -84,8 +84,12 @@ type FeedbackWidgetProps = {
|
|
|
84
84
|
nameStorageKey?: string;
|
|
85
85
|
/** Label on the floating button (default "Give feedback"). */
|
|
86
86
|
fabLabel?: string;
|
|
87
|
+
/** Stacking context for the widget's layers — sugar for the `--lfb-z` CSS variable. */
|
|
88
|
+
zIndex?: number;
|
|
89
|
+
/** Extra headers sent with the submission (e.g. x-feedback-token for `headerGate`). */
|
|
90
|
+
requestHeaders?: Record<string, string>;
|
|
87
91
|
};
|
|
88
|
-
declare function FeedbackWidget({ endpoint, brandColor, position, types, nameRequired, nameStorageKey, fabLabel, }: FeedbackWidgetProps): react.JSX.Element;
|
|
92
|
+
declare function FeedbackWidget({ endpoint, brandColor, position, types, nameRequired, nameStorageKey, fabLabel, zIndex, requestHeaders, }: FeedbackWidgetProps): react.JSX.Element;
|
|
89
93
|
|
|
90
94
|
type FeedbackGateProps = FeedbackWidgetProps & {
|
|
91
95
|
/** URL param that toggles the tool: `?feedback` / `?feedback=1` on, `?feedback=0` off. */
|
|
@@ -105,6 +109,8 @@ declare function FeedbackGate({ urlParam, cookieName, cookieValue, cookieMaxAgeS
|
|
|
105
109
|
type SubmitOptions = {
|
|
106
110
|
/** Endpoint that runs the server handler (default "/api/feedback"). */
|
|
107
111
|
endpoint?: string;
|
|
112
|
+
/** Extra request headers (e.g. the embed's x-feedback-token for `headerGate`). */
|
|
113
|
+
headers?: Record<string, string>;
|
|
108
114
|
};
|
|
109
115
|
declare function submitFeedback(annotation: FeedbackAnnotation, opts?: SubmitOptions): Promise<FeedbackResult | null>;
|
|
110
116
|
|
package/dist/react/index.js
CHANGED
|
@@ -57,7 +57,7 @@ async function submitFeedback(annotation, opts = {}) {
|
|
|
57
57
|
try {
|
|
58
58
|
const res = await fetch(endpoint, {
|
|
59
59
|
method: "POST",
|
|
60
|
-
headers: { "Content-Type": "application/json" },
|
|
60
|
+
headers: { "Content-Type": "application/json", ...opts.headers },
|
|
61
61
|
body: JSON.stringify(payload)
|
|
62
62
|
});
|
|
63
63
|
if (!res.ok) {
|
|
@@ -193,6 +193,8 @@ var CSS2 = `
|
|
|
193
193
|
|
|
194
194
|
.lfb-rect { position: absolute; border: 2px solid var(--lfb-rect); background: rgba(255,0,85,0.12); border-radius: 3px; pointer-events: none; }
|
|
195
195
|
|
|
196
|
+
.lfb-shield { position: fixed; inset: 0; pointer-events: auto; cursor: default; }
|
|
197
|
+
|
|
196
198
|
.lfb-anchor { position: absolute; pointer-events: auto; }
|
|
197
199
|
|
|
198
200
|
.lfb-card {
|
|
@@ -207,6 +209,7 @@ var CSS2 = `
|
|
|
207
209
|
line-height: 1.4;
|
|
208
210
|
}
|
|
209
211
|
.lfb-composer { position: absolute; top: 100%; left: 0; margin-top: 8px; width: 320px; max-width: calc(100vw - 32px); }
|
|
212
|
+
.lfb-composer--above { top: auto; bottom: 100%; margin-top: 0; margin-bottom: 8px; }
|
|
210
213
|
|
|
211
214
|
.lfb-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
|
212
215
|
.lfb-eyebrow { font-size: 12px; font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase; color: var(--lfb-fg-tertiary); }
|
|
@@ -290,6 +293,8 @@ function ensureStyles() {
|
|
|
290
293
|
}
|
|
291
294
|
var MIN_DRAG = 12;
|
|
292
295
|
var DEFAULT_BOX = { width: 220, height: 130 };
|
|
296
|
+
var COMPOSER_WIDTH = 320;
|
|
297
|
+
var COMPOSER_EST_HEIGHT = 320;
|
|
293
298
|
var DEFAULT_TYPES = [
|
|
294
299
|
{ id: "bug", label: "Bug", color: "#ef4444", icon: "bug" },
|
|
295
300
|
{ id: "improvement", label: "Improvement", color: "#22c55e", icon: "improvement" }
|
|
@@ -301,7 +306,9 @@ function FeedbackWidget({
|
|
|
301
306
|
types = DEFAULT_TYPES,
|
|
302
307
|
nameRequired = true,
|
|
303
308
|
nameStorageKey = "wh_feedback_name",
|
|
304
|
-
fabLabel = "Give feedback"
|
|
309
|
+
fabLabel = "Give feedback",
|
|
310
|
+
zIndex,
|
|
311
|
+
requestHeaders
|
|
305
312
|
}) {
|
|
306
313
|
const isEdge = position === "right" || position === "left";
|
|
307
314
|
const [mode, setMode] = useState({ kind: "idle" });
|
|
@@ -316,6 +323,11 @@ function FeedbackWidget({
|
|
|
316
323
|
const [drag, setDrag] = useState(null);
|
|
317
324
|
const textareaRef = useRef(null);
|
|
318
325
|
const nameInputRef = useRef(null);
|
|
326
|
+
const nameEditRef = useRef(null);
|
|
327
|
+
const submittingRef = useRef(false);
|
|
328
|
+
submittingRef.current = submitting;
|
|
329
|
+
const editingNameRef = useRef(false);
|
|
330
|
+
editingNameRef.current = editingName;
|
|
319
331
|
useEffect(() => {
|
|
320
332
|
ensureStyles();
|
|
321
333
|
try {
|
|
@@ -325,12 +337,19 @@ function FeedbackWidget({
|
|
|
325
337
|
}
|
|
326
338
|
}, [nameStorageKey]);
|
|
327
339
|
useEffect(() => {
|
|
328
|
-
if (mode.kind
|
|
340
|
+
if (mode.kind === "idle") return;
|
|
329
341
|
const onKey = (e) => {
|
|
330
|
-
if (e.key
|
|
331
|
-
|
|
332
|
-
|
|
342
|
+
if (e.key !== "Escape" || submittingRef.current) return;
|
|
343
|
+
if (editingNameRef.current) {
|
|
344
|
+
setEditingName(false);
|
|
345
|
+
return;
|
|
333
346
|
}
|
|
347
|
+
setMode({ kind: "idle" });
|
|
348
|
+
setDrag(null);
|
|
349
|
+
setText("");
|
|
350
|
+
setError(null);
|
|
351
|
+
setSubmitting(false);
|
|
352
|
+
setEditingName(false);
|
|
334
353
|
};
|
|
335
354
|
document.addEventListener("keydown", onKey);
|
|
336
355
|
return () => document.removeEventListener("keydown", onKey);
|
|
@@ -345,6 +364,22 @@ function FeedbackWidget({
|
|
|
345
364
|
return () => window.clearTimeout(t);
|
|
346
365
|
}
|
|
347
366
|
}, [mode.kind]);
|
|
367
|
+
useEffect(() => {
|
|
368
|
+
if (editingName) nameEditRef.current?.focus();
|
|
369
|
+
}, [editingName]);
|
|
370
|
+
useEffect(() => {
|
|
371
|
+
if (mode.kind !== "drawing" && mode.kind !== "composing") return;
|
|
372
|
+
const el = document.documentElement;
|
|
373
|
+
const prevOverflow = el.style.overflow;
|
|
374
|
+
const prevPaddingRight = el.style.paddingRight;
|
|
375
|
+
const scrollbar = window.innerWidth - el.clientWidth;
|
|
376
|
+
el.style.overflow = "hidden";
|
|
377
|
+
if (scrollbar > 0) el.style.paddingRight = `${scrollbar}px`;
|
|
378
|
+
return () => {
|
|
379
|
+
el.style.overflow = prevOverflow;
|
|
380
|
+
el.style.paddingRight = prevPaddingRight;
|
|
381
|
+
};
|
|
382
|
+
}, [mode.kind]);
|
|
348
383
|
const persistName = (value) => {
|
|
349
384
|
try {
|
|
350
385
|
window.localStorage.setItem(nameStorageKey, value);
|
|
@@ -398,12 +433,17 @@ function FeedbackWidget({
|
|
|
398
433
|
vy = end.y - vh / 2;
|
|
399
434
|
}
|
|
400
435
|
const rect = { x: Math.max(0, vx + window.scrollX), y: Math.max(0, vy + window.scrollY), width: vw, height: vh };
|
|
436
|
+
const fitsBelow = vy + vh + COMPOSER_EST_HEIGHT <= window.innerHeight;
|
|
437
|
+
const fitsAbove = vy >= COMPOSER_EST_HEIGHT;
|
|
438
|
+
const placement = !fitsBelow && fitsAbove ? "above" : "below";
|
|
439
|
+
const maxAnchorX = window.scrollX + Math.max(16, window.innerWidth - COMPOSER_WIDTH - 16);
|
|
440
|
+
const anchorX = Math.min(rect.x, maxAnchorX);
|
|
401
441
|
setDrag(null);
|
|
402
442
|
setText("");
|
|
403
443
|
setIssueType(types[0]?.id ?? "bug");
|
|
404
444
|
setError(null);
|
|
405
445
|
setEditingName(false);
|
|
406
|
-
setMode({ kind: "composing", rect });
|
|
446
|
+
setMode({ kind: "composing", rect, placement, anchorX });
|
|
407
447
|
};
|
|
408
448
|
const cancelComposer = () => {
|
|
409
449
|
setMode({ kind: "idle" });
|
|
@@ -424,7 +464,7 @@ function FeedbackWidget({
|
|
|
424
464
|
if (trimmedName) persistName(trimmedName);
|
|
425
465
|
const res = await submitFeedback(
|
|
426
466
|
{ rect: mode.rect, note: trimmedNote, type: issueType, typeLabel: selected?.label, name: trimmedName || void 0 },
|
|
427
|
-
{ endpoint }
|
|
467
|
+
{ endpoint, headers: requestHeaders }
|
|
428
468
|
);
|
|
429
469
|
setSubmitting(false);
|
|
430
470
|
if (res) {
|
|
@@ -436,7 +476,11 @@ function FeedbackWidget({
|
|
|
436
476
|
setError("Couldn't send \u2014 please try again.");
|
|
437
477
|
}
|
|
438
478
|
};
|
|
439
|
-
const rootStyle = {
|
|
479
|
+
const rootStyle = {
|
|
480
|
+
display: "contents",
|
|
481
|
+
...brandColor ? { "--lfb-brand": brandColor } : {},
|
|
482
|
+
...zIndex != null ? { "--lfb-z": String(zIndex) } : {}
|
|
483
|
+
};
|
|
440
484
|
const overlayProps = { [FEEDBACK_OVERLAY_ATTR]: "" };
|
|
441
485
|
const live = drag ? {
|
|
442
486
|
left: Math.min(drag.start.x, drag.current.x),
|
|
@@ -446,93 +490,102 @@ function FeedbackWidget({
|
|
|
446
490
|
} : null;
|
|
447
491
|
return /* @__PURE__ */ jsxs("div", { className: "lfb-root", style: rootStyle, children: [
|
|
448
492
|
/* @__PURE__ */ jsx("div", { ...overlayProps, className: "lfb-doc-layer", children: mode.kind === "composing" && /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
493
|
+
/* @__PURE__ */ jsx("div", { className: "lfb-shield", "aria-hidden": "true" }),
|
|
449
494
|
/* @__PURE__ */ jsx("div", { className: "lfb-rect", style: { left: mode.rect.x, top: mode.rect.y, width: mode.rect.width, height: mode.rect.height } }),
|
|
450
|
-
/* @__PURE__ */ jsx(
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
495
|
+
/* @__PURE__ */ jsx(
|
|
496
|
+
"div",
|
|
497
|
+
{
|
|
498
|
+
className: "lfb-anchor",
|
|
499
|
+
style: { left: mode.anchorX, top: mode.placement === "below" ? mode.rect.y + mode.rect.height : mode.rect.y },
|
|
500
|
+
children: /* @__PURE__ */ jsxs("form", { className: `lfb-card lfb-composer${mode.placement === "above" ? " lfb-composer--above" : ""}`, onSubmit: handleSubmit, children: [
|
|
501
|
+
/* @__PURE__ */ jsxs("div", { className: "lfb-row", children: [
|
|
502
|
+
/* @__PURE__ */ jsx("span", { className: "lfb-eyebrow", children: "New feedback" }),
|
|
503
|
+
/* @__PURE__ */ jsx("button", { type: "button", className: "lfb-iconbtn", "aria-label": "Cancel", onClick: cancelComposer, children: /* @__PURE__ */ jsx(XIcon, {}) })
|
|
504
|
+
] }),
|
|
505
|
+
/* @__PURE__ */ jsxs("div", { style: { marginTop: 12 }, children: [
|
|
506
|
+
/* @__PURE__ */ jsx("span", { className: "lfb-field-label", children: "Issue type" }),
|
|
507
|
+
/* @__PURE__ */ jsx("div", { className: "lfb-types", children: types.map((t) => {
|
|
508
|
+
const Icon = TYPE_ICONS[t.icon ?? "dot"];
|
|
509
|
+
return /* @__PURE__ */ jsxs(
|
|
510
|
+
"button",
|
|
511
|
+
{
|
|
512
|
+
type: "button",
|
|
513
|
+
className: "lfb-type",
|
|
514
|
+
"aria-pressed": issueType === t.id,
|
|
515
|
+
onClick: () => setIssueType(t.id),
|
|
516
|
+
children: [
|
|
517
|
+
/* @__PURE__ */ jsx("span", { className: "lfb-swatch", style: { background: t.color }, children: /* @__PURE__ */ jsx(Icon, { size: 14 }) }),
|
|
518
|
+
t.label
|
|
519
|
+
]
|
|
520
|
+
},
|
|
521
|
+
t.id
|
|
522
|
+
);
|
|
523
|
+
}) })
|
|
524
|
+
] }),
|
|
525
|
+
/* @__PURE__ */ jsx(
|
|
526
|
+
"textarea",
|
|
461
527
|
{
|
|
462
|
-
|
|
463
|
-
className: "lfb-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
{
|
|
478
|
-
ref: textareaRef,
|
|
479
|
-
className: "lfb-textarea",
|
|
480
|
-
placeholder: "What's on your mind?",
|
|
481
|
-
value: text,
|
|
482
|
-
onChange: (e) => setText(e.target.value),
|
|
483
|
-
maxLength: 5e3,
|
|
484
|
-
required: true,
|
|
485
|
-
disabled: submitting,
|
|
486
|
-
rows: 3,
|
|
487
|
-
onKeyDown: (e) => {
|
|
488
|
-
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
|
489
|
-
e.preventDefault();
|
|
490
|
-
handleSubmit(e);
|
|
491
|
-
}
|
|
492
|
-
}
|
|
493
|
-
}
|
|
494
|
-
),
|
|
495
|
-
editingName ? /* @__PURE__ */ jsx(
|
|
496
|
-
"input",
|
|
497
|
-
{
|
|
498
|
-
className: "lfb-input",
|
|
499
|
-
type: "text",
|
|
500
|
-
value: name,
|
|
501
|
-
autoFocus: true,
|
|
502
|
-
maxLength: 80,
|
|
503
|
-
onChange: (e) => setName(e.target.value),
|
|
504
|
-
onBlur: () => {
|
|
505
|
-
const t = name.trim();
|
|
506
|
-
if (t) persistName(t);
|
|
507
|
-
setEditingName(false);
|
|
508
|
-
},
|
|
509
|
-
onKeyDown: (e) => {
|
|
510
|
-
if (e.key === "Enter") {
|
|
511
|
-
e.preventDefault();
|
|
512
|
-
e.target.blur();
|
|
528
|
+
ref: textareaRef,
|
|
529
|
+
className: "lfb-textarea",
|
|
530
|
+
placeholder: "What's on your mind?",
|
|
531
|
+
value: text,
|
|
532
|
+
onChange: (e) => setText(e.target.value),
|
|
533
|
+
maxLength: 5e3,
|
|
534
|
+
required: true,
|
|
535
|
+
disabled: submitting,
|
|
536
|
+
rows: 3,
|
|
537
|
+
onKeyDown: (e) => {
|
|
538
|
+
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
|
539
|
+
e.preventDefault();
|
|
540
|
+
handleSubmit(e);
|
|
541
|
+
}
|
|
542
|
+
}
|
|
513
543
|
}
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
544
|
+
),
|
|
545
|
+
editingName ? /* @__PURE__ */ jsx(
|
|
546
|
+
"input",
|
|
547
|
+
{
|
|
548
|
+
ref: nameEditRef,
|
|
549
|
+
className: "lfb-input",
|
|
550
|
+
type: "text",
|
|
551
|
+
value: name,
|
|
552
|
+
maxLength: 80,
|
|
553
|
+
onChange: (e) => setName(e.target.value),
|
|
554
|
+
onBlur: () => {
|
|
555
|
+
const t = name.trim();
|
|
556
|
+
if (t) persistName(t);
|
|
557
|
+
setEditingName(false);
|
|
558
|
+
},
|
|
559
|
+
onKeyDown: (e) => {
|
|
560
|
+
if (e.key === "Enter") {
|
|
561
|
+
e.preventDefault();
|
|
562
|
+
e.target.blur();
|
|
563
|
+
}
|
|
564
|
+
if (e.key === "Escape") {
|
|
565
|
+
e.preventDefault();
|
|
566
|
+
e.stopPropagation();
|
|
567
|
+
setEditingName(false);
|
|
568
|
+
}
|
|
569
|
+
}
|
|
517
570
|
}
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
571
|
+
) : /* @__PURE__ */ jsxs("div", { className: "lfb-namerow", children: [
|
|
572
|
+
/* @__PURE__ */ jsxs("span", { children: [
|
|
573
|
+
"Posting as ",
|
|
574
|
+
/* @__PURE__ */ jsx("span", { className: "lfb-name", children: name || "anonymous" })
|
|
575
|
+
] }),
|
|
576
|
+
/* @__PURE__ */ jsxs("button", { type: "button", className: "lfb-link", onClick: () => setEditingName(true), children: [
|
|
577
|
+
/* @__PURE__ */ jsx(EditIcon, { size: 12 }),
|
|
578
|
+
"change"
|
|
579
|
+
] })
|
|
580
|
+
] }),
|
|
581
|
+
error && /* @__PURE__ */ jsx("p", { className: "lfb-error", children: error }),
|
|
582
|
+
/* @__PURE__ */ jsxs("div", { className: "lfb-actions", children: [
|
|
583
|
+
/* @__PURE__ */ jsx("button", { type: "button", className: "lfb-btn lfb-btn-ghost", onClick: cancelComposer, disabled: submitting, children: "Cancel" }),
|
|
584
|
+
/* @__PURE__ */ jsx("button", { type: "submit", className: "lfb-btn lfb-btn-primary", disabled: submitting || !text.trim(), children: submitting ? "Sending\u2026" : "Send to Linear" })
|
|
585
|
+
] })
|
|
528
586
|
] })
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
/* @__PURE__ */ jsxs("div", { className: "lfb-actions", children: [
|
|
532
|
-
/* @__PURE__ */ jsx("button", { type: "button", className: "lfb-btn lfb-btn-ghost", onClick: cancelComposer, disabled: submitting, children: "Cancel" }),
|
|
533
|
-
/* @__PURE__ */ jsx("button", { type: "submit", className: "lfb-btn lfb-btn-primary", disabled: submitting || !text.trim(), children: submitting ? "Sending\u2026" : "Send to Linear" })
|
|
534
|
-
] })
|
|
535
|
-
] }) })
|
|
587
|
+
}
|
|
588
|
+
)
|
|
536
589
|
] }) }),
|
|
537
590
|
/* @__PURE__ */ jsxs("div", { ...overlayProps, className: "lfb-fixed-layer", children: [
|
|
538
591
|
mode.kind === "drawing" && /* @__PURE__ */ jsxs("div", { className: "lfb-draw", role: "presentation", onMouseDown: onDrawMouseDown, onMouseMove: onDrawMouseMove, onMouseUp: onDrawMouseUp, children: [
|
package/dist/server/index.cjs
CHANGED
|
@@ -4,6 +4,12 @@ var sdk = require('@linear/sdk');
|
|
|
4
4
|
|
|
5
5
|
// src/server/core.ts
|
|
6
6
|
var MAX_NOTE = 5e3;
|
|
7
|
+
var DEFAULT_LABEL_TTL_MS = 10 * 60 * 1e3;
|
|
8
|
+
var MISS_TTL_MS = 60 * 1e3;
|
|
9
|
+
var labelCache = /* @__PURE__ */ new Map();
|
|
10
|
+
function labelCacheKey(teamId, name) {
|
|
11
|
+
return `${teamId}:${name.toLowerCase()}`;
|
|
12
|
+
}
|
|
7
13
|
async function createFeedbackIssue(payload, config) {
|
|
8
14
|
const { apiKey, teamId } = config;
|
|
9
15
|
if (!apiKey || !teamId) throw new Error("not_configured");
|
|
@@ -36,7 +42,8 @@ async function createFeedbackIssue(payload, config) {
|
|
|
36
42
|
].filter(Boolean).join("\n");
|
|
37
43
|
const title = `${typeLabel}: ${note.slice(0, 60)}${note.length > 60 ? "\u2026" : ""}`;
|
|
38
44
|
const labelName = config.labels?.[annotation.type] ?? annotation.type;
|
|
39
|
-
const
|
|
45
|
+
const ttl = config.labelCacheTtlMs ?? DEFAULT_LABEL_TTL_MS;
|
|
46
|
+
const labelId = labelName ? await resolveLabelIdCached(linear, labelName, teamId, ttl) : null;
|
|
40
47
|
if (labelName && !labelId) console.warn(`[feedback] label "${labelName}" not found \u2014 creating issue without it`);
|
|
41
48
|
const create = async (ids) => {
|
|
42
49
|
const created = await linear.createIssue({ teamId, title, description, labelIds: ids.length ? ids : void 0 });
|
|
@@ -44,6 +51,7 @@ async function createFeedbackIssue(payload, config) {
|
|
|
44
51
|
};
|
|
45
52
|
const issue = await create(labelId ? [labelId] : []).catch((err) => {
|
|
46
53
|
if (!labelId) throw err;
|
|
54
|
+
if (labelName) labelCache.delete(labelCacheKey(teamId, labelName));
|
|
47
55
|
console.warn("[feedback] create failed with label, retrying without it", err);
|
|
48
56
|
return create([]);
|
|
49
57
|
});
|
|
@@ -52,6 +60,15 @@ async function createFeedbackIssue(payload, config) {
|
|
|
52
60
|
function capitalize(s) {
|
|
53
61
|
return s.charAt(0).toUpperCase() + s.slice(1);
|
|
54
62
|
}
|
|
63
|
+
async function resolveLabelIdCached(linear, name, teamId, ttlMs) {
|
|
64
|
+
if (ttlMs <= 0) return resolveLabelId(linear, name, teamId);
|
|
65
|
+
const key = labelCacheKey(teamId, name);
|
|
66
|
+
const hit = labelCache.get(key);
|
|
67
|
+
if (hit && hit.expires > Date.now()) return hit.id;
|
|
68
|
+
const id = await resolveLabelId(linear, name, teamId);
|
|
69
|
+
labelCache.set(key, { id, expires: Date.now() + (id ? ttlMs : Math.min(ttlMs, MISS_TTL_MS)) });
|
|
70
|
+
return id;
|
|
71
|
+
}
|
|
55
72
|
async function resolveLabelId(linear, name, teamId) {
|
|
56
73
|
try {
|
|
57
74
|
const { nodes } = await linear.issueLabels({ filter: { name: { eqIgnoreCase: name } }, first: 50 });
|
|
@@ -90,32 +107,51 @@ async function uploadScreenshot(linear, dataUrl) {
|
|
|
90
107
|
return upload.uploadFile.assetUrl;
|
|
91
108
|
}
|
|
92
109
|
|
|
93
|
-
// src/server/
|
|
94
|
-
var BAD_REQUEST = /* @__PURE__ */ new Set(["note_required", "note_too_long", "bad_type", "not_configured"]);
|
|
95
|
-
function json(body, status) {
|
|
96
|
-
return new Response(JSON.stringify(body), {
|
|
110
|
+
// src/server/web.ts
|
|
111
|
+
var BAD_REQUEST = /* @__PURE__ */ new Set(["note_required", "note_too_long", "bad_type", "bad_json", "not_configured"]);
|
|
112
|
+
function json(body, status, headers = {}) {
|
|
113
|
+
return new Response(JSON.stringify(body), {
|
|
114
|
+
status,
|
|
115
|
+
headers: { "content-type": "application/json", ...headers }
|
|
116
|
+
});
|
|
97
117
|
}
|
|
98
|
-
function
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
118
|
+
function corsHeaders(req, allowed) {
|
|
119
|
+
const origin = req.headers.get("origin");
|
|
120
|
+
if (!origin || allowed.length === 0) return {};
|
|
121
|
+
if (!allowed.includes("*") && !allowed.includes(origin)) return {};
|
|
122
|
+
return {
|
|
123
|
+
"access-control-allow-origin": allowed.includes("*") ? "*" : origin,
|
|
124
|
+
vary: "Origin",
|
|
125
|
+
"access-control-allow-methods": "POST, OPTIONS",
|
|
126
|
+
"access-control-allow-headers": "content-type, x-feedback-token",
|
|
127
|
+
"access-control-max-age": "86400"
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
function createWebHandler(config) {
|
|
131
|
+
const allowed = config.allowedOrigins ?? (config.allowedOrigin ? [config.allowedOrigin] : []);
|
|
132
|
+
if (!config.apiKey || !config.teamId)
|
|
133
|
+
console.warn("[feedback] LINEAR apiKey/teamId missing \u2014 submissions will fail with 500 not_configured");
|
|
134
|
+
return async function handler(req) {
|
|
135
|
+
const cors = corsHeaders(req, allowed);
|
|
136
|
+
const origin = req.headers.get("origin");
|
|
137
|
+
if (origin && allowed.length > 0 && !allowed.includes("*") && !allowed.includes(origin))
|
|
138
|
+
return json({ error: "forbidden_origin" }, 403);
|
|
139
|
+
if (req.method === "OPTIONS") return new Response(null, { status: 204, headers: cors });
|
|
140
|
+
if (config.authorize && !await config.authorize(req)) return json({ error: "unauthorized" }, 404, cors);
|
|
105
141
|
let payload;
|
|
106
142
|
try {
|
|
107
143
|
payload = await req.json();
|
|
108
144
|
} catch {
|
|
109
|
-
return json({ error: "bad_json" }, 400);
|
|
145
|
+
return json({ error: "bad_json" }, 400, cors);
|
|
110
146
|
}
|
|
111
147
|
try {
|
|
112
148
|
const issue = await createFeedbackIssue(payload, config);
|
|
113
|
-
return json({ ok: true, ...issue }, 200);
|
|
149
|
+
return json({ ok: true, ...issue }, 200, cors);
|
|
114
150
|
} catch (err) {
|
|
115
151
|
const message = err instanceof Error ? err.message : String(err);
|
|
116
|
-
if (BAD_REQUEST.has(message)) return json({ error: message }, message === "not_configured" ? 500 : 400);
|
|
152
|
+
if (BAD_REQUEST.has(message)) return json({ error: message }, message === "not_configured" ? 500 : 400, cors);
|
|
117
153
|
console.error("[feedback] issue create failed", err);
|
|
118
|
-
return json({ error: "issue_create_failed", message }, 502);
|
|
154
|
+
return json({ error: "issue_create_failed", message }, 502, cors);
|
|
119
155
|
}
|
|
120
156
|
};
|
|
121
157
|
}
|
|
@@ -126,46 +162,55 @@ function cookieGate(name, value = "1") {
|
|
|
126
162
|
return cookie.split(";").map((c) => c.trim()).some((c) => c === `${name}=${value}`);
|
|
127
163
|
};
|
|
128
164
|
}
|
|
165
|
+
function headerGate(value, headerName = "x-feedback-token") {
|
|
166
|
+
const wanted = headerName.toLowerCase();
|
|
167
|
+
return (req) => {
|
|
168
|
+
const { headers } = req;
|
|
169
|
+
const got = typeof headers.get === "function" ? headers.get(wanted) : headers[wanted];
|
|
170
|
+
return got === value;
|
|
171
|
+
};
|
|
172
|
+
}
|
|
129
173
|
|
|
130
174
|
// src/server/node.ts
|
|
131
|
-
var BAD_REQUEST2 = /* @__PURE__ */ new Set(["note_required", "note_too_long", "bad_type", "bad_json", "not_configured"]);
|
|
132
|
-
function send(res, status, body) {
|
|
133
|
-
res.statusCode = status;
|
|
134
|
-
res.setHeader("content-type", "application/json");
|
|
135
|
-
res.end(JSON.stringify(body));
|
|
136
|
-
}
|
|
137
|
-
async function readJson(req) {
|
|
138
|
-
const chunks = [];
|
|
139
|
-
for await (const chunk of req) chunks.push(Buffer.from(chunk));
|
|
140
|
-
const raw = Buffer.concat(chunks).toString("utf8");
|
|
141
|
-
if (!raw) throw new Error("bad_json");
|
|
142
|
-
try {
|
|
143
|
-
return JSON.parse(raw);
|
|
144
|
-
} catch {
|
|
145
|
-
throw new Error("bad_json");
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
175
|
function createNodeHandler(config) {
|
|
176
|
+
const originals = /* @__PURE__ */ new WeakMap();
|
|
177
|
+
const web = createWebHandler({
|
|
178
|
+
...config,
|
|
179
|
+
authorize: config.authorize ? (request) => config.authorize(originals.get(request)) : void 0
|
|
180
|
+
});
|
|
149
181
|
return async function handler(req, res) {
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
const payload = req.body ?? await readJson(req);
|
|
157
|
-
const issue = await createFeedbackIssue(payload, config);
|
|
158
|
-
send(res, 200, { ok: true, ...issue });
|
|
159
|
-
} catch (err) {
|
|
160
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
161
|
-
if (BAD_REQUEST2.has(message)) return send(res, message === "not_configured" ? 500 : 400, { error: message });
|
|
162
|
-
console.error("[feedback] issue create failed", err);
|
|
163
|
-
send(res, 502, { error: "issue_create_failed", message });
|
|
164
|
-
}
|
|
182
|
+
const request = await toWebRequest(req);
|
|
183
|
+
originals.set(request, req);
|
|
184
|
+
const response = await web(request);
|
|
185
|
+
res.statusCode = response.status;
|
|
186
|
+
response.headers.forEach((value, key) => res.setHeader(key, value));
|
|
187
|
+
res.end(await response.text());
|
|
165
188
|
};
|
|
166
189
|
}
|
|
190
|
+
async function toWebRequest(req) {
|
|
191
|
+
const url = `http://${req.headers.host ?? "localhost"}${req.url ?? "/"}`;
|
|
192
|
+
const headers = new Headers();
|
|
193
|
+
for (const [key, value] of Object.entries(req.headers)) {
|
|
194
|
+
if (value == null) continue;
|
|
195
|
+
headers.set(key, Array.isArray(value) ? value.join(", ") : value);
|
|
196
|
+
}
|
|
197
|
+
const method = req.method ?? "POST";
|
|
198
|
+
let body;
|
|
199
|
+
if (method !== "GET" && method !== "HEAD" && method !== "OPTIONS") {
|
|
200
|
+
if (req.body !== void 0) {
|
|
201
|
+
body = typeof req.body === "string" ? req.body : JSON.stringify(req.body);
|
|
202
|
+
} else {
|
|
203
|
+
const chunks = [];
|
|
204
|
+
for await (const chunk of req) chunks.push(Buffer.from(chunk));
|
|
205
|
+
body = Buffer.concat(chunks).toString("utf8");
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return new Request(url, { method, headers, body });
|
|
209
|
+
}
|
|
167
210
|
|
|
168
211
|
exports.cookieGate = cookieGate;
|
|
169
212
|
exports.createFeedbackIssue = createFeedbackIssue;
|
|
170
|
-
exports.createNextRoute =
|
|
213
|
+
exports.createNextRoute = createWebHandler;
|
|
171
214
|
exports.createNodeHandler = createNodeHandler;
|
|
215
|
+
exports.createWebHandler = createWebHandler;
|
|
216
|
+
exports.headerGate = headerGate;
|
package/dist/server/index.d.cts
CHANGED
|
@@ -55,6 +55,11 @@ type FeedbackServerConfig = {
|
|
|
55
55
|
* "bug" looks for a label named "bug". Labels are resolved by name at request time.
|
|
56
56
|
*/
|
|
57
57
|
labels?: Record<string, string>;
|
|
58
|
+
/**
|
|
59
|
+
* How long resolved label name→ID lookups are cached (module scope, so warm serverless
|
|
60
|
+
* invocations reuse them). Default 10 minutes; 0 disables caching.
|
|
61
|
+
*/
|
|
62
|
+
labelCacheTtlMs?: number;
|
|
58
63
|
};
|
|
59
64
|
type CreatedIssue = {
|
|
60
65
|
id?: string;
|
|
@@ -63,26 +68,41 @@ type CreatedIssue = {
|
|
|
63
68
|
};
|
|
64
69
|
declare function createFeedbackIssue(payload: FeedbackPayload, config: FeedbackServerConfig): Promise<CreatedIssue>;
|
|
65
70
|
|
|
66
|
-
type
|
|
67
|
-
/**
|
|
71
|
+
type WebHandlerConfig = FeedbackServerConfig & {
|
|
72
|
+
/**
|
|
73
|
+
* Origins allowed to call this endpoint cross-origin (exact origins, or "*"). Enables
|
|
74
|
+
* CORS (preflight + response headers) — required when the widget is embedded via the
|
|
75
|
+
* script tag on a different domain than the handler.
|
|
76
|
+
*/
|
|
77
|
+
allowedOrigins?: string[];
|
|
78
|
+
/** @deprecated Use `allowedOrigins`. Restrict to a single site origin. */
|
|
68
79
|
allowedOrigin?: string;
|
|
69
|
-
/** Return false to reject the request (e.g. a cookie/session check). */
|
|
80
|
+
/** Return false to reject the request (e.g. a cookie/session/token check). */
|
|
70
81
|
authorize?: (req: Request) => boolean | Promise<boolean>;
|
|
71
82
|
};
|
|
72
|
-
declare function
|
|
83
|
+
declare function createWebHandler(config: WebHandlerConfig): (req: Request) => Promise<Response>;
|
|
73
84
|
/**
|
|
74
85
|
* Authorize helper: allow only requests carrying `name=value` in the Cookie header.
|
|
75
86
|
*
|
|
76
|
-
* Works with BOTH a Web `Request` (
|
|
77
|
-
* `IncomingMessage` (
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
87
|
+
* Works with BOTH a Web `Request` (createWebHandler/createNextRoute) and a Node
|
|
88
|
+
* `IncomingMessage` (createNodeHandler). The two runtimes expose headers differently —
|
|
89
|
+
* `headers.get("cookie")` vs the plain `headers.cookie` string — so we feature-detect
|
|
90
|
+
* instead of assuming one shape.
|
|
91
|
+
*
|
|
92
|
+
* Same-origin only: the gate cookie is written by `FeedbackGate` on the PAGE's origin, so it
|
|
93
|
+
* is never sent with cross-origin embed submissions. Gate embedded widgets with `headerGate`.
|
|
81
94
|
*/
|
|
82
95
|
declare function cookieGate(name: string, value?: string): (req: Request | IncomingMessage) => boolean;
|
|
96
|
+
/**
|
|
97
|
+
* Authorize helper for embedded (cross-origin) widgets: allow only requests carrying
|
|
98
|
+
* `headerName: value`. Pair it with the embed's `token` / `data-token` config, which sends
|
|
99
|
+
* the token as `x-feedback-token`. The token is visible in the page source — treat it as a
|
|
100
|
+
* tripwire against drive-by spam, not as authentication.
|
|
101
|
+
*/
|
|
102
|
+
declare function headerGate(value: string, headerName?: string): (req: Request | IncomingMessage) => boolean;
|
|
83
103
|
|
|
84
|
-
type NodeHandlerConfig =
|
|
85
|
-
|
|
104
|
+
type NodeHandlerConfig = Omit<WebHandlerConfig, "authorize"> & {
|
|
105
|
+
/** Return false to reject the request. Receives the original Node `IncomingMessage`. */
|
|
86
106
|
authorize?: (req: IncomingMessage) => boolean | Promise<boolean>;
|
|
87
107
|
};
|
|
88
108
|
type NodeReq = IncomingMessage & {
|
|
@@ -90,4 +110,4 @@ type NodeReq = IncomingMessage & {
|
|
|
90
110
|
};
|
|
91
111
|
declare function createNodeHandler(config: NodeHandlerConfig): (req: NodeReq, res: ServerResponse) => Promise<void>;
|
|
92
112
|
|
|
93
|
-
export { type CreatedIssue, type FeedbackAnnotation, type FeedbackContext, type FeedbackPayload, type FeedbackRect, type FeedbackServerConfig, type NextRouteConfig, type NodeHandlerConfig, cookieGate, createFeedbackIssue, createNextRoute, createNodeHandler };
|
|
113
|
+
export { type CreatedIssue, type FeedbackAnnotation, type FeedbackContext, type FeedbackPayload, type FeedbackRect, type FeedbackServerConfig, type WebHandlerConfig as NextRouteConfig, type NodeHandlerConfig, type WebHandlerConfig, cookieGate, createFeedbackIssue, createWebHandler as createNextRoute, createNodeHandler, createWebHandler, headerGate };
|