visual-feedback 0.0.0-stage → 0.2.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,114 @@
1
+ # Recipe: Postgres
2
+
3
+ Each report becomes one row in `feedback_reports`: the `FeedbackReport` (docs/API.md), its
4
+ screenshots without their images, as `jsonb`, and the user who sent it, taken from your own session.
5
+ Each screenshot's image becomes one row in `feedback_screenshots`, by its `position` in
6
+ `report.screenshots`: the image in `image` (`bytea`) and its type in `image_type`. `user_id` is the
7
+ proof of who sent a report; `report.context` is not (docs/API.md).
8
+
9
+ To keep images in a bucket instead (docs/recipes/s3.md):
10
+
11
+ 1. Install `aws4fetch` and set the `S3_*` variables as that recipe's step 1 says.
12
+ 2. Copy its `bucket()` function into this route.
13
+ 3. Before the insert, PUT each image to `url(report.id, "screenshot-<position>.png")` or `.webp`,
14
+ as that recipe's `POST` does:
15
+ with `"if-none-match": "*"`, so a reused id never replaces a stored image, and throw on any
16
+ refusal but 412, so a row never lacks its image.
17
+ 4. Insert `null` for `image` and keep `image_type`: `image/png` is the file
18
+ `screenshot-<position>.png`, and `image/webp` is `screenshot-<position>.webp`.
19
+ 5. Read images back with that recipe's `GET`, its `canRead` replaced by the same admin check your
20
+ screens use.
21
+
22
+ The images show the screen as the user saw it, with nothing masked (SPEC §7), so read the table only
23
+ from your own admin screens.
24
+
25
+ ## 1. Install and create the table
26
+
27
+ ```sh
28
+ npm install postgres
29
+ ```
30
+
31
+ ```sql
32
+ create table feedback_reports (
33
+ id uuid primary key,
34
+ user_id text not null,
35
+ created_at timestamptz not null,
36
+ report jsonb not null
37
+ );
38
+
39
+ create table feedback_screenshots (
40
+ report_id uuid not null references feedback_reports (id) on delete cascade,
41
+ position integer not null,
42
+ image bytea,
43
+ image_type text not null,
44
+ primary key (report_id, position)
45
+ );
46
+ ```
47
+
48
+ Set `DATABASE_URL` on the server. On a serverless host, use your provider's pooled URL, since each
49
+ instance opens its own connections; a pooler in transaction mode also needs `prepare: false` beside
50
+ `idle_timeout` below.
51
+
52
+ ## 2. The route
53
+
54
+ The widget posts to it with `createFeedback({ endpoint: "/api/feedback" })`. This is a Next.js
55
+ App Router route.
56
+
57
+ ```ts
58
+ // app/api/feedback/route.ts
59
+ import postgres from "postgres";
60
+ import { FeedbackRequestError, parseFeedbackRequest } from "visual-feedback/server";
61
+
62
+ let sql: postgres.Sql | undefined;
63
+
64
+ // Connects on the first send, so a build or an import never opens a connection.
65
+ function db(): postgres.Sql {
66
+ if (!process.env.DATABASE_URL) throw new Error("Set DATABASE_URL.");
67
+ // idle_timeout closes a pool a hot reload left behind, instead of holding its connections.
68
+ sql ??= postgres(process.env.DATABASE_URL, { idle_timeout: 20 });
69
+ return sql;
70
+ }
71
+
72
+ // Replace with your own session: the user who is signed in. Until then, nobody can send.
73
+ // Never take the user from report.context, which the browser controls.
74
+ async function currentUser(_request: Request): Promise<string | null> {
75
+ return null;
76
+ }
77
+
78
+ export async function POST(request: Request): Promise<Response> {
79
+ const user = await currentUser(request);
80
+ if (!user) return new Response(null, { status: 401 });
81
+ try {
82
+ const { report } = await parseFeedbackRequest(request);
83
+ const screenshots = report.screenshots.map(({ image: _, ...shot }) => shot);
84
+ // The report was parsed from JSON, so it is JSON; context is only typed as unknown values.
85
+ const json = db().json({ ...report, screenshots } as postgres.JSONValue);
86
+ const images = await Promise.all(
87
+ report.screenshots.map(async ({ image }, position) => ({
88
+ report_id: report.id,
89
+ position,
90
+ image: new Uint8Array(await image.arrayBuffer()),
91
+ image_type: image.type,
92
+ })),
93
+ );
94
+ // One transaction, so a report row never lacks its screenshots.
95
+ await db().begin(async (sql) => {
96
+ // A "Try again" sends the same id, so the rows it already wrote stay as they are.
97
+ const inserted = await sql`
98
+ insert into feedback_reports (id, user_id, created_at, report)
99
+ values (${report.id}, ${user}, ${report.createdAt}, ${json})
100
+ on conflict (id) do nothing
101
+ returning id`;
102
+ if (inserted.length && images.length) {
103
+ await sql`insert into feedback_screenshots ${sql(images)}`;
104
+ }
105
+ });
106
+ return new Response(null, { status: 204 });
107
+ } catch (error) {
108
+ if (error instanceof FeedbackRequestError) {
109
+ return new Response(error.message, { status: error.status });
110
+ }
111
+ throw error;
112
+ }
113
+ }
114
+ ```
@@ -0,0 +1,129 @@
1
+ # Recipe: an S3-compatible bucket (S3, R2, Railway Buckets)
2
+
3
+ Each report becomes objects in a private bucket, its report and one per screenshot, named and shaped as in the local recipe
4
+ (docs/recipes/local.md): `feedback/<id>/report.json`, where each screenshot's `image` is its file
5
+ name, and `feedback/<id>/screenshot-<n>.png` or `.webp` for each screenshot, numbered from 0.
6
+
7
+ The bucket stays private: nothing is public, and people read reports only through your own route,
8
+ behind your own admin check. `report.context` comes from the browser (docs/API.md), so an admin view
9
+ must not show it as proof of who sent a report. The images show the screen as the user saw it, with nothing masked
10
+ (SPEC §7).
11
+
12
+ ## 1. Create the bucket, install, configure
13
+
14
+ Create the bucket with public access off (on S3, keep Block Public Access on), then check that an
15
+ object URL opened without a key is refused.
16
+
17
+ ```sh
18
+ npm install aws4fetch
19
+ ```
20
+
21
+ Set these on the server. Never send them to the browser.
22
+
23
+ | variable | for example |
24
+ |---|---|
25
+ | `S3_ENDPOINT` | `https://s3.eu-west-2.amazonaws.com`, `https://<account>.r2.cloudflarestorage.com`, or your Railway Bucket's endpoint |
26
+ | `S3_REGION` | `eu-west-2`; optional, `auto` when unset, which R2 takes and AWS does not |
27
+ | `S3_BUCKET` | `feedback` |
28
+ | `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY` | a key that may read and write this bucket only |
29
+
30
+ ## 2. The route
31
+
32
+ The widget posts to it with `createFeedback({ endpoint: "/api/feedback" })`. This is a Next.js
33
+ App Router route. As written, anyone who can reach `POST` can write to the bucket: before production,
34
+ put it behind your own session check and a rate limit. A report already in the bucket is never
35
+ replaced: the route checks for it first, and every write is create-only (`If-None-Match: *`, which
36
+ S3 and R2 support). On a store that ignores that header, only the first check protects it.
37
+
38
+ ```ts
39
+ // app/api/feedback/route.ts
40
+ import { AwsClient } from "aws4fetch";
41
+ import { FeedbackRequestError, parseFeedbackRequest } from "visual-feedback/server";
42
+
43
+ // Replace with your own admin check, from your session. Until then, nobody can read.
44
+ async function canRead(_request: Request): Promise<boolean> {
45
+ return false;
46
+ }
47
+
48
+ function bucket() {
49
+ const { S3_ENDPOINT, S3_REGION, S3_BUCKET, S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY } = process.env;
50
+ if (!S3_ENDPOINT || !S3_BUCKET || !S3_ACCESS_KEY_ID || !S3_SECRET_ACCESS_KEY) {
51
+ throw new Error("Set S3_ENDPOINT, S3_BUCKET, S3_ACCESS_KEY_ID and S3_SECRET_ACCESS_KEY.");
52
+ }
53
+ const client = new AwsClient({
54
+ accessKeyId: S3_ACCESS_KEY_ID,
55
+ secretAccessKey: S3_SECRET_ACCESS_KEY,
56
+ region: S3_REGION || "auto",
57
+ service: "s3",
58
+ // aws4fetch retries 5xx about 50 s by default; the user would wait on "Sending…" that long.
59
+ retries: 2,
60
+ });
61
+ const base = `${S3_ENDPOINT.replace(/\/+$/, "")}/${S3_BUCKET}/feedback`;
62
+ return { client, url: (id: string, name: string) => `${base}/${id}/${name}` };
63
+ }
64
+
65
+ export async function POST(request: Request): Promise<Response> {
66
+ try {
67
+ const { report } = await parseFeedbackRequest(request);
68
+ const { client, url } = bucket();
69
+ // A report already stored under this id stays: a retry, or a reused id, changes nothing.
70
+ const stored = await client.fetch(url(report.id, "report.json"), { method: "HEAD" });
71
+ if (stored.ok) return new Response(null, { status: 204 });
72
+ // S3 answers 403 for a missing key when the key may not list; any other answer is unclear.
73
+ if (stored.status !== 403 && stored.status !== 404) {
74
+ throw new Error(`The bucket answered ${stored.status} for report.json.`);
75
+ }
76
+ const name = (image: Blob, i: number) =>
77
+ `screenshot-${i}.${image.type === "image/png" ? "png" : "webp"}`;
78
+ const screenshots = report.screenshots.map((shot, i) => ({ ...shot, image: name(shot.image, i) }));
79
+ // The images first: a report.json in the bucket then always has its images beside it.
80
+ const puts: [string, BodyInit, string][] = [
81
+ ...report.screenshots.map(
82
+ ({ image }, i): [string, BodyInit, string] => [name(image, i), image, image.type],
83
+ ),
84
+ ["report.json", JSON.stringify({ ...report, screenshots }), "application/json"],
85
+ ];
86
+ for (const [file, body, type] of puts) {
87
+ const put = await client.fetch(url(report.id, file), {
88
+ method: "PUT",
89
+ body,
90
+ // Create-only: S3 and R2 answer 412 and keep the object a request stored first.
91
+ headers: { "content-type": type, "if-none-match": "*" },
92
+ });
93
+ if (!put.ok && put.status !== 412) {
94
+ throw new Error(`The bucket answered ${put.status} for ${file}.`);
95
+ }
96
+ }
97
+ return new Response(null, { status: 204 });
98
+ } catch (error) {
99
+ if (error instanceof FeedbackRequestError) {
100
+ return new Response(error.message, { status: error.status });
101
+ }
102
+ throw error;
103
+ }
104
+ }
105
+
106
+ // GET /api/feedback?id=<report id>&file=report.json | screenshot-<n>.png | screenshot-<n>.webp
107
+ export async function GET(request: Request): Promise<Response> {
108
+ if (!(await canRead(request))) return new Response(null, { status: 403 });
109
+ const query = new URL(request.url).searchParams;
110
+ const id = query.get("id") ?? "";
111
+ const file = query.get("file") ?? "";
112
+ // Only a report id and a known file name, so the key cannot leave feedback/<id>/.
113
+ const known = file === "report.json" || /^screenshot-\d\.(png|webp)$/.test(file);
114
+ if (!/^[0-9a-f-]{36}$/.test(id) || !known) {
115
+ return new Response(null, { status: 400 });
116
+ }
117
+ const { client, url } = bucket();
118
+ const object = await client.fetch(url(id, file));
119
+ // S3 answers 403, not 404, for a missing key when the key may not list the bucket.
120
+ const missing = object.status === 403 || object.status === 404;
121
+ if (!object.ok) return new Response(null, { status: missing ? 404 : 502 });
122
+ return new Response(object.body, {
123
+ headers: {
124
+ "content-type": object.headers.get("content-type") ?? "application/octet-stream",
125
+ "cache-control": "private, no-store",
126
+ },
127
+ });
128
+ }
129
+ ```
@@ -0,0 +1,124 @@
1
+ # Recipe: Supabase
2
+
3
+ Each report becomes one row in a table and one image per screenshot in the private Storage bucket
4
+ `feedback`, named `<id>/screenshot-<n>.png` or `.webp`, numbered from 0. The row has the columns of
5
+ the Postgres recipe's `feedback_reports` (docs/recipes/postgres.md), except that `user_id` is the
6
+ Supabase user's uuid. The images are in Storage instead of a second table, and each screenshot's
7
+ `image` in `report` is its file name. Row-level security lets only your app's admins read either,
8
+ and nobody but your server can write.
9
+
10
+ `user_id` is the proof of who sent a report; `report.context` is not (docs/API.md). The images show
11
+ the screen as the user saw it, with nothing masked (SPEC §7).
12
+
13
+ ## 1. Install and create the table, the bucket and the policies
14
+
15
+ ```sh
16
+ npm install @supabase/supabase-js
17
+ ```
18
+
19
+ Run this in the SQL editor. An admin is a user whose `app_metadata.role` is `admin`. Only your
20
+ server can set it, with the service role:
21
+ `supabase.auth.admin.updateUserById(id, { app_metadata: { role: "admin" } })`. A change, removal
22
+ included, reaches the user's token only at their next sign-in or token refresh (within an hour by
23
+ default). Never use `user_metadata` for this: users can edit their own.
24
+
25
+ ```sql
26
+ create table feedback_reports (
27
+ id uuid primary key,
28
+ user_id uuid not null references auth.users (id) on delete cascade,
29
+ created_at timestamptz not null,
30
+ report jsonb not null
31
+ );
32
+ alter table feedback_reports enable row level security;
33
+ grant select on feedback_reports to authenticated;
34
+ grant all on feedback_reports to service_role;
35
+ create policy "Admins read feedback" on feedback_reports for select to authenticated
36
+ using ((auth.jwt() -> 'app_metadata' ->> 'role') = 'admin');
37
+
38
+ insert into storage.buckets (id, name, public) values ('feedback', 'feedback', false);
39
+ create policy "Admins read feedback images" on storage.objects for select to authenticated
40
+ using (bucket_id = 'feedback' and (auth.jwt() -> 'app_metadata' ->> 'role') = 'admin');
41
+ ```
42
+
43
+ No policy allows an insert, an update or a delete, so only the service role, on your server, writes.
44
+ Deleting a user deletes their rows, which list their report ids, so remove their images under
45
+ `<id>/` first.
46
+
47
+ Check it: with the anon key, `select` on `feedback_reports` returns an empty list or a permission
48
+ error, never a row, and a download from `feedback` is refused.
49
+
50
+ An admin screen reads with the admin's own session, never the service role key:
51
+ `from("feedback_reports").select()` for the rows, filtered and paged as the screen needs, and
52
+ `storage.from("feedback").createSignedUrl("<id>/<image>", 60)` for a screenshot, where `<image>` is
53
+ its `image` in `report`, such as `screenshot-0.png`. A signed URL works for anyone who holds it for
54
+ those 60 seconds: show it, never share it.
55
+
56
+ Set `SUPABASE_URL` and `SUPABASE_SERVICE_ROLE_KEY` on the server. The service role key bypasses
57
+ row-level security: never send it to the browser.
58
+
59
+ ## 2. The route
60
+
61
+ The widget posts to it with `createFeedback({ endpoint: "/api/feedback" })`. This is a Next.js
62
+ App Router route. As written, it refuses every send until you fill in `currentUser`; then any
63
+ signed-in user can send, so put a rate limit in front of it before production.
64
+
65
+ ```ts
66
+ // app/api/feedback/route.ts
67
+ import { createClient } from "@supabase/supabase-js";
68
+ import { FeedbackRequestError, parseFeedbackRequest } from "visual-feedback/server";
69
+
70
+ // Replace with your own session: the signed-in user's Supabase id (a uuid), for example from
71
+ // supabase.auth.getUser() in @supabase/ssr. Until then, nobody can send. Never use report.context.
72
+ async function currentUser(_request: Request): Promise<string | null> {
73
+ return null;
74
+ }
75
+
76
+ function supabase() {
77
+ const { SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY } = process.env;
78
+ if (!SUPABASE_URL || !SUPABASE_SERVICE_ROLE_KEY) {
79
+ throw new Error("Set SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY.");
80
+ }
81
+ return createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, {
82
+ auth: { persistSession: false, autoRefreshToken: false },
83
+ });
84
+ }
85
+
86
+ export async function POST(request: Request): Promise<Response> {
87
+ const user = await currentUser(request);
88
+ if (!user) return new Response(null, { status: 401 });
89
+ try {
90
+ const { report } = await parseFeedbackRequest(request);
91
+ const db = supabase();
92
+ const name = (image: Blob, i: number) =>
93
+ `screenshot-${i}.${image.type === "image/png" ? "png" : "webp"}`;
94
+ const screenshots = report.screenshots.map((shot, i) => ({ ...shot, image: name(shot.image, i) }));
95
+ // The images first, so a row never lacks its images.
96
+ for (const [i, { image }] of report.screenshots.entries()) {
97
+ const upload = await db.storage
98
+ .from("feedback")
99
+ .upload(`${report.id}/${name(image, i)}`, image, { contentType: image.type, upsert: false });
100
+ // A resend finds its image there (409); the id comes from the browser, so never replace it.
101
+ const error = upload.error;
102
+ const stored = error && "statusCode" in error && error.statusCode === "409";
103
+ if (error && !stored) throw error;
104
+ }
105
+ // A "Try again" sends the same id, so the row it already wrote stays as it is.
106
+ const insert = await db.from("feedback_reports").upsert(
107
+ {
108
+ id: report.id,
109
+ user_id: user,
110
+ created_at: report.createdAt,
111
+ report: { ...report, screenshots },
112
+ },
113
+ { onConflict: "id", ignoreDuplicates: true },
114
+ );
115
+ if (insert.error) throw insert.error;
116
+ return new Response(null, { status: 204 });
117
+ } catch (error) {
118
+ if (error instanceof FeedbackRequestError) {
119
+ return new Response(error.message, { status: error.status });
120
+ }
121
+ throw error;
122
+ }
123
+ }
124
+ ```
@@ -0,0 +1,129 @@
1
+ # Recipe: Vercel Blob
2
+
3
+ Each report becomes private blobs, named and shaped as in the S3 recipe (docs/recipes/s3.md):
4
+ `feedback/<id>/report.json` and `feedback/<id>/screenshot-<n>.png` or `.webp` for each screenshot.
5
+ People read them only
6
+ through your own route, behind your own admin check. `report.context` comes from the browser
7
+ (docs/API.md), so an admin view must not show it as proof of who sent a report. The images show the
8
+ screen as the user saw it, with nothing masked (SPEC §7).
9
+
10
+ It uses `fetch` and Vercel Blob's REST API, so it needs no package. The URLs, headers and token
11
+ shape are the ones `@vercel/blob` 2.8.1 sends; if Vercel changes its API, compare with that client.
12
+
13
+ ## 1. Create the store
14
+
15
+ Create a Blob store with private access in your Vercel project. That sets `BLOB_READ_WRITE_TOKEN`
16
+ on the server. Never send it to the browser. Check it: open
17
+ `https://<store id>.private.blob.vercel-storage.com/feedback/<id>/report.json` without the token,
18
+ where the store id is the part after `vercel_blob_rw_` in the token, and Vercel refuses it.
19
+
20
+ Vercel refuses a request body over 4.5 MB, all screenshots of a report together, with 413, and the
21
+ widget then shows its send error. The route takes images up to 3 MB each; a smaller
22
+ `capture.maxWidth` (docs/API.md) keeps several screenshots under the limit.
23
+
24
+ ## 2. The route
25
+
26
+ The widget posts to it with `createFeedback({ endpoint: "/api/feedback" })`. This is a Next.js
27
+ App Router route. As written, anyone who can reach `POST` can write: before production, put it
28
+ behind your own session check and a rate limit.
29
+
30
+ ```ts
31
+ // app/api/feedback/route.ts
32
+ import { FeedbackRequestError, parseFeedbackRequest } from "visual-feedback/server";
33
+
34
+ // Replace with your own admin check, from your session. Until then, nobody can read.
35
+ async function canRead(_request: Request): Promise<boolean> {
36
+ return false;
37
+ }
38
+
39
+ function blob() {
40
+ const token = process.env.BLOB_READ_WRITE_TOKEN;
41
+ // A read-write token is vercel_blob_rw_<store id>_<secret>.
42
+ const store = token?.split("_")[3];
43
+ if (!token?.startsWith("vercel_blob_rw_") || !store) {
44
+ throw new Error("Set BLOB_READ_WRITE_TOKEN.");
45
+ }
46
+ const auth = { authorization: `Bearer ${token}` };
47
+ const path = (id: string, name: string) => `feedback/${id}/${name}`;
48
+ return {
49
+ // Next.js 14 caches a fetch in a route by default; a cached 404 would hide a stored report.
50
+ read: (id: string, name: string) =>
51
+ fetch(`https://${store}.private.blob.vercel-storage.com/${path(id, name)}`, {
52
+ headers: auth,
53
+ cache: "no-store",
54
+ }),
55
+ async put(id: string, name: string, body: BodyInit, type: string) {
56
+ const query = new URLSearchParams({ pathname: path(id, name) });
57
+ const put = await fetch(`https://vercel.com/api/blob/?${query}`, {
58
+ method: "PUT",
59
+ body,
60
+ headers: {
61
+ ...auth,
62
+ "x-api-version": "12",
63
+ "x-vercel-blob-access": "private",
64
+ "x-content-type": type,
65
+ "x-add-random-suffix": "0",
66
+ "x-allow-overwrite": "1",
67
+ },
68
+ });
69
+ if (!put.ok) throw new Error(`Vercel Blob answered ${put.status} for ${name}.`);
70
+ },
71
+ };
72
+ }
73
+
74
+ export async function POST(request: Request): Promise<Response> {
75
+ try {
76
+ // Vercel refuses a request body over 4.5 MB, all screenshots together; this caps each image.
77
+ const { report } = await parseFeedbackRequest(request, { maxImageBytes: 3_000_000 });
78
+ const store = blob();
79
+ // The id comes from the browser: once a report is stored, a later request does not replace it.
80
+ // Two requests with one id at the same moment still can; the check and the write are separate.
81
+ const stored = await store.read(report.id, "report.json");
82
+ await stored.body?.cancel();
83
+ if (stored.ok) return new Response(null, { status: 204 });
84
+ // Only a 404 means "not stored"; on any other answer, writing could replace a report.
85
+ if (stored.status !== 404) {
86
+ throw new Error(`Vercel Blob answered ${stored.status} for report.json.`);
87
+ }
88
+ const name = (image: Blob, i: number) =>
89
+ `screenshot-${i}.${image.type === "image/png" ? "png" : "webp"}`;
90
+ const screenshots = report.screenshots.map((shot, i) => ({ ...shot, image: name(shot.image, i) }));
91
+ // The images first: a report.json then always has its images beside it.
92
+ for (const [i, { image }] of report.screenshots.entries()) {
93
+ await store.put(report.id, name(image, i), image, image.type);
94
+ }
95
+ const json = JSON.stringify({ ...report, screenshots });
96
+ await store.put(report.id, "report.json", json, "application/json");
97
+ return new Response(null, { status: 204 });
98
+ } catch (error) {
99
+ if (error instanceof FeedbackRequestError) {
100
+ return new Response(error.message, { status: error.status });
101
+ }
102
+ throw error;
103
+ }
104
+ }
105
+
106
+ // GET /api/feedback?id=<report id>&file=report.json | screenshot-<n>.png | screenshot-<n>.webp
107
+ export async function GET(request: Request): Promise<Response> {
108
+ if (!(await canRead(request))) return new Response(null, { status: 403 });
109
+ const query = new URL(request.url).searchParams;
110
+ const id = query.get("id") ?? "";
111
+ const file = query.get("file") ?? "";
112
+ // Only a report id and a known file name, so the read cannot leave feedback/<id>/.
113
+ const known = file === "report.json" || /^screenshot-\d\.(png|webp)$/.test(file);
114
+ if (!/^[0-9a-f-]{36}$/.test(id) || !known) {
115
+ return new Response(null, { status: 400 });
116
+ }
117
+ const object = await blob().read(id, file);
118
+ if (!object.ok) {
119
+ await object.body?.cancel();
120
+ return new Response(null, { status: object.status === 404 ? 404 : 502 });
121
+ }
122
+ return new Response(object.body, {
123
+ headers: {
124
+ "content-type": object.headers.get("content-type") ?? "application/octet-stream",
125
+ "cache-control": "private, no-store",
126
+ },
127
+ });
128
+ }
129
+ ```
package/package.json CHANGED
@@ -1,6 +1,103 @@
1
1
  {
2
2
  "name": "visual-feedback",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.0",
4
+ "description": "A small button lets your users screenshot the page, mark it up and write a note. You receive one report and store it where you want.",
5
+ "keywords": [
6
+ "feedback",
7
+ "bug-report",
8
+ "screenshot",
9
+ "annotate",
10
+ "widget",
11
+ "react",
12
+ "nextjs"
13
+ ],
14
+ "license": "MIT",
15
+ "author": "Doru Moraru",
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/dorumrr/visual-feedback.git"
19
+ },
20
+ "homepage": "https://github.com/dorumrr/visual-feedback#readme",
21
+ "bugs": "https://github.com/dorumrr/visual-feedback/issues",
22
+ "type": "module",
23
+ "sideEffects": false,
24
+ "exports": {
25
+ ".": {
26
+ "types": "./dist/index.d.mts",
27
+ "import": "./dist/index.mjs"
28
+ },
29
+ "./server": {
30
+ "types": "./dist/server.d.mts",
31
+ "import": "./dist/server.mjs"
32
+ },
33
+ "./react": {
34
+ "types": "./dist/react.d.mts",
35
+ "import": "./dist/react.mjs"
36
+ },
37
+ "./package.json": "./package.json"
38
+ },
39
+ "types": "./dist/index.d.mts",
40
+ "files": [
41
+ "dist",
42
+ "AGENTS.md",
43
+ "CHANGELOG.md",
44
+ "docs/API.md",
45
+ "docs/SPEC.md",
46
+ "docs/THEMING.md",
47
+ "docs/recipes"
48
+ ],
49
+ "engines": {
50
+ "node": ">=20"
51
+ },
52
+ "scripts": {
53
+ "build": "tsdown",
54
+ "lint": "biome check .",
55
+ "typecheck": "tsc --noEmit && tsc --noEmit -p tests",
56
+ "test": "vitest run",
57
+ "test:edge": "deno test --no-config --sloppy-imports tests/server/parse.deno.ts",
58
+ "test:e2e": "playwright test",
59
+ "test:react": "playwright test -c tests/react/playwright.config.ts",
60
+ "test:pack": "playwright test -c tests/pack-smoke/playwright.config.ts",
61
+ "size": "size-limit",
62
+ "publint": "publint --strict",
63
+ "check": "npm run check:static && npm run check:browser",
64
+ "check:static": "npm run lint && npm run typecheck && npm run build && npm run test && npm run test:edge && npm run size && npm run publint",
65
+ "check:browser": "npm run test:e2e && npm run test:react && npm run test:pack",
66
+ "prepack": "npm run --silent build -- --logLevel warn",
67
+ "dev": "node tests/dev.mjs",
68
+ "demo": "node tests/demo.mjs",
69
+ "release": "node tests/release.mjs"
70
+ },
71
+ "devDependencies": {
72
+ "@axe-core/playwright": "^4.13.0",
73
+ "@biomejs/biome": "^2.5.15",
74
+ "@electric-sql/pglite": "0.5.8",
75
+ "@electric-sql/pglite-socket": "^0.2.11",
76
+ "@playwright/test": "^1.64.0",
77
+ "@size-limit/preset-small-lib": "^14.2.0",
78
+ "@supabase/supabase-js": "^2.117.3",
79
+ "@types/node": "^22.20.5",
80
+ "@types/react": "^19.3.0",
81
+ "aws4fetch": "^1.0.20",
82
+ "next": "^16.4.0",
83
+ "postgres": "^3.4.9",
84
+ "publint": "^0.3.25",
85
+ "react": "^19.3.0",
86
+ "react-dom": "^19.3.0",
87
+ "size-limit": "^14.2.0",
88
+ "tsdown": "^0.23.0",
89
+ "typescript": "^7.0.2",
90
+ "vitest": "^5.0.3"
91
+ },
92
+ "peerDependencies": {
93
+ "react": ">=18"
94
+ },
95
+ "peerDependenciesMeta": {
96
+ "react": {
97
+ "optional": true
98
+ }
99
+ },
100
+ "dependencies": {
101
+ "@zumer/snapdom": "3.3.0"
102
+ }
103
+ }