Skip to content

Stream and process CLI listen webhooks#188

Merged
maxktz merged 4 commits into
mainfrom
feat/listen-webhooks
May 23, 2026
Merged

Stream and process CLI listen webhooks#188
maxktz merged 4 commits into
mainfrom
feat/listen-webhooks

Conversation

@maxktz
Copy link
Copy Markdown
Contributor

@maxktz maxktz commented May 23, 2026

Summary

  • persist provider webhook deliveries in D1 and stream them to the CLI over Durable Object WebSockets
  • default paykitjs listen to direct PayKit webhook handling, with --forward-to as an optional localhost fallback
  • add CLI version gating, replay/ack/fail handling, reconnect behavior, and duplicate session replacement
  • polish CLI pretty logs and use async Stripe webhook verification for direct mode

Testing

  • bun --filter wh typecheck
  • bun --filter paykitjs typecheck
  • bun --filter @paykitjs/stripe typecheck
  • bun lint
  • node ./node_modules/vitest/vitest.mjs run --config vitest.unit.config.ts packages/paykit/src/core/__tests__/logger.test.ts

Summary by CodeRabbit

  • New Features

    • Added WebSocket-based real-time webhook delivery streaming instead of polling
    • Introduced --forward-to option to forward webhooks to a local origin
    • Enhanced webhook delivery tracking with sent timestamps
  • Bug Fixes

    • Improved webhook error handling and asynchronous signature verification
    • Better CLI logging and output formatting

Review Change Stack

maxktz added 3 commits May 10, 2026 15:28
Store webhook deliveries in D1 before returning success, then use a Durable Object websocket to push pending deliveries to paykitjs listen.

Adds sent_at delivery tracking for in-flight websocket deliveries.
Default listen delivery to PayKit handleWebhook instead of forwarding over localhost HTTP. Keep --forward-to as an escape hatch.

Use async Stripe webhook verification for Bun direct mode and tune CLI pretty logs.
@vercel
Copy link
Copy Markdown
Contributor

vercel Bot commented May 23, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
paykit Skipped Skipped May 23, 2026 1:43pm

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 23, 2026

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 265973d7-2e41-4ab4-a80e-b113fdd111f6

📥 Commits

Reviewing files that changed from the base of the PR and between 060b602 and 92f2925.

📒 Files selected for processing (3)
  • apps/wh/src/tunnel-object.ts
  • packages/paykit/src/cli/commands/listen.ts
  • packages/paykit/src/core/logger.ts

📝 Walkthrough

Walkthrough

This PR implements an end-to-end WebSocket-based tunnel for delivering webhooks from a Cloudflare Worker to a CLI client, replacing polling with persistent connections. It adds database sent_at tracking to distinguish delivery states (unsent, in-flight, delivered, failed) and introduces a Durable Object to manage client sessions and dispatch queued deliveries.

Changes

WebSocket Tunnel Delivery System

Layer / File(s) Summary
Database schema and migrations for in-flight delivery tracking
apps/wh/migrations/0002_delivery-sent-tracking.sql, apps/wh/migrations/meta/0002_snapshot.json, apps/wh/migrations/meta/_journal.json, apps/wh/src/db/schema.ts, apps/wh/package.json, apps/wh/wrangler.jsonc
Adds sent_at timestamp column to delivery table and updates the composite index to track in-flight delivery state. Migration snapshot and journal entries are created; D1/wrangler and package deploy/migration scripts updated.
Cloudflare Durable Object tunnel handler
apps/wh/src/tunnel-object.ts
Implements TunnelObject Durable Object that manages WebSocket connections from CLI clients, validates device tokens via D1, tracks delivery state (pending, in-flight, delivered, failed), dispatches queued deliveries by atomically claiming sentAt, processes ack/fail/ping messages, and resets in-flight deliveries on disconnect or reconnection.
Worker request routing, version gating, and delivery management
apps/wh/src/index.ts
Adds CLI version parsing/comparison and gating for WebSocket/API requests via 426 upgrade responses. Implements routing handlers to intercept WebSocket upgrades and provider webhook POSTs, validate tunnel status, enforce max body size, persist deliveries with pruning, update tunnel webhook endpoint timestamps, and notify the Durable Object asynchronously. Updates delivery-ack/fail endpoints to clear sentAt and triggers async object notifications; refactors default export to a fetch handler.
CLI listen command WebSocket tunnel client
packages/paykit/src/cli/commands/listen.ts
Refactors CLI listen command from polling-based fetching to WebSocket-driven stream consumption. Adds --forward-to option for local webhook replay, introduces DeliveryMode type, updates enable/retry subcommands to use forwarding, and implements WebSocket lifecycle, message consumption, ack/fail responses, retry/backoff, and session replacement detection.
PayKit type/logger/webhook/Stripe updates and tests
packages/paykit/src/cli/utils/get-config.ts, packages/paykit/src/core/logger.ts, packages/paykit/src/core/__tests__/logger.test.ts, packages/paykit/src/webhook/webhook.service.ts, packages/stripe/src/stripe-provider.ts
Introduces ConfiguredPayKit type and returns resolved paykit from loader; replaces pretty logger wiring with a Transform-based stream pipeline and updates tests; removes startTime from webhook processing and adjusts logging/error detail; updates Stripe webhook verification to async constructEventAsync.

