@kolibri-day/feedback 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +289 -2
- package/dist/cjs/adapters/capture/capture.d.ts +112 -0
- package/dist/cjs/adapters/capture/capture.js +398 -0
- package/dist/cjs/adapters/capture/client-context.d.ts +9 -0
- package/dist/cjs/adapters/capture/client-context.js +41 -0
- package/dist/cjs/adapters/capture/reporter.d.ts +42 -0
- package/dist/cjs/adapters/capture/reporter.js +83 -0
- package/dist/cjs/adapters/capture/submit.d.ts +30 -0
- package/dist/cjs/adapters/capture/submit.js +32 -0
- package/dist/cjs/adapters/kolibri/env.d.ts +16 -0
- package/dist/cjs/adapters/kolibri/env.js +40 -0
- package/dist/cjs/adapters/kolibri/preflight.d.ts +6 -0
- package/dist/cjs/adapters/kolibri/preflight.js +536 -0
- package/dist/cjs/adapters/kolibri/target.d.ts +100 -0
- package/dist/cjs/adapters/kolibri/target.js +175 -0
- package/dist/cjs/adapters/react/reporter.d.ts +25 -0
- package/dist/cjs/adapters/react/reporter.js +54 -0
- package/dist/cjs/browser.d.ts +4 -0
- package/dist/cjs/browser.js +22 -0
- package/dist/cjs/index.d.ts +6 -0
- package/dist/cjs/index.js +25 -0
- package/dist/cjs/kernel/feedback/dispatch.d.ts +64 -0
- package/dist/cjs/kernel/feedback/dispatch.js +93 -0
- package/dist/cjs/kernel/feedback/fake-target.d.ts +58 -0
- package/dist/cjs/kernel/feedback/fake-target.js +87 -0
- package/dist/cjs/kernel/feedback/files.d.ts +6 -0
- package/dist/cjs/kernel/feedback/files.js +21 -0
- package/dist/cjs/kernel/feedback/format.d.ts +53 -0
- package/dist/cjs/kernel/feedback/format.js +140 -0
- package/dist/cjs/kernel/feedback/input.d.ts +79 -0
- package/dist/cjs/kernel/feedback/input.js +157 -0
- package/dist/cjs/kernel/feedback/messages.d.ts +76 -0
- package/dist/cjs/kernel/feedback/messages.js +138 -0
- package/dist/cjs/kernel/feedback/notice.d.ts +10 -0
- package/dist/cjs/kernel/feedback/notice.js +19 -0
- package/dist/cjs/kernel/feedback/types.d.ts +158 -0
- package/dist/cjs/kernel/feedback/types.js +47 -0
- package/dist/cjs/kolibri.d.ts +2 -0
- package/dist/cjs/kolibri.js +12 -0
- package/dist/cjs/package.json +1 -0
- package/dist/cjs/preflight-cli.d.ts +2 -0
- package/dist/cjs/preflight-cli.js +10 -0
- package/dist/cjs/react.d.ts +3 -0
- package/dist/cjs/react.js +9 -0
- package/dist/cjs/server.d.ts +4 -0
- package/dist/cjs/server.js +18 -0
- package/dist/cjs/wiring.d.ts +38 -0
- package/dist/cjs/wiring.js +72 -0
- package/dist/esm/adapters/capture/capture.d.ts +112 -0
- package/dist/esm/adapters/capture/capture.js +357 -0
- package/dist/esm/adapters/capture/client-context.d.ts +9 -0
- package/dist/esm/adapters/capture/client-context.js +38 -0
- package/dist/esm/adapters/capture/reporter.d.ts +42 -0
- package/dist/esm/adapters/capture/reporter.js +79 -0
- package/dist/esm/adapters/capture/submit.d.ts +30 -0
- package/dist/esm/adapters/capture/submit.js +28 -0
- package/dist/esm/adapters/kolibri/env.d.ts +16 -0
- package/dist/esm/adapters/kolibri/env.js +36 -0
- package/dist/esm/adapters/kolibri/preflight.d.ts +6 -0
- package/dist/esm/adapters/kolibri/preflight.js +533 -0
- package/dist/esm/adapters/kolibri/target.d.ts +100 -0
- package/dist/esm/adapters/kolibri/target.js +170 -0
- package/dist/esm/adapters/react/reporter.d.ts +25 -0
- package/dist/esm/adapters/react/reporter.js +49 -0
- package/dist/esm/browser.d.ts +4 -0
- package/dist/esm/browser.js +7 -0
- package/dist/esm/index.d.ts +6 -0
- package/dist/esm/index.js +9 -0
- package/dist/esm/kernel/feedback/dispatch.d.ts +64 -0
- package/dist/esm/kernel/feedback/dispatch.js +89 -0
- package/dist/esm/kernel/feedback/fake-target.d.ts +58 -0
- package/dist/esm/kernel/feedback/fake-target.js +82 -0
- package/dist/esm/kernel/feedback/files.d.ts +6 -0
- package/dist/esm/kernel/feedback/files.js +16 -0
- package/dist/esm/kernel/feedback/format.d.ts +53 -0
- package/dist/esm/kernel/feedback/format.js +133 -0
- package/dist/esm/kernel/feedback/input.d.ts +79 -0
- package/dist/esm/kernel/feedback/input.js +150 -0
- package/dist/esm/kernel/feedback/messages.d.ts +76 -0
- package/dist/esm/kernel/feedback/messages.js +135 -0
- package/dist/esm/kernel/feedback/notice.d.ts +10 -0
- package/dist/esm/kernel/feedback/notice.js +15 -0
- package/dist/esm/kernel/feedback/types.d.ts +158 -0
- package/dist/esm/kernel/feedback/types.js +42 -0
- package/dist/esm/kolibri.d.ts +2 -0
- package/dist/esm/kolibri.js +3 -0
- package/dist/esm/preflight-cli.d.ts +2 -0
- package/dist/esm/preflight-cli.js +8 -0
- package/dist/esm/react.d.ts +3 -0
- package/dist/esm/react.js +3 -0
- package/dist/esm/server.d.ts +4 -0
- package/dist/esm/server.js +6 -0
- package/dist/esm/wiring.d.ts +38 -0
- package/dist/esm/wiring.js +68 -0
- package/fixtures/preflight-screenshot.webp +0 -0
- package/package.json +82 -4
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import type { FeedbackTarget, FeedbackReport, FeedbackResume, FeedbackSubmission, FeedbackTargetDescriptor, FetchLike } from '../../kernel/feedback/types.js';
|
|
2
|
+
import { type FormatOptions } from '../../kernel/feedback/format.js';
|
|
3
|
+
import { type FeedbackMessages } from '../../kernel/feedback/messages.js';
|
|
4
|
+
/**
|
|
5
|
+
* What a Kolibri target is, before it has credentials — so a web tier that holds no
|
|
6
|
+
* token can still tell the reporter where the screenshot goes.
|
|
7
|
+
*
|
|
8
|
+
* Self-hosted by the team that runs the app: same controller, no processor, no
|
|
9
|
+
* third-country transfer. It settles the EXTERNAL question only. The internal one —
|
|
10
|
+
* who inside the company can open a screenshot — is answered by the feedback
|
|
11
|
+
* project's membership, and only on a Kolibri that scopes attachments by the project
|
|
12
|
+
* of the task they hang off. On an older instance attachments were visible to the
|
|
13
|
+
* whole workspace, which is why a MINIMUM KOLIBRI VERSION is part of the setup, not
|
|
14
|
+
* just a private project (see the README).
|
|
15
|
+
*/
|
|
16
|
+
export declare const KOLIBRI_TARGET: FeedbackTargetDescriptor;
|
|
17
|
+
export interface KolibriConfig {
|
|
18
|
+
/**
|
|
19
|
+
* Kolibri's origin — e.g. https://kolibri.example.com. NOT an /api path. Kolibri
|
|
20
|
+
* serves its API and its web app from the same origin, so the task backlink is built
|
|
21
|
+
* from this too.
|
|
22
|
+
*/
|
|
23
|
+
apiUrl: string;
|
|
24
|
+
/** API token ("kol_…"), sent as `Authorization: Bearer …`. Needs the `write` scope. */
|
|
25
|
+
token: string;
|
|
26
|
+
/** Workspace UUID. A slug is refused with 403 (§A2). */
|
|
27
|
+
workspaceId: string;
|
|
28
|
+
/** UUID of the (private) feedback project. A project key is refused with 400 (§A3). */
|
|
29
|
+
projectId: string;
|
|
30
|
+
/** Per-request timeout. fetch() has none by default. */
|
|
31
|
+
timeoutMs?: number;
|
|
32
|
+
/** Language of the ticket body and of the error messages. Default: German. */
|
|
33
|
+
messages?: FeedbackMessages;
|
|
34
|
+
/** Display labels for the app's kinds in the title. */
|
|
35
|
+
kindLabels?: FormatOptions['kindLabels'];
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Kolibri addresses workspaces and projects by UUID and refuses anything else — a
|
|
39
|
+
* slug with 403, a project key with 400 (§A2/§A3). Checking the shape up front turns
|
|
40
|
+
* a per-report round-trip that reads as "Kolibri rejected us" into "this setting is
|
|
41
|
+
* wrong".
|
|
42
|
+
*/
|
|
43
|
+
export declare const UUID_PATTERN: RegExp;
|
|
44
|
+
/**
|
|
45
|
+
* Is this status worth retrying?
|
|
46
|
+
*
|
|
47
|
+
* 4xx means we sent something Kolibri will reject identically next time — a bad token,
|
|
48
|
+
* a project that doesn't exist, a body it won't process. Retrying burns the attempt
|
|
49
|
+
* budget and delays the failed report an operator needs to see. 429 and 408 are the
|
|
50
|
+
* exceptions inside 4xx: both explicitly mean "later".
|
|
51
|
+
*
|
|
52
|
+
* 5xx is transient. Kolibri used to answer a NOT NULL violation with 500, so a
|
|
53
|
+
* permanently-malformed write could masquerade as an outage; current instances answer
|
|
54
|
+
* those with 400, so a 5xx is what it claims to be.
|
|
55
|
+
*/
|
|
56
|
+
export declare function isPermanentStatus(status: number): boolean;
|
|
57
|
+
export declare class KolibriFeedbackTarget implements FeedbackTarget {
|
|
58
|
+
readonly name: string;
|
|
59
|
+
readonly label: string;
|
|
60
|
+
readonly trustBoundary: import("../../kernel/feedback/types.js").TrustBoundary;
|
|
61
|
+
private readonly config;
|
|
62
|
+
private readonly fetchFn;
|
|
63
|
+
private readonly timeoutMs;
|
|
64
|
+
private readonly messages;
|
|
65
|
+
constructor(config: KolibriConfig, fetchFn?: FetchLike);
|
|
66
|
+
/** Base with any trailing slash removed, so path joins never double up. */
|
|
67
|
+
private get base();
|
|
68
|
+
/** The human backlink. `/t/{id}` accepts the row id or the identifier; the id is stable across a project-key rename (§F24). */
|
|
69
|
+
taskUrl(taskId: string): string;
|
|
70
|
+
private request;
|
|
71
|
+
/**
|
|
72
|
+
* POST /api/workspaces/{ws}/tasks — idempotent on the report's own id.
|
|
73
|
+
*
|
|
74
|
+
* `state_id` is deliberately absent: Kolibri fills in the project's default state
|
|
75
|
+
* (§A1), and naming one here would mean the app owning a workflow decision that
|
|
76
|
+
* belongs to whoever configures the board.
|
|
77
|
+
*/
|
|
78
|
+
private createTask;
|
|
79
|
+
/**
|
|
80
|
+
* POST /api/workspaces/{ws}/files?task_id={id} — the body IS the file.
|
|
81
|
+
*
|
|
82
|
+
* Not multipart and not base64 (§C11): Kolibri takes the raw bytes with the name in
|
|
83
|
+
* `x-filename` and the type in `content-type`, and creates the attachment row itself
|
|
84
|
+
* because `?task_id=` is present. That is why there is no third call.
|
|
85
|
+
*
|
|
86
|
+
* Repeating this call is safe — Kolibri matches identical bytes against what already
|
|
87
|
+
* hangs on the task and hands back the existing row.
|
|
88
|
+
*/
|
|
89
|
+
private uploadAttachments;
|
|
90
|
+
/**
|
|
91
|
+
* Create the task, then upload attachments.
|
|
92
|
+
*
|
|
93
|
+
* `resume` is accepted for the interface's sake but carries no information this
|
|
94
|
+
* target needs: the task id is the report id, so it is known before the first call
|
|
95
|
+
* and identical on every attempt. Re-running the create on a resume is deliberate —
|
|
96
|
+
* it is an upsert, it costs one request, and it repairs a task whose description
|
|
97
|
+
* failed to write the first time.
|
|
98
|
+
*/
|
|
99
|
+
submit(report: FeedbackReport, _resume?: FeedbackResume): Promise<FeedbackSubmission>;
|
|
100
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { FeedbackTargetError } from "../../kernel/feedback/types.js";
|
|
2
|
+
import { buildTitle, buildDescriptionMarkdown } from "../../kernel/feedback/format.js";
|
|
3
|
+
import { en } from "../../kernel/feedback/messages.js";
|
|
4
|
+
/**
|
|
5
|
+
* What a Kolibri target is, before it has credentials — so a web tier that holds no
|
|
6
|
+
* token can still tell the reporter where the screenshot goes.
|
|
7
|
+
*
|
|
8
|
+
* Self-hosted by the team that runs the app: same controller, no processor, no
|
|
9
|
+
* third-country transfer. It settles the EXTERNAL question only. The internal one —
|
|
10
|
+
* who inside the company can open a screenshot — is answered by the feedback
|
|
11
|
+
* project's membership, and only on a Kolibri that scopes attachments by the project
|
|
12
|
+
* of the task they hang off. On an older instance attachments were visible to the
|
|
13
|
+
* whole workspace, which is why a MINIMUM KOLIBRI VERSION is part of the setup, not
|
|
14
|
+
* just a private project (see the README).
|
|
15
|
+
*/
|
|
16
|
+
export const KOLIBRI_TARGET = Object.freeze({
|
|
17
|
+
name: 'kolibri',
|
|
18
|
+
label: 'Kolibri',
|
|
19
|
+
trustBoundary: 'self_hosted',
|
|
20
|
+
});
|
|
21
|
+
const DEFAULT_TIMEOUT_MS = 15_000;
|
|
22
|
+
/**
|
|
23
|
+
* Kolibri addresses workspaces and projects by UUID and refuses anything else — a
|
|
24
|
+
* slug with 403, a project key with 400 (§A2/§A3). Checking the shape up front turns
|
|
25
|
+
* a per-report round-trip that reads as "Kolibri rejected us" into "this setting is
|
|
26
|
+
* wrong".
|
|
27
|
+
*/
|
|
28
|
+
export const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
29
|
+
/**
|
|
30
|
+
* Is this status worth retrying?
|
|
31
|
+
*
|
|
32
|
+
* 4xx means we sent something Kolibri will reject identically next time — a bad token,
|
|
33
|
+
* a project that doesn't exist, a body it won't process. Retrying burns the attempt
|
|
34
|
+
* budget and delays the failed report an operator needs to see. 429 and 408 are the
|
|
35
|
+
* exceptions inside 4xx: both explicitly mean "later".
|
|
36
|
+
*
|
|
37
|
+
* 5xx is transient. Kolibri used to answer a NOT NULL violation with 500, so a
|
|
38
|
+
* permanently-malformed write could masquerade as an outage; current instances answer
|
|
39
|
+
* those with 400, so a 5xx is what it claims to be.
|
|
40
|
+
*/
|
|
41
|
+
export function isPermanentStatus(status) {
|
|
42
|
+
if (status === 429 || status === 408)
|
|
43
|
+
return false;
|
|
44
|
+
return status >= 400 && status < 500;
|
|
45
|
+
}
|
|
46
|
+
/** Truncated response body for the error message. Never contains the token — that only ever travels in a header. */
|
|
47
|
+
async function errorBody(res) {
|
|
48
|
+
const text = await res.text().catch(() => '');
|
|
49
|
+
return text.slice(0, 500);
|
|
50
|
+
}
|
|
51
|
+
export class KolibriFeedbackTarget {
|
|
52
|
+
name = KOLIBRI_TARGET.name;
|
|
53
|
+
label = KOLIBRI_TARGET.label;
|
|
54
|
+
trustBoundary = KOLIBRI_TARGET.trustBoundary;
|
|
55
|
+
config;
|
|
56
|
+
fetchFn;
|
|
57
|
+
timeoutMs;
|
|
58
|
+
messages;
|
|
59
|
+
constructor(config, fetchFn = (input, init) => fetch(input, init)) {
|
|
60
|
+
this.config = config;
|
|
61
|
+
this.fetchFn = fetchFn;
|
|
62
|
+
this.timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
63
|
+
this.messages = config.messages ?? en;
|
|
64
|
+
}
|
|
65
|
+
/** Base with any trailing slash removed, so path joins never double up. */
|
|
66
|
+
get base() {
|
|
67
|
+
return this.config.apiUrl.replace(/\/+$/, '');
|
|
68
|
+
}
|
|
69
|
+
/** The human backlink. `/t/{id}` accepts the row id or the identifier; the id is stable across a project-key rename (§F24). */
|
|
70
|
+
taskUrl(taskId) {
|
|
71
|
+
return `${this.base}/t/${taskId}`;
|
|
72
|
+
}
|
|
73
|
+
async request(path, init) {
|
|
74
|
+
const url = `${this.base}${path}`;
|
|
75
|
+
try {
|
|
76
|
+
return await this.fetchFn(url, {
|
|
77
|
+
...init,
|
|
78
|
+
headers: { Authorization: `Bearer ${this.config.token}`, ...(init.headers ?? {}) },
|
|
79
|
+
signal: AbortSignal.timeout(this.timeoutMs),
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
catch (e) {
|
|
83
|
+
// Network-level: DNS, TLS, connection refused, or our own timeout. No response
|
|
84
|
+
// to classify, and all of them are worth another attempt later.
|
|
85
|
+
throw new FeedbackTargetError(this.messages.kolibri.unreachable(path, e instanceof Error ? e.message : String(e)), false);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* POST /api/workspaces/{ws}/tasks — idempotent on the report's own id.
|
|
90
|
+
*
|
|
91
|
+
* `state_id` is deliberately absent: Kolibri fills in the project's default state
|
|
92
|
+
* (§A1), and naming one here would mean the app owning a workflow decision that
|
|
93
|
+
* belongs to whoever configures the board.
|
|
94
|
+
*/
|
|
95
|
+
async createTask(report) {
|
|
96
|
+
const format = { messages: this.messages, kindLabels: this.config.kindLabels };
|
|
97
|
+
const res = await this.request(`/api/workspaces/${this.config.workspaceId}/tasks`, {
|
|
98
|
+
method: 'POST',
|
|
99
|
+
headers: { 'Content-Type': 'application/json' },
|
|
100
|
+
body: JSON.stringify({
|
|
101
|
+
id: report.id,
|
|
102
|
+
project_id: this.config.projectId,
|
|
103
|
+
title: buildTitle(report, format),
|
|
104
|
+
description: buildDescriptionMarkdown(report, format),
|
|
105
|
+
}),
|
|
106
|
+
});
|
|
107
|
+
if (!res.ok) {
|
|
108
|
+
const body = await errorBody(res);
|
|
109
|
+
throw new FeedbackTargetError(this.messages.kolibri.createRejected(res.status, body), isPermanentStatus(res.status), res.status);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* POST /api/workspaces/{ws}/files?task_id={id} — the body IS the file.
|
|
114
|
+
*
|
|
115
|
+
* Not multipart and not base64 (§C11): Kolibri takes the raw bytes with the name in
|
|
116
|
+
* `x-filename` and the type in `content-type`, and creates the attachment row itself
|
|
117
|
+
* because `?task_id=` is present. That is why there is no third call.
|
|
118
|
+
*
|
|
119
|
+
* Repeating this call is safe — Kolibri matches identical bytes against what already
|
|
120
|
+
* hangs on the task and hands back the existing row.
|
|
121
|
+
*/
|
|
122
|
+
async uploadAttachments(taskId, report) {
|
|
123
|
+
if (report.attachments.length === 0)
|
|
124
|
+
return 0;
|
|
125
|
+
let uploaded = 0;
|
|
126
|
+
for (const att of report.attachments) {
|
|
127
|
+
// Copy into a fresh ArrayBuffer before sending. Two reasons, and the second is
|
|
128
|
+
// the one that bites: a Uint8Array read out of a database driver may be a view
|
|
129
|
+
// onto a larger pooled Node Buffer, so sending the view risks sending its
|
|
130
|
+
// neighbours; and `Uint8Array<ArrayBufferLike>` is not a `BodyInit`, because the
|
|
131
|
+
// buffer behind it could be shared. The copy settles both.
|
|
132
|
+
const body = new ArrayBuffer(att.bytes.byteLength);
|
|
133
|
+
new Uint8Array(body).set(att.bytes);
|
|
134
|
+
const res = await this.request(`/api/workspaces/${this.config.workspaceId}/files?task_id=${encodeURIComponent(taskId)}`, {
|
|
135
|
+
method: 'POST',
|
|
136
|
+
headers: {
|
|
137
|
+
'Content-Type': att.contentType,
|
|
138
|
+
// Kolibri decodes this and strips path separators itself; encoding is what
|
|
139
|
+
// keeps a non-ASCII or spaced filename from breaking the header.
|
|
140
|
+
'x-filename': encodeURIComponent(att.filename),
|
|
141
|
+
},
|
|
142
|
+
body,
|
|
143
|
+
});
|
|
144
|
+
if (!res.ok) {
|
|
145
|
+
const detail = await errorBody(res);
|
|
146
|
+
throw new FeedbackTargetError(this.messages.kolibri.uploadRejected(res.status, detail), isPermanentStatus(res.status), res.status,
|
|
147
|
+
// The task EXISTS. Carrying the id lets a durable caller persist it, which is
|
|
148
|
+
// belt-and-braces here rather than load-bearing: the id is the report's own,
|
|
149
|
+
// so a retry would find the task even without this.
|
|
150
|
+
taskId, this.taskUrl(taskId));
|
|
151
|
+
}
|
|
152
|
+
uploaded += 1;
|
|
153
|
+
}
|
|
154
|
+
return uploaded;
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Create the task, then upload attachments.
|
|
158
|
+
*
|
|
159
|
+
* `resume` is accepted for the interface's sake but carries no information this
|
|
160
|
+
* target needs: the task id is the report id, so it is known before the first call
|
|
161
|
+
* and identical on every attempt. Re-running the create on a resume is deliberate —
|
|
162
|
+
* it is an upsert, it costs one request, and it repairs a task whose description
|
|
163
|
+
* failed to write the first time.
|
|
164
|
+
*/
|
|
165
|
+
async submit(report, _resume) {
|
|
166
|
+
await this.createTask(report);
|
|
167
|
+
const attachmentsUploaded = await this.uploadAttachments(report.id, report);
|
|
168
|
+
return { externalId: report.id, externalUrl: this.taskUrl(report.id), attachmentsUploaded };
|
|
169
|
+
}
|
|
170
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { type ReactNode } from 'react';
|
|
2
|
+
import { type FeedbackReporterOptions, type FeedbackReporterSnapshot } from '../capture/reporter.js';
|
|
3
|
+
import type { CaptureResult } from '../capture/capture.js';
|
|
4
|
+
export interface FeedbackActions {
|
|
5
|
+
/** Start the capture and open the dialog. A no-op while it is already open. */
|
|
6
|
+
openFeedback: () => void;
|
|
7
|
+
}
|
|
8
|
+
export interface FeedbackReporterState extends FeedbackReporterSnapshot {
|
|
9
|
+
openFeedback: () => void;
|
|
10
|
+
close: () => void;
|
|
11
|
+
/** The settled capture — for a submit that outruns the renderer. */
|
|
12
|
+
waitForCapture: () => Promise<CaptureResult | null>;
|
|
13
|
+
}
|
|
14
|
+
export interface FeedbackReporterProviderProps extends FeedbackReporterOptions {
|
|
15
|
+
children?: ReactNode;
|
|
16
|
+
}
|
|
17
|
+
export declare function FeedbackReporterProvider({ children, capture, captureOptions }: FeedbackReporterProviderProps): import("react").JSX.Element;
|
|
18
|
+
/**
|
|
19
|
+
* For triggers. Safe to call from anywhere; a no-op outside the provider, so a
|
|
20
|
+
* component also rendered on a page without a reporter (a public page with nobody to
|
|
21
|
+
* attribute a report to) does not crash.
|
|
22
|
+
*/
|
|
23
|
+
export declare function useFeedback(): FeedbackActions;
|
|
24
|
+
/** For the dialog: the full state plus the actions. Must be inside FeedbackReporterProvider. */
|
|
25
|
+
export declare function useFeedbackReporter(): FeedbackReporterState;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
// React bindings for the reporter: one dialog, any number of triggers.
|
|
4
|
+
//
|
|
5
|
+
// The indirection through context is the design. Every entry point — a floating
|
|
6
|
+
// button, a menu item, a keyboard shortcut, a future shake-to-report — calls
|
|
7
|
+
// `openFeedback()` and knows nothing else; the dialog reads the state. A second copy of
|
|
8
|
+
// the surface would be a second copy of the rules.
|
|
9
|
+
//
|
|
10
|
+
// Two contexts, on purpose: triggers subscribe only to the stable `openFeedback`, so
|
|
11
|
+
// a capture finishing re-renders the dialog and not every button in the app.
|
|
12
|
+
//
|
|
13
|
+
// The markup is the app's. This package ships no dialog, because the one thing every
|
|
14
|
+
// app already has is its own dialog component — and a second one, styled differently,
|
|
15
|
+
// would be the odd one out. Remember the two attributes that keep it out of its own
|
|
16
|
+
// screenshot: REDACT_ATTRIBUTE on the dialog panel AND on its backdrop.
|
|
17
|
+
import { createContext, useContext, useMemo, useState, useSyncExternalStore } from 'react';
|
|
18
|
+
import { FeedbackReporterController, } from "../capture/reporter.js";
|
|
19
|
+
const ActionsContext = createContext(null);
|
|
20
|
+
const ControllerContext = createContext(null);
|
|
21
|
+
export function FeedbackReporterProvider({ children, capture, captureOptions }) {
|
|
22
|
+
// Created once per provider. The options are read at creation; changing them later
|
|
23
|
+
// would mean a different reporter, which no app has needed.
|
|
24
|
+
const [controller] = useState(() => new FeedbackReporterController({ capture, captureOptions }));
|
|
25
|
+
const actions = useMemo(() => ({ openFeedback: controller.open }), [controller]);
|
|
26
|
+
return (_jsx(ControllerContext.Provider, { value: controller, children: _jsx(ActionsContext.Provider, { value: actions, children: children }) }));
|
|
27
|
+
}
|
|
28
|
+
const noop = { openFeedback: () => { } };
|
|
29
|
+
/**
|
|
30
|
+
* For triggers. Safe to call from anywhere; a no-op outside the provider, so a
|
|
31
|
+
* component also rendered on a page without a reporter (a public page with nobody to
|
|
32
|
+
* attribute a report to) does not crash.
|
|
33
|
+
*/
|
|
34
|
+
export function useFeedback() {
|
|
35
|
+
return useContext(ActionsContext) ?? noop;
|
|
36
|
+
}
|
|
37
|
+
/** For the dialog: the full state plus the actions. Must be inside FeedbackReporterProvider. */
|
|
38
|
+
export function useFeedbackReporter() {
|
|
39
|
+
const controller = useContext(ControllerContext);
|
|
40
|
+
if (!controller)
|
|
41
|
+
throw new Error('useFeedbackReporter must be used inside <FeedbackReporterProvider>');
|
|
42
|
+
const snapshot = useSyncExternalStore(controller.subscribe, controller.getSnapshot, controller.getSnapshot);
|
|
43
|
+
return useMemo(() => ({
|
|
44
|
+
...snapshot,
|
|
45
|
+
openFeedback: controller.open,
|
|
46
|
+
close: controller.close,
|
|
47
|
+
waitForCapture: controller.waitForCapture,
|
|
48
|
+
}), [snapshot, controller]);
|
|
49
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { captureViewport, encodeWithinBudget, fitWithin, isRequestedType, REDACT_ATTRIBUTE, MAX_EDGE_PX, TARGET_MAX_BYTES, HARD_MAX_BYTES, type CaptureOptions, type CaptureResult, } from './adapters/capture/capture.js';
|
|
2
|
+
export { collectClientContext } from './adapters/capture/client-context.js';
|
|
3
|
+
export { FeedbackReporterController, type CaptureState, type FeedbackReporterSnapshot, type FeedbackReporterOptions, } from './adapters/capture/reporter.js';
|
|
4
|
+
export { buildFeedbackForm, submitFeedback, type FeedbackSubmitInput, type FeedbackSubmitResult, } from './adapters/capture/submit.js';
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// @kolibri-day/feedback/browser — viewport capture, browser facts, the reporter's
|
|
2
|
+
// state machine, and the client half of the wire protocol. Browser-only at call time;
|
|
3
|
+
// importing it on a server is harmless (nothing runs at module load).
|
|
4
|
+
export { captureViewport, encodeWithinBudget, fitWithin, isRequestedType, REDACT_ATTRIBUTE, MAX_EDGE_PX, TARGET_MAX_BYTES, HARD_MAX_BYTES, } from "./adapters/capture/capture.js";
|
|
5
|
+
export { collectClientContext } from "./adapters/capture/client-context.js";
|
|
6
|
+
export { FeedbackReporterController, } from "./adapters/capture/reporter.js";
|
|
7
|
+
export { buildFeedbackForm, submitFeedback, } from "./adapters/capture/submit.js";
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export * from './kernel/feedback/types.js';
|
|
2
|
+
export * from './kernel/feedback/messages.js';
|
|
3
|
+
export * from './kernel/feedback/format.js';
|
|
4
|
+
export * from './kernel/feedback/notice.js';
|
|
5
|
+
export * from './kernel/feedback/fake-target.js';
|
|
6
|
+
export * from './kernel/feedback/files.js';
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// @kolibri-day/feedback — the isomorphic core: the report shape, the target contract,
|
|
2
|
+
// the Markdown formatter, the privacy notice, messages, and the in-memory targets.
|
|
3
|
+
// No Node or DOM APIs; safe to import from server and browser code alike.
|
|
4
|
+
export * from "./kernel/feedback/types.js";
|
|
5
|
+
export * from "./kernel/feedback/messages.js";
|
|
6
|
+
export * from "./kernel/feedback/format.js";
|
|
7
|
+
export * from "./kernel/feedback/notice.js";
|
|
8
|
+
export * from "./kernel/feedback/fake-target.js";
|
|
9
|
+
export * from "./kernel/feedback/files.js";
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { type FeedbackReport, type FeedbackSubmission, type FeedbackTarget } from './types.js';
|
|
2
|
+
export type StoredFeedbackStatus = 'pending' | 'submitted' | 'failed';
|
|
3
|
+
export interface StoredFeedback {
|
|
4
|
+
report: FeedbackReport;
|
|
5
|
+
status: StoredFeedbackStatus;
|
|
6
|
+
/** Set when an earlier attempt created the item at the target but did not finish. */
|
|
7
|
+
externalId: string | null;
|
|
8
|
+
}
|
|
9
|
+
export interface FeedbackSubmittedUpdate {
|
|
10
|
+
targetName: string;
|
|
11
|
+
externalId: string;
|
|
12
|
+
externalUrl: string | null;
|
|
13
|
+
submittedAt: Date;
|
|
14
|
+
}
|
|
15
|
+
export interface FeedbackFailureUpdate {
|
|
16
|
+
targetName: string;
|
|
17
|
+
/** The error message, truncated — safe to show an operator (targets never put credentials in it). */
|
|
18
|
+
message: string;
|
|
19
|
+
/** Mark the report failed for good; no attempt will ever succeed. */
|
|
20
|
+
permanent: boolean;
|
|
21
|
+
/** The item exists at the target — persist it so the next attempt resumes instead of re-creating. */
|
|
22
|
+
externalId?: string;
|
|
23
|
+
externalUrl?: string | null;
|
|
24
|
+
}
|
|
25
|
+
/** Where the app keeps its reports. Implement it over your own database. */
|
|
26
|
+
export interface FeedbackStore {
|
|
27
|
+
load(id: string): Promise<StoredFeedback | null>;
|
|
28
|
+
/** Record delivery — AND drop the stored screenshot bytes (rule 3). Clears any recorded error. */
|
|
29
|
+
markSubmitted(id: string, update: FeedbackSubmittedUpdate): Promise<void>;
|
|
30
|
+
/** Record a failed attempt (rule 1 and 2). */
|
|
31
|
+
recordFailure(id: string, update: FeedbackFailureUpdate): Promise<void>;
|
|
32
|
+
}
|
|
33
|
+
export type DispatchResult = {
|
|
34
|
+
outcome: 'submitted';
|
|
35
|
+
submission: FeedbackSubmission;
|
|
36
|
+
} | {
|
|
37
|
+
outcome: 'missing';
|
|
38
|
+
} | {
|
|
39
|
+
outcome: 'already_submitted';
|
|
40
|
+
} | {
|
|
41
|
+
outcome: 'failed_permanently';
|
|
42
|
+
message: string;
|
|
43
|
+
};
|
|
44
|
+
export interface DispatchLogger {
|
|
45
|
+
log(message: string): void;
|
|
46
|
+
error(message: string): void;
|
|
47
|
+
}
|
|
48
|
+
export interface DispatchOptions {
|
|
49
|
+
store: FeedbackStore;
|
|
50
|
+
target: FeedbackTarget;
|
|
51
|
+
logger?: DispatchLogger;
|
|
52
|
+
}
|
|
53
|
+
/** Longest error message recorded on a report. */
|
|
54
|
+
export declare const MAX_ERROR_LENGTH = 1000;
|
|
55
|
+
/**
|
|
56
|
+
* Deliver one stored report.
|
|
57
|
+
*
|
|
58
|
+
* Resolves on success, on nothing-to-do, and on a PERMANENT failure (recorded — the
|
|
59
|
+
* failed report IS the alarm, and rethrowing would only burn the retry budget).
|
|
60
|
+
* Throws on a TRANSIENT failure, after recording it, so the caller's queue backs off
|
|
61
|
+
* and tries again. Any error that is not a FeedbackTargetError (a bug, a database
|
|
62
|
+
* hiccup) counts as transient: it is not known to be hopeless.
|
|
63
|
+
*/
|
|
64
|
+
export declare function dispatchFeedback(id: string, options: DispatchOptions): Promise<DispatchResult>;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// Delivering a STORED report to the configured target — the half of the design that
|
|
2
|
+
// makes feedback honest about failure.
|
|
3
|
+
//
|
|
4
|
+
// The recommended shape is durable: the app confirms a report as soon as it is
|
|
5
|
+
// stored, and delivery runs later from a job queue that provides backoff, an attempt
|
|
6
|
+
// budget and an operator-visible failed state. A synchronous submit in the request
|
|
7
|
+
// path would lose the report outright whenever the tracker is down — which is
|
|
8
|
+
// precisely when people are filing bug reports.
|
|
9
|
+
//
|
|
10
|
+
// The package owns the RULES; the app owns storage and the queue, behind the small
|
|
11
|
+
// FeedbackStore interface below. Three rules worth reading before changing anything:
|
|
12
|
+
//
|
|
13
|
+
// 1. THE EXTERNAL ID IS PERSISTED BEFORE THE ATTACHMENT UPLOAD IS RETRIED. Creating
|
|
14
|
+
// the item and uploading the screenshot are separate calls at most trackers; a
|
|
15
|
+
// failure between them must resume the upload, never create a second ticket. The
|
|
16
|
+
// target reports a post-create failure by carrying the id on the error, and it is
|
|
17
|
+
// recorded immediately.
|
|
18
|
+
//
|
|
19
|
+
// 2. PERMANENT FAILURES DO NOT RETRY. A bad token or a missing project fails
|
|
20
|
+
// identically on every attempt, and the delay only postpones the failed report
|
|
21
|
+
// someone needs to see. Transient failures are rethrown into the queue's backoff.
|
|
22
|
+
//
|
|
23
|
+
// 3. THE SCREENSHOT IS DELETED ON SUCCESS. Once the target holds the bytes, a second
|
|
24
|
+
// copy in the app's database is pure added exposure — markSubmitted must drop it
|
|
25
|
+
// (keeping only metadata such as type and size, if the app wants to show that one
|
|
26
|
+
// was attached).
|
|
27
|
+
import { FeedbackTargetError } from "./types.js";
|
|
28
|
+
const defaultLogger = {
|
|
29
|
+
log: (m) => console.log(`[feedback] ${m}`),
|
|
30
|
+
error: (m) => console.error(`[feedback] ${m}`),
|
|
31
|
+
};
|
|
32
|
+
/** Longest error message recorded on a report. */
|
|
33
|
+
export const MAX_ERROR_LENGTH = 1_000;
|
|
34
|
+
/**
|
|
35
|
+
* Deliver one stored report.
|
|
36
|
+
*
|
|
37
|
+
* Resolves on success, on nothing-to-do, and on a PERMANENT failure (recorded — the
|
|
38
|
+
* failed report IS the alarm, and rethrowing would only burn the retry budget).
|
|
39
|
+
* Throws on a TRANSIENT failure, after recording it, so the caller's queue backs off
|
|
40
|
+
* and tries again. Any error that is not a FeedbackTargetError (a bug, a database
|
|
41
|
+
* hiccup) counts as transient: it is not known to be hopeless.
|
|
42
|
+
*/
|
|
43
|
+
export async function dispatchFeedback(id, options) {
|
|
44
|
+
const { store, target } = options;
|
|
45
|
+
const logger = options.logger ?? defaultLogger;
|
|
46
|
+
const stored = await store.load(id);
|
|
47
|
+
// Gone, or already delivered: complete rather than throw. A job that can never
|
|
48
|
+
// succeed must not retry to exhaustion and then sit failed with nothing for an
|
|
49
|
+
// operator to act on.
|
|
50
|
+
if (!stored) {
|
|
51
|
+
logger.log(`report ${id} no longer exists — skipping`);
|
|
52
|
+
return { outcome: 'missing' };
|
|
53
|
+
}
|
|
54
|
+
if (stored.status === 'submitted') {
|
|
55
|
+
logger.log(`report ${id} already submitted (${stored.externalId ?? '?'}) — skipping`);
|
|
56
|
+
return { outcome: 'already_submitted' };
|
|
57
|
+
}
|
|
58
|
+
try {
|
|
59
|
+
// Resume when the item already exists (create succeeded, upload didn't).
|
|
60
|
+
const resume = stored.externalId ? { externalId: stored.externalId } : undefined;
|
|
61
|
+
const submission = await target.submit(stored.report, resume);
|
|
62
|
+
await store.markSubmitted(id, {
|
|
63
|
+
targetName: target.name,
|
|
64
|
+
externalId: submission.externalId,
|
|
65
|
+
externalUrl: submission.externalUrl,
|
|
66
|
+
submittedAt: new Date(),
|
|
67
|
+
});
|
|
68
|
+
logger.log(`report ${id} -> ${target.name} ${submission.externalId}`);
|
|
69
|
+
return { outcome: 'submitted', submission };
|
|
70
|
+
}
|
|
71
|
+
catch (e) {
|
|
72
|
+
const err = e instanceof FeedbackTargetError ? e : null;
|
|
73
|
+
const message = (e instanceof Error ? e.message : String(e)).slice(0, MAX_ERROR_LENGTH);
|
|
74
|
+
const permanent = err?.permanent ?? false;
|
|
75
|
+
await store.recordFailure(id, {
|
|
76
|
+
targetName: target.name,
|
|
77
|
+
message,
|
|
78
|
+
permanent,
|
|
79
|
+
// Written the moment it is known — rule 1. Without it, the next attempt creates
|
|
80
|
+
// a duplicate ticket at any target whose create is not idempotent.
|
|
81
|
+
...(err?.externalId ? { externalId: err.externalId, externalUrl: err.externalUrl ?? null } : {}),
|
|
82
|
+
});
|
|
83
|
+
if (permanent) {
|
|
84
|
+
logger.error(`report ${id} permanently failed: ${message}`);
|
|
85
|
+
return { outcome: 'failed_permanently', message };
|
|
86
|
+
}
|
|
87
|
+
throw e;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { FeedbackTarget, FeedbackReport, FeedbackResume, FeedbackSubmission, TrustBoundary } from './types.js';
|
|
2
|
+
import { FeedbackTargetError } from './types.js';
|
|
3
|
+
export interface FakeSubmission {
|
|
4
|
+
report: FeedbackReport;
|
|
5
|
+
resume: FeedbackResume | undefined;
|
|
6
|
+
externalId: string;
|
|
7
|
+
}
|
|
8
|
+
export interface FakeFeedbackTargetOptions {
|
|
9
|
+
/** Origin used for the fake backlinks. */
|
|
10
|
+
webUrl?: string;
|
|
11
|
+
label?: string;
|
|
12
|
+
}
|
|
13
|
+
export declare class FakeFeedbackTarget implements FeedbackTarget {
|
|
14
|
+
readonly name = "fake";
|
|
15
|
+
readonly label: string;
|
|
16
|
+
/** Nothing leaves the process, so it is trivially inside the trust boundary. */
|
|
17
|
+
readonly trustBoundary: TrustBoundary;
|
|
18
|
+
/** Every submit() call, in order — including the ones that then threw. */
|
|
19
|
+
readonly submissions: FakeSubmission[];
|
|
20
|
+
/** Calls that actually created a new item (i.e. were not resumes). Duplicate-ticket detector. */
|
|
21
|
+
readonly creates: string[];
|
|
22
|
+
private seq;
|
|
23
|
+
private failures;
|
|
24
|
+
private readonly webUrl;
|
|
25
|
+
constructor(options?: FakeFeedbackTargetOptions);
|
|
26
|
+
/** Queue a failure for the NEXT submit() call (repeat to script a sequence). */
|
|
27
|
+
failNext(error: FeedbackTargetError): this;
|
|
28
|
+
/** Convenience: the two classifications, scripted. */
|
|
29
|
+
failNextTransient(message?: string): this;
|
|
30
|
+
failNextPermanent(message?: string): this;
|
|
31
|
+
/**
|
|
32
|
+
* Script the partial failure that resumability exists for: the item was created,
|
|
33
|
+
* the attachment upload then failed. The error carries the id, exactly as a real
|
|
34
|
+
* target does, so a durable caller can persist it and resume.
|
|
35
|
+
*/
|
|
36
|
+
failNextAfterCreate(message?: string): this;
|
|
37
|
+
submit(report: FeedbackReport, resume?: FeedbackResume): Promise<FeedbackSubmission>;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* A target that always fails permanently with a configuration message.
|
|
41
|
+
*
|
|
42
|
+
* This is what an unconfigured production deployment gets, and the choice is
|
|
43
|
+
* deliberate: reports are still PERSISTED and the failure is LOUD (a failed report
|
|
44
|
+
* naming the missing variables). The alternative — silently discarding reports, or
|
|
45
|
+
* refusing the button — would mean people file feedback into nothing and conclude the
|
|
46
|
+
* team ignored them.
|
|
47
|
+
*
|
|
48
|
+
* It is also why a missing variable does not throw at process BOOT: that would take
|
|
49
|
+
* every other job in the same worker down over a feedback misconfiguration.
|
|
50
|
+
*/
|
|
51
|
+
export declare class UnconfiguredFeedbackTarget implements FeedbackTarget {
|
|
52
|
+
readonly name = "unconfigured";
|
|
53
|
+
readonly trustBoundary: TrustBoundary;
|
|
54
|
+
readonly label: string;
|
|
55
|
+
private readonly reason;
|
|
56
|
+
constructor(reason: string, label?: string);
|
|
57
|
+
submit(_report?: FeedbackReport, _resume?: FeedbackResume): Promise<FeedbackSubmission>;
|
|
58
|
+
}
|