Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .changeset/dispatchit-express-adapter.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
'dispatchit': minor
---

Adds an Express adapter. Import `dispatchitExpress` from `dispatchit/adapters/express` and mount it on any Express 4 or 5 app.

Because Express parses the request body before your middleware runs, the adapter needs the raw body to verify signatures. Capture it with a verify hook when you configure `express.json`, then mount the adapter after it:

```ts
app.use(
'/api/webhooks/dispatchit',
express.json({
verify: (req, res, buf) => {
;(req as DispatchitRequest).rawBody = buf
},
}),
dispatchitExpress(config)
)
```
82 changes: 82 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
name: Deploy hono-api

on:
push:
branches: [main]
paths:
- 'packages/demos/hono-api/**'
- 'packages/dispatchit/**'
- '.github/workflows/deploy.yml'
pull_request:
types: [opened, synchronize, reopened]
paths:
- 'packages/demos/hono-api/**'
- 'packages/dispatchit/**'
- '.github/workflows/deploy.yml'

permissions:
contents: read
pull-requests: write

# One deploy at a time per PR/branch.
concurrency:
group: deploy-hono-api-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true

jobs:
deploy:
name: Deploy hono-api
runs-on: ubuntu-24.04-arm
# Manual approval gate for production, if wanted: create the "production"
# environment in repo settings and add required reviewers.
environment: ${{ github.event_name == 'push' && 'production' || '' }}
steps:
- uses: actions/checkout@v7

- uses: jdx/mise-action@v4

- run: pnpm install --frozen-lockfile

# wrangler re-runs build.command from wrangler.jsonc before bundling; this
# step keeps the mandatory SDK build visible and fails fast if it breaks.
- name: Build dispatchit SDK
run: pnpm --filter dispatchit build

- name: Skip notice for fork PRs
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork
run: 'echo "::notice::Preview deploy skipped: fork PRs have no access to CLOUDFLARE secrets."'

# `versions upload` never activates the production version, so previews on
# PRs cannot affect production traffic. Exposes the version preview URL.
- name: Upload preview version
id: preview
if: github.event_name == 'pull_request' && !github.event.pull_request.head.repo.fork
shell: bash
run: |
set -o pipefail
pnpm --filter hono-api exec wrangler versions upload \
--message "preview: ${{ github.head_ref }} @ ${{ github.event.pull_request.head.sha }}" \
| tee "$RUNNER_TEMP/wrangler-preview.log"
preview_url=$(grep -oE 'https://[a-zA-Z0-9.-]+\.workers\.dev[^[:space:]]*' "$RUNNER_TEMP/wrangler-preview.log" | head -n 1 || true)
echo "preview_url=$preview_url" >> "$GITHUB_OUTPUT"
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}

