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.
@@ -0,0 +1,212 @@
1
+ 'use strict';
2
+
3
+ var sdk = require('@linear/sdk');
4
+
5
+ // src/server/core.ts
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
+ }
13
+ async function createFeedbackIssue(payload, config) {
14
+ const { apiKey, teamId } = config;
15
+ if (!apiKey || !teamId) throw new Error("not_configured");
16
+ const note = payload?.annotation?.note?.trim();
17
+ if (!note) throw new Error("note_required");
18
+ if (note.length > MAX_NOTE) throw new Error("note_too_long");
19
+ const { annotation, context } = payload;
20
+ const typeLabel = annotation.typeLabel || capitalize(annotation.type || "Feedback");
21
+ const linear = new sdk.LinearClient({ apiKey });
22
+ let assetUrl = null;
23
+ if (payload.screenshot?.startsWith("data:image/")) {
24
+ try {
25
+ assetUrl = await uploadScreenshot(linear, payload.screenshot);
26
+ } catch (err) {
27
+ console.error("[feedback] screenshot upload failed", err);
28
+ }
29
+ }
30
+ const description = [
31
+ note,
32
+ "",
33
+ "---",
34
+ assetUrl ? `![screenshot](${encodeURI(assetUrl)})` : "_No screenshot captured._",
35
+ "",
36
+ "**Context**",
37
+ `- Type: ${typeLabel}`,
38
+ context?.url ? `- Page: ${context.url}` : null,
39
+ annotation.name ? `- Reported by: ${annotation.name}` : null,
40
+ context?.userAgent ? `- User agent: ${context.userAgent}` : null,
41
+ context?.timestamp ? `- Submitted: ${context.timestamp}` : null
42
+ ].filter(Boolean).join("\n");
43
+ const title = `${typeLabel}: ${note.slice(0, 60)}${note.length > 60 ? "\u2026" : ""}`;
44
+ const labelName = config.labels?.[annotation.type] ?? annotation.type;
45
+ const ttl = config.labelCacheTtlMs ?? DEFAULT_LABEL_TTL_MS;
46
+ const labelId = labelName ? await resolveLabelIdCached(linear, labelName, teamId, ttl) : null;
47
+ if (labelName && !labelId) console.warn(`[feedback] label "${labelName}" not found \u2014 creating issue without it`);
48
+ const create = async (ids) => {
49
+ const created = await linear.createIssue({ teamId, title, description, labelIds: ids.length ? ids : void 0 });
50
+ return await created.issue;
51
+ };
52
+ const issue = await create(labelId ? [labelId] : []).catch((err) => {
53
+ if (!labelId) throw err;
54
+ if (labelName) labelCache.delete(labelCacheKey(teamId, labelName));
55
+ console.warn("[feedback] create failed with label, retrying without it", err);
56
+ return create([]);
57
+ });
58
+ return { id: issue?.id, identifier: issue?.identifier, url: issue?.url };
59
+ }
60
+ function capitalize(s) {
61
+ return s.charAt(0).toUpperCase() + s.slice(1);
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
+ }
72
+ async function resolveLabelId(linear, name, teamId) {
73
+ try {
74
+ const { nodes } = await linear.issueLabels({ filter: { name: { eqIgnoreCase: name } }, first: 50 });
75
+ if (nodes.length === 0) return null;
76
+ if (nodes.length === 1) return nodes[0].id;
77
+ const scored = await Promise.all(
78
+ nodes.map(async (n) => {
79
+ try {
80
+ const team = await n.team;
81
+ return { id: n.id, teamId: team?.id ?? null };
82
+ } catch {
83
+ return { id: n.id, teamId: null };
84
+ }
85
+ })
86
+ );
87
+ const pick = scored.find((s) => s.teamId === teamId) ?? scored.find((s) => s.teamId === null) ?? scored[0];
88
+ return pick.id;
89
+ } catch (err) {
90
+ console.warn("[feedback] label lookup failed", name, err);
91
+ return null;
92
+ }
93
+ }
94
+ async function uploadScreenshot(linear, dataUrl) {
95
+ const [meta, b64] = dataUrl.split(",");
96
+ const contentType = /data:(.*?);base64/.exec(meta)?.[1] ?? "image/jpeg";
97
+ const bytes = Buffer.from(b64, "base64");
98
+ const filename = `feedback-${Date.now()}.jpg`;
99
+ const upload = await linear.fileUpload(contentType, filename, bytes.length);
100
+ if (!upload.success || !upload.uploadFile) throw new Error("failed to request upload URL");
101
+ const headers = new Headers();
102
+ headers.set("Content-Type", contentType);
103
+ headers.set("Cache-Control", "public, max-age=31536000");
104
+ upload.uploadFile.headers.forEach(({ key, value }) => headers.set(key, value));
105
+ const put = await fetch(upload.uploadFile.uploadUrl, { method: "PUT", headers, body: new Uint8Array(bytes) });
106
+ if (!put.ok) throw new Error(`upload PUT failed: ${put.status}`);
107
+ return upload.uploadFile.assetUrl;
108
+ }
109
+
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
+ });
117
+ }
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);
141
+ let payload;
142
+ try {
143
+ payload = await req.json();
144
+ } catch {
145
+ return json({ error: "bad_json" }, 400, cors);
146
+ }
147
+ try {
148
+ const issue = await createFeedbackIssue(payload, config);
149
+ return json({ ok: true, ...issue }, 200, cors);
150
+ } catch (err) {
151
+ const message = err instanceof Error ? err.message : String(err);
152
+ if (BAD_REQUEST.has(message)) return json({ error: message }, message === "not_configured" ? 500 : 400, cors);
153
+ console.error("[feedback] issue create failed", err);
154
+ return json({ error: "issue_create_failed", message }, 502, cors);
155
+ }
156
+ };
157
+ }
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
+
196
+ // src/vite/index.ts
197
+ function linearFeedback(config) {
198
+ const endpoint = config.endpoint ?? "/api/feedback";
199
+ const handler = createNodeHandler(config);
200
+ return {
201
+ name: "react-linear-feedback",
202
+ apply: "serve",
203
+ configureServer(server) {
204
+ server.middlewares.use(endpoint, (req, res, next) => {
205
+ if (req.method !== "POST" && req.method !== "OPTIONS") return next();
206
+ handler(req, res).catch(next);
207
+ });
208
+ }
209
+ };
210
+ }
211
+
212
+ exports.linearFeedback = linearFeedback;
@@ -0,0 +1,45 @@
1
+ import { Plugin } from 'vite';
2
+ import { IncomingMessage } from 'node:http';
3
+
4
+ type FeedbackServerConfig = {
5
+ /** Linear personal API key (server-side secret). */
6
+ apiKey: string;
7
+ /** Target team UUID. */
8
+ teamId: string;
9
+ /**
10
+ * Map a type id (e.g. "bug") to a Linear label NAME. Defaults to the type id itself, so a type
11
+ * "bug" looks for a label named "bug". Labels are resolved by name at request time.
12
+ */
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;
19
+ };
20
+
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. */
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`. */
36
+ authorize?: (req: IncomingMessage) => boolean | Promise<boolean>;
37
+ };
38
+
39
+ type LinearFeedbackViteConfig = NodeHandlerConfig & {
40
+ /** Path the widget POSTs to; must match `<FeedbackGate endpoint>`. Default `/api/feedback`. */
41
+ endpoint?: string;
42
+ };
43
+ declare function linearFeedback(config: LinearFeedbackViteConfig): Plugin;
44
+
45
+ export { type LinearFeedbackViteConfig, linearFeedback };
@@ -0,0 +1,45 @@
1
+ import { Plugin } from 'vite';
2
+ import { IncomingMessage } from 'node:http';
3
+
4
+ type FeedbackServerConfig = {
5
+ /** Linear personal API key (server-side secret). */
6
+ apiKey: string;
7
+ /** Target team UUID. */
8
+ teamId: string;
9
+ /**
10
+ * Map a type id (e.g. "bug") to a Linear label NAME. Defaults to the type id itself, so a type
11
+ * "bug" looks for a label named "bug". Labels are resolved by name at request time.
12
+ */
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;
19
+ };
20
+
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. */
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`. */
36
+ authorize?: (req: IncomingMessage) => boolean | Promise<boolean>;
37
+ };
38
+
39
+ type LinearFeedbackViteConfig = NodeHandlerConfig & {
40
+ /** Path the widget POSTs to; must match `<FeedbackGate endpoint>`. Default `/api/feedback`. */
41
+ endpoint?: string;
42
+ };
43
+ declare function linearFeedback(config: LinearFeedbackViteConfig): Plugin;
44
+
45
+ export { type LinearFeedbackViteConfig, linearFeedback };
@@ -0,0 +1,210 @@
1
+ import { LinearClient } from '@linear/sdk';
2
+
3
+ // src/server/core.ts
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
+ }
11
+ async function createFeedbackIssue(payload, config) {
12
+ const { apiKey, teamId } = config;
13
+ if (!apiKey || !teamId) throw new Error("not_configured");
14
+ const note = payload?.annotation?.note?.trim();
15
+ if (!note) throw new Error("note_required");
16
+ if (note.length > MAX_NOTE) throw new Error("note_too_long");
17
+ const { annotation, context } = payload;
18
+ const typeLabel = annotation.typeLabel || capitalize(annotation.type || "Feedback");
19
+ const linear = new LinearClient({ apiKey });
20
+ let assetUrl = null;
21
+ if (payload.screenshot?.startsWith("data:image/")) {
22
+ try {
23
+ assetUrl = await uploadScreenshot(linear, payload.screenshot);
24
+ } catch (err) {
25
+ console.error("[feedback] screenshot upload failed", err);
26
+ }
27
+ }
28
+ const description = [
29
+ note,
30
+ "",
31
+ "---",
32
+ assetUrl ? `![screenshot](${encodeURI(assetUrl)})` : "_No screenshot captured._",
33
+ "",
34
+ "**Context**",
35
+ `- Type: ${typeLabel}`,
36
+ context?.url ? `- Page: ${context.url}` : null,
37
+ annotation.name ? `- Reported by: ${annotation.name}` : null,
38
+ context?.userAgent ? `- User agent: ${context.userAgent}` : null,
39
+ context?.timestamp ? `- Submitted: ${context.timestamp}` : null
40
+ ].filter(Boolean).join("\n");
41
+ const title = `${typeLabel}: ${note.slice(0, 60)}${note.length > 60 ? "\u2026" : ""}`;
42
+ const labelName = config.labels?.[annotation.type] ?? annotation.type;
43
+ const ttl = config.labelCacheTtlMs ?? DEFAULT_LABEL_TTL_MS;
44
+ const labelId = labelName ? await resolveLabelIdCached(linear, labelName, teamId, ttl) : null;
45
+ if (labelName && !labelId) console.warn(`[feedback] label "${labelName}" not found \u2014 creating issue without it`);
46
+ const create = async (ids) => {
47
+ const created = await linear.createIssue({ teamId, title, description, labelIds: ids.length ? ids : void 0 });
48
+ return await created.issue;
49
+ };
50
+ const issue = await create(labelId ? [labelId] : []).catch((err) => {
51
+ if (!labelId) throw err;
52
+ if (labelName) labelCache.delete(labelCacheKey(teamId, labelName));
53
+ console.warn("[feedback] create failed with label, retrying without it", err);
54
+ return create([]);
55
+ });
56
+ return { id: issue?.id, identifier: issue?.identifier, url: issue?.url };
57
+ }
58
+ function capitalize(s) {
59
+ return s.charAt(0).toUpperCase() + s.slice(1);
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
+ }
70
+ async function resolveLabelId(linear, name, teamId) {
71
+ try {
72
+ const { nodes } = await linear.issueLabels({ filter: { name: { eqIgnoreCase: name } }, first: 50 });
73
+ if (nodes.length === 0) return null;
74
+ if (nodes.length === 1) return nodes[0].id;
75
+ const scored = await Promise.all(
76
+ nodes.map(async (n) => {
77
+ try {
78
+ const team = await n.team;
79
+ return { id: n.id, teamId: team?.id ?? null };
80
+ } catch {
81
+ return { id: n.id, teamId: null };
82
+ }
83
+ })
84
+ );
85
+ const pick = scored.find((s) => s.teamId === teamId) ?? scored.find((s) => s.teamId === null) ?? scored[0];
86
+ return pick.id;
87
+ } catch (err) {
88
+ console.warn("[feedback] label lookup failed", name, err);
89
+ return null;
90
+ }
91
+ }
92
+ async function uploadScreenshot(linear, dataUrl) {
93
+ const [meta, b64] = dataUrl.split(",");
94
+ const contentType = /data:(.*?);base64/.exec(meta)?.[1] ?? "image/jpeg";
95
+ const bytes = Buffer.from(b64, "base64");
96
+ const filename = `feedback-${Date.now()}.jpg`;
97
+ const upload = await linear.fileUpload(contentType, filename, bytes.length);
98
+ if (!upload.success || !upload.uploadFile) throw new Error("failed to request upload URL");
99
+ const headers = new Headers();
100
+ headers.set("Content-Type", contentType);
101
+ headers.set("Cache-Control", "public, max-age=31536000");
102
+ upload.uploadFile.headers.forEach(({ key, value }) => headers.set(key, value));
103
+ const put = await fetch(upload.uploadFile.uploadUrl, { method: "PUT", headers, body: new Uint8Array(bytes) });
104
+ if (!put.ok) throw new Error(`upload PUT failed: ${put.status}`);
105
+ return upload.uploadFile.assetUrl;
106
+ }
107
+
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
+ });
115
+ }
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);
139
+ let payload;
140
+ try {
141
+ payload = await req.json();
142
+ } catch {
143
+ return json({ error: "bad_json" }, 400, cors);
144
+ }
145
+ try {
146
+ const issue = await createFeedbackIssue(payload, config);
147
+ return json({ ok: true, ...issue }, 200, cors);
148
+ } catch (err) {
149
+ const message = err instanceof Error ? err.message : String(err);
150
+ if (BAD_REQUEST.has(message)) return json({ error: message }, message === "not_configured" ? 500 : 400, cors);
151
+ console.error("[feedback] issue create failed", err);
152
+ return json({ error: "issue_create_failed", message }, 502, cors);
153
+ }
154
+ };
155
+ }
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
+
194
+ // src/vite/index.ts
195
+ function linearFeedback(config) {
196
+ const endpoint = config.endpoint ?? "/api/feedback";
197
+ const handler = createNodeHandler(config);
198
+ return {
199
+ name: "react-linear-feedback",
200
+ apply: "serve",
201
+ configureServer(server) {
202
+ server.middlewares.use(endpoint, (req, res, next) => {
203
+ if (req.method !== "POST" && req.method !== "OPTIONS") return next();
204
+ handler(req, res).catch(next);
205
+ });
206
+ }
207
+ };
208
+ }
209
+
210
+ export { linearFeedback };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "react-linear-feedback",
3
- "version": "0.1.1",
4
- "description": "Drop-in React feedback widget: draw a box, write a note, and it captures a screenshot and opens a Linear issue. Framework-agnostic, self-contained styles, zero design-system dependencies.",
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": false,
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
- "widget"
28
+ "bug-reporting",
29
+ "widget",
30
+ "embed",
31
+ "script-tag",
32
+ "marker"
27
33
  ],
