@madebywild/feedback
v0.2.1
Published
Headless, self-hosted marker.io equivalent — embeddable feedback widget that posts to Supabase and fans out to one or more configurable channels (Productive.io, etc.) via an adapter pattern.
Readme
@madebywild/feedback

Embeddable feedback widget for browser apps. It captures an annotated screenshot plus runtime context, posts to a Supabase Edge Function, and fans out to one or more configured channels (Productive.io today; Linear, Slack, GitHub Issues, webhook, … via the same adapter pattern).
- Package source:
packages/feedback/src - Edge Function source:
packages/feedback/supabase/functions/ingest-feedback - Channel adapters:
packages/feedback/supabase/functions/ingest-feedback/channels - Schema/migrations:
packages/feedback/supabase/migrations
Install
Consumers install the published package from npm:
pnpm add @madebywild/feedbackInside this repository, use PNPM workspace commands while developing the package:
pnpm --filter @madebywild/feedback dev
pnpm --filter @madebywild/feedback buildClient usage
Using React? Skip ahead to React bindings for the
@madebywild/feedback/reacthooks.
import Feedback from "@madebywild/feedback";
Feedback.init({
projectKey: "feedback_demo",
endpoint: "https://<project-ref>.supabase.co/functions/v1/ingest-feedback",
authToken: async () =>
(await supabase.auth.getSession()).data.session?.access_token,
trigger: { button: true, hotkey: "Shift+F" },
position: "bottom-right",
theme: "auto",
reporter: { id: "user_123", name: "Avery", email: "[email protected]" },
metadata: { release: "2026.04.27", page: "settings" },
onSubmitted: (result) => console.log(result.id, result.status),
onError: (error) => console.error(error.message),
});
Feedback.open();
Feedback.close();
Feedback.setReporter({ id: "user_123", name: "Avery" });
Feedback.shutdown();Public API
default export Feedback (packages/feedback/src/index.ts):
init(config: FeedbackConfig)- Validates required
projectKey+endpoint(must be a valid absolute URL) - Validates that
metadata(if provided) is JSON-serializable - Installs console/network capture
- Mounts/re-mounts the widget
- Validates required
open()opens capture/composerclose()closes composersetReporter(reporter)mutates reporter used for next submitsetMetadata(metadata)replaces the per-submissionmetadatapayload without re-mounting the widget. Use this on route changes — re-callinginitresets capture buffers and churns event listeners. Validates JSON-serializability and throws on circular refs /BigInt.shutdown()unmounts widget and restores patched console/fetch/xhrclearStoredReporter()wipes the cached reportername/emailfromsessionStorageandlocalStorage. Call on sign-out for shared-device flows.
FeedbackConfig
| Field | Required | Default | Notes |
| ---------------- | -------- | ---------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| projectKey | yes | — | Must match widget_projects.project_key. |
| endpoint | yes | — | Supabase Edge Function URL. |
| authToken | no | undefined | Supabase access token (or async getter). Sent as Authorization: Bearer .... Required when backend auth_mode='authenticated'. |
| trigger.button | no | true | Floating action button visibility. |
| trigger.hotkey | no | "Shift+F" | Set false to disable keyboard trigger. |
| position | no | "bottom-right" | bottom-right | bottom-left | top-right | top-left. |
| theme | no | "auto" | light | dark | auto (prefers-color-scheme). |
| reporter | no | undefined | { id?, name?, email? }. |
| metadata | no | undefined | Arbitrary JSON persisted as custom_metadata. |
| onSubmitted | no | undefined | Receives { id, status }. |
| onError | no | undefined | Called on capture/submit errors. |
What is captured
- Screenshot of
document.documentElement(modern-screenshot), excluding:- Widget host (
data-feedback-ignoreon host) - Any node with
data-feedback-ignore
- Widget host (
- Annotations: arrow, box, pen, text (prompt), blur
- Console ring buffer: last 200 entries (
log/info/warn/error/debug+ uncaught/unhandled) - Network ring buffer: last 50
fetch/XMLHttpRequestcalls - Metadata: URL, referrer, user-agent, language, viewport/screen, timezone, timestamp
React bindings
A thin set of hooks at @madebywild/feedback/react wraps the imperative API. react and react-dom are optional peer dependencies (^18 || ^19).
import {
useFeedback,
useFeedbackReporter,
useFeedbackMetadata,
useFeedbackActions,
clearStoredReporter,
} from "@madebywild/feedback/react";
function App() {
useFeedback({
projectKey: "feedback_demo",
endpoint: import.meta.env.VITE_FEEDBACK_ENDPOINT,
authToken: () => session?.access_token,
onSubmitted: (r) => console.log(r.id, r.status),
});
useFeedbackReporter(
user ? { id: user.id, name: user.name, email: user.email } : undefined,
);
useFeedbackMetadata({ route: pathname, release: __BUILD__ });
return <Routes />;
}
function HelpMenu() {
const { open } = useFeedbackActions();
return <button onClick={open}>Report a bug</button>;
}The hooks solve the four things React users get wrong on their own:
- StrictMode-safe: development double-invocation does not double-init or reset capture buffers.
- SSR-safe: no-op on the server; mounts on the first browser-side effect.
- Stable callbacks:
authToken,onSubmitted,onErrorare read through refs so inline closures don't retrigger init. - Reactive reporter / metadata:
useFeedbackReporteranduseFeedbackMetadatashallow-compare and callsetReporter/setMetadataon change. Use them on route or auth changes instead of recallinguseFeedback, which would reset the console and network ring buffers.
clearStoredReporter() (re-exported from the React entry; also Feedback.clearStoredReporter()) wipes the mbwf:reporter cache from sessionStorage and localStorage. The composer prefills name/email from this cache when config.reporter is missing those fields, so the cached values can leak across sign-outs on shared devices unless explicitly cleared.
See docs/react.md for the full reference, including reporter-cache precedence and the singleton-divergence pitfall when mixing entries.
Transport contract
Widget submits multipart/form-data:
project_key: stringpayload: JSON string containing:title(required)descriptionreportermetadatacustomMetadataconsoleLognetworkLog
screenshot: PNG blob (screenshot.png)
fetch uses credentials: "omit" and includes Authorization only when authToken is provided.
Successful API response shape is { id: string, status: "received" | "synced" | "partial" | "failed" }:
received— submission stored, no channels were configured (or the channel lookup itself failed).synced— every active channel for this project succeeded.partial— at least one channel succeeded and at least one failed. Per-channel detail is infeedback_dispatches.failed— every active channel failed.
Supabase backend setup
The npm package ships a complete Supabase workdir at supabase/:
supabase/config.toml— local/deploy config;ingest-feedbackhasverify_jwt = falsebecause the function performs project/origin/auth-mode checks itself.supabase/migrations/20260507091706_initial_wild_feedback.sql— the consolidated production schema.supabase/functions/ingest-feedback— the Edge Function and channel adapters.
From a consuming app after pnpm add @madebywild/feedback, the recommended production path is to copy the packaged Supabase workdir into your app repo and manage it in version control:
FEEDBACK_PKG_DIR="$(pnpm root)/@madebywild/feedback"
mkdir -p supabase
cp -R "$FEEDBACK_PKG_DIR/supabase/." supabase/
supabase link --project-ref <project-ref> --password '<database-password>'
supabase db push --password '<database-password>'
supabase secrets set \
--project-ref <project-ref> \
PRODUCTIVE_API_TOKEN=<token> \
PRODUCTIVE_ORGANIZATION_ID=<org-id>
supabase functions deploy ingest-feedback --project-ref <project-ref> --use-apiFor one-off evaluation without copying files, run against the packaged workdir directly. Use a percent-encoded database URL because this flow does not link a local project:
FEEDBACK_PKG_DIR="$(pnpm root)/@madebywild/feedback"
DB_URL='postgresql://postgres:<percent-encoded-password>@db.<project-ref>.supabase.co:5432/postgres'
supabase db push --workdir "$FEEDBACK_PKG_DIR" --db-url "$DB_URL"
supabase functions deploy ingest-feedback \
--workdir "$FEEDBACK_PKG_DIR" \
--project-ref <project-ref> \
--use-apiRun migrations before sending traffic. The schema creates private service-role-only tables, the feedback-screenshots storage bucket, the consume_rate_limit RPC, and RLS deny policies for public client roles.
Required project data
Each client app needs one widget_projects row and at least one active widget_project_channels row. project_key must match Feedback.init({ projectKey }); allowed_origins should contain exact browser origins such as https://app.example.com. An empty origin array disables origin enforcement and should be reserved for development.
insert into public.widget_projects
(project_key, name, allowed_origins, auth_mode)
values
(
'feedback_demo',
'Feedback demo',
array['https://app.example.com'],
'both'
)
returning id;
insert into public.widget_project_channels
(widget_project_id, kind, config)
values
(
'<widget-project-id>',
'productive',
jsonb_build_object(
'projectId', '<productive project id>',
'taskListId', '<productive task list id>',
'assigneeId', null,
'titlePrefix', '[Feedback] '
)
);auth_mode is anonymous, authenticated, or both. In authenticated mode, configure authToken on the browser widget so the Edge Function can validate the reporter with Supabase Auth.
Channels
Every submission for a given widget_projects.id is fanned out to the active rows in widget_project_channels for that project. Each row is one configured instance of a ChannelAdapter. Channels run concurrently (Promise.allSettled); each writes one feedback_dispatches row and the submission's status rolls them up:
| Outcome across channels | feedback_submissions.status |
| ----------------------------------------- | ----------------------------- |
| no active channels | received |
| all channels synced | synced |
| at least one synced + at least one failed | partial |
| all channels failed | failed |
feedback_submissions.sync_error carries a short summary ("1/3 channels failed"); per-channel detail is in feedback_dispatches.sync_error.
Built-in channels
productive
Config (widget_project_channels.config):
{
"projectId": "<productive project id>",
"taskListId": "<productive task list id>",
"assigneeId": "<optional productive person id>",
"titlePrefix": "[Feedback] "
}Required env on the Edge Function: PRODUCTIVE_API_TOKEN, PRODUCTIVE_ORGANIZATION_ID.
Adding a new channel kind
The adapter contract lives in supabase/functions/ingest-feedback/channels/types.ts:
export interface ChannelAdapter<TConfig = unknown> {
readonly kind: string;
parseConfig(raw: unknown): TConfig; // throw on invalid
dispatch(
input: ChannelDispatchInput,
config: TConfig,
): Promise<DispatchResult>;
}To add (e.g.) a Linear channel:
- Create
channels/linear/index.tsexporting an adapter withkind: "linear". - Register it in
channels/registry.ts. - Insert a
widget_project_channelsrow withkind = 'linear'and an appropriateconfigjsonb. - Set any required env vars (e.g.
LINEAR_API_KEY) on the Edge Function.
Adapters MUST NOT throw — they return DispatchFailure on error so the orchestrator can record the failure without taking down the rest of the fan-out. The orchestrator catches throws as a defensive guard, but this should be treated as a contract violation.
Edge Function deployment + config
Function entrypoint: packages/feedback/supabase/functions/ingest-feedback/index.ts
Secrets/runtime env
Used by code:
SUPABASE_URL(runtime; expected in function environment)SUPABASE_SERVICE_ROLE_KEY(runtime; expected in function environment)SUPABASE_ANON_KEY(runtime; required for bearer token validation inauth_mode='authenticated')PRODUCTIVE_API_TOKEN(required)PRODUCTIVE_ORGANIZATION_ID(required)
If Productive secrets are missing/invalid, submissions are still audited and returned as status: "failed" with sync_error persisted.
Deploy notes
- Prefer the packaged-workdir or copied-workdir CLI flow in "Supabase backend setup" above.
supabase/config.tomlsets[functions.ingest-feedback].verify_jwt = false; do not rely on the default JWT gateway check for this function.- You can deploy via the Supabase dashboard using the same function source and secret values.
- Ensure browser access mode matches your auth plan. The widget always sends
credentials: "omit"and only sends JWT auth whenauthTokenis configured. - Ensure migrations above are applied before traffic.
Operational behavior (audit/status semantics)
For accepted requests (valid project key/origin/auth mode):
- Insert
feedback_submissionsrow immediately withstatus = 'received'. - Upload screenshot to
feedback-screenshots(<widget_project_id>/<submission_id>.png). - Build a channel-agnostic markdown description, including a 30-day signed screenshot URL when available.
- Load active rows from
widget_project_channelsfor this project; fan out via the channel orchestrator (concurrent). - For each adapter result, write one
feedback_dispatchesrow (statussyncedorfailed, withexternal_id/external_urlon success andsync_erroron failure). - Update the submission row with the rolled-up
status(received|synced|partial|failed) and a shortsync_errorsummary. - Return
200with{ id, status }.
This guarantees an audit record even when downstream sync fails, and per-channel detail is queryable from feedback_dispatches.
Error contract
The widget treats persistence to Supabase as the single point of success. Once a submission is durably stored, the user is shown the success toast regardless of what happens to downstream side effects. This is intentional: the user has done their job; integration plumbing should never punish them for it.
Concretely:
| Stage | Failure → user-visible? | Failure → onError? | Failure → result.status |
| ----------------------------------------------------------------------------- | ------------------------------------- | -------------------- | ---------------------------------------------------------------------------- |
| Network / fetch reject | yes (error banner) | yes | n/a |
| Edge function returns non-2xx (validation, rate limit, auth, insert_failed) | yes (error banner) | yes | n/a |
| Supabase row insert fails | yes (returned as 500 insert_failed) | yes | n/a |
| Storage screenshot upload | no | no | rolled-up status; sync_error prefixed screenshot: |
| Signed URL generation | no | no | rolled-up status |
| Channel dispatch (any adapter — task create, webhook POST, etc.) | no | no | "partial" or "failed", with per-channel feedback_dispatches.sync_error |
| Channel attachment / follow-up step | no | no | "partial" or "failed"; Productive prefixes attachment: |
| Adapter throws (contract violation) | no | no | "partial" or "failed"; orchestrator records as a failed dispatch |
| Any other unexpected error after row insert | no | no | "failed", with sync_error |
Where the contract is enforced:
packages/feedback/supabase/functions/ingest-feedback/index.ts— the whole post-insert side-effect block is wrapped in a defensivetry/catch. Even an unanticipated runtime error after the audit row is created will return200 { id, status: "failed" }with the failure reason persisted infeedback_submissions.sync_error.packages/feedback/src/transport/submit.ts— only throws on non-2xx or a malformed response body.status: "failed"from a 200 response is not an error.packages/feedback/src/widget/Composer.tsx— callsonSubmitted(result)on every 2xx and shows the success toast;result.statusis forwarded to the integrator but never rendered as a user-facing error.
Operating implications:
- Channel outages, schema drift, attachment-size limits, and similar
third-party issues do not degrade the reporter UX. They produce
status: 'partial'or'failed'rows infeedback_submissionsand per-channelfeedback_dispatchesrows for triage. onErroris only called for failures that prevent durable storage. Wire it to your error monitoring; do not treat it as a sync-health signal — queryfeedback_dispatches(filtered bystatus='failed') for that.- New integrations land as channel adapters (see "Adding a new channel
kind" above) rather than ad-hoc additions to the post-insert block. The
orchestrator already provides the silent-failure guarantee for any
adapter registered in
channels/registry.ts.
Rate limiting, CORS, auth mode
From ingest-feedback/index.ts:
- Rate limit:
30requests per5minutes per(widget_project_id, client_ip)bucket viaconsume_rate_limit. If the RPC fails, ingestion now fails closed with503 rate_limit_unavailable. - CORS:
- Preflight: wildcard (
*) response. - POST: project-specific check against
widget_projects.allowed_origins. - If
allowed_originsis empty, origin enforcement is skipped.
- Preflight: wildcard (
- Auth mode enforcement (
widget_projects.auth_mode):authenticated: requires a validAuthorization: Bearer <Supabase access token>.reporter.idfrom payload is ignored; verified auth user id is used.anonymous: rejects whenpayload.reporter.idis present.both: accepts anonymous submissions, but uses verified auth user id when a valid bearer token is sent.
Client integration lifecycle
- Call
Feedback.initonce when your app shell mounts. In React StrictMode, guard the effect so development double-invocation does not reinitialize capture buffers. - On route changes, call
Feedback.setMetadatato update per-submission context without tearing down global console/network capture or re-mounting the widget. - Call
Feedback.shutdown()only when the app shell is truly unmounting; it restores patched globals.
Troubleshooting
| Symptom | Likely cause | Where to check |
| -------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
| unknown_project_key (404) | Missing/inactive widget_projects row or wrong projectKey | widget_projects.project_key, is_active |
| origin_not_allowed (403) | Request origin not in allowed_origins | widget_projects.allowed_origins |
| authenticated_required (401) | auth_mode='authenticated' without a valid bearer token | Authorization header + auth_mode |
| anonymous_only (400) | auth_mode='anonymous' with reporter.id | Payload reporter + auth_mode |
| rate_limited (429) | Bucket exceeded 30/5min | rate_limits table + traffic source |
| rate_limit_unavailable (503) | Rate-limit RPC errored | Edge logs + consume_rate_limit function |
| auth_unavailable (500) | Missing SUPABASE_ANON_KEY in authenticated mode | Function env secrets |
| insert_failed (500) | DB write failed | Edge logs + table schema/migrations |
| Client receives status: failed / partial (200) | One or more channel dispatches failed | feedback_dispatches.sync_error (per channel), feedback_submissions.sync_error summary, Edge logs |
| Screenshot missing in destination | Storage upload or attachment flow failed | screenshot_path, feedback_dispatches.sync_error, channel API logs |
| status: received despite a configured channel | No active row in widget_project_channels for the project, or widget_project_channels lookup errored | widget_project_channels (is_active, widget_project_id), feedback_submissions.sync_error |
Tests
The package ships three test tiers (Vitest):
| Tier | Command | What it covers | Needs Docker? |
| ----------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------- |
| Unit | pnpm test | Pure logic in src/, the orchestrator, the Productive API client + adapter, the description template, capture utilities, transport submit. All against in-process mocks. | No |
| Integration | pnpm test:integration | Migrations applied to a real Postgres (Testcontainers): schema + constraints, the unique partial index on widget_project_channels, idempotent backfill blocks for 0009/0010, the consume_rate_limit RPC under burst + window rollover, and the orchestrator persisting to the real feedback_dispatches table. | Yes |
| E2E | pnpm test:e2e | A realistic submission flow: real Postgres + an in-process Productive HTTP mock running the full 4-step attach dance. Drives the actual orchestrator + Productive adapter through happy path, createTask 422, attachment-link 502, multi-channel partial rollup, and missing-env failure. | Yes |
| All three | pnpm test:all | The above, in order. | Yes |
pnpm typecheck runs both tsconfig.json (production sources) and tsconfig.test.json (which adds the tests/ directory and the Node-compatible Edge Function modules to the typecheck graph).
What's intentionally not covered
The thin Deno HTTP shell (supabase/functions/ingest-feedback/index.ts — CORS, multipart parsing, auth header extraction, rate-limit RPC wiring) is transport code that wires already-tested pieces. To exercise it, run supabase start && supabase functions serve locally and POST a multipart submission directly. There's no automated tier for it; if the shell ever grows logic, add a Deno-runtime test under supabase/functions/tests/.
Adding a new channel adapter — what to test
For each new adapter:
- Unit: cover
parseConfig(every required field + defaults) anddispatch(success path, every failure path returning aDispatchFailure). Mock the API client at the module boundary. - E2E: spin up an in-process mock of the destination's API, register the real adapter in the orchestrator, and assert
feedback_dispatches.external_id/external_url+ the submission rollup status.
The orchestrator's tests already cover fan-out, partial rollup, and adapter-throws-defensively semantics — adapters don't need to re-test those.
Known limitations / follow-ups
- No automatic retry worker for failed channel dispatches; triage is via
feedback_dispatches.status='failed'. - No PII redaction/masking in console, network, or custom metadata payloads.
- Console/network buffers are process-local ring buffers and are not cleared per submission.
- No session replay or two-way sync from any channel.
