c0b84595a3
## Summary The server half of hosted webhooks: the public ingress endpoint, signature verification, the delivery pipeline (Postgres partitioned storage + ClickHouse for ordering), the in-app partition manager, the HTTP API, and the dashboard (Deliveries, Endpoints, and the in-app test console). The public SDK and docs half is #4537. That PR carries the user-facing API (`webhook()`, `chat.event` / `chat.channels`, the `@trigger.dev/slack` connector) and builds on the shared `@trigger.dev/core` schemas that ship here. ## Shipping behind a flag A `WEBHOOK_ENABLED` env var (default off) gates the public ingress route and the engine worker plus partition cron, so merging and deploying this changes nothing in production until it is flipped on per environment. The dashboard is separately gated per org by the `hasWebhooksAccess` feature flag. ## Note on packages This PR includes the `@trigger.dev/core` schema additions the server compiles against, but carries no changeset. Core is not consumed independently of the SDK, so it is released together with the SDK via #4537. Keeping its changeset off `main` means no release cut from `main` publishes it early.
198 lines
7.4 KiB
TypeScript
198 lines
7.4 KiB
TypeScript
import { MagnifyingGlassIcon } from "@heroicons/react/20/solid";
|
|
import { useEffect, useMemo, useState } from "react";
|
|
import { useTypedFetcher } from "remix-typedjson";
|
|
import { Input } from "~/components/primitives/Input";
|
|
import { Spinner } from "~/components/primitives/Spinner";
|
|
import { cn } from "~/utils/cn";
|
|
import {
|
|
type WebhookProviderMeta,
|
|
type WebhookSampleMeta,
|
|
type loader as samplesLoader,
|
|
} from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.webhooks.samples";
|
|
|
|
export function SampleSourcePicker({
|
|
samplesPath,
|
|
endpointSource,
|
|
onLoad,
|
|
}: {
|
|
samplesPath: string;
|
|
/** The endpoint's provider (e.g. "github"); pre-selected in the producer list. */
|
|
endpointSource?: string;
|
|
onLoad: (body: string, headers: Record<string, string>) => void;
|
|
}) {
|
|
const listFetcher = useTypedFetcher<typeof samplesLoader>();
|
|
const bodyFetcher = useTypedFetcher<typeof samplesLoader>();
|
|
const [selectedProvider, setSelectedProvider] = useState<string | null>(null);
|
|
const [producerQuery, setProducerQuery] = useState("");
|
|
const [topicQuery, setTopicQuery] = useState("");
|
|
|
|
useEffect(() => {
|
|
if (listFetcher.state === "idle" && listFetcher.data === undefined) {
|
|
listFetcher.load(samplesPath);
|
|
}
|
|
}, [listFetcher, samplesPath]);
|
|
|
|
useEffect(() => {
|
|
const data = bodyFetcher.data;
|
|
if (data?.kind === "body") {
|
|
onLoad(data.body, data.extraHeaders ?? {});
|
|
}
|
|
}, [bodyFetcher.data, onLoad]);
|
|
|
|
const manifest = listFetcher.data?.kind === "manifest" ? listFetcher.data : undefined;
|
|
const providers = manifest?.providers ?? [];
|
|
const samples = manifest?.samples ?? [];
|
|
const listLoading = listFetcher.data === undefined;
|
|
|
|
useEffect(() => {
|
|
if (providers.length === 0) return;
|
|
setSelectedProvider((current) => {
|
|
if (current && providers.some((p) => p.id === current)) return current;
|
|
if (endpointSource && providers.some((p) => p.id === endpointSource)) return endpointSource;
|
|
return null;
|
|
});
|
|
}, [providers, endpointSource]);
|
|
|
|
const filteredProviders = useMemo(() => {
|
|
const query = producerQuery.trim().toLowerCase();
|
|
if (!query) return providers;
|
|
return providers.filter(
|
|
(p) => p.label.toLowerCase().includes(query) || (p.category ?? "").includes(query)
|
|
);
|
|
}, [providers, producerQuery]);
|
|
|
|
const groupedProviders = useMemo(() => {
|
|
const groups = new Map<string, WebhookProviderMeta[]>();
|
|
for (const provider of filteredProviders) {
|
|
const key = provider.category ?? "other";
|
|
const group = groups.get(key) ?? [];
|
|
group.push(provider);
|
|
groups.set(key, group);
|
|
}
|
|
return [...groups.entries()];
|
|
}, [filteredProviders]);
|
|
|
|
const events = samples
|
|
.filter((item) => item.provider === selectedProvider)
|
|
.filter((item) => {
|
|
const query = topicQuery.trim().toLowerCase();
|
|
return !query || item.eventType.toLowerCase().includes(query);
|
|
});
|
|
|
|
const [loadingEventType, setLoadingEventType] = useState<string | undefined>(undefined);
|
|
|
|
function selectEvent(item: WebhookSampleMeta) {
|
|
setLoadingEventType(item.eventType);
|
|
const query = new URLSearchParams({ provider: item.provider, eventType: item.eventType });
|
|
bodyFetcher.load(`${samplesPath}?${query.toString()}`);
|
|
}
|
|
|
|
return (
|
|
<div className="flex h-full flex-col overflow-hidden bg-charcoal-900">
|
|
<div className="grid min-h-0 flex-1 grid-cols-[minmax(0,0.45fr)_minmax(0,0.55fr)]">
|
|
<div className="flex min-h-0 flex-col border-r border-grid-dimmed">
|
|
<SearchInput
|
|
placeholder="Webhook producer"
|
|
value={producerQuery}
|
|
onChange={setProducerQuery}
|
|
/>
|
|
<div className="flex-1 overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
|
|
{listLoading ? (
|
|
<div className="flex items-center justify-center py-8">
|
|
<Spinner className="size-4" />
|
|
</div>
|
|
) : filteredProviders.length === 0 ? (
|
|
<p className="px-3 py-6 text-center text-xs text-text-dimmed">No providers</p>
|
|
) : (
|
|
groupedProviders.map(([category, group]) => (
|
|
<div key={category}>
|
|
<p className="sticky top-0 z-10 bg-charcoal-900 px-3 pb-1 pt-3 text-xs font-medium text-text-dimmed">
|
|
{group[0]?.categoryLabel ?? category}
|
|
</p>
|
|
{group.map((provider) => (
|
|
<button
|
|
key={provider.id}
|
|
type="button"
|
|
onClick={() => setSelectedProvider(provider.id)}
|
|
className={cn(
|
|
"flex w-full items-center gap-2 px-3 py-2 text-left text-sm transition-colors",
|
|
provider.id === selectedProvider
|
|
? "bg-indigo-500/15 text-indigo-400"
|
|
: "text-text-bright hover:bg-charcoal-800"
|
|
)}
|
|
>
|
|
<span className="flex-1 truncate">{provider.label}</span>
|
|
<span className="shrink-0 text-xxs tabular-nums text-text-dimmed">
|
|
{provider.eventCount}
|
|
</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex min-h-0 flex-col">
|
|
<SearchInput
|
|
placeholder="Webhook type or topic"
|
|
value={topicQuery}
|
|
onChange={setTopicQuery}
|
|
/>
|
|
<div className="flex-1 overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
|
|
{!selectedProvider ? (
|
|
<p className="px-3 py-6 text-center text-xs text-text-dimmed">
|
|
Select a producer to see its events.
|
|
</p>
|
|
) : events.length === 0 ? (
|
|
<p className="px-3 py-6 text-center text-xs text-text-dimmed">No matching events</p>
|
|
) : (
|
|
events.map((item) => (
|
|
<button
|
|
key={`${item.provider}:${item.eventType}`}
|
|
type="button"
|
|
onClick={() => selectEvent(item)}
|
|
disabled={bodyFetcher.state !== "idle"}
|
|
className="flex w-full items-center justify-between gap-2 px-3 py-2 text-left transition-colors hover:bg-charcoal-800 disabled:opacity-60"
|
|
>
|
|
<span className="truncate font-mono text-sm text-text-bright">
|
|
{item.eventType}
|
|
</span>
|
|
{bodyFetcher.state !== "idle" && loadingEventType === item.eventType ? (
|
|
<Spinner className="size-3.5 shrink-0" />
|
|
) : null}
|
|
</button>
|
|
))
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="shrink-0 border-t border-grid-dimmed px-3 py-2 text-xxs text-text-dimmed">
|
|
Curated event payloads. Signed with this endpoint's config at send time.
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SearchInput({
|
|
placeholder,
|
|
value,
|
|
onChange,
|
|
}: {
|
|
placeholder: string;
|
|
value: string;
|
|
onChange: (value: string) => void;
|
|
}) {
|
|
return (
|
|
<div className="border-b border-grid-dimmed p-2">
|
|
<Input
|
|
variant="small"
|
|
placeholder={placeholder}
|
|
value={value}
|
|
icon={MagnifyingGlassIcon}
|
|
onChange={(event) => onChange(event.target.value)}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|