diff --git a/.changeset/inngest-addon.md b/.changeset/inngest-addon.md new file mode 100644 index 00000000..7b3b32ca --- /dev/null +++ b/.changeset/inngest-addon.md @@ -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. diff --git a/packages/cli/tests-e2e/helpers.ts b/packages/cli/tests-e2e/helpers.ts index b564f07d..ed2ca112 100644 --- a/packages/cli/tests-e2e/helpers.ts +++ b/packages/cli/tests-e2e/helpers.ts @@ -33,6 +33,8 @@ type CreateAppFixtureOptions = { afterCreate?: (appDir: string) => Promise skipDevServer?: boolean runQualityGatesChecks?: boolean + serverScript?: 'dev' | 'preview' + serverEnv?: Record } export type RuntimeGuards = { @@ -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': @@ -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 () => { @@ -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), @@ -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) diff --git a/packages/cli/tests-e2e/inngest-smoke.spec.ts b/packages/cli/tests-e2e/inngest-smoke.spec.ts new file mode 100644 index 00000000..41b2e1f9 --- /dev/null +++ b/packages/cli/tests-e2e/inngest-smoke.spec.ts @@ -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((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((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()]) + } +}) diff --git a/packages/create/src/frameworks/react/add-ons/inngest/README.md b/packages/create/src/frameworks/react/add-ons/inngest/README.md new file mode 100644 index 00000000..5bc2875d --- /dev/null +++ b/packages/create/src/frameworks/react/add-ons/inngest/README.md @@ -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) diff --git a/packages/create/src/frameworks/react/add-ons/inngest/assets/_dot_env.local.append b/packages/create/src/frameworks/react/add-ons/inngest/assets/_dot_env.local.append new file mode 100644 index 00000000..d88d608c --- /dev/null +++ b/packages/create/src/frameworks/react/add-ons/inngest/assets/_dot_env.local.append @@ -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= diff --git a/packages/create/src/frameworks/react/add-ons/inngest/assets/src/inngest/client.ts.ejs b/packages/create/src/frameworks/react/add-ons/inngest/assets/src/inngest/client.ts.ejs new file mode 100644 index 00000000..b58d4f23 --- /dev/null +++ b/packages/create/src/frameworks/react/add-ons/inngest/assets/src/inngest/client.ts.ejs @@ -0,0 +1,5 @@ +import { Inngest } from 'inngest' + +export const inngest = new Inngest({ + id: '<%= projectName %>', +}) diff --git a/packages/create/src/frameworks/react/add-ons/inngest/assets/src/inngest/functions.ts b/packages/create/src/frameworks/react/add-ons/inngest/assets/src/inngest/functions.ts new file mode 100644 index 00000000..f0029d8d --- /dev/null +++ b/packages/create/src/frameworks/react/add-ons/inngest/assets/src/inngest/functions.ts @@ -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}!` } + }, +) diff --git a/packages/create/src/frameworks/react/add-ons/inngest/assets/src/routes/api/inngest.ts b/packages/create/src/frameworks/react/add-ons/inngest/assets/src/routes/api/inngest.ts new file mode 100644 index 00000000..176c6277 --- /dev/null +++ b/packages/create/src/frameworks/react/add-ons/inngest/assets/src/routes/api/inngest.ts @@ -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), + }, + }, +}) diff --git a/packages/create/src/frameworks/react/add-ons/inngest/assets/src/routes/demo/inngest.tsx b/packages/create/src/frameworks/react/add-ons/inngest/assets/src/routes/demo/inngest.tsx new file mode 100644 index 00000000..1dcd2980 --- /dev/null +++ b/packages/create/src/frameworks/react/add-ons/inngest/assets/src/routes/demo/inngest.tsx @@ -0,0 +1,135 @@ +import { createFileRoute } from '@tanstack/react-router' +import { createServerFn } from '@tanstack/react-start' +import { useState } from 'react' +import { inngest } from '../../inngest/client' +import { helloWorld } from '../../inngest/functions' + +const sendHelloEvent = createServerFn({ method: 'POST' }) + .validator((name: unknown) => { + if (typeof name !== 'string') throw new Error('Name must be a string') + const trimmed = name.trim() + if (!trimmed) throw new Error('Name is required') + return trimmed + }) + .handler(async ({ data: name }) => { + // This demo sender is public, so only allow it in development. + if (!import.meta.env.DEV) { + throw new Error('The Inngest demo sender is disabled in production.') + } + const { ids } = await inngest.send(helloWorld.create({ name })) + const eventId = ids[0] + if (!eventId) throw new Error('Inngest did not return an event ID') + return { eventId } + }) + +export const Route = createFileRoute('/demo/inngest')({ + component: RouteComponent, +}) + +function RouteComponent() { + const [name, setName] = useState('world') + const [result, setResult] = useState(null) + const [sending, setSending] = useState(false) + const [error, setError] = useState(null) + + const onSend = async () => { + setSending(true) + setError(null) + setResult(null) + try { + const { eventId } = await sendHelloEvent({ data: name }) + setResult(eventId) + } catch (e) { + setError(e instanceof Error ? e.message : 'Unknown error') + } finally { + setSending(false) + } + } + + return ( +
+
+
+
+

Inngest Demo

+

+ Send an event, trigger a durable function. +

+

+ This page dispatches the demo/hello.world event. The + Inngest Dev Server picks it up and runs helloWorld, + which sleeps briefly and returns a greeting. +

+ +
+ + + + + {result && ( +
+

Event sent

+ + {result} + +
+ )} + + {error && ( +
+ {error} +
+ )} +
+
+ +
+

Quick Start

+
    +
  • + Uncomment INNGEST_DEV=1 in .env.local{' '} + during development — without it, the SDK defaults to cloud mode and + will try to authenticate against Inngest Cloud. You can also use{' '} + INNGEST_DEV=1 pnpm dev to start the application. +
  • +
  • + Run the Dev Server in a second terminal:{' '} + npx --ignore-scripts=false inngest-cli@latest dev. +
  • +
  • + View traces and runs at{' '} + + localhost:8288 + + . +
  • +
+
+
+ ) +} diff --git a/packages/create/src/frameworks/react/add-ons/inngest/info.json b/packages/create/src/frameworks/react/add-ons/inngest/info.json new file mode 100644 index 00000000..58ac0f72 --- /dev/null +++ b/packages/create/src/frameworks/react/add-ons/inngest/info.json @@ -0,0 +1,19 @@ +{ + "name": "Inngest", + "description": "Add Inngest for durable workflows, background jobs, and AI agents.", + "phase": "add-on", + "modes": ["file-router"], + "type": "add-on", + "category": "workflows", + "color": "#2C9B63", + "priority": 140, + "link": "https://www.inngest.com", + "routes": [ + { + "url": "/demo/inngest", + "name": "Inngest", + "path": "src/routes/demo/inngest.tsx", + "jsName": "InngestDemo" + } + ] +} diff --git a/packages/create/src/frameworks/react/add-ons/inngest/logo.svg b/packages/create/src/frameworks/react/add-ons/inngest/logo.svg new file mode 100644 index 00000000..3a6e3b7e --- /dev/null +++ b/packages/create/src/frameworks/react/add-ons/inngest/logo.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/create/src/frameworks/react/add-ons/inngest/package.json b/packages/create/src/frameworks/react/add-ons/inngest/package.json new file mode 100644 index 00000000..3dab2842 --- /dev/null +++ b/packages/create/src/frameworks/react/add-ons/inngest/package.json @@ -0,0 +1,5 @@ +{ + "dependencies": { + "inngest": "^4.26.2" + } +} diff --git a/packages/create/src/frameworks/react/add-ons/inngest/small-logo.svg b/packages/create/src/frameworks/react/add-ons/inngest/small-logo.svg new file mode 100644 index 00000000..7238711f --- /dev/null +++ b/packages/create/src/frameworks/react/add-ons/inngest/small-logo.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/create/src/types.ts b/packages/create/src/types.ts index 99ca24f2..74487a42 100644 --- a/packages/create/src/types.ts +++ b/packages/create/src/types.ts @@ -56,6 +56,7 @@ export const AddOnBaseSchema = z.object({ 'analytics', 'i18n', 'tooling', + 'workflows', 'other', ]) .optional(),