Menu

Change language

GitHub repo

Webhook Tester

blockdevops

Svix and Stripe style live webhook delivery inspector and event simulator with signature generation, payload editor, and request/response inspection.

Installation

$npx uipkge-ng@latest add webhook-tester
Manifest:https://uipkge.dev/r/angular/webhook-tester.jsonInstalls to:src/app/components/blocks/webhook-tester/

Variants

Loading interactive previews…

Schema

Type aliases exported from this item's source. Use these to shape the data you pass in.

WebhookEventPreset
interface WebhookEventPreset {
  id: string
  name: string
  category: string
  description: string
  defaultPayload: object
}
DeliveryAttempt
interface DeliveryAttempt {
  id: string
  eventId: string
  eventType: string
  status: number
  statusText: string
  timestamp: string
  timeAgo: string
  latencyMs: number
  endpointUrl: string
  signature: string
  requestHeaders: Record<string, string>
  requestBody: string
  responseHeaders: Record<string, string>
  responseBody: string
  timings: {
    dns: number
    tls: number
    ttfb: number
    download: number
    total: number
  }
}

Files installed (2)

  • src/app/components/blocks/webhook-tester/webhook-tester.component.ts47.7 kB
    // Svix and Stripe style live webhook delivery inspector and event simulator with
    // signature generation, payload editor, and request/response inspection.
    // Port of the React block 1:1.
    import { ChangeDetectionStrategy, Component, Input, OnDestroy, OnInit, computed, signal } from '@angular/core'
    import {
      Activity,
      ArrowRight,
      Check,
      CheckCircle2,
      Clock,
      Copy,
      FileCode,
      Globe,
      Key,
      Layers,
      LucideAngularModule,
      RefreshCw,
      RotateCw,
      Send,
      ShieldCheck,
      Zap,
    } from 'lucide-angular'
    import { cn } from '@/lib/utils'
    import { UiBadgeComponent } from '@/ui/badge/badge.component'
    import { UiButtonComponent } from '@/ui/button/button.component'
    import {
      UiCardComponent,
      UiCardContentComponent,
      UiCardDescriptionComponent,
      UiCardFooterComponent,
      UiCardHeaderComponent,
      UiCardTitleComponent,
    } from '@/ui/card/card.component'
    import { UiInputComponent } from '@/ui/input/input.component'
    import {
      UiSelectComponent,
      UiSelectContentComponent,
      UiSelectItemComponent,
      UiSelectTriggerComponent,
      UiSelectValueComponent,
    } from '@/ui/select/select.component'
    import { UiSeparatorComponent } from '@/ui/separator/separator.component'
    import {
      UiTableBodyComponent,
      UiTableCellComponent,
      UiTableComponent,
      UiTableHeadComponent,
      UiTableHeaderComponent,
      UiTableRowComponent,
    } from '@/ui/table/table.component'
    import {
      UiTabsComponent,
      UiTabsContentComponent,
      UiTabsListComponent,
      UiTabsTriggerComponent,
    } from '@/ui/tabs/tabs.component'
    import { UiTextareaComponent } from '@/ui/textarea/textarea.component'
    
    export interface WebhookEventPreset {
      id: string
      name: string
      category: string
      description: string
      defaultPayload: object
    }
    
    export interface DeliveryAttempt {
      id: string
      eventId: string
      eventType: string
      status: number
      statusText: string
      timestamp: string
      timeAgo: string
      latencyMs: number
      endpointUrl: string
      signature: string
      requestHeaders: Record<string, string>
      requestBody: string
      responseHeaders: Record<string, string>
      responseBody: string
      timings: {
        dns: number
        tls: number
        ttfb: number
        download: number
        total: number
      }
    }
    
    export type WebhookSignatureFormat = 'stripe' | 'svix'
    
    const eventPresets: WebhookEventPreset[] = [
      {
        id: 'customer.created',
        name: 'customer.created',
        category: 'Customers',
        description: 'Dispatched whenever a new customer account or profile is created.',
        defaultPayload: {
          id: 'evt_1PqN8y2eZvKYlo2C79x01abc',
          object: 'event',
          api_version: '2026-08-01',
          created: 1787313600,
          type: 'customer.created',
          data: {
            object: {
              id: 'cus_N0wP9XyZ12345',
              object: 'customer',
              name: 'Sarah Connor',
              email: '[email protected]',
              phone: '+1 415 555 0199',
              currency: 'usd',
              metadata: {
                source: 'onboarding_flow_v2',
                tier: 'enterprise',
              },
            },
          },
        },
      },
      {
        id: 'invoice.payment_succeeded',
        name: 'invoice.payment_succeeded',
        category: 'Billing',
        description: 'Triggered upon successful capture and settlement of recurring invoice payment.',
        defaultPayload: {
          id: 'evt_2MqK7x3eZvKYlo2C88y02def',
          object: 'event',
          api_version: '2026-08-01',
          created: 1787313580,
          type: 'invoice.payment_succeeded',
          data: {
            object: {
              id: 'in_1QpZ2w3eZvKYlo2C99182312',
              object: 'invoice',
              customer: 'cus_N0wP9XyZ12345',
              amount_paid: 49000,
              amount_due: 49000,
              currency: 'usd',
              status: 'paid',
              paid_at: 1787313575,
              lines: {
                data: [
                  {
                    description: 'Pro Tier Subscription (10 Seats)',
                    amount: 49000,
                    period: {
                      start: 1787313500,
                      end: 1789905500,
                    },
                  },
                ],
              },
            },
          },
        },
      },
      {
        id: 'order.fulfilled',
        name: 'order.fulfilled',
        category: 'Fulfillment',
        description: 'Emitted when logistics carrier records the parcel as dispatched.',
        defaultPayload: {
          id: 'evt_3KpJ5v4eZvKYlo2C77z03ghi',
          object: 'event',
          api_version: '2026-08-01',
          created: 1787313520,
          type: 'order.fulfilled',
          data: {
            object: {
              id: 'ord_88192341',
              object: 'order',
              customer_id: 'cus_N0wP9XyZ12345',
              carrier: 'FedEx Priority',
              tracking_number: '794829103948',
              status: 'fulfilled',
              items_count: 3,
              fulfillment_date: '2026-08-21T14:38:40Z',
            },
          },
        },
      },
      {
        id: 'subscription.updated',
        name: 'subscription.updated',
        category: 'Subscriptions',
        description: 'Emitted when plan, billing frequency, or seat quantities update.',
        defaultPayload: {
          id: 'evt_4TqL9m1eZvKYlo2C66w04jkl',
          object: 'event',
          api_version: '2026-08-01',
          created: 1787313460,
          type: 'subscription.updated',
          data: {
            object: {
              id: 'sub_1QzX89aZvKYlo2C01',
              object: 'subscription',
              customer: 'cus_N0wP9XyZ12345',
              current_period_end: 1792497600,
              plan: {
                id: 'price_enterprise_yearly',
                name: 'Enterprise Annual',
                amount: 588000,
              },
              quantity: 25,
              status: 'active',
            },
          },
        },
      },
    ]
    
    export function generateDeterministicSignature(secret: string, payload: string, timestamp: number): string {
      let hash = 0
      const str = `${timestamp}.${secret}.${payload}`
      for (let i = 0; i < str.length; i++) {
        const char = str.charCodeAt(i)
        hash = (hash << 5) - hash + char
        hash |= 0
      }
      const hex1 = Math.abs(hash).toString(16).padStart(8, '0')
      const hex2 = Math.abs((hash * 31 + 17) | 0)
        .toString(16)
        .padStart(8, '0')
      const hex3 = Math.abs((hash * 127 + 59) | 0)
        .toString(16)
        .padStart(8, '0')
      const hex4 = Math.abs((hash * 8191 + 97) | 0)
        .toString(16)
        .padStart(8, '0')
      return `${hex1}${hex2}${hex3}${hex4}`
    }
    
    const initialDeliveries: DeliveryAttempt[] = [
      {
        id: 'del_01J6A7BC8D9EF01',
        eventId: 'evt_1PqN8y2eZvKYlo2C79x01abc',
        eventType: 'customer.created',
        status: 200,
        statusText: 'OK',
        timestamp: '2026-08-21 14:40:12 UTC',
        timeAgo: '12s ago',
        latencyMs: 142,
        endpointUrl: 'https://api.acme.dev/webhooks',
        signature: 't=1787313612,v1=5257a869e7eceeda32ab62f1a9338f6cf60c9b07',
        requestHeaders: {
          'content-type': 'application/json; charset=utf-8',
          'user-agent': 'UIPKGE-Webhooks/2.0 (webhook-tester)',
          'stripe-signature': 't=1787313612,v1=5257a869e7eceeda32ab62f1a9338f6cf60c9b07',
          'webhook-id': 'msg_01J6A7BC8D9EF01',
          'webhook-timestamp': '1787313612',
        },
        requestBody: JSON.stringify(eventPresets[0]!.defaultPayload, null, 2),
        responseHeaders: {
          'content-type': 'application/json; charset=utf-8',
          date: 'Fri, 21 Aug 2026 14:40:12 GMT',
          server: 'cloudflare-worker',
          'x-request-id': 'req_98b47120aef1',
        },
        responseBody: JSON.stringify(
          {
            received: true,
            handler: 'customer_sync_v2',
            job_id: 'job_984129',
            processed_at: '2026-08-21T14:40:12.140Z',
          },
          null,
          2,
        ),
        timings: {
          dns: 14,
          tls: 26,
          ttfb: 88,
          download: 14,
          total: 142,
        },
      },
      {
        id: 'del_01J6A6ZZ7E8D9C0',
        eventId: 'evt_2MqK7x3eZvKYlo2C88y02def',
        eventType: 'invoice.payment_succeeded',
        status: 200,
        statusText: 'OK',
        timestamp: '2026-08-21 14:38:10 UTC',
        timeAgo: '2m ago',
        latencyMs: 98,
        endpointUrl: 'https://api.acme.dev/webhooks',
        signature: 't=1787313490,v1=99fa1b2388c4710aef12d098bc762a41289fe1b0',
        requestHeaders: {
          'content-type': 'application/json; charset=utf-8',
          'user-agent': 'UIPKGE-Webhooks/2.0 (webhook-tester)',
          'stripe-signature': 't=1787313490,v1=99fa1b2388c4710aef12d098bc762a41289fe1b0',
          'webhook-id': 'msg_01J6A6ZZ7E8D9C0',
          'webhook-timestamp': '1787313490',
        },
        requestBody: JSON.stringify(eventPresets[1]!.defaultPayload, null, 2),
        responseHeaders: {
          'content-type': 'application/json; charset=utf-8',
          date: 'Fri, 21 Aug 2026 14:38:10 GMT',
          server: 'nginx/1.24.0',
          'x-request-id': 'req_77a1902bc450',
        },
        responseBody: JSON.stringify(
          {
            status: 'ok',
            ledger_updated: true,
            invoice_id: 'in_1QpZ2w3eZvKYlo2C99182312',
          },
          null,
          2,
        ),
        timings: {
          dns: 10,
          tls: 20,
          ttfb: 56,
          download: 12,
          total: 98,
        },
      },
      {
        id: 'del_01J6A5YY4C2B1A9',
        eventId: 'evt_3KpJ5v4eZvKYlo2C77z03ghi',
        eventType: 'order.fulfilled',
        status: 500,
        statusText: 'Internal Server Error',
        timestamp: '2026-08-21 14:34:00 UTC',
        timeAgo: '6m ago',
        latencyMs: 1240,
        endpointUrl: 'https://api.acme.dev/webhooks',
        signature: 't=1787313240,v1=12fe48a90bb76c123490fdba891230cd78129aef',
        requestHeaders: {
          'content-type': 'application/json; charset=utf-8',
          'user-agent': 'UIPKGE-Webhooks/2.0 (webhook-tester)',
          'stripe-signature': 't=1787313240,v1=12fe48a90bb76c123490fdba891230cd78129aef',
          'webhook-id': 'msg_01J6A5YY4C2B1A9',
          'webhook-timestamp': '1787313240',
        },
        requestBody: JSON.stringify(eventPresets[2]!.defaultPayload, null, 2),
        responseHeaders: {
          'content-type': 'application/json; charset=utf-8',
          date: 'Fri, 21 Aug 2026 14:34:01 GMT',
          server: 'express-gateway',
          'x-request-id': 'req_33f8101cd991',
        },
        responseBody: JSON.stringify(
          {
            error: 'InternalServerError',
            message: 'Failed to connect to upstream ERP database at 10.0.4.12:5432 (ETIMEDOUT)',
            code: 'DB_CONN_TIMEOUT',
            retryable: true,
          },
          null,
          2,
        ),
        timings: {
          dns: 12,
          tls: 24,
          ttfb: 1190,
          download: 14,
          total: 1240,
        },
      },
    ]
    
    @Component({
      changeDetection: ChangeDetectionStrategy.Eager,
      selector: 'ui-webhook-tester, [ui-webhook-tester]',
      standalone: true,
      // React renders the root <div> itself: the host stays out of layout and `class` goes to the root.
      host: { '[attr.class]': '"contents"' },
      imports: [
        LucideAngularModule,
        UiBadgeComponent,
        UiButtonComponent,
        UiCardComponent,
        UiCardContentComponent,
        UiCardDescriptionComponent,
        UiCardFooterComponent,
        UiCardHeaderComponent,
        UiCardTitleComponent,
        UiInputComponent,
        UiSelectComponent,
        UiSelectContentComponent,
        UiSelectItemComponent,
        UiSelectTriggerComponent,
        UiSelectValueComponent,
        UiSeparatorComponent,
        UiTableBodyComponent,
        UiTableCellComponent,
        UiTableComponent,
        UiTableHeadComponent,
        UiTableHeaderComponent,
        UiTableRowComponent,
        UiTabsComponent,
        UiTabsContentComponent,
        UiTabsListComponent,
        UiTabsTriggerComponent,
        UiTextareaComponent,
      ],
      template: `
        <div data-slot="webhook-tester" [class]="rootClass">
          <!-- Top Header Banner & Primary Endpoint Controls -->
          <div ui-card class="border-border bg-card shadow-xs">
            <div ui-card-header class="pb-4">
              <div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
                <div class="space-y-1">
                  <div class="flex items-center gap-2">
                    <div class="bg-primary/10 text-primary flex size-8 items-center justify-center rounded-lg">
                      <lucide-icon [img]="Zap" class="size-4" />
                    </div>
                    <h3 ui-card-title class="text-base font-semibold">Webhook Event Simulator & Inspector</h3>
                  </div>
                  <p ui-card-description class="text-xs">
                    Trigger test events and verify signature headers against your endpoint.
                  </p>
                </div>
    
                <div class="flex items-center gap-2">
                  <span ui-badge variant="outline" class="border-border bg-muted/50 font-mono text-xs">
                    <span class="bg-success mr-1.5 inline-block size-2 animate-pulse rounded-full"></span>
                    Ingress Ready
                  </span>
                </div>
              </div>
            </div>
    
            <div ui-card-content class="pt-0">
              <div class="grid grid-cols-1 gap-3 md:grid-cols-12">
                <!-- Endpoint URL Input -->
                <div class="md:col-span-8 lg:col-span-9">
                  <div class="relative flex items-center">
                    <span
                      class="text-muted-foreground pointer-events-none absolute left-3 flex items-center gap-1.5 font-mono text-xs"
                    >
                      <lucide-icon [img]="Globe" class="size-3.5" />
                      <span class="text-foreground font-semibold">POST</span>
                    </span>
                    <ui-input
                      [value]="endpointUrl()"
                      (valueChange)="endpointUrl.set($event)"
                      placeholder="https://api.acme.dev/webhooks"
                      class="pl-20 font-mono text-xs"
                    />
                  </div>
                </div>
    
                <!-- Trigger Button -->
                <div class="md:col-span-4 lg:col-span-3">
                  <button
                    ui-button
                    size="default"
                    class="w-full gap-2 text-xs font-medium shadow-xs"
                    [disabled]="isSending() || !endpointUrl()"
                    (click)="sendWebhook()"
                  >
                    @if (isSending()) {
                      <span
                        class="border-primary-foreground size-3.5 animate-spin rounded-full border-2 border-t-transparent"
                      ></span>
                    } @else {
                      <lucide-icon [img]="Send" class="size-3.5" />
                    }
                    <span>{{ isSending() ? 'Delivering...' : 'Send Test Webhook' }}</span>
                    <kbd class="bg-primary-foreground/20 hidden rounded px-1 py-0.5 font-mono text-xs sm:inline-flex"
                      >⌘↵</kbd
                    >
                  </button>
                </div>
              </div>
            </div>
          </div>
    
          <!-- 2-Column Inspector Layout -->
          <div class="grid grid-cols-1 gap-6 lg:grid-cols-12">
            <!-- Left Panel: Event Payload Selector, JSON Editor & Signing Secret -->
            <div class="space-y-6 lg:col-span-6">
              <!-- Event Selector & Payload Schema Card -->
              <div ui-card class="border-border bg-card shadow-xs">
                <div ui-card-header class="pb-3">
                  <div class="flex items-center justify-between">
                    <div class="flex items-center gap-2">
                      <div class="bg-primary/10 text-primary flex size-7 items-center justify-center rounded-md">
                        <lucide-icon [img]="Layers" class="size-4" />
                      </div>
                      <div>
                        <h3 ui-card-title class="text-sm font-semibold">Event Configuration</h3>
                        <p ui-card-description class="text-xs">Select event payload and adjust test parameters</p>
                      </div>
                    </div>
                    <span ui-badge variant="secondary" class="font-mono text-xs">{{ activePreset().category }}</span>
                  </div>
                </div>
    
                <div ui-card-content class="space-y-4 pt-0">
                  <!-- Event Dropdown -->
                  <div class="space-y-1.5">
                    <label class="text-foreground text-xs font-medium">Event Type</label>
                    <ui-select [value]="selectedEvent()" (valueChange)="handleEventChange($event)">
                      <button ui-select-trigger class="font-mono text-xs">
                        <ui-select-value placeholder="Select event preset" />
                      </button>
                      <ui-select-content>
                        @for (preset of presets; track preset.id) {
                          <ui-select-item [value]="preset.id" class="font-mono text-xs">
                            {{ preset.name }}
                          </ui-select-item>
                        }
                      </ui-select-content>
                    </ui-select>
                    <p class="text-muted-foreground text-xs">{{ activePreset().description }}</p>
                  </div>
    
                  <!-- Quick Selection Badges -->
                  <div class="flex flex-wrap items-center gap-1.5 pt-1">
                    @for (preset of presets; track preset.id) {
                      <button type="button" [class]="presetChipClass(preset.id)" (click)="handleEventChange(preset.id)">
                        {{ preset.id }}
                      </button>
                    }
                  </div>
    
                  <ui-separator />
    
                  <!-- JSON Payload Editor -->
                  <div class="space-y-2">
                    <div class="flex items-center justify-between">
                      <label class="text-foreground flex items-center gap-1.5 text-xs font-medium">
                        <lucide-icon [img]="FileCode" class="text-primary size-3.5" />
                        <span>Payload Body (JSON)</span>
                      </label>
    
                      <div class="flex items-center gap-1.5">
                        <button
                          ui-button
                          variant="ghost"
                          size="sm"
                          class="text-muted-foreground hover:text-foreground h-7 px-2 text-xs"
                          (click)="handleFormatJson()"
                        >
                          Format
                        </button>
                        <button
                          ui-button
                          variant="ghost"
                          size="sm"
                          class="text-muted-foreground hover:text-foreground h-7 px-2 text-xs"
                          (click)="handleResetPayload()"
                        >
                          <lucide-icon [img]="RefreshCw" class="mr-1 size-3" />
                          Reset
                        </button>
                        <button
                          ui-button
                          variant="ghost"
                          size="sm"
                          class="text-muted-foreground hover:text-foreground h-7 px-2 text-xs"
                          (click)="handleCopy(currentPayloadStr(), 'payload')"
                        >
                          @if (copiedKey() === 'payload') {
                            <lucide-icon [img]="Check" class="text-success mr-1 size-3" />
                          } @else {
                            <lucide-icon [img]="Copy" class="mr-1 size-3" />
                          }
                          <span>{{ copiedKey() === 'payload' ? 'Copied' : 'Copy' }}</span>
                        </button>
                      </div>
                    </div>
    
                    <ui-textarea
                      [value]="currentPayloadStr()"
                      (valueChange)="currentPayloadStr.set($event)"
                      [rows]="11"
                      [noResize]="true"
                      class="border-border/70 focus:border-primary font-mono text-xs leading-relaxed"
                      placeholder='{
      "type": "event.name"
    }'
                    />
                  </div>
                </div>
    
                <div
                  ui-card-footer
                  class="border-border text-muted-foreground flex items-center justify-between border-t pt-3 text-xs"
                >
                  <span class="font-mono">{{ currentPayloadStr().length }} bytes</span>
                  <span class="font-mono">Content-Type: application/json</span>
                </div>
              </div>
    
              <!-- Signing Secret & Signature Verification Preview Card -->
              <div ui-card class="border-border bg-card shadow-xs">
                <div ui-card-header class="pb-3">
                  <div class="flex items-center justify-between">
                    <div class="flex items-center gap-2">
                      <div class="bg-primary/10 text-primary flex size-7 items-center justify-center rounded-md">
                        <lucide-icon [img]="ShieldCheck" class="size-4" />
                      </div>
                      <div>
                        <h3 ui-card-title class="text-sm font-semibold">Signature & Security Headers</h3>
                        <p ui-card-description class="text-xs">HMAC-SHA256 signature calculated from secret & timestamp</p>
                      </div>
                    </div>
                    <div class="flex items-center gap-1">
                      <button type="button" [class]="formatChipClass('stripe')" (click)="signatureFormat.set('stripe')">
                        Stripe
                      </button>
                      <button type="button" [class]="formatChipClass('svix')" (click)="signatureFormat.set('svix')">
                        Svix Standard
                      </button>
                    </div>
                  </div>
                </div>
    
                <div ui-card-content class="space-y-4 pt-0">
                  <!-- Signing Secret Input -->
                  <div class="space-y-1.5">
                    <div class="flex items-center justify-between">
                      <label class="text-foreground text-xs font-medium">Endpoint Signing Secret</label>
                      <button
                        type="button"
                        class="text-primary flex min-h-6 cursor-pointer items-center gap-1 text-xs hover:underline"
                        (click)="handleRegenerateSecret()"
                      >
                        <lucide-icon [img]="RotateCw" class="size-3" />
                        <span>Regenerate</span>
                      </button>
                    </div>
                    <div class="relative flex items-center">
                      <lucide-icon [img]="Key" class="text-muted-foreground pointer-events-none absolute left-3 size-3.5" />
                      <ui-input
                        [value]="signingSecret()"
                        (valueChange)="signingSecret.set($event)"
                        class="pr-16 pl-8 font-mono text-xs"
                        placeholder="whsec_..."
                      />
                      <button
                        ui-button
                        aria-label="Copy signing secret"
                        variant="ghost"
                        size="sm"
                        class="text-muted-foreground hover:text-foreground absolute right-1 h-7 px-2 text-xs"
                        (click)="handleCopy(signingSecret(), 'secret')"
                      >
                        @if (copiedKey() === 'secret') {
                          <lucide-icon [img]="Check" class="text-success size-3" />
                        } @else {
                          <lucide-icon [img]="Copy" class="size-3" />
                        }
                      </button>
                    </div>
                  </div>
    
                  <!-- Signature Header Box -->
                  <div class="space-y-1.5">
                    <div class="flex items-center justify-between">
                      <label class="text-foreground text-xs font-medium">Generated Request Headers</label>
                      <button
                        ui-button
                        variant="ghost"
                        size="sm"
                        class="text-muted-foreground hover:text-foreground h-6 px-2 text-xs"
                        (click)="handleCopy(computedAllHeaders(), 'headers')"
                      >
                        @if (copiedKey() === 'headers') {
                          <lucide-icon [img]="Check" class="text-success mr-1 size-3" />
                        } @else {
                          <lucide-icon [img]="Copy" class="mr-1 size-3" />
                        }
                        <span>{{ copiedKey() === 'headers' ? 'Copied' : 'Copy Headers' }}</span>
                      </button>
                    </div>
                    <pre
                      class="border-border bg-muted/40 text-foreground overflow-x-auto rounded-md border p-3 font-mono text-xs leading-relaxed"
                    ><code>{{ computedAllHeaders() }}</code></pre>
                  </div>
                </div>
              </div>
            </div>
    
            <!-- Right Panel: Delivery History & Active Delivery Inspector -->
            <div class="space-y-6 lg:col-span-6">
              <!-- Recent Delivery Attempts List -->
              <div ui-card class="border-border bg-card shadow-xs">
                <div ui-card-header class="pb-3">
                  <div class="flex items-center justify-between">
                    <div class="flex items-center gap-2">
                      <div class="bg-primary/10 text-primary flex size-7 items-center justify-center rounded-md">
                        <lucide-icon [img]="Activity" class="size-4" />
                      </div>
                      <div>
                        <h3 ui-card-title class="text-sm font-semibold">Delivery History</h3>
                        <p ui-card-description class="text-xs">Recent dispatch logs and response telemetry</p>
                      </div>
                    </div>
                    <span class="text-muted-foreground font-mono text-xs"
                      >{{ deliveryHistory().length }} events logged</span
                    >
                  </div>
                </div>
    
                <div ui-card-content class="p-0">
                  <div class="divide-border max-h-[250px] divide-y overflow-y-auto">
                    @for (attempt of deliveryHistory(); track attempt.id) {
                      <div [class]="attemptRowClass(attempt.id)" (click)="selectedDeliveryId.set(attempt.id)">
                        <div class="flex min-w-0 items-center gap-2.5">
                          <span
                            ui-badge
                            [variant]="attempt.status === 200 ? 'success' : 'destructive'"
                            class="shrink-0 px-1.5 py-0.5 font-mono text-xs"
                          >
                            {{ attempt.status }}
                          </span>
                          <div class="min-w-0 truncate">
                            <p class="text-foreground truncate font-mono">{{ attempt.eventType }}</p>
                            <p class="text-muted-foreground truncate font-mono text-xs">{{ attempt.id }}</p>
                          </div>
                        </div>
    
                        <div class="flex shrink-0 items-center gap-3 text-right">
                          <span class="text-muted-foreground font-mono">{{ attempt.latencyMs }}ms</span>
                          <span class="text-muted-foreground text-xs">{{ attempt.timeAgo }}</span>
                          <lucide-icon [img]="ArrowRight" class="text-muted-foreground size-3.5" />
                        </div>
                      </div>
                    }
                  </div>
                </div>
              </div>
    
              <!-- Active Delivery Detail Inspector -->
              <div ui-card class="border-border bg-card shadow-xs">
                <div ui-card-header class="pb-3">
                  <div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
                    <div class="space-y-1">
                      <div class="flex items-center gap-2">
                        <span
                          ui-badge
                          [variant]="activeDelivery().status === 200 ? 'success' : 'destructive'"
                          class="font-mono text-xs"
                        >
                          {{ activeDelivery().status }} {{ activeDelivery().statusText }}
                        </span>
                        <span class="text-foreground font-mono text-xs font-semibold">{{ activeDelivery().id }}</span>
                      </div>
                      <p class="text-muted-foreground truncate font-mono text-xs">{{ activeDelivery().endpointUrl }}</p>
                    </div>
    
                    <div class="flex items-center gap-2">
                      <button
                        ui-button
                        variant="outline"
                        size="sm"
                        class="h-8 gap-1.5 text-xs font-medium"
                        (click)="retryDelivery(activeDelivery())"
                      >
                        <lucide-icon [img]="RotateCw" class="size-3.5" />
                        <span>Retry</span>
                      </button>
                    </div>
                  </div>
                </div>
    
                <div ui-card-content class="pt-0">
                  <ui-tabs [value]="activeInspectorTab()" (valueChange)="activeInspectorTab.set($event)" class="w-full">
                    <ui-tabs-list class="grid w-full grid-cols-3">
                      <button ui-tabs-trigger value="request" class="text-xs">Request</button>
                      <button ui-tabs-trigger value="response" class="text-xs">Response</button>
                      <button ui-tabs-trigger value="timings" class="text-xs">Diagnostics</button>
                    </ui-tabs-list>
    
                    <!-- Request Tab -->
                    <ui-tabs-content value="request" class="space-y-4 pt-3">
                      <!-- Request Headers Table -->
                      <div class="space-y-1.5">
                        <div class="flex items-center justify-between">
                          <label class="text-foreground text-xs font-medium">Request Headers</label>
                          <button
                            ui-button
                            variant="ghost"
                            size="sm"
                            class="text-muted-foreground hover:text-foreground h-6 px-2 text-xs"
                            (click)="handleCopy(stringify(activeDelivery().requestHeaders), 'req-headers')"
                          >
                            @if (copiedKey() === 'req-headers') {
                              <lucide-icon [img]="Check" class="text-success mr-1 size-3" />
                            } @else {
                              <lucide-icon [img]="Copy" class="mr-1 size-3" />
                            }
                            <span>{{ copiedKey() === 'req-headers' ? 'Copied' : 'Copy' }}</span>
                          </button>
                        </div>
                        <div class="border-border bg-muted/20 overflow-hidden rounded-md border">
                          <table ui-table>
                            <thead ui-table-header>
                              <tr ui-table-row>
                                <th ui-table-head class="h-8 text-xs font-medium">Header</th>
                                <th ui-table-head class="h-8 text-xs font-medium">Value</th>
                              </tr>
                            </thead>
                            <tbody ui-table-body>
                              @for (row of headerEntries(activeDelivery().requestHeaders); track row[0]) {
                                <tr ui-table-row>
                                  <td ui-table-cell class="text-muted-foreground py-1.5 font-mono text-xs">{{ row[0] }}</td>
                                  <td ui-table-cell class="text-foreground max-w-[200px] truncate py-1.5 font-mono text-xs">
                                    {{ row[1] }}
                                  </td>
                                </tr>
                              }
                            </tbody>
                          </table>
                        </div>
                      </div>
    
                      <!-- Request Body -->
                      <div class="space-y-1.5">
                        <div class="flex items-center justify-between">
                          <label class="text-foreground text-xs font-medium">Request Body Payload</label>
                          <button
                            ui-button
                            variant="ghost"
                            size="sm"
                            class="text-muted-foreground hover:text-foreground h-6 px-2 text-xs"
                            (click)="handleCopy(activeDelivery().requestBody, 'req-body')"
                          >
                            @if (copiedKey() === 'req-body') {
                              <lucide-icon [img]="Check" class="text-success mr-1 size-3" />
                            } @else {
                              <lucide-icon [img]="Copy" class="mr-1 size-3" />
                            }
                            <span>{{ copiedKey() === 'req-body' ? 'Copied' : 'Copy' }}</span>
                          </button>
                        </div>
                        <pre
                          class="border-border bg-muted/40 text-foreground max-h-[220px] overflow-auto rounded-md border p-3 font-mono text-xs leading-relaxed"
                        ><code>{{ activeDelivery().requestBody }}</code></pre>
                      </div>
                    </ui-tabs-content>
    
                    <!-- Response Tab -->
                    <ui-tabs-content value="response" class="space-y-4 pt-3">
                      <!-- Response Headers Table -->
                      <div class="space-y-1.5">
                        <div class="flex items-center justify-between">
                          <label class="text-foreground text-xs font-medium">Response Headers</label>
                          <button
                            ui-button
                            variant="ghost"
                            size="sm"
                            class="text-muted-foreground hover:text-foreground h-6 px-2 text-xs"
                            (click)="handleCopy(stringify(activeDelivery().responseHeaders), 'res-headers')"
                          >
                            @if (copiedKey() === 'res-headers') {
                              <lucide-icon [img]="Check" class="text-success mr-1 size-3" />
                            } @else {
                              <lucide-icon [img]="Copy" class="mr-1 size-3" />
                            }
                            <span>{{ copiedKey() === 'res-headers' ? 'Copied' : 'Copy' }}</span>
                          </button>
                        </div>
                        <div class="border-border bg-muted/20 overflow-hidden rounded-md border">
                          <table ui-table>
                            <thead ui-table-header>
                              <tr ui-table-row>
                                <th ui-table-head class="h-8 text-xs font-medium">Header</th>
                                <th ui-table-head class="h-8 text-xs font-medium">Value</th>
                              </tr>
                            </thead>
                            <tbody ui-table-body>
                              @for (row of headerEntries(activeDelivery().responseHeaders); track row[0]) {
                                <tr ui-table-row>
                                  <td ui-table-cell class="text-muted-foreground py-1.5 font-mono text-xs">{{ row[0] }}</td>
                                  <td ui-table-cell class="text-foreground max-w-[200px] truncate py-1.5 font-mono text-xs">
                                    {{ row[1] }}
                                  </td>
                                </tr>
                              }
                            </tbody>
                          </table>
                        </div>
                      </div>
    
                      <!-- Response Body Payload -->
                      <div class="space-y-1.5">
                        <div class="flex items-center justify-between">
                          <label class="text-foreground text-xs font-medium">Response Body</label>
                          <button
                            ui-button
                            variant="ghost"
                            size="sm"
                            class="text-muted-foreground hover:text-foreground h-6 px-2 text-xs"
                            (click)="handleCopy(activeDelivery().responseBody, 'res-body')"
                          >
                            @if (copiedKey() === 'res-body') {
                              <lucide-icon [img]="Check" class="text-success mr-1 size-3" />
                            } @else {
                              <lucide-icon [img]="Copy" class="mr-1 size-3" />
                            }
                            <span>{{ copiedKey() === 'res-body' ? 'Copied' : 'Copy' }}</span>
                          </button>
                        </div>
                        <pre [class]="responseBodyClass"><code>{{ activeDelivery().responseBody }}</code></pre>
                      </div>
                    </ui-tabs-content>
    
                    <!-- Diagnostics & Timeline Tab -->
                    <ui-tabs-content value="timings" class="space-y-4 pt-3">
                      <div class="grid grid-cols-2 gap-3 sm:grid-cols-4">
                        <div class="border-border bg-muted/30 rounded-lg border p-2.5 text-center">
                          <p class="text-muted-foreground text-xs">DNS Lookup</p>
                          <p class="text-foreground font-mono text-sm font-semibold">
                            {{ activeDelivery().timings.dns }}ms
                          </p>
                        </div>
                        <div class="border-border bg-muted/30 rounded-lg border p-2.5 text-center">
                          <p class="text-muted-foreground text-xs">TLS Handshake</p>
                          <p class="text-foreground font-mono text-sm font-semibold">
                            {{ activeDelivery().timings.tls }}ms
                          </p>
                        </div>
                        <div class="border-border bg-muted/30 rounded-lg border p-2.5 text-center">
                          <p class="text-muted-foreground text-xs">Server TTFB</p>
                          <p class="text-foreground font-mono text-sm font-semibold">
                            {{ activeDelivery().timings.ttfb }}ms
                          </p>
                        </div>
                        <div class="border-border bg-muted/30 rounded-lg border p-2.5 text-center">
                          <p class="text-muted-foreground text-xs">Total Roundtrip</p>
                          <p class="text-primary font-mono text-sm font-semibold">{{ activeDelivery().timings.total }}ms</p>
                        </div>
                      </div>
    
                      <div class="border-border bg-muted/20 space-y-2 rounded-lg border p-3">
                        <h4 class="text-foreground text-xs font-semibold">Security & Delivery Verification</h4>
                        <ul class="space-y-1.5 text-xs">
                          <li class="text-success flex items-center gap-2">
                            <lucide-icon [img]="CheckCircle2" class="size-3.5 shrink-0" />
                            <span>HMAC signature matched endpoint verification secret</span>
                          </li>
                          <li class="text-success flex items-center gap-2">
                            <lucide-icon [img]="CheckCircle2" class="size-3.5 shrink-0" />
                            <span>TLS 1.3 certificate chain verified (Cloudflare Inc)</span>
                          </li>
                          <li class="text-success flex items-center gap-2">
                            <lucide-icon [img]="CheckCircle2" class="size-3.5 shrink-0" />
                            <span>Timestamp within tolerance envelope (300s window)</span>
                          </li>
                        </ul>
                      </div>
                    </ui-tabs-content>
                  </ui-tabs>
                </div>
    
                <div
                  ui-card-footer
                  class="border-border text-muted-foreground flex items-center justify-between border-t pt-3 text-xs"
                >
                  <span class="flex items-center gap-1.5">
                    <lucide-icon [img]="Clock" class="size-3.5" />
                    <span>Dispatched at: {{ activeDelivery().timestamp }}</span>
                  </span>
                  <span class="font-mono">Latency: {{ activeDelivery().latencyMs }}ms</span>
                </div>
              </div>
            </div>
          </div>
        </div>
      `,
    })
    export class UiWebhookTesterComponent implements OnInit, OnDestroy {
      protected readonly Activity = Activity
      protected readonly ArrowRight = ArrowRight
      protected readonly Check = Check
      protected readonly CheckCircle2 = CheckCircle2
      protected readonly Clock = Clock
      protected readonly Copy = Copy
      protected readonly FileCode = FileCode
      protected readonly Globe = Globe
      protected readonly Key = Key
      protected readonly Layers = Layers
      protected readonly RefreshCw = RefreshCw
      protected readonly RotateCw = RotateCw
      protected readonly Send = Send
      protected readonly ShieldCheck = ShieldCheck
      protected readonly Zap = Zap
    
      protected readonly presets = eventPresets
    
      @Input('class') className?: string
    
      readonly endpointUrl = signal('https://api.acme.dev/webhooks')
      readonly signingSecret = signal('whsec_9f83a8b271d4e680c102a9b6c7a3efd8')
      readonly selectedEvent = signal('customer.created')
      readonly currentPayloadStr = signal(JSON.stringify(eventPresets[0]!.defaultPayload, null, 2))
      readonly signatureFormat = signal<WebhookSignatureFormat>('stripe')
      readonly activeInspectorTab = signal('request')
    
      readonly isSending = signal(false)
      readonly copiedKey = signal<string | null>(null)
      private copyTimer: ReturnType<typeof setTimeout> | undefined
    
      readonly deliveryHistory = signal<DeliveryAttempt[]>([...initialDeliveries])
      readonly selectedDeliveryId = signal<string>(initialDeliveries[0]!.id)
      readonly currentTimestamp = signal(1787313600)
    
      private readonly keyHandler = (e: KeyboardEvent) => {
        if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
          e.preventDefault()
          this.sendWebhook()
        }
      }
    
      ngOnInit(): void {
        window.addEventListener('keydown', this.keyHandler)
      }
    
      ngOnDestroy(): void {
        window.removeEventListener('keydown', this.keyHandler)
        clearTimeout(this.copyTimer)
      }
    
      readonly activeDelivery = computed(() => {
        const history = this.deliveryHistory()
        return history.find((d) => d.id === this.selectedDeliveryId()) ?? history[0]!
      })
    
      readonly activePreset = computed(() => {
        return eventPresets.find((p) => p.id === this.selectedEvent()) ?? eventPresets[0]!
      })
    
      readonly computedSignatureHex = computed(() => {
        return generateDeterministicSignature(this.signingSecret(), this.currentPayloadStr(), this.currentTimestamp())
      })
    
      readonly computedSignatureHeader = computed(() => {
        if (this.signatureFormat() === 'stripe') {
          return `Stripe-Signature: t=${this.currentTimestamp()},v1=${this.computedSignatureHex()}`
        }
        const b64 = typeof window !== 'undefined' ? btoa(this.computedSignatureHex().slice(0, 32)) : ''
        return `webhook-signature: v1,${b64}`
      })
    
      readonly computedAllHeaders = computed(() => {
        const t = this.currentTimestamp()
        const sigHex = this.computedSignatureHex()
        if (this.signatureFormat() === 'stripe') {
          return `POST ${this.endpointUrl()}\nHost: api.acme.dev\nContent-Type: application/json\nUser-Agent: UIPKGE-Webhooks/2.0\nStripe-Signature: t=${t},v1=${sigHex}`
        }
        const b64 = typeof window !== 'undefined' ? btoa(sigHex.slice(0, 32)) : sigHex.slice(0, 32)
        return `POST ${this.endpointUrl()}\nHost: api.acme.dev\nContent-Type: application/json\nUser-Agent: UIPKGE-Webhooks/2.0\nwebhook-id: msg_${sigHex.slice(0, 14)}\nwebhook-timestamp: ${t}\nwebhook-signature: v1,${b64}`
      })
    
      get rootClass(): string {
        return cn('w-full space-y-6', this.className)
      }
    
      get responseBodyClass(): string {
        return cn(
          'max-h-[220px] overflow-auto rounded-md border p-3 font-mono text-xs leading-relaxed',
          this.activeDelivery().status === 200
            ? 'border-border bg-muted/40 text-foreground'
            : 'border-destructive/30 bg-destructive/10 text-destructive',
        )
      }
    
      presetChipClass(id: string): string {
        return cn(
          'min-h-6 cursor-pointer rounded border px-2 py-0.5 font-mono text-xs transition-colors',
          this.selectedEvent() === id
            ? 'border-primary bg-primary/10 text-primary font-medium'
            : 'border-border bg-muted/30 text-muted-foreground hover:bg-muted hover:text-foreground',
        )
      }
    
      formatChipClass(format: WebhookSignatureFormat): string {
        return cn(
          'min-h-6 cursor-pointer rounded px-2 py-0.5 font-mono text-xs transition-colors',
          this.signatureFormat() === format
            ? 'bg-primary text-primary-foreground font-medium'
            : 'bg-muted text-muted-foreground hover:text-foreground',
        )
      }
    
      attemptRowClass(id: string): string {
        return cn(
          'flex cursor-pointer items-center justify-between gap-3 p-3.5 text-xs transition-colors',
          this.selectedDeliveryId() === id ? 'bg-muted/80 font-medium' : 'hover:bg-muted/40',
        )
      }
    
      headerEntries(headers: Record<string, string>): [string, string][] {
        return Object.entries(headers)
      }
    
      stringify(value: object): string {
        return JSON.stringify(value, null, 2)
      }
    
      handleEventChange(eventId: string): void {
        this.selectedEvent.set(eventId)
        const preset = eventPresets.find((p) => p.id === eventId)
        if (preset) {
          this.currentPayloadStr.set(JSON.stringify(preset.defaultPayload, null, 2))
        }
      }
    
      handleFormatJson(): void {
        try {
          const parsed = JSON.parse(this.currentPayloadStr())
          this.currentPayloadStr.set(JSON.stringify(parsed, null, 2))
        } catch {
          // Keep as is if invalid
        }
      }
    
      handleResetPayload(): void {
        const preset = eventPresets.find((p) => p.id === this.selectedEvent())
        if (preset) {
          this.currentPayloadStr.set(JSON.stringify(preset.defaultPayload, null, 2))
        }
      }
    
      handleRegenerateSecret(): void {
        const chars = 'abcdef0123456789'
        let result = 'whsec_'
        for (let i = 0; i < 32; i++) {
          result += chars.charAt(Math.floor(Math.random() * chars.length))
        }
        this.signingSecret.set(result)
      }
    
      handleCopy(text: string, key: string): void {
        try {
          navigator.clipboard?.writeText(text)
        } catch {
          // Clipboard blocked: the payload stays visible and selectable.
        }
        this.copiedKey.set(key)
        clearTimeout(this.copyTimer)
        const current = key
        this.copyTimer = setTimeout(() => {
          if (this.copiedKey() === current) this.copiedKey.set(null)
        }, 2000)
      }
    
      sendWebhook(): void {
        if (this.isSending()) return
        this.isSending.set(true)
    
        const nowSec = Math.floor(Date.now() / 1000)
        this.currentTimestamp.set(nowSec)
    
        setTimeout(() => {
          let parsedBody: Record<string, string> = {}
          let isInvalidJson = false
          try {
            parsedBody = JSON.parse(this.currentPayloadStr()) as Record<string, string>
          } catch {
            isInvalidJson = true
          }
    
          const url = this.endpointUrl()
          const isFailureUrl = url.includes('fail') || url.includes('error')
          const status = isInvalidJson ? 400 : isFailureUrl ? 500 : 200
          const statusText = isInvalidJson ? 'Bad Request' : isFailureUrl ? 'Internal Server Error' : 'OK'
          const latency = isInvalidJson ? 32 : isFailureUrl ? 850 : Math.floor(Math.random() * 90) + 65
    
          const deliveryId = `del_${Math.random().toString(36).substring(2, 11).toUpperCase()}`
          const eventId = parsedBody['id'] ?? `evt_${Math.random().toString(36).substring(2, 10)}`
          const eventType = parsedBody['type'] ?? this.selectedEvent()
          const sig = `t=${nowSec},v1=${this.computedSignatureHex()}`
    
          const newDelivery: DeliveryAttempt = {
            id: deliveryId,
            eventId,
            eventType,
            status,
            statusText,
            timestamp: 'Just now',
            timeAgo: 'Just now',
            latencyMs: latency,
            endpointUrl: url,
            signature: sig,
            requestHeaders: {
              'content-type': 'application/json; charset=utf-8',
              'user-agent': 'UIPKGE-Webhooks/2.0 (webhook-tester)',
              'stripe-signature': sig,
              'webhook-id': `msg_${deliveryId}`,
              'webhook-timestamp': String(nowSec),
            },
            requestBody: this.currentPayloadStr(),
            responseHeaders: {
              'content-type': 'application/json; charset=utf-8',
              date: new Date().toUTCString(),
              server: 'acme-edge-router/1.8',
              'x-request-id': `req_${Math.random().toString(36).substring(2, 12)}`,
            },
            responseBody: isInvalidJson
              ? JSON.stringify({ error: 'BadRequest', message: 'Payload is not valid RFC 8259 JSON' }, null, 2)
              : isFailureUrl
                ? JSON.stringify(
                    { error: 'InternalServerError', message: 'Webhook handler threw uncaught exception' },
                    null,
                    2,
                  )
                : JSON.stringify(
                    {
                      received: true,
                      event_type: eventType,
                      delivery_id: deliveryId,
                      status: 'acknowledged',
                      timestamp: new Date().toISOString(),
                    },
                    null,
                    2,
                  ),
            timings: {
              dns: Math.floor(latency * 0.1),
              tls: Math.floor(latency * 0.2),
              ttfb: Math.floor(latency * 0.6),
              download: Math.floor(latency * 0.1),
              total: latency,
            },
          }
    
          this.deliveryHistory.update((prev) => [newDelivery, ...prev])
          this.selectedDeliveryId.set(newDelivery.id)
          this.isSending.set(false)
        }, 450)
      }
    
      retryDelivery(attempt: DeliveryAttempt): void {
        this.currentPayloadStr.set(attempt.requestBody)
        this.endpointUrl.set(attempt.endpointUrl)
        this.selectedEvent.set(attempt.eventType)
        this.sendWebhook()
      }
    }
    
  • src/app/components/blocks/webhook-tester/index.ts0.2 kB

Raw manifest:https://uipkge.dev/r/angular/webhook-tester.json