- name: Comment preview URL on the PR
if: github.event_name == 'pull_request' && steps.preview.outputs.preview_url != ''
env:
GH_TOKEN: ${{ github.token }}
PREVIEW_URL: ${{ steps.preview.outputs.preview_url }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: 'gh pr comment "$PR_NUMBER" --body "🧪 Preview deploy for `$HEAD_SHA`: $PREVIEW_URL"'

# Production deploys only from main; uploads above never touch production
# traffic, this does.
- name: Deploy to production
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
run: pnpm --filter hono-api exec wrangler deploy
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Dispatchit SDK

This repository contains the Dispatchit SDK source & demo apps. Currently the SDK only supports Node.js, other languages & frameworks might be added in the future.

## Releasing

Releases are managed with [Changesets](https://github.com/changesets/changesets) — see
[`.changeset/README.md`](.changeset/README.md) in the repository root for the full flow:

1. Add a changeset on your PR (`pnpm changeset`).
2. Merging to `main` opens a _Version Packages_ PR that bumps the version and writes `CHANGELOG.md`.
3. Merging that PR publishes to npm and tags the release.
36 changes: 36 additions & 0 deletions packages/demos/express-api/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# express-api

Demo webhook endpoint for Dispatchit (Express on Node.js) built on [`dispatchit`](../../dispatchit) and Express. It exposes mocked delivery/logistics capabilities the Dispatchit assistant can invoke during a conversation. It has no database, deliveries are seeded in memory per phone number.

## Running

```bash
pnpm dev # node --watch, from the repo root or the package
pnpm --filter express-api dev # or on its own
```

## Connecting Dispatchit

1. Start this package locally (it listens on `http://127.0.0.1:8787`).
2. In the Dispatchit app (Settings → Webhooks), configure:
- URL: `http://127.0.0.1:8787/api/webhooks/dispatchit`
- Signing key: the same secret this package verifies with (`DEMO_WEBHOOK_SECRET`, defaults to `dev-webhook-secret`).
3. Press **Test** — Dispatchit sends a signed ping and the webhook flips to ready.

## Environment

| Variable | Default | Purpose |
| --------------------- | -------------------- | --------------------------------------------------- |
| `DEMO_WEBHOOK_SECRET` | `dev-webhook-secret` | HMAC secret used to verify requests from Dispatchit |
| `PORT` | `8787` | Port the demo server listens on |

## Capabilities

| Capability | Purpose | Failure behavior |
| -------------------- | -------------------------------------------------- | --------------------------------------- |
| `listOrders` | List the customer's deliveries with status and ETA | Tells the assistant to try again later |
| `getDeliveryStatus` | Look up one delivery by order id | Tells the assistant the id wasn't found |
| `rescheduleDelivery` | Reschedule a confirmed delivery to a new date | Reroutes the conversation to a human |

Seeded deliveries per phone number: `DLV-1001` (out for delivery, today), `DLV-1002` (confirmed, in 3 days),
`DLV-1003` (delivered, 2 days ago). Only `confirmed` deliveries can be rescheduled.
24 changes: 24 additions & 0 deletions packages/demos/express-api/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
{
"name": "express-api",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "node --watch --env-file-if-exists=.env src/index.ts",
"start": "node src/index.ts",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"dispatchit": "workspace:*",
"express": "^5.1.0",
"zod": "catalog:"
},
"devDependencies": {
"@types/express": "catalog:",
"@types/node": "catalog:",
"typescript": "catalog:"
},
"engines": {
"node": ">=22.9"
}
}
114 changes: 114 additions & 0 deletions packages/demos/express-api/src/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import {loadEnvFile} from 'node:process'
try {
loadEnvFile()
} catch {}

import {defineCapability, dispatchit} from 'dispatchit'
import * as z from 'zod'

const deliveryStatuses = ['confirmed', 'preparing', 'out_for_delivery', 'delivered'] as const

interface MockDelivery {
orderId: string
status: (typeof deliveryStatuses)[number]
/** ISO date (YYYY-MM-DD) the delivery is scheduled for. */
deliveryDate: string
}

/** In-memory mock store keyed by phone number — the demo has no database. */
const deliveriesByPhone = new Map<string, Array<MockDelivery>>()

export const config = dispatchit({
webhookSecret: process.env.DEMO_WEBHOOK_SECRET ?? 'dev-webhook-secret',
// The demo authorizes every WhatsApp number.
onAuth: () => true,
capabilities: {
listOrders: defineCapability({
description: "List the customer's deliveries with their current status.",
access: 'public',
input: z.object({}),
onSuccess: z.object({
orders: z.array(
z.object({orderId: z.string().trim(), status: z.enum(deliveryStatuses), eta: z.string().trim()})
),
}),
onFail: {message: 'I could not load the deliveries right now. Ask the customer to try again shortly.'},
handler: async ({context}) => ({
orders: deliveriesFor(context.phoneNumber).map(delivery => ({
orderId: delivery.orderId,
status: delivery.status,
eta: etaFor(delivery),
})),
}),
}),

getDeliveryStatus: defineCapability({
description: 'Look up the status and delivery window of one delivery by its order id.',
access: 'public',
input: z.object({
orderId: z.string().trim().min(1),
}),
onSuccess: z.object({
orderId: z.string().trim(),
status: z.enum(deliveryStatuses),
eta: z.string().trim(),
}),
onFail: {
message: 'I could not find a delivery with that order id. Ask the customer to double-check it.',
},
handler: async ({input, context}) => {
const delivery = deliveriesFor(context.phoneNumber).find(order => order.orderId === input.orderId)
if (!delivery) throw new Error('Unknown order id')

return {orderId: delivery.orderId, status: delivery.status, eta: etaFor(delivery)}
},
}),

rescheduleDelivery: defineCapability({
description:
'Reschedule a delivery to a new date (YYYY-MM-DD). Deliveries that are out for delivery or already delivered cannot be rescheduled.',
input: z.object({
orderId: z.string().trim().min(1),
newDate: z.iso.date(),
}),
onSuccess: z.object({
orderId: z.string().trim(),
status: z.enum(deliveryStatuses),
eta: z.string().trim(),
}),
onFail: {
action: 'reroute-to-human',
},
handler: async ({input, context}) => {
const delivery = deliveriesFor(context.phoneNumber).find(order => order.orderId === input.orderId)
if (!delivery || delivery.status !== 'confirmed') throw new Error('Delivery cannot be rescheduled')

delivery.deliveryDate = input.newDate
return {orderId: delivery.orderId, status: delivery.status, eta: etaFor(delivery)}
},
}),
},
})

function deliveriesFor(phoneNumber: string): Array<MockDelivery> {
let deliveries = deliveriesByPhone.get(phoneNumber)
if (!deliveries) {
deliveries = [
{orderId: 'DLV-1001', status: 'out_for_delivery', deliveryDate: isoDateOffset(0)},
{orderId: 'DLV-1002', status: 'confirmed', deliveryDate: isoDateOffset(3)},
{orderId: 'DLV-1003', status: 'delivered', deliveryDate: isoDateOffset(-2)},
]
deliveriesByPhone.set(phoneNumber, deliveries)
}
return deliveries
}

function etaFor(delivery: MockDelivery): string {
return delivery.status === 'delivered' ? 'already delivered' : `expected on ${delivery.deliveryDate}`
}

function isoDateOffset(days: number): string {
const date = new Date()
date.setDate(date.getDate() + days)
return date.toISOString().slice(0, 10)
}
35 changes: 35 additions & 0 deletions packages/demos/express-api/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import {loadEnvFile} from 'node:process'
try {
loadEnvFile()
} catch {}

import {dispatchitExpress} from 'dispatchit/adapters/express'
import type {DispatchitRequest} from 'dispatchit/adapters/express'
import express from 'express'

import {config} from './config.ts'

const port = Number(process.env.PORT ?? 8787)

const app = express()

app.get('/', (_request, response) => {
response.json({status: 'ok'})
})

app.post(
'/api/webhooks/dispatchit',
// Capture the raw body before parsing — Dispatchit verifies HMAC signatures against it.
express.json({
// body-parser's verify hook inherently receives (req, res, buf).
// oxlint-disable-next-line max-params
verify: (request, _response, buffer) => {
;(request as DispatchitRequest).rawBody = buffer
},
}),
dispatchitExpress(config)
)

app.listen(port, () => {
console.log(`express-api listening on http://127.0.0.1:${port}`)
})
25 changes: 25 additions & 0 deletions packages/demos/express-api/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
{
"include": ["**/*.ts"],
"compilerOptions": {
"target": "esnext",
"module": "preserve",
"lib": ["ESNext"],
"types": ["node"],

"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"erasableSyntaxOnly": true,
"allowJs": true,
"verbatimModuleSyntax": true,

"noEmit": true,

/* Linting */
"skipLibCheck": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
}
}
1 change: 1 addition & 0 deletions packages/demos/hono-api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"private": true,
"type": "module",
"scripts": {
"build": "pnpm --filter dispatchit build",
"dev": "wrangler dev",
"deploy": "wrangler deploy",
"typecheck": "tsc --noEmit"
Expand Down
6 changes: 6 additions & 0 deletions packages/demos/hono-api/wrangler.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,13 @@
"main": "src/index.ts",
"compatibility_date": "2025-09-01",
"compatibility_flags": ["nodejs_compat"],
// Exposes per-version preview URLs on `wrangler versions upload` (used for PR previews).
"preview_urls": true,
"observability": {
"enabled": true,
},
// Builds the dispatchit SDK (dist/) on fresh clones before wrangler bundles the worker.
"build": {
"command": "pnpm --filter dispatchit build",
},
}
Loading
Loading