react-linear-feedback 0.1.1 → 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.
- package/README.md +190 -36
- package/dist/embed/index.d.ts +48 -0
- package/dist/embed/index.js +2695 -0
- package/dist/embed/linear-feedback.js +127 -0
- package/dist/react/index.cjs +37 -15
- package/dist/react/index.d.cts +10 -2
- package/dist/react/index.d.ts +10 -2
- package/dist/react/index.js +37 -15
- package/dist/server/index.cjs +96 -50
- package/dist/server/index.d.cts +37 -9
- package/dist/server/index.d.ts +37 -9
- package/dist/server/index.js +94 -50
- package/dist/vite/index.cjs +212 -0
- package/dist/vite/index.d.cts +45 -0
- package/dist/vite/index.d.ts +45 -0
- package/dist/vite/index.js +210 -0
- package/package.json +30 -10
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,81 +107,110 @@ 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
|
}
|
|
122
158
|
function cookieGate(name, value = "1") {
|
|
123
159
|
return (req) => {
|
|
124
|
-
const
|
|
160
|
+
const { headers } = req;
|
|
161
|
+
const cookie = typeof headers.get === "function" ? headers.get("cookie") ?? "" : headers.cookie ?? "";
|
|
125
162
|
return cookie.split(";").map((c) => c.trim()).some((c) => c === `${name}=${value}`);
|
|
126
163
|
};
|
|
127
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
|
+
}
|
|
128
173
|
|
|
129
174
|
// src/server/node.ts
|
|
130
|
-
var BAD_REQUEST2 = /* @__PURE__ */ new Set(["note_required", "note_too_long", "bad_type", "bad_json", "not_configured"]);
|
|
131
|
-
function send(res, status, body) {
|
|
132
|
-
res.statusCode = status;
|
|
133
|
-
res.setHeader("content-type", "application/json");
|
|
134
|
-
res.end(JSON.stringify(body));
|
|
135
|
-
}
|
|
136
|
-
async function readJson(req) {
|
|
137
|
-
const chunks = [];
|
|
138
|
-
for await (const chunk of req) chunks.push(Buffer.from(chunk));
|
|
139
|
-
const raw = Buffer.concat(chunks).toString("utf8");
|
|
140
|
-
if (!raw) throw new Error("bad_json");
|
|
141
|
-
try {
|
|
142
|
-
return JSON.parse(raw);
|
|
143
|
-
} catch {
|
|
144
|
-
throw new Error("bad_json");
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
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
|
+
});
|
|
148
181
|
return async function handler(req, res) {
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
const payload = req.body ?? await readJson(req);
|
|
156
|
-
const issue = await createFeedbackIssue(payload, config);
|
|
157
|
-
send(res, 200, { ok: true, ...issue });
|
|
158
|
-
} catch (err) {
|
|
159
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
160
|
-
if (BAD_REQUEST2.has(message)) return send(res, message === "not_configured" ? 500 : 400, { error: message });
|
|
161
|
-
console.error("[feedback] issue create failed", err);
|
|
162
|
-
send(res, 502, { error: "issue_create_failed", message });
|
|
163
|
-
}
|
|
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());
|
|
164
188
|
};
|
|
165
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
|
+
}
|
|
166
210
|
|
|
167
211
|
exports.cookieGate = cookieGate;
|
|
168
212
|
exports.createFeedbackIssue = createFeedbackIssue;
|
|
169
|
-
exports.createNextRoute =
|
|
213
|
+
exports.createNextRoute = createWebHandler;
|
|
170
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,18 +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
|
|
73
|
-
/**
|
|
74
|
-
|
|
83
|
+
declare function createWebHandler(config: WebHandlerConfig): (req: Request) => Promise<Response>;
|
|
84
|
+
/**
|
|
85
|
+
* Authorize helper: allow only requests carrying `name=value` in the Cookie header.
|
|
86
|
+
*
|
|
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`.
|
|
94
|
+
*/
|
|
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;
|
|
75
103
|
|
|
76
|
-
type NodeHandlerConfig =
|
|
77
|
-
|
|
104
|
+
type NodeHandlerConfig = Omit<WebHandlerConfig, "authorize"> & {
|
|
105
|
+
/** Return false to reject the request. Receives the original Node `IncomingMessage`. */
|
|
78
106
|
authorize?: (req: IncomingMessage) => boolean | Promise<boolean>;
|
|
79
107
|
};
|
|
80
108
|
type NodeReq = IncomingMessage & {
|
|
@@ -82,4 +110,4 @@ type NodeReq = IncomingMessage & {
|
|
|
82
110
|
};
|
|
83
111
|
declare function createNodeHandler(config: NodeHandlerConfig): (req: NodeReq, res: ServerResponse) => Promise<void>;
|
|
84
112
|
|
|
85
|
-
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 };
|
package/dist/server/index.d.ts
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,18 +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
|
|
73
|
-
/**
|
|
74
|
-
|
|
83
|
+
declare function createWebHandler(config: WebHandlerConfig): (req: Request) => Promise<Response>;
|
|
84
|
+
/**
|
|
85
|
+
* Authorize helper: allow only requests carrying `name=value` in the Cookie header.
|
|
86
|
+
*
|
|
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`.
|
|
94
|
+
*/
|
|
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;
|
|
75
103
|
|
|
76
|
-
type NodeHandlerConfig =
|
|
77
|
-
|
|
104
|
+
type NodeHandlerConfig = Omit<WebHandlerConfig, "authorize"> & {
|
|
105
|
+
/** Return false to reject the request. Receives the original Node `IncomingMessage`. */
|
|
78
106
|
authorize?: (req: IncomingMessage) => boolean | Promise<boolean>;
|
|
79
107
|
};
|
|
80
108
|
type NodeReq = IncomingMessage & {
|
|
@@ -82,4 +110,4 @@ type NodeReq = IncomingMessage & {
|
|
|
82
110
|
};
|
|
83
111
|
declare function createNodeHandler(config: NodeHandlerConfig): (req: NodeReq, res: ServerResponse) => Promise<void>;
|
|
84
112
|
|
|
85
|
-
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 };
|
package/dist/server/index.js
CHANGED
|
@@ -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
|
|
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,78 +105,105 @@ async function uploadScreenshot(linear, dataUrl) {
|
|
|
88
105
|
return upload.uploadFile.assetUrl;
|
|
89
106
|
}
|
|
90
107
|
|
|
91
|
-
// src/server/
|
|
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), {
|
|
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
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
}
|
|
120
156
|
function cookieGate(name, value = "1") {
|
|
121
157
|
return (req) => {
|
|
122
|
-
const
|
|
158
|
+
const { headers } = req;
|
|
159
|
+
const cookie = typeof headers.get === "function" ? headers.get("cookie") ?? "" : headers.cookie ?? "";
|
|
123
160
|
return cookie.split(";").map((c) => c.trim()).some((c) => c === `${name}=${value}`);
|
|
124
161
|
};
|
|
125
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
|
+
}
|
|
126
171
|
|
|
127
172
|
// src/server/node.ts
|
|
128
|
-
var BAD_REQUEST2 = /* @__PURE__ */ new Set(["note_required", "note_too_long", "bad_type", "bad_json", "not_configured"]);
|
|
129
|
-
function send(res, status, body) {
|
|
130
|
-
res.statusCode = status;
|
|
131
|
-
res.setHeader("content-type", "application/json");
|
|
132
|
-
res.end(JSON.stringify(body));
|
|
133
|
-
}
|
|
134
|
-
async function readJson(req) {
|
|
135
|
-
const chunks = [];
|
|
136
|
-
for await (const chunk of req) chunks.push(Buffer.from(chunk));
|
|
137
|
-
const raw = Buffer.concat(chunks).toString("utf8");
|
|
138
|
-
if (!raw) throw new Error("bad_json");
|
|
139
|
-
try {
|
|
140
|
-
return JSON.parse(raw);
|
|
141
|
-
} catch {
|
|
142
|
-
throw new Error("bad_json");
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
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
|
+
});
|
|
146
179
|
return async function handler(req, res) {
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
const payload = req.body ?? await readJson(req);
|
|
154
|
-
const issue = await createFeedbackIssue(payload, config);
|
|
155
|
-
send(res, 200, { ok: true, ...issue });
|
|
156
|
-
} catch (err) {
|
|
157
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
158
|
-
if (BAD_REQUEST2.has(message)) return send(res, message === "not_configured" ? 500 : 400, { error: message });
|
|
159
|
-
console.error("[feedback] issue create failed", err);
|
|
160
|
-
send(res, 502, { error: "issue_create_failed", message });
|
|
161
|
-
}
|
|
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());
|
|
162
186
|
};
|
|
163
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
|
+
}
|
|
164
208
|
|
|
165
|
-
export { cookieGate, createFeedbackIssue, createNextRoute, createNodeHandler };
|
|
209
|
+
export { cookieGate, createFeedbackIssue, createWebHandler as createNextRoute, createNodeHandler, createWebHandler, headerGate };
|