28
34
  "files": [
29
35
  "dist",
@@ -40,37 +46,51 @@
40
46
  "types": "./dist/server/index.d.ts",
41
47
  "import": "./dist/server/index.js",
42
48
  "require": "./dist/server/index.cjs"
49
+ },
50
+ "./vite": {
51
+ "types": "./dist/vite/index.d.ts",
52
+ "import": "./dist/vite/index.js",
53
+ "require": "./dist/vite/index.cjs"
54
+ },
55
+ "./embed": {
56
+ "types": "./dist/embed/index.d.ts",
57
+ "import": "./dist/embed/index.js"
43
58
  }
44
59
  },
60
+ "unpkg": "dist/embed/linear-feedback.js",
61
+ "jsdelivr": "dist/embed/linear-feedback.js",
45
62
  "scripts": {
46
63
  "build": "tsup && node scripts/postbuild.mjs",
47
64
  "typecheck": "tsc --noEmit",
65
+ "size": "gzip -c dist/embed/linear-feedback.js | wc -c",
48
66
  "prepublishOnly": "npm run build"
49
67
  },
50
68
  "dependencies": {
69
+ "@linear/sdk": "^86.0.0",
51
70
  "modern-screenshot": "^4.7.0"
52
71
  },
53
72
  "peerDependencies": {
54
- "@linear/sdk": ">=40",
55
73
  "react": ">=18",
56
- "react-dom": ">=18"
74
+ "react-dom": ">=18",
75
+ "vite": ">=5"
57
76
  },
58
77
  "peerDependenciesMeta": {
59
- "@linear/sdk": {
78
+ "react-dom": {
60
79
  "optional": true
61
80
  },
62
- "react-dom": {
81
+ "vite": {
63
82
  "optional": true
64
83
  }
65
84
  },
66
85
  "devDependencies": {
67
- "@linear/sdk": "^86.0.0",
68
86
  "@types/node": "^22.0.0",
69
87
  "@types/react": "^19.0.0",
70
88
  "@types/react-dom": "^19.0.0",
89
+ "preact": "^10.29.2",
71
90
  "react": "^19.0.0",
72
91
  "react-dom": "^19.0.0",
73
92
  "tsup": "^8.5.0",
74
- "typescript": "^5.7.0"
93
+ "typescript": "^5.7.0",
94
+ "vite": "^6.0.0"
75
95
  }
76
96
  }