Skip to content
Open
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
6 changes: 6 additions & 0 deletions .changeset/inngest-addon.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@tanstack/cli': minor
'@tanstack/create': minor
---

Add a React Inngest add-on with a client, a sample function, the signed `/api/inngest` route, and a demo page whose event sender only runs in development.
18 changes: 13 additions & 5 deletions packages/cli/tests-e2e/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ type CreateAppFixtureOptions = {
afterCreate?: (appDir: string) => Promise<void>
skipDevServer?: boolean
runQualityGatesChecks?: boolean
serverScript?: 'dev' | 'preview'
serverEnv?: Record<string, string>
}

export type RuntimeGuards = {
Expand Down Expand Up @@ -219,7 +221,7 @@ export async function createReactAppFixture(

function getPackageManagerCommandForScript(
packageManager: 'pnpm' | 'npm' | 'yarn' | 'bun' | 'deno',
script: 'dev' | 'build',
script: 'dev' | 'build' | 'preview',
) {
switch (packageManager) {
case 'npm':
Expand Down Expand Up @@ -372,6 +374,8 @@ export async function createAppFixture(
bundler = 'vite',
packageManager = 'pnpm',
routerOnly = false,
serverScript = 'dev',
serverEnv,
} = options
const appDir = join(rootDir, appName)
const cleanup = async () => {
Expand Down Expand Up @@ -466,14 +470,18 @@ export async function createAppFixture(
}
}

const dev = getPackageManagerCommandForScript(packageManager, 'dev')
const serverCommand = getPackageManagerCommandForScript(
packageManager,
serverScript,
)

const devServerStartedAt = now()
const server = spawn(dev.command, dev.args, {
const serverStartedAt = now()
const server = spawn(serverCommand.command, serverCommand.args, {
cwd: appDir,
env: {
...process.env,
CI: '1',
...serverEnv,
PATH: [dirname(process.execPath), process.env.PATH]
.filter(Boolean)
.join(delimiter),
Expand All @@ -496,7 +504,7 @@ export async function createAppFixture(
try {
url = await waitForDevServerURL(() => serverStdout)
await waitForServer(url)
mark('dev-server-ready', devServerStartedAt)
mark('server-ready', serverStartedAt)
} catch (error) {
await stopChild(server)
await cleanup().catch(() => undefined)
Expand Down
158 changes: 158 additions & 0 deletions packages/cli/tests-e2e/inngest-smoke.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import { createHmac } from 'node:crypto'
import { createServer } from 'node:http'
import type { AddressInfo } from 'node:net'
import { expect, test } from '@playwright/test'
import { createReactAppFixture, optimizePageForFastE2E } from './helpers'

const SIGNING_KEY = `signkey-test-${'ab'.repeat(32)}`

// Stands in for the Inngest API so no test can send a real event.
async function startMockInngest() {
const requests: Array<{ method?: string; url?: string; body: string }> = []
const server = createServer((req, res) => {
let body = ''
req.on('data', (chunk) => (body += chunk))
req.on('end', () => {
requests.push({ method: req.method, url: req.url, body })
res.setHeader('content-type', 'application/json')
res.end(JSON.stringify({ ids: ['01MOCKEVENTID'], status: 200 }))
})
})
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve))
const { port } = server.address() as AddressInfo
return {
url: `http://127.0.0.1:${port}`,
requests,
close: () => new Promise<void>((resolve) => server.close(() => resolve())),
}
}

const EVENT_KEY = 'test-event-key'

// Pin every outbound Inngest URL, the dev/cloud mode, and the keys the SDK
// uses so nothing inherited from the shell can route the app to a real
// Inngest endpoint.
function inngestEnv(mockUrl: string, dev: boolean) {
return {
INNGEST_DEV: dev ? '1' : '0',
INNGEST_EVENT_KEY: EVENT_KEY,
INNGEST_SIGNING_KEY: SIGNING_KEY,
INNGEST_SIGNING_KEY_FALLBACK: '',
INNGEST_ENV: 'e2e',
INNGEST_BASE_URL: mockUrl,
INNGEST_EVENT_API_BASE_URL: mockUrl,
INNGEST_API_BASE_URL: mockUrl,
}
}

// Vars a developer's shell could plausibly set that point the SDK elsewhere.
// Intentionally independent of inngestEnv so the negative check still catches
// a variable someone forgets to pin there.
const INHERITABLE_INNGEST_VARS = [
'INNGEST_EVENT_API_BASE_URL',
'INNGEST_API_BASE_URL',
'INNGEST_BASE_URL',
'INNGEST_DEV',
]

function signRequest(body: string) {
const ts = Math.floor(Date.now() / 1000).toString()
const key = SIGNING_KEY.replace(/^signkey-\w+-/, '')
const sig = createHmac('sha256', key)
.update(body + ts)
.digest('hex')
return `t=${ts}&s=${sig}`
}

test('@blocking production Inngest app rejects the demo sender and serves the signed callback', async ({
page,
}) => {
const inngestApi = await startMockInngest()
try {
const fixture = await createReactAppFixture({
appName: 'react-inngest-prod-app',
addOns: ['inngest'],
runQualityGatesChecks: true,
serverScript: 'preview',
serverEnv: inngestEnv(inngestApi.url, false),
})
try {
const unsigned = await page.request.get(`${fixture.url}/api/inngest`)
expect(unsigned.status()).toBe(401)

const signed = await page.request.get(`${fixture.url}/api/inngest`, {
headers: { 'x-inngest-signature': signRequest('') },
})
expect(signed.status()).toBe(200)
expect(await signed.json()).toMatchObject({
authentication_succeeded: true,
mode: 'cloud',
function_count: 1,
})

await optimizePageForFastE2E(page)
await page.goto(`${fixture.url}/demo/inngest`)
await page.getByRole('button', { name: 'Send demo/hello.world' }).click()
await expect(page.getByRole('alert')).toHaveText(
'The Inngest demo sender is disabled in production.',
)
expect(inngestApi.requests).toEqual([])
} finally {
await fixture.stop()
await fixture.cleanup()
}
} finally {
await inngestApi.close()
}
})

test('@blocking development Inngest app sends the demo event to the local Inngest server and ignores inherited Inngest URLs', async ({
page,
}) => {
const [inngestApi, sentinel] = await Promise.all([
startMockInngest(),
startMockInngest(),
])
// Each Playwright worker is its own process running one test at a time, and
// every child it spawns in this test (scaffold, install, app server)
// inherits these values.
const saved = Object.fromEntries(
INHERITABLE_INNGEST_VARS.map((key) => [key, process.env[key]]),
)
try {
for (const key of INHERITABLE_INNGEST_VARS) process.env[key] = sentinel.url
const fixture = await createReactAppFixture({
appName: 'react-inngest-dev-app',
addOns: ['inngest'],
serverEnv: inngestEnv(inngestApi.url, true),
})
try {
await optimizePageForFastE2E(page)
await page.goto(`${fixture.url}/demo/inngest`)
await page.getByRole('button', { name: 'Send demo/hello.world' }).click()
// Wait for the send to land on either server so a misroute is reported as
// a sentinel hit rather than a missing UI result.
await expect
.poll(() => inngestApi.requests.length + sentinel.requests.length)
.toBe(1)
expect(sentinel.requests).toEqual([])
await expect(page.getByText('01MOCKEVENTID')).toBeVisible()
expect(inngestApi.requests).toHaveLength(1)
const [sent] = inngestApi.requests
expect(sent.method).toBe('POST')
expect(sent.url).toBe(`/e/${EVENT_KEY}`)
expect(JSON.parse(sent.body)).toMatchObject([
{ name: 'demo/hello.world', data: { name: 'world' } },
])
} finally {
await fixture.stop()
await fixture.cleanup()
}
} finally {
for (const [key, value] of Object.entries(saved)) {
if (value === undefined) delete process.env[key]
else process.env[key] = value
}
await Promise.all([inngestApi.close(), sentinel.close()])
}
})
86 changes: 86 additions & 0 deletions packages/create/src/frameworks/react/add-ons/inngest/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
## Inngest

This add-on wires up [Inngest](https://www.inngest.com) for durable workflows, background jobs, and AI agents — a client, a sample event and function, the `serve()` API route, and an interactive demo page.

### Run it locally

Inngest needs two processes: your app and the Inngest Dev Server.

First, uncomment `INNGEST_DEV=1` in `.env.local`. In v4 the SDK defaults to **cloud mode** and requires a signing key — `INNGEST_DEV=1` points it at your local Dev Server instead. **Don't set it in production.**

```bash
# Terminal 1 — your app
npm run dev # or INNGEST_DEV=1 npm run dev

# Terminal 2 — Inngest Dev Server (UI at http://localhost:8288)
npx --ignore-scripts=false inngest-cli@latest dev
```

Open [http://localhost:3000/demo/inngest](http://localhost:3000/demo/inngest), click the button to send an event, and watch it run in the [Dev Server UI](http://localhost:8288).

### Where things live

| File | What it does |
| ----------------------------- | ----------------------------------------------------------------------- |
| `src/inngest/client.ts` | Shared `inngest` client |
| `src/inngest/functions.ts` | Events + functions — ships with a `helloWorld` event and `helloWorldFn` |
| `src/routes/api/inngest.ts` | The `serve()` handler mounted at `/api/inngest` (GET/POST/PUT) |
| `src/routes/demo/inngest.tsx` | Demo page at `/demo/inngest` that sends the sample event |
| `.env.local` | Commented `INNGEST_DEV=1` and placeholders for production keys |

### Add your own function

1. Define an event type and function in `src/inngest/functions.ts`:

```ts
export const userSignedUp = eventType('app/user.signed-up', {
schema: staticSchema<{ userId: string }>(),
})

export const userSignedUpFn = inngest.createFunction(
{ id: 'user-signed-up', triggers: [userSignedUp] },
async ({ event, step }) => {
await step.run('send-welcome-email', async () => {
// event.data.userId is typed
})
},
)
```

2. Register it in `src/routes/api/inngest.ts`:

```ts
const handler = serve({
client: inngest,
functions: [helloWorldFn, userSignedUpFn],
})
```

3. Send the event from anywhere in your app:

```ts
await inngest.send(userSignedUp.create({ userId: '123' }))
```

`eventType()` ties the event name to its payload shape, so `event.data` is inferred in both the function handler and `inngest.send()`. Want runtime validation too? Swap `staticSchema` for a Zod schema — see the [trigger helpers reference](https://www.inngest.com/docs/reference/typescript/v4/functions/triggers).

### Deploying to production

1. [Sync your app](https://www.inngest.com/docs/apps/cloud) from the Inngest dashboard, pointing it at `https://your-domain.com/api/inngest`.
2. Set these environment variables in your deployment:
- `INNGEST_EVENT_KEY` — authorizes `inngest.send()` to deliver events
- `INNGEST_SIGNING_KEY` — lets the `serve()` handler verify requests from Inngest
3. Make sure `INNGEST_DEV` is **not** set.

The `/demo/inngest` sender only works in development; production builds reject it. `/api/inngest` is unaffected.

> Running on serverless (Vercel, Netlify, etc.)? Set `checkpointing: { maxRuntime: '50s' }` on the client (~80% of your platform's max duration) so checkpointed steps don't get cut off mid-run. [More on checkpointing here](https://www.inngest.com/docs/setup/checkpointing).

See [Inngest's deploy guides](https://www.inngest.com/docs/platform/deployment) for platform specifics.

### Learn more

- [Writing functions](https://www.inngest.com/docs/functions)
- [Steps & flow control](https://www.inngest.com/docs/features/inngest-functions/steps-workflows)
- [Events & triggers](https://www.inngest.com/docs/features/events-triggers)
- [TanStack Start quick start](https://www.inngest.com/docs/getting-started/tanstack-start-quick-start)
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Inngest
#
# In development, uncomment INNGEST_DEV=1 so the SDK connects to the local
# Inngest Dev Server (npx --ignore-scripts=false inngest-cli@latest dev on localhost:8288) instead
# of defaulting to cloud mode and trying to authenticate with Inngest Cloud.
# See the add-on README for details.
# INNGEST_DEV=1
#
# Production — set these in your deployment environment, not here.
# INNGEST_EVENT_KEY=
# INNGEST_SIGNING_KEY=
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { Inngest } from 'inngest'

export const inngest = new Inngest({
id: '<%= projectName %>',
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { eventType, staticSchema } from 'inngest'
import { inngest } from './client'

export const helloWorld = eventType('demo/hello.world', {
schema: staticSchema<{ name: string }>(),
})

export const helloWorldFn = inngest.createFunction(
{ id: 'hello-world', triggers: [helloWorld] },
async ({ event, step }) => {
await step.sleep('wait-a-moment', '1s')
return { message: `Hello ${event.data.name}!` }
},
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { createFileRoute } from '@tanstack/react-router'
import { serve } from 'inngest/edge'
import { inngest } from '../../inngest/client'
import { helloWorldFn } from '../../inngest/functions'

const handler = serve({
client: inngest,
functions: [helloWorldFn],
})

export const Route = createFileRoute('/api/inngest')({
server: {
handlers: {
GET: ({ request }) => handler(request),
POST: ({ request }) => handler(request),
PUT: ({ request }) => handler(request),
},
},
})
Loading