react-linear-feedback 0.2.0 → 0.3.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.
@@ -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 };
@@ -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 };
@@ -2,6 +2,12 @@ import { LinearClient } from '@linear/sdk';
2
2
 
3
3
  // src/server/core.ts
4
4
  var MAX_NOTE = 5e3;
5
+ var DEFAULT_LABEL_TTL_MS = 10 * 60 * 1e3;
6
+ var MISS_TTL_MS = 60 * 1e3;
7
+ var labelCache = /* @__PURE__ */ new Map();
8
+ function labelCacheKey(teamId, name) {
9
+ return `${teamId}:${name.toLowerCase()}`;
10
+ }
5
11
  async function createFeedbackIssue(payload, config) {
6
12
  const { apiKey, teamId } = config;
7
13
  if (!apiKey || !teamId) throw new Error("not_configured");
@@ -34,7 +40,8 @@ async function createFeedbackIssue(payload, config) {
34
40
  ].filter(Boolean).join("\n");
35
41
  const title = `${typeLabel}: ${note.slice(0, 60)}${note.length > 60 ? "\u2026" : ""}`;
36
42
  const labelName = config.labels?.[annotation.type] ?? annotation.type;
37
- const labelId = labelName ? await resolveLabelId(linear, labelName, teamId) : null;
43
+ const ttl = config.labelCacheTtlMs ?? DEFAULT_LABEL_TTL_MS;
44
+ const labelId = labelName ? await resolveLabelIdCached(linear, labelName, teamId, ttl) : null;
38
45
  if (labelName && !labelId) console.warn(`[feedback] label "${labelName}" not found \u2014 creating issue without it`);
39
46
  const create = async (ids) => {
40
47
  const created = await linear.createIssue({ teamId, title, description, labelIds: ids.length ? ids : void 0 });
@@ -42,6 +49,7 @@ async function createFeedbackIssue(payload, config) {
42
49
  };
43
50
  const issue = await create(labelId ? [labelId] : []).catch((err) => {
44
51
  if (!labelId) throw err;
52
+ if (labelName) labelCache.delete(labelCacheKey(teamId, labelName));
45
53
  console.warn("[feedback] create failed with label, retrying without it", err);
46
54
  return create([]);
47
55
  });
@@ -50,6 +58,15 @@ async function createFeedbackIssue(payload, config) {
50
58
  function capitalize(s) {
51
59
  return s.charAt(0).toUpperCase() + s.slice(1);
52
60
  }
61
+ async function resolveLabelIdCached(linear, name, teamId, ttlMs) {
62
+ if (ttlMs <= 0) return resolveLabelId(linear, name, teamId);
63
+ const key = labelCacheKey(teamId, name);
64
+ const hit = labelCache.get(key);
65
+ if (hit && hit.expires > Date.now()) return hit.id;
66
+ const id = await resolveLabelId(linear, name, teamId);
67
+ labelCache.set(key, { id, expires: Date.now() + (id ? ttlMs : Math.min(ttlMs, MISS_TTL_MS)) });
68
+ return id;
69
+ }
53
70
  async function resolveLabelId(linear, name, teamId) {
54
71
  try {
55
72
  const { nodes } = await linear.issueLabels({ filter: { name: { eqIgnoreCase: name } }, first: 50 });
@@ -88,32 +105,51 @@ async function uploadScreenshot(linear, dataUrl) {
88
105
  return upload.uploadFile.assetUrl;
89
106
  }
90
107
 
91
- // src/server/next.ts
92
- var BAD_REQUEST = /* @__PURE__ */ new Set(["note_required", "note_too_long", "bad_type", "not_configured"]);
93
- function json(body, status) {
94
- return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
108
+ // src/server/web.ts
109
+ var BAD_REQUEST = /* @__PURE__ */ new Set(["note_required", "note_too_long", "bad_type", "bad_json", "not_configured"]);
110
+ function json(body, status, headers = {}) {
111
+ return new Response(JSON.stringify(body), {
112
+ status,
113
+ headers: { "content-type": "application/json", ...headers }
114
+ });
95
115
  }
96
- function createNextRoute(config) {
97
- return async function POST(req) {
98
- if (config.authorize && !await config.authorize(req)) return json({ error: "unauthorized" }, 404);
99
- if (config.allowedOrigin) {
100
- const origin = req.headers.get("origin") ?? "";
101
- if (origin && origin !== config.allowedOrigin) return json({ error: "forbidden_origin" }, 403);
102
- }
116
+ function corsHeaders(req, allowed) {
117
+ const origin = req.headers.get("origin");
118
+ if (!origin || allowed.length === 0) return {};
119
+ if (!allowed.includes("*") && !allowed.includes(origin)) return {};
120
+ return {
121
+ "access-control-allow-origin": allowed.includes("*") ? "*" : origin,
122
+ vary: "Origin",
123
+ "access-control-allow-methods": "POST, OPTIONS",
124
+ "access-control-allow-headers": "content-type, x-feedback-token",
125
+ "access-control-max-age": "86400"
126
+ };
127
+ }
128
+ function createWebHandler(config) {
129
+ const allowed = config.allowedOrigins ?? (config.allowedOrigin ? [config.allowedOrigin] : []);
130
+ if (!config.apiKey || !config.teamId)
131
+ console.warn("[feedback] LINEAR apiKey/teamId missing \u2014 submissions will fail with 500 not_configured");
132
+ return async function handler(req) {
133
+ const cors = corsHeaders(req, allowed);
134
+ const origin = req.headers.get("origin");
135
+ if (origin && allowed.length > 0 && !allowed.includes("*") && !allowed.includes(origin))
136
+ return json({ error: "forbidden_origin" }, 403);
137
+ if (req.method === "OPTIONS") return new Response(null, { status: 204, headers: cors });
138
+ if (config.authorize && !await config.authorize(req)) return json({ error: "unauthorized" }, 404, cors);
103
139
  let payload;
104
140
  try {
105
141
  payload = await req.json();
106
142
  } catch {
107
- return json({ error: "bad_json" }, 400);
143
+ return json({ error: "bad_json" }, 400, cors);
108
144
  }
109
145
  try {
110
146
  const issue = await createFeedbackIssue(payload, config);
111
- return json({ ok: true, ...issue }, 200);
147
+ return json({ ok: true, ...issue }, 200, cors);
112
148
  } catch (err) {
113
149
  const message = err instanceof Error ? err.message : String(err);
114
- if (BAD_REQUEST.has(message)) return json({ error: message }, message === "not_configured" ? 500 : 400);
150
+ if (BAD_REQUEST.has(message)) return json({ error: message }, message === "not_configured" ? 500 : 400, cors);
115
151
  console.error("[feedback] issue create failed", err);
116
- return json({ error: "issue_create_failed", message }, 502);
152
+ return json({ error: "issue_create_failed", message }, 502, cors);
117
153
  }
118
154
  };
119
155
  }
@@ -124,43 +160,50 @@ function cookieGate(name, value = "1") {
124
160
  return cookie.split(";").map((c) => c.trim()).some((c) => c === `${name}=${value}`);
125
161
  };
126
162
  }
163
+ function headerGate(value, headerName = "x-feedback-token") {
164
+ const wanted = headerName.toLowerCase();
165
+ return (req) => {
166
+ const { headers } = req;
167
+ const got = typeof headers.get === "function" ? headers.get(wanted) : headers[wanted];
168
+ return got === value;
169
+ };
170
+ }
127
171
 
128
172
  // src/server/node.ts
129
- var BAD_REQUEST2 = /* @__PURE__ */ new Set(["note_required", "note_too_long", "bad_type", "bad_json", "not_configured"]);
130
- function send(res, status, body) {
131
- res.statusCode = status;
132
- res.setHeader("content-type", "application/json");
133
- res.end(JSON.stringify(body));
134
- }
135
- async function readJson(req) {
136
- const chunks = [];
137
- for await (const chunk of req) chunks.push(Buffer.from(chunk));
138
- const raw = Buffer.concat(chunks).toString("utf8");
139
- if (!raw) throw new Error("bad_json");
140
- try {
141
- return JSON.parse(raw);
142
- } catch {
143
- throw new Error("bad_json");
144
- }
145
- }
146
173
  function createNodeHandler(config) {
174
+ const originals = /* @__PURE__ */ new WeakMap();
175
+ const web = createWebHandler({
176
+ ...config,
177
+ authorize: config.authorize ? (request) => config.authorize(originals.get(request)) : void 0
178
+ });
147
179
  return async function handler(req, res) {
148
- try {
149
- if (config.authorize && !await config.authorize(req)) return send(res, 404, { error: "unauthorized" });
150
- if (config.allowedOrigin) {
151
- const origin = req.headers.origin ?? "";
152
- if (origin && origin !== config.allowedOrigin) return send(res, 403, { error: "forbidden_origin" });
153
- }
154
- const payload = req.body ?? await readJson(req);
155
- const issue = await createFeedbackIssue(payload, config);
156
- send(res, 200, { ok: true, ...issue });
157
- } catch (err) {
158
- const message = err instanceof Error ? err.message : String(err);
159
- if (BAD_REQUEST2.has(message)) return send(res, message === "not_configured" ? 500 : 400, { error: message });
160
- console.error("[feedback] issue create failed", err);
161
- send(res, 502, { error: "issue_create_failed", message });
162
- }
180
+ const request = await toWebRequest(req);
181
+ originals.set(request, req);
182
+ const response = await web(request);
183
+ res.statusCode = response.status;
184
+ response.headers.forEach((value, key) => res.setHeader(key, value));
185
+ res.end(await response.text());
163
186
  };
164
187
  }
188
+ async function toWebRequest(req) {
189
+ const url = `http://${req.headers.host ?? "localhost"}${req.url ?? "/"}`;
190
+ const headers = new Headers();
191
+ for (const [key, value] of Object.entries(req.headers)) {
192
+ if (value == null) continue;
193
+ headers.set(key, Array.isArray(value) ? value.join(", ") : value);
194
+ }
195
+ const method = req.method ?? "POST";
196
+ let body;
197
+ if (method !== "GET" && method !== "HEAD" && method !== "OPTIONS") {
198
+ if (req.body !== void 0) {
199
+ body = typeof req.body === "string" ? req.body : JSON.stringify(req.body);
200
+ } else {
201
+ const chunks = [];
202
+ for await (const chunk of req) chunks.push(Buffer.from(chunk));
203
+ body = Buffer.concat(chunks).toString("utf8");
204
+ }
205
+ }
206
+ return new Request(url, { method, headers, body });
207
+ }
165
208
 
166
- export { cookieGate, createFeedbackIssue, createNextRoute, createNodeHandler };
209
+ export { cookieGate, createFeedbackIssue, createWebHandler as createNextRoute, createNodeHandler, createWebHandler, headerGate };
@@ -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,44 +107,92 @@ async function uploadScreenshot(linear, dataUrl) {
90
107
  return upload.uploadFile.assetUrl;
91
108
  }
92
109
 
93
- // src/server/node.ts
110
+ // src/server/web.ts
94
111
  var BAD_REQUEST = /* @__PURE__ */ new Set(["note_required", "note_too_long", "bad_type", "bad_json", "not_configured"]);
95
- function send(res, status, body) {
96
- res.statusCode = status;
97
- res.setHeader("content-type", "application/json");
98
- res.end(JSON.stringify(body));
112
+ function json(body, status, headers = {}) {
113
+ return new Response(JSON.stringify(body), {
114
+ status,
115
+ headers: { "content-type": "application/json", ...headers }
116
+ });
99
117
  }
100
- async function readJson(req) {
101
- const chunks = [];
102
- for await (const chunk of req) chunks.push(Buffer.from(chunk));
103
- const raw = Buffer.concat(chunks).toString("utf8");
104
- if (!raw) throw new Error("bad_json");
105
- try {
106
- return JSON.parse(raw);
107
- } catch {
108
- throw new Error("bad_json");
109
- }
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
+ };
110
129
  }
111
- function createNodeHandler(config) {
112
- return async function handler(req, res) {
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);
141
+ let payload;
142
+ try {
143
+ payload = await req.json();
144
+ } catch {
145
+ return json({ error: "bad_json" }, 400, cors);
146
+ }
113
147
  try {
114
- if (config.authorize && !await config.authorize(req)) return send(res, 404, { error: "unauthorized" });
115
- if (config.allowedOrigin) {
116
- const origin = req.headers.origin ?? "";
117
- if (origin && origin !== config.allowedOrigin) return send(res, 403, { error: "forbidden_origin" });
118
- }
119
- const payload = req.body ?? await readJson(req);
120
148
  const issue = await createFeedbackIssue(payload, config);
121
- send(res, 200, { ok: true, ...issue });
149
+ return json({ ok: true, ...issue }, 200, cors);
122
150
  } catch (err) {
123
151
  const message = err instanceof Error ? err.message : String(err);
124
- if (BAD_REQUEST.has(message)) return send(res, message === "not_configured" ? 500 : 400, { error: message });
152
+ if (BAD_REQUEST.has(message)) return json({ error: message }, message === "not_configured" ? 500 : 400, cors);
125
153
  console.error("[feedback] issue create failed", err);
126
- send(res, 502, { error: "issue_create_failed", message });
154
+ return json({ error: "issue_create_failed", message }, 502, cors);
127
155
  }
128
156
  };
129
157
  }
130
158
 
159
+ // src/server/node.ts
160
+ function createNodeHandler(config) {
161
+ const originals = /* @__PURE__ */ new WeakMap();
162
+ const web = createWebHandler({
163
+ ...config,
164
+ authorize: config.authorize ? (request) => config.authorize(originals.get(request)) : void 0
165
+ });
166
+ return async function handler(req, res) {
167
+ const request = await toWebRequest(req);
168
+ originals.set(request, req);
169
+ const response = await web(request);
170
+ res.statusCode = response.status;
171
+ response.headers.forEach((value, key) => res.setHeader(key, value));
172
+ res.end(await response.text());
173
+ };
174
+ }
175
+ async function toWebRequest(req) {
176
+ const url = `http://${req.headers.host ?? "localhost"}${req.url ?? "/"}`;
177
+ const headers = new Headers();
178
+ for (const [key, value] of Object.entries(req.headers)) {
179
+ if (value == null) continue;
180
+ headers.set(key, Array.isArray(value) ? value.join(", ") : value);
181
+ }
182
+ const method = req.method ?? "POST";
183
+ let body;
184
+ if (method !== "GET" && method !== "HEAD" && method !== "OPTIONS") {
185
+ if (req.body !== void 0) {
186
+ body = typeof req.body === "string" ? req.body : JSON.stringify(req.body);
187
+ } else {
188
+ const chunks = [];
189
+ for await (const chunk of req) chunks.push(Buffer.from(chunk));
190
+ body = Buffer.concat(chunks).toString("utf8");
191
+ }
192
+ }
193
+ return new Request(url, { method, headers, body });
194
+ }
195
+
131
196
  // src/vite/index.ts
132
197
  function linearFeedback(config) {
133
198
  const endpoint = config.endpoint ?? "/api/feedback";
@@ -137,7 +202,7 @@ function linearFeedback(config) {
137
202
  apply: "serve",
138
203
  configureServer(server) {
139
204
  server.middlewares.use(endpoint, (req, res, next) => {
140
- if (req.method !== "POST") return next();
205
+ if (req.method !== "POST" && req.method !== "OPTIONS") return next();
141
206
  handler(req, res).catch(next);
142
207
  });
143
208
  }
@@ -11,10 +11,28 @@ type FeedbackServerConfig = {
11
11
  * "bug" looks for a label named "bug". Labels are resolved by name at request time.
12
12
  */
13
13
  labels?: Record<string, string>;
14
+ /**
15
+ * How long resolved label name→ID lookups are cached (module scope, so warm serverless
16
+ * invocations reuse them). Default 10 minutes; 0 disables caching.
17
+ */
18
+ labelCacheTtlMs?: number;
14
19
  };
15
20
 
16
- type NodeHandlerConfig = FeedbackServerConfig & {
21
+ type WebHandlerConfig = FeedbackServerConfig & {
22
+ /**
23
+ * Origins allowed to call this endpoint cross-origin (exact origins, or "*"). Enables
24
+ * CORS (preflight + response headers) — required when the widget is embedded via the
25
+ * script tag on a different domain than the handler.
26
+ */
27
+ allowedOrigins?: string[];
28
+ /** @deprecated Use `allowedOrigins`. Restrict to a single site origin. */
17
29
  allowedOrigin?: string;
30
+ /** Return false to reject the request (e.g. a cookie/session/token check). */
31
+ authorize?: (req: Request) => boolean | Promise<boolean>;
32
+ };
33
+
34
+ type NodeHandlerConfig = Omit<WebHandlerConfig, "authorize"> & {
35
+ /** Return false to reject the request. Receives the original Node `IncomingMessage`. */
18
36
  authorize?: (req: IncomingMessage) => boolean | Promise<boolean>;
19
37
  };
20
38
 
@@ -11,10 +11,28 @@ type FeedbackServerConfig = {
11
11
  * "bug" looks for a label named "bug". Labels are resolved by name at request time.
12
12
  */
13
13
  labels?: Record<string, string>;
14
+ /**
15
+ * How long resolved label name→ID lookups are cached (module scope, so warm serverless
16
+ * invocations reuse them). Default 10 minutes; 0 disables caching.
17
+ */
18
+ labelCacheTtlMs?: number;
14
19
  };
15
20
 
16
- type NodeHandlerConfig = FeedbackServerConfig & {
21
+ type WebHandlerConfig = FeedbackServerConfig & {
22
+ /**
23
+ * Origins allowed to call this endpoint cross-origin (exact origins, or "*"). Enables
24
+ * CORS (preflight + response headers) — required when the widget is embedded via the
25
+ * script tag on a different domain than the handler.
26
+ */
27
+ allowedOrigins?: string[];
28
+ /** @deprecated Use `allowedOrigins`. Restrict to a single site origin. */
17
29
  allowedOrigin?: string;
30
+ /** Return false to reject the request (e.g. a cookie/session/token check). */
31
+ authorize?: (req: Request) => boolean | Promise<boolean>;
32
+ };
33
+
34
+ type NodeHandlerConfig = Omit<WebHandlerConfig, "authorize"> & {
35
+ /** Return false to reject the request. Receives the original Node `IncomingMessage`. */
18
36
  authorize?: (req: IncomingMessage) => boolean | Promise<boolean>;
19
37
  };
20
38