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.
@@ -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
 
@@ -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 !== "drawing" && mode.kind !== "naming") return;
340
+ if (mode.kind === "idle") return;
329
341
  const onKey = (e) => {
330
- if (e.key === "Escape") {
331
- setMode({ kind: "idle" });
332
- setDrag(null);
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 = { display: "contents", ...brandColor ? { "--lfb-brand": brandColor } : {} };
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("div", { className: "lfb-anchor", style: { left: mode.rect.x, top: mode.rect.y + mode.rect.height }, children: /* @__PURE__ */ jsxs("form", { className: "lfb-card lfb-composer", onSubmit: handleSubmit, children: [
451
- /* @__PURE__ */ jsxs("div", { className: "lfb-row", children: [
452
- /* @__PURE__ */ jsx("span", { className: "lfb-eyebrow", children: "New feedback" }),
453
- /* @__PURE__ */ jsx("button", { type: "button", className: "lfb-iconbtn", "aria-label": "Cancel", onClick: cancelComposer, children: /* @__PURE__ */ jsx(XIcon, {}) })
454
- ] }),
455
- /* @__PURE__ */ jsxs("div", { style: { marginTop: 12 }, children: [
456
- /* @__PURE__ */ jsx("span", { className: "lfb-field-label", children: "Issue type" }),
457
- /* @__PURE__ */ jsx("div", { className: "lfb-types", children: types.map((t) => {
458
- const Icon = TYPE_ICONS[t.icon ?? "dot"];
459
- return /* @__PURE__ */ jsxs(
460
- "button",
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
- type: "button",
463
- className: "lfb-type",
464
- "aria-pressed": issueType === t.id,
465
- onClick: () => setIssueType(t.id),
466
- children: [
467
- /* @__PURE__ */ jsx("span", { className: "lfb-swatch", style: { background: t.color }, children: /* @__PURE__ */ jsx(Icon, { size: 14 }) }),
468
- t.label
469
- ]
470
- },
471
- t.id
472
- );
473
- }) })
474
- ] }),
475
- /* @__PURE__ */ jsx(
476
- "textarea",
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
- if (e.key === "Escape") {
515
- e.preventDefault();
516
- setEditingName(false);
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
- ) : /* @__PURE__ */ jsxs("div", { className: "lfb-namerow", children: [
521
- /* @__PURE__ */ jsxs("span", { children: [
522
- "Posting as ",
523
- /* @__PURE__ */ jsx("span", { className: "lfb-name", children: name || "anonymous" })
524
- ] }),
525
- /* @__PURE__ */ jsxs("button", { type: "button", className: "lfb-link", onClick: () => setEditingName(true), children: [
526
- /* @__PURE__ */ jsx(EditIcon, { size: 12 }),
527
- "change"
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
- error && /* @__PURE__ */ jsx("p", { className: "lfb-error", children: error }),
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: [
@@ -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 labelId = labelName ? await resolveLabelId(linear, labelName, teamId) : null;
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/next.ts
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), { status, headers: { "content-type": "application/json" } });
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 createNextRoute(config) {
99
- return async function POST(req) {
100
- if (config.authorize && !await config.authorize(req)) return json({ error: "unauthorized" }, 404);
101
- if (config.allowedOrigin) {
102
- const origin = req.headers.get("origin") ?? "";
103
- if (origin && origin !== config.allowedOrigin) return json({ error: "forbidden_origin" }, 403);
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
- try {
151
- if (config.authorize && !await config.authorize(req)) return send(res, 404, { error: "unauthorized" });
152
- if (config.allowedOrigin) {
153
- const origin = req.headers.origin ?? "";
154
- if (origin && origin !== config.allowedOrigin) return send(res, 403, { error: "forbidden_origin" });
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 = createNextRoute;
213
+ exports.createNextRoute = createWebHandler;
171
214
  exports.createNodeHandler = createNodeHandler;
215
+ exports.createWebHandler = createWebHandler;
216
+ exports.headerGate = headerGate;
@@ -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 NextRouteConfig = FeedbackServerConfig & {
67
- /** Restrict to a single site origin (cheap extra; not a hard auth boundary). */
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 createNextRoute(config: NextRouteConfig): (req: Request) => Promise<Response>;
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` (Next.js App Router via `createNextRoute`) and a Node
77
- * `IncomingMessage` (Vercel / Express via `createNodeHandler`). The two runtimes expose
78
- * headers differently — `headers.get("cookie")` vs the plain `headers.cookie` string — so
79
- * we feature-detect instead of assuming one shape. (Previously this only handled the Web
80
- * `Request`, so the documented `createNodeHandler` usage threw `headers.get is not a function`.)
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 = FeedbackServerConfig & {
85
- allowedOrigin?: string;
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 };