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.
- package/README.md +150 -35
- 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 +10 -4
- package/dist/react/index.d.cts +7 -1
- package/dist/react/index.d.ts +7 -1
- package/dist/react/index.js +10 -4
- 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/vite/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,44 +105,92 @@ async function uploadScreenshot(linear, dataUrl) {
|
|
|
88
105
|
return upload.uploadFile.assetUrl;
|
|
89
106
|
}
|
|
90
107
|
|
|
91
|
-
// src/server/
|
|
108
|
+
// src/server/web.ts
|
|
92
109
|
var BAD_REQUEST = /* @__PURE__ */ new Set(["note_required", "note_too_long", "bad_type", "bad_json", "not_configured"]);
|
|
93
|
-
function
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
110
|
+
function json(body, status, headers = {}) {
|
|
111
|
+
return new Response(JSON.stringify(body), {
|
|
112
|
+
status,
|
|
113
|
+
headers: { "content-type": "application/json", ...headers }
|
|
114
|
+
});
|
|
97
115
|
}
|
|
98
|
-
|
|
99
|
-
const
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
+
};
|
|
108
127
|
}
|
|
109
|
-
function
|
|
110
|
-
|
|
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);
|
|
139
|
+
let payload;
|
|
140
|
+
try {
|
|
141
|
+
payload = await req.json();
|
|
142
|
+
} catch {
|
|
143
|
+
return json({ error: "bad_json" }, 400, cors);
|
|
144
|
+
}
|
|
111
145
|
try {
|
|
112
|
-
if (config.authorize && !await config.authorize(req)) return send(res, 404, { error: "unauthorized" });
|
|
113
|
-
if (config.allowedOrigin) {
|
|
114
|
-
const origin = req.headers.origin ?? "";
|
|
115
|
-
if (origin && origin !== config.allowedOrigin) return send(res, 403, { error: "forbidden_origin" });
|
|
116
|
-
}
|
|
117
|
-
const payload = req.body ?? await readJson(req);
|
|
118
146
|
const issue = await createFeedbackIssue(payload, config);
|
|
119
|
-
|
|
147
|
+
return json({ ok: true, ...issue }, 200, cors);
|
|
120
148
|
} catch (err) {
|
|
121
149
|
const message = err instanceof Error ? err.message : String(err);
|
|
122
|
-
if (BAD_REQUEST.has(message)) return
|
|
150
|
+
if (BAD_REQUEST.has(message)) return json({ error: message }, message === "not_configured" ? 500 : 400, cors);
|
|
123
151
|
console.error("[feedback] issue create failed", err);
|
|
124
|
-
|
|
152
|
+
return json({ error: "issue_create_failed", message }, 502, cors);
|
|
125
153
|
}
|
|
126
154
|
};
|
|
127
155
|
}
|
|
128
156
|
|
|
157
|
+
// src/server/node.ts
|
|
158
|
+
function createNodeHandler(config) {
|
|
159
|
+
const originals = /* @__PURE__ */ new WeakMap();
|
|
160
|
+
const web = createWebHandler({
|
|
161
|
+
...config,
|
|
162
|
+
authorize: config.authorize ? (request) => config.authorize(originals.get(request)) : void 0
|
|
163
|
+
});
|
|
164
|
+
return async function handler(req, res) {
|
|
165
|
+
const request = await toWebRequest(req);
|
|
166
|
+
originals.set(request, req);
|
|
167
|
+
const response = await web(request);
|
|
168
|
+
res.statusCode = response.status;
|
|
169
|
+
response.headers.forEach((value, key) => res.setHeader(key, value));
|
|
170
|
+
res.end(await response.text());
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
async function toWebRequest(req) {
|
|
174
|
+
const url = `http://${req.headers.host ?? "localhost"}${req.url ?? "/"}`;
|
|
175
|
+
const headers = new Headers();
|
|
176
|
+
for (const [key, value] of Object.entries(req.headers)) {
|
|
177
|
+
if (value == null) continue;
|
|
178
|
+
headers.set(key, Array.isArray(value) ? value.join(", ") : value);
|
|
179
|
+
}
|
|
180
|
+
const method = req.method ?? "POST";
|
|
181
|
+
let body;
|
|
182
|
+
if (method !== "GET" && method !== "HEAD" && method !== "OPTIONS") {
|
|
183
|
+
if (req.body !== void 0) {
|
|
184
|
+
body = typeof req.body === "string" ? req.body : JSON.stringify(req.body);
|
|
185
|
+
} else {
|
|
186
|
+
const chunks = [];
|
|
187
|
+
for await (const chunk of req) chunks.push(Buffer.from(chunk));
|
|
188
|
+
body = Buffer.concat(chunks).toString("utf8");
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return new Request(url, { method, headers, body });
|
|
192
|
+
}
|
|
193
|
+
|
|
129
194
|
// src/vite/index.ts
|
|
130
195
|
function linearFeedback(config) {
|
|
131
196
|
const endpoint = config.endpoint ?? "/api/feedback";
|
|
@@ -135,7 +200,7 @@ function linearFeedback(config) {
|
|
|
135
200
|
apply: "serve",
|
|
136
201
|
configureServer(server) {
|
|
137
202
|
server.middlewares.use(endpoint, (req, res, next) => {
|
|
138
|
-
if (req.method !== "POST") return next();
|
|
203
|
+
if (req.method !== "POST" && req.method !== "OPTIONS") return next();
|
|
139
204
|
handler(req, res).catch(next);
|
|
140
205
|
});
|
|
141
206
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "react-linear-feedback",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Drop-in React
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Drop-in feedback widget for React apps or any site via a script tag: draw a box, write a note, and it captures a screenshot and opens a Linear issue. Self-contained styles, zero design-system dependencies.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Oliver Odgaard",
|
|
7
7
|
"repository": {
|
|
@@ -16,14 +16,20 @@
|
|
|
16
16
|
"access": "public"
|
|
17
17
|
},
|
|
18
18
|
"type": "module",
|
|
19
|
-
"sideEffects":
|
|
19
|
+
"sideEffects": [
|
|
20
|
+
"dist/embed/linear-feedback.js"
|
|
21
|
+
],
|
|
20
22
|
"keywords": [
|
|
21
23
|
"react",
|
|
22
24
|
"feedback",
|
|
23
25
|
"linear",
|
|
24
26
|
"screenshot",
|
|
25
27
|
"bug-report",
|
|
26
|
-
"
|
|
28
|
+
"bug-reporting",
|
|
29
|
+
"widget",
|
|
30
|
+
"embed",
|
|
31
|
+
"script-tag",
|
|
32
|
+
"marker"
|
|
27
33
|
],
|
|
28
34
|
"files": [
|
|
29
35
|
"dist",
|
|
@@ -45,11 +51,18 @@
|
|
|
45
51
|
"types": "./dist/vite/index.d.ts",
|
|
46
52
|
"import": "./dist/vite/index.js",
|
|
47
53
|
"require": "./dist/vite/index.cjs"
|
|
54
|
+
},
|
|
55
|
+
"./embed": {
|
|
56
|
+
"types": "./dist/embed/index.d.ts",
|
|
57
|
+
"import": "./dist/embed/index.js"
|
|
48
58
|
}
|
|
49
59
|
},
|
|
60
|
+
"unpkg": "dist/embed/linear-feedback.js",
|
|
61
|
+
"jsdelivr": "dist/embed/linear-feedback.js",
|
|
50
62
|
"scripts": {
|
|
51
63
|
"build": "tsup && node scripts/postbuild.mjs",
|
|
52
64
|
"typecheck": "tsc --noEmit",
|
|
65
|
+
"size": "gzip -c dist/embed/linear-feedback.js | wc -c",
|
|
53
66
|
"prepublishOnly": "npm run build"
|
|
54
67
|
},
|
|
55
68
|
"dependencies": {
|
|
@@ -73,6 +86,7 @@
|
|
|
73
86
|
"@types/node": "^22.0.0",
|
|
74
87
|
"@types/react": "^19.0.0",
|
|
75
88
|
"@types/react-dom": "^19.0.0",
|
|
89
|
+
"preact": "^10.29.2",
|
|
76
90
|
"react": "^19.0.0",
|
|
77
91
|
"react-dom": "^19.0.0",
|
|
78
92
|
"tsup": "^8.5.0",
|