Sequence Diagram

sequenceDiagram
  participant CLI as CLI Client
  participant Worker as Webhook Worker
  participant DO as TunnelObject DO
  participant DB as D1 Database
  participant Webhook as Webhook Source
  
  CLI->>Worker: WebSocket upgrade /api/tunnels/:id/connect + auth
  Worker->>DB: Verify tunnel & device token
  Worker->>DO: Forward WebSocket
  DO->>DB: Count pending deliveries
  DO->>CLI: hello message + pending count
  
  Webhook->>Worker: POST /provider-webhook
  Worker->>DB: Store delivery, prune old
  Worker->>DO: POST /internal/push
  DO->>DB: Mark delivery sentAt=now()
  DO->>CLI: delivery payload
  
  CLI->>DO: ack message
  DO->>DB: Set deliveredAt, clear sentAt
  
  DO->>DB: Query next eligible delivery
  DO->>CLI: delivery payload
  CLI->>DO: fail message with error
  DO->>DB: Set failedAt, clear sentAt
  
  CLI->>Worker: [reconnect]
  Worker->>DB: Reset sentAt for tunnel
Loading

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • getpaykit/paykit#173: Evolves the hosted webhook-listener worker's D1-backed delivery "tunnel" workflow by adding sent_at tracking and updating Durable Object ack/fail/send-next delivery selection, directly aligning with the webhook listen workflow and delivery storage changes.

🐇 Hops through the tunnel with glee,
WebSockets replace polls—delivery's free!
sent_at tracks flights, ack/fail marks the way,
From cloud to the CLI, webhooks relay! 🚀

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 3.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Stream and process CLI listen webhooks' directly and accurately reflects the main change: streaming webhook deliveries to the CLI over WebSockets and processing them, which is the core feature described in the PR objectives and implemented across multiple files (tunnel-object.ts, listen.ts, index.ts).
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/listen-webhooks

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

ESLint skipped: no ESLint configuration detected in root package.json. To enable, add eslint to devDependencies.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🧹 Nitpick comments (2)
packages/paykit/src/core/logger.ts (1)

18-33: ⚡ Quick win

Add brief JSDoc to new core helper functions.

dimDetailLines() and createPrettyStream() are new library-core helpers; add short JSDoc so intent stays discoverable and consistent with repo standards.

Suggested patch
 const DETAIL_LINE_PATTERN = /(^|\n)(\s+[^\n]+)/g;
 
+/** Dim indented detail lines in pretty log output. */
 function dimDetailLines(input: string): string {
   return input.replace(DETAIL_LINE_PATTERN, (_match, prefix: string, line: string) => {
     return `${prefix}${dim(line)}`;
   });
 }
 
