visual-feedback 0.1.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.
@@ -1,10 +1,12 @@
1
1
  # Recipe: GitHub Issues (private repo only)
2
2
 
3
- Each report becomes an issue: the note, the page URL, the browser data and the image, with the
4
- category as a label. The image is committed to the repo with GitHub's contents API, at
5
- `feedback/<id>/image.png`, and shown inside the issue. Then the whole report is committed beside
6
- it as `feedback/<id>/report.json`, with `image` set to the image's GitHub link (or `null`) and
7
- `issue` to the issue number: a resend that finds it opens no second issue. The route checks on every send that the repo
3
+ Each report becomes an issue: the note, the page URL, the browser data and the screenshots, with
4
+ the category as a label. Each screenshot's image is committed to the repo with GitHub's contents
5
+ API, at `feedback/<id>/screenshot-<n>.png`, numbered from 0, and shown inside the issue, with its
6
+ own page URL under it when it was taken on another page than the report's. Then the
7
+ whole report is committed beside them as `feedback/<id>/report.json`, with each screenshot's `image`
8
+ set to its GitHub link and `issue` to the issue number: a resend that finds it opens no second
9
+ issue. The route checks on every send that the repo
8
10
  is private, and refuses otherwise. It uses `fetch` and GitHub's REST API, so it needs no package.
9
11
 
10
12
  `report.context` comes from the browser (docs/API.md): the issue shows it as data, never as proof
@@ -50,7 +52,7 @@ Create a fine-grained personal access token for that one repo, with **Issues: re
50
52
 
51
53
  Send PNG from the widget, the image type this recipe was checked with inside an issue:
52
54
  `createFeedback({ endpoint: "/api/feedback", capture: { type: "image/png" } })`. Without it the
53
- widget sends WebP (docs/API.md): the route commits and links `image.webp` the same way, but whether
55
+ widget sends WebP (docs/API.md): the route commits and links `screenshot-<n>.webp` the same way, but whether
54
56
  the issue shows a WebP image inline is not checked.
55
57
 
56
58
  ## 3. The route
@@ -101,24 +103,46 @@ function fenced(text: string, max: number): string {
101
103
  return `${fence}text\n${shown}\n${fence}`;
102
104
  }
103
105
 
104
- function issueBody(report: FeedbackReport, imageUrl: string | null): string {
106
+ // A screenshot of another page than the report's names its own, as a draft can span routes.
107
+ function shots(report: FeedbackReport, imageUrls: string[], share: number): string {
108
+ return imageUrls
109
+ .map((url, i) => {
110
+ const page = report.screenshots[i]?.page.url ?? report.page.url;
111
+ const where = page === report.page.url ? "" : `\n${fenced(page, 2_000 * share)}`;
112
+ return `![Screenshot ${i + 1}](<${url}>)${where}`;
113
+ })
114
+ .join("\n\n");
115
+ }
116
+
117
+ function issueBody(report: FeedbackReport, imageUrls: string[]): string {
118
+ // A fence can be as long as its text, so a report of backticks can pass GitHub's limit at the
119
+ // usual cuts: they shrink until it fits. At a tenth, the longest report is far under it.
120
+ let body = "";
121
+ for (const share of [1, 0.5, 0.25, 0.1]) {
122
+ body = bodyAt(report, imageUrls, share);
123
+ if (body.length <= 65_536) break;
124
+ }
125
+ return body;
126
+ }
127
+
128
+ function bodyAt(report: FeedbackReport, imageUrls: string[], share: number): string {
105
129
  const { page, viewport, userAgent, language, timeZone, createdAt, id } = report;
106
130
  const screen = `${viewport.width}x${viewport.height} @${viewport.devicePixelRatio}x`;
107
131
  // Each fence starts its own line: after other text on that line it is no block.
108
132
  return [
109
- fenced(report.note || "(no note)", 10_000),
133
+ fenced(report.note || "(no note)", 10_000 * share),
110
134
  // The angle brackets keep a ( or ) in a branch name from ending the link.
111
- imageUrl ? `![Screenshot](<${imageUrl}>)` : "No screenshot.",
112
- `**Page:**\n${fenced(page.url, 2_000)}`,
113
- `**Browser:**\n${fenced(`${userAgent}\n${screen}, ${language}, ${timeZone}`, 1_000)}`,
135
+ shots(report, imageUrls, share) || "No screenshot.",
136
+ `**Page:**\n${fenced(page.url, 2_000 * share)}`,
137
+ `**Browser:**\n${fenced(`${userAgent}\n${screen}, ${language}, ${timeZone}`, 1_000 * share)}`,
114
138
  `**Sent:** ${createdAt}, report \`${id}\``,
115
- `**Context, from the browser:**\n${fenced(JSON.stringify(report.context, null, 2), 6_000)}`,
139
+ `**Context, from the browser:**\n${fenced(JSON.stringify(report.context, null, 2), 6_000 * share)}`,
116
140
  ].join("\n\n");
117
141
  }
118
142
 
119
143
  export async function POST(request: Request): Promise<Response> {
120
144
  try {
121
- const { report, image } = await parseFeedbackRequest(request);
145
+ const { report } = await parseFeedbackRequest(request);
122
146
  const api = github();
123
147
  const repo = await api("GET", "");
124
148
  // Checked on every send: a repo made public later must not receive reports.
@@ -151,24 +175,24 @@ export async function POST(request: Request): Promise<Response> {
151
175
  throw new Error(`GitHub answered ${commit.status} for ${name}.`);
152
176
  }
153
177
  };
154
- let imageUrl: string | null = null;
155
- if (image) {
156
- const name = `image.${image.type === "image/png" ? "png" : "webp"}`;
178
+ const imageUrls: string[] = [];
179
+ for (const [i, { image }] of report.screenshots.entries()) {
180
+ const name = `screenshot-${i}.${image.type === "image/png" ? "png" : "webp"}`;
157
181
  await put(name, new Uint8Array(await image.arrayBuffer()));
158
182
  const path = `${branch.split("/").map(encodeURIComponent).join("/")}/${folder}/${name}`;
159
- imageUrl = `https://github.com/${repo.json.full_name}/blob/${path}?raw=true`;
183
+ imageUrls.push(`https://github.com/${repo.json.full_name}/blob/${path}?raw=true`);
160
184
  }
161
185
  // GitHub refuses a label name over 50 characters, and one that is only space.
162
186
  const category = cut(report.category?.replace(/\s+/g, " ").trim() ?? "", 49);
163
187
  const title = cut(report.note.split("\n")[0]?.trim() || "Feedback", 80);
164
188
  const issue = await api("POST", "/issues", {
165
189
  title,
166
- body: issueBody(report, imageUrl),
190
+ body: issueBody(report, imageUrls),
167
191
  labels: ["feedback", ...(category ? [category] : [])],
168
192
  });
169
193
  if (issue.status !== 201) throw new Error(`GitHub answered ${issue.status} for the issue.`);
170
- const { image: _, ...rest } = report;
171
- const json = JSON.stringify({ ...rest, image: imageUrl, issue: issue.json.number }, null, 2);
194
+ const screenshots = report.screenshots.map((shot, i) => ({ ...shot, image: imageUrls[i] }));
195
+ const json = JSON.stringify({ ...report, screenshots, issue: issue.json.number }, null, 2);
172
196
  await put("report.json", new TextEncoder().encode(json));
173
197
  return new Response(null, { status: 204 });
174
198
  } catch (error) {
@@ -3,9 +3,9 @@
3
3
  For local development only: it writes only when `NODE_ENV` is `development`. `next dev` sets it;
4
4
  start any other server with `NODE_ENV=development`.
5
5
 
6
- Each report becomes one folder, `.visual-feedback/<id>/`, holding `image.png` or `image.webp` and
7
- `report.json`. That file is the `FeedbackReport` (docs/API.md), with `image` set to the image's file
8
- name, or `null` when the report has none.
6
+ Each report becomes one folder, `.visual-feedback/<id>/`, holding `report.json` and one image per
7
+ screenshot: `screenshot-0.png` or `screenshot-0.webp`, then `screenshot-1`, and so on. That file is
8
+ the `FeedbackReport` (docs/API.md), with each screenshot's `image` set to its file name.
9
9
 
10
10
  It does not work on hosts whose disk is read-only or wiped on each deploy or restart, such as
11
11
  Vercel, Netlify, Cloudflare and most containers. Use another recipe there.
@@ -31,7 +31,7 @@ interface unless it is started as `next dev -H 127.0.0.1`.
31
31
 
32
32
  ```ts
33
33
  // app/api/feedback/route.ts
34
- import { mkdir, rm, writeFile } from "node:fs/promises";
34
+ import { mkdir, readdir, rm, writeFile } from "node:fs/promises";
35
35
  import { join } from "node:path";
36
36
  import { FeedbackRequestError, parseFeedbackRequest } from "visual-feedback/server";
37
37
 
@@ -41,21 +41,25 @@ export async function POST(request: Request): Promise<Response> {
41
41
  return new Response("Local feedback storage works only in development.", { status: 503 });
42
42
  }
43
43
  try {
44
- const { report, image } = await parseFeedbackRequest(request);
44
+ const { report } = await parseFeedbackRequest(request);
45
45
  // report.id is a checked UUID, so it is safe as a folder name.
46
46
  const folder = join(process.cwd(), ".visual-feedback", report.id);
47
- const file = image ? `image.${image.type === "image/png" ? "png" : "webp"}` : null;
47
+ const name = (image: Blob, i: number) =>
48
+ `screenshot-${i}.${image.type === "image/png" ? "png" : "webp"}`;
48
49
  await mkdir(folder, { recursive: true });
49
- // The image first: a report.json on disk then always has its image beside it.
50
- if (image && file)
51
- await writeFile(join(folder, file), new Uint8Array(await image.arrayBuffer()));
50
+ // The images first: a report.json on disk then always has its images beside it.
51
+ for (const [i, { image }] of report.screenshots.entries()) {
52
+ await writeFile(join(folder, name(image, i)), new Uint8Array(await image.arrayBuffer()));
53
+ }
54
+ const screenshots = report.screenshots.map((shot, i) => ({ ...shot, image: name(shot.image, i) }));
52
55
  await writeFile(
53
56
  join(folder, "report.json"),
54
- JSON.stringify({ ...report, image: file }, null, 2),
57
+ JSON.stringify({ ...report, screenshots }, null, 2),
55
58
  );
56
- // After report.json, so a resend with another image type, or none, leaves no stray image.
57
- for (const old of ["image.png", "image.webp"]) {
58
- if (old !== file) await rm(join(folder, old), { force: true });
59
+ // After report.json, so a resend with other images, or fewer, leaves no stray image.
60
+ const kept = new Set(["report.json", ...screenshots.map((shot) => shot.image)]);
61
+ for (const old of await readdir(folder)) {
62
+ if (!kept.has(old)) await rm(join(folder, old), { force: true });
59
63
  }
60
64
  return new Response(null, { status: 204 });
61
65
  } catch (error) {
@@ -1,18 +1,21 @@
1
1
  # Recipe: Postgres
2
2
 
3
- Each report becomes one row: the `FeedbackReport` (docs/API.md) without its `image` as `jsonb`, the
4
- image in `image` (`bytea`) with its type in `image_type`, and the user who sent it, taken from your
5
- own session. `user_id` is the proof of who sent a report; `report.context` is not (docs/API.md).
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).
6
8
 
7
9
  To keep images in a bucket instead (docs/recipes/s3.md):
8
10
 
9
11
  1. Install `aws4fetch` and set the `S3_*` variables as that recipe's step 1 says.
10
12
  2. Copy its `bucket()` function into this route.
11
- 3. Before the insert, PUT the image to `url(report.id, "image.png")` or `"image.webp"`, as that
12
- recipe's `POST` does: with `"if-none-match": "*"`, so a reused id never replaces a stored
13
- image, and throw on any refusal but 412, so a row never lacks its image.
14
- 4. Insert `null` for `image` and keep `image_type`: `image/png` is the file `image.png`, and
15
- `image/webp` is `image.webp`.
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`.
16
19
  5. Read images back with that recipe's `GET`, its `canRead` replaced by the same admin check your
17
20
  screens use.
18
21
 
@@ -30,9 +33,15 @@ create table feedback_reports (
30
33
  id uuid primary key,
31
34
  user_id text not null,
32
35
  created_at timestamptz not null,
33
- report jsonb 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,
34
42
  image bytea,
35
- image_type text
43
+ image_type text not null,
44
+ primary key (report_id, position)
36
45
  );
37
46
  ```
38
47
 
@@ -70,17 +79,30 @@ export async function POST(request: Request): Promise<Response> {
70
79
  const user = await currentUser(request);
71
80
  if (!user) return new Response(null, { status: 401 });
72
81
  try {
73
- const { report, image } = await parseFeedbackRequest(request);
74
- const { image: _, ...stored } = report;
75
- const bytes = image ? new Uint8Array(await image.arrayBuffer()) : null;
82
+ const { report } = await parseFeedbackRequest(request);
83
+ const screenshots = report.screenshots.map(({ image: _, ...shot }) => shot);
76
84
  // The report was parsed from JSON, so it is JSON; context is only typed as unknown values.
77
- const json = db().json(stored as postgres.JSONValue);
78
- // A "Try again" sends the same id, so the row it already wrote stays as it is.
79
- await db()`
80
- insert into feedback_reports (id, user_id, created_at, report, image, image_type)
81
- values (${report.id}, ${user}, ${report.createdAt}, ${json}, ${bytes},
82
- ${image?.type ?? null})
83
- on conflict (id) do nothing`;
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
+ });
84
106
  return new Response(null, { status: 204 });
85
107
  } catch (error) {
86
108
  if (error instanceof FeedbackRequestError) {
@@ -1,8 +1,8 @@
1
1
  # Recipe: an S3-compatible bucket (S3, R2, Railway Buckets)
2
2
 
3
- Each report becomes two objects in a private bucket, named and shaped as in the local recipe
4
- (docs/recipes/local.md): `feedback/<id>/report.json`, whose `image` is the image's name, and
5
- `feedback/<id>/image.png` or `image.webp`.
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
6
 
7
7
  The bucket stays private: nothing is public, and people read reports only through your own route,
8
8
  behind your own admin check. `report.context` comes from the browser (docs/API.md), so an admin view
@@ -64,7 +64,7 @@ function bucket() {
64
64
 
65
65
  export async function POST(request: Request): Promise<Response> {
66
66
  try {
67
- const { report, image } = await parseFeedbackRequest(request);
67
+ const { report } = await parseFeedbackRequest(request);
68
68
  const { client, url } = bucket();
69
69
  // A report already stored under this id stays: a retry, or a reused id, changes nothing.
70
70
  const stored = await client.fetch(url(report.id, "report.json"), { method: "HEAD" });
@@ -73,11 +73,15 @@ export async function POST(request: Request): Promise<Response> {
73
73
  if (stored.status !== 403 && stored.status !== 404) {
74
74
  throw new Error(`The bucket answered ${stored.status} for report.json.`);
75
75
  }
76
- const name = image ? `image.${image.type === "image/png" ? "png" : "webp"}` : null;
77
- // The image first: a report.json in the bucket then always has its image beside it.
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.
78
80
  const puts: [string, BodyInit, string][] = [
79
- ...(image && name ? [[name, image, image.type] as [string, BodyInit, string]] : []),
80
- ["report.json", JSON.stringify({ ...report, image: name }), "application/json"],
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"],
81
85
  ];
82
86
  for (const [file, body, type] of puts) {
83
87
  const put = await client.fetch(url(report.id, file), {
@@ -99,14 +103,15 @@ export async function POST(request: Request): Promise<Response> {
99
103
  }
100
104
  }
101
105
 
102
- // GET /api/feedback?id=<report id>&file=report.json | image.png | image.webp
106
+ // GET /api/feedback?id=<report id>&file=report.json | screenshot-<n>.png | screenshot-<n>.webp
103
107
  export async function GET(request: Request): Promise<Response> {
104
108
  if (!(await canRead(request))) return new Response(null, { status: 403 });
105
109
  const query = new URL(request.url).searchParams;
106
110
  const id = query.get("id") ?? "";
107
111
  const file = query.get("file") ?? "";
108
112
  // Only a report id and a known file name, so the key cannot leave feedback/<id>/.
109
- if (!/^[0-9a-f-]{36}$/.test(id) || !["report.json", "image.png", "image.webp"].includes(file)) {
113
+ const known = file === "report.json" || /^screenshot-\d\.(png|webp)$/.test(file);
114
+ if (!/^[0-9a-f-]{36}$/.test(id) || !known) {
110
115
  return new Response(null, { status: 400 });
111
116
  }
112
117
  const { client, url } = bucket();
@@ -1,10 +1,11 @@
1
1
  # Recipe: Supabase
2
2
 
3
- Each report becomes one row in a table and, when it has one, one image in the private Storage
4
- bucket `feedback`, named `<id>/image.png` or `<id>/image.webp`. The row has the Postgres recipe's columns
5
- (docs/recipes/postgres.md), except that `user_id` is the Supabase user's uuid and the image is in
6
- Storage instead of `bytea`. Row-level security lets
7
- only your app's admins read either, and nobody but your server can write.
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.
8
9
 
9
10
  `user_id` is the proof of who sent a report; `report.context` is not (docs/API.md). The images show
10
11
  the screen as the user saw it, with nothing masked (SPEC §7).
@@ -26,8 +27,7 @@ create table feedback_reports (
26
27
  id uuid primary key,
27
28
  user_id uuid not null references auth.users (id) on delete cascade,
28
29
  created_at timestamptz not null,
29
- report jsonb not null,
30
- image_type text
30
+ report jsonb not null
31
31
  );
32
32
  alter table feedback_reports enable row level security;
33
33
  grant select on feedback_reports to authenticated;
@@ -49,9 +49,9 @@ error, never a row, and a download from `feedback` is refused.
49
49
 
50
50
  An admin screen reads with the admin's own session, never the service role key:
51
51
  `from("feedback_reports").select()` for the rows, filtered and paged as the screen needs, and
52
- `storage.from("feedback").createSignedUrl("<id>/image.png", 60)` for an image, `image.webp` when
53
- `image_type` is `image/webp`. A signed URL works for anyone who holds it for those 60 seconds: show
54
- it, never share it.
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
55
 
56
56
  Set `SUPABASE_URL` and `SUPABASE_SERVICE_ROLE_KEY` on the server. The service role key bypasses
57
57
  row-level security: never send it to the browser.
@@ -87,28 +87,28 @@ export async function POST(request: Request): Promise<Response> {
87
87
  const user = await currentUser(request);
88
88
  if (!user) return new Response(null, { status: 401 });
89
89
  try {
90
- const { report, image } = await parseFeedbackRequest(request);
90
+ const { report } = await parseFeedbackRequest(request);
91
91
  const db = supabase();
92
- // The image first, so a row never lacks its image.
93
- if (image) {
94
- const name = `${report.id}/image.${image.type === "image/png" ? "png" : "webp"}`;
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()) {
95
97
  const upload = await db.storage
96
98
  .from("feedback")
97
- .upload(name, image, { contentType: image.type, upsert: false });
99
+ .upload(`${report.id}/${name(image, i)}`, image, { contentType: image.type, upsert: false });
98
100
  // A resend finds its image there (409); the id comes from the browser, so never replace it.
99
101
  const error = upload.error;
100
102
  const stored = error && "statusCode" in error && error.statusCode === "409";
101
103
  if (error && !stored) throw error;
102
104
  }
103
- const { image: _, ...stored } = report;
104
105
  // A "Try again" sends the same id, so the row it already wrote stays as it is.
105
106
  const insert = await db.from("feedback_reports").upsert(
106
107
  {
107
108
  id: report.id,
108
109
  user_id: user,
109
110
  created_at: report.createdAt,
110
- report: stored,
111
- image_type: image?.type ?? null,
111
+ report: { ...report, screenshots },
112
112
  },
113
113
  { onConflict: "id", ignoreDuplicates: true },
114
114
  );
@@ -1,7 +1,8 @@
1
1
  # Recipe: Vercel Blob
2
2
 
3
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>/image.png` or `image.webp`. People read them only
4
+ `feedback/<id>/report.json` and `feedback/<id>/screenshot-<n>.png` or `.webp` for each screenshot.
5
+ People read them only
5
6
  through your own route, behind your own admin check. `report.context` comes from the browser
6
7
  (docs/API.md), so an admin view must not show it as proof of who sent a report. The images show the
7
8
  screen as the user saw it, with nothing masked (SPEC §7).
@@ -16,9 +17,9 @@ on the server. Never send it to the browser. Check it: open
16
17
  `https://<store id>.private.blob.vercel-storage.com/feedback/<id>/report.json` without the token,
17
18
  where the store id is the part after `vercel_blob_rw_` in the token, and Vercel refuses it.
18
19
 
19
- The route takes images up to 3 MB, so the whole request stays under Vercel's 4.5 MB body limit. A
20
- larger one is refused with 413 and the widget shows its send error; a smaller `capture.maxWidth`
21
- (docs/API.md) keeps screenshots under it.
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.
22
23
 
23
24
  ## 2. The route
24
25
 
@@ -72,8 +73,8 @@ function blob() {
72
73
 
73
74
  export async function POST(request: Request): Promise<Response> {
74
75
  try {
75
- // Vercel refuses a function request body over 4.5 MB; 3 MB plus the report stays under it.
76
- const { report, image } = await parseFeedbackRequest(request, { maxImageBytes: 3_000_000 });
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 });
77
78
  const store = blob();
78
79
  // The id comes from the browser: once a report is stored, a later request does not replace it.
79
80
  // Two requests with one id at the same moment still can; the check and the write are separate.
@@ -84,10 +85,14 @@ export async function POST(request: Request): Promise<Response> {
84
85
  if (stored.status !== 404) {
85
86
  throw new Error(`Vercel Blob answered ${stored.status} for report.json.`);
86
87
  }
87
- const name = image ? `image.${image.type === "image/png" ? "png" : "webp"}` : null;
88
- // The image first: a report.json then always has its image beside it.
89
- if (image && name) await store.put(report.id, name, image, image.type);
90
- const json = JSON.stringify({ ...report, image: name });
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 });
91
96
  await store.put(report.id, "report.json", json, "application/json");
92
97
  return new Response(null, { status: 204 });
93
98
  } catch (error) {
@@ -98,14 +103,15 @@ export async function POST(request: Request): Promise<Response> {
98
103
  }
99
104
  }
100
105
 
101
- // GET /api/feedback?id=<report id>&file=report.json | image.png | image.webp
106
+ // GET /api/feedback?id=<report id>&file=report.json | screenshot-<n>.png | screenshot-<n>.webp
102
107
  export async function GET(request: Request): Promise<Response> {
103
108
  if (!(await canRead(request))) return new Response(null, { status: 403 });
104
109
  const query = new URL(request.url).searchParams;
105
110
  const id = query.get("id") ?? "";
106
111
  const file = query.get("file") ?? "";
107
112
  // Only a report id and a known file name, so the read cannot leave feedback/<id>/.
108
- if (!/^[0-9a-f-]{36}$/.test(id) || !["report.json", "image.png", "image.webp"].includes(file)) {
113
+ const known = file === "report.json" || /^screenshot-\d\.(png|webp)$/.test(file);
114
+ if (!/^[0-9a-f-]{36}$/.test(id) || !known) {
109
115
  return new Response(null, { status: 400 });
110
116
  }
111
117
  const object = await blob().read(id, file);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "visual-feedback",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
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
5
  "keywords": [
6
6
  "feedback",
@@ -60,10 +60,13 @@
60
60
  "test:pack": "playwright test -c tests/pack-smoke/playwright.config.ts",
61
61
  "size": "size-limit",
62
62
  "publint": "publint --strict",
63
- "check": "npm run lint && npm run typecheck && npm run build && npm run test && npm run test:edge && npm run test:e2e && npm run test:react && npm run test:pack && npm run size && npm run publint",
63
+ "check": "node tests/check.mjs",
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",
64
66
  "prepack": "npm run --silent build -- --logLevel warn",
65
67
  "dev": "node tests/dev.mjs",
66
- "demo": "node tests/demo.mjs"
68
+ "demo": "node tests/demo.mjs",
69
+ "release": "node tests/release.mjs"
67
70
  },
68
71
  "devDependencies": {
69
72
  "@axe-core/playwright": "^4.13.0",