+/** Create a pretty logger stream that dims detail lines before writing to stdout. */
 function createPrettyStream() {
   const output = new Transform({
     transform(chunk, _encoding, callback) {
       callback(null, dimDetailLines(String(chunk)));
     },

As per coding guidelines, "Add JSDoc on most functions in the library core and on some object properties, mainly if the API is used in many places or is user-facing".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/paykit/src/core/logger.ts` around lines 18 - 33, Add concise JSDoc
comments for the two new helpers so their intent and signature are discoverable:
place a short description, `@param` and `@returns` on dimDetailLines(input: string)
explaining it finds detail lines using DETAIL_LINE_PATTERN and returns the input
with those lines dimmed (string -> string); and add a brief JSDoc on
createPrettyStream() describing it creates a Transform stream that pipes dimmed
log output to process.stdout and returns the configured pretty logger/stream.
Ensure the JSDoc style matches existing repo conventions (one-line summary plus
param/return tags) and attach them directly above the dimDetailLines and
createPrettyStream declarations.
packages/paykit/src/cli/commands/listen.ts (1)

609-611: ⚡ Quick win

Use a protocol-level replacement signal instead of a reason regex.

This stop path depends on the worker keeping the exact phrase "replaced by a newer session". Any wording drift turns an intentional session replacement into the generic reconnect loop below. A shared app-specific close code or explicit control message would make this contract much safer.

Also applies to: 722-727

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/paykit/src/cli/commands/listen.ts` around lines 609 - 611, The
current isReplacedSessionClose function relies on matching the human-readable
close.reason string, which is fragile; instead define and use a protocol-level
replacement signal (e.g., a dedicated close code constant like
REPLACED_SESSION_CODE or a structured reason payload token) and update
isReplacedSessionClose to check that constant (or parse the structured payload)
first; also replace any other regex-based checks of close.reason elsewhere (the
similar check currently duplicated later in the file) to use the same
constant/structured token so the client and worker share a deterministic
contract.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/wh/src/tunnel-object.ts`:
- Around line 162-169: The close handler webSocketClose currently calls
resetInFlightDeliveries unconditionally using the attachment.tunnelId, which
lets an old socket close clear in-flight deliveries for a newly-created session;
change webSocketClose to first read the attachment (readSocketAttachment) and
then verify that the attachment's session identifier (e.g., attachment.sessionId
or attachment.socketId) still matches the active session/socket stored for that
tunnel before calling resetInFlightDeliveries(attachment.tunnelId); if they
differ, return without resetting. Apply the same session-match guard to the
other close/error handler that calls resetInFlightDeliveries so only the current
active socket can clear in-flight deliveries.
- Around line 316-327: The update that claims a delivery by setting sentAt
currently ignores the DB result so concurrent callers can still send the same
payload; change the claim to inspect the update result (e.g., const res = await
this.db.update(delivery).set({ sentAt: now() }).where(...)) and only proceed
with sending if the update affected exactly one row
(res.rowCount/res.affectedRows/res.numUpdated === 1), otherwise abort/skip;
apply the same pattern to the analogous update that sets deliveredAt (the other
update block using nextDelivery, attachment, delivery, sentAt/deliveredAt) so
only the successful claimer sends/marks the delivery.

In `@packages/paykit/src/cli/commands/listen.ts`:
- Around line 898-901: normalizeLocalOrigin() still throws an error message that
references the old flag (--url) instead of the renamed flag (--forward-to);
update the validation/error text inside normalizeLocalOrigin (and the similar
validation code around the second occurrence at the other block) to reference
"--forward-to" (and/or both flags if you want backward clarity) so users see the
correct flag name when a non-origin value is passed.
- Around line 572-582: deliverWebhook currently awaits replayDelivery without a
bound, which can hang the socket consumer; when params.forwardTo is set, wrap
the replayDelivery call in a bounded timeout (e.g., Promise.race or
AbortController if replayDelivery supports an AbortSignal) so the call fails
after a configurable short timeout (e.g., a few seconds). On timeout return a
ReplayResult indicating failure (consistent shape used elsewhere) and include
context (e.g., reason "forward-to timeout" and the localWebhookUrl) so the
caller can ack/fail the delivery; modify deliverWebhook to use this timed call
path while leaving applyDeliveryDirectly untouched.

---

Nitpick comments:
In `@packages/paykit/src/cli/commands/listen.ts`:
- Around line 609-611: The current isReplacedSessionClose function relies on
matching the human-readable close.reason string, which is fragile; instead
define and use a protocol-level replacement signal (e.g., a dedicated close code
constant like REPLACED_SESSION_CODE or a structured reason payload token) and
update isReplacedSessionClose to check that constant (or parse the structured
payload) first; also replace any other regex-based checks of close.reason
elsewhere (the similar check currently duplicated later in the file) to use the
same constant/structured token so the client and worker share a deterministic
contract.

In `@packages/paykit/src/core/logger.ts`:
- Around line 18-33: Add concise JSDoc comments for the two new helpers so their
intent and signature are discoverable: place a short description, `@param` and
`@returns` on dimDetailLines(input: string) explaining it finds detail lines using
DETAIL_LINE_PATTERN and returns the input with those lines dimmed (string ->
string); and add a brief JSDoc on createPrettyStream() describing it creates a
Transform stream that pipes dimmed log output to process.stdout and returns the
configured pretty logger/stream. Ensure the JSDoc style matches existing repo
conventions (one-line summary plus param/return tags) and attach them directly
above the dimDetailLines and createPrettyStream declarations.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: b2117921-6cbe-428d-9c63-a9de89138e62

📥 Commits

Reviewing files that changed from the base of the PR and between 98acaa3 and 060b602.

📒 Files selected for processing (14)
  • apps/wh/migrations/0002_delivery-sent-tracking.sql
  • apps/wh/migrations/meta/0002_snapshot.json
  • apps/wh/migrations/meta/_journal.json
  • apps/wh/package.json
  • apps/wh/src/db/schema.ts
  • apps/wh/src/index.ts
  • apps/wh/src/tunnel-object.ts
  • apps/wh/wrangler.jsonc
  • packages/paykit/src/cli/commands/listen.ts
  • packages/paykit/src/cli/utils/get-config.ts
  • packages/paykit/src/core/__tests__/logger.test.ts
  • packages/paykit/src/core/logger.ts
  • packages/paykit/src/webhook/webhook.service.ts
  • packages/stripe/src/stripe-provider.ts

Comment thread apps/wh/src/tunnel-object.ts
Comment thread apps/wh/src/tunnel-object.ts Outdated
Comment thread packages/paykit/src/cli/commands/listen.ts
Comment thread packages/paykit/src/cli/commands/listen.ts
@maxktz maxktz merged commit 26e735e into main May 23, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant