From 93d39026efd43d4764441d2d3f80e3b7dba5c5dc Mon Sep 17 00:00:00 2001 From: Linell Bonnette Date: Mon, 27 Apr 2026 13:39:19 -0700 Subject: [PATCH 1/7] feat: add Inngest framework Add Inngest as a framework selectable for installation during `create`. Adds a new 'workflows' category because Inngest didn't fit cleanly into the others . --- .../react/add-ons/inngest/README.md | 84 +++++++++++++ .../inngest/assets/_dot_env.local.append | 11 ++ .../inngest/assets/src/inngest/client.ts.ejs | 5 + .../inngest/assets/src/inngest/functions.ts | 14 +++ .../inngest/assets/src/routes/api/inngest.ts | 19 +++ .../assets/src/routes/demo/inngest.tsx | 119 ++++++++++++++++++ .../react/add-ons/inngest/info.json | 19 +++ .../frameworks/react/add-ons/inngest/logo.svg | 3 + .../react/add-ons/inngest/package.json | 5 + .../react/add-ons/inngest/small-logo.svg | 5 + packages/create/src/types.ts | 1 + 11 files changed, 285 insertions(+) create mode 100644 packages/create/src/frameworks/react/add-ons/inngest/README.md create mode 100644 packages/create/src/frameworks/react/add-ons/inngest/assets/_dot_env.local.append create mode 100644 packages/create/src/frameworks/react/add-ons/inngest/assets/src/inngest/client.ts.ejs create mode 100644 packages/create/src/frameworks/react/add-ons/inngest/assets/src/inngest/functions.ts create mode 100644 packages/create/src/frameworks/react/add-ons/inngest/assets/src/routes/api/inngest.ts create mode 100644 packages/create/src/frameworks/react/add-ons/inngest/assets/src/routes/demo/inngest.tsx create mode 100644 packages/create/src/frameworks/react/add-ons/inngest/info.json create mode 100644 packages/create/src/frameworks/react/add-ons/inngest/logo.svg create mode 100644 packages/create/src/frameworks/react/add-ons/inngest/package.json create mode 100644 packages/create/src/frameworks/react/add-ons/inngest/small-logo.svg 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..42d8f393 --- /dev/null +++ b/packages/create/src/frameworks/react/add-ons/inngest/README.md @@ -0,0 +1,84 @@ +## 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 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. + +> 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..19c62c67 --- /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 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..2ac8c079 --- /dev/null +++ b/packages/create/src/frameworks/react/add-ons/inngest/assets/src/routes/demo/inngest.tsx @@ -0,0 +1,119 @@ +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' }) + .inputValidator((name: string) => name) + .handler(async ({ data: name }) => { + const { ids } = await inngest.send(helloWorld.create({ name })) + return { eventId: ids[0] } + }) + +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 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..8c0db34a --- /dev/null +++ b/packages/create/src/frameworks/react/add-ons/inngest/package.json @@ -0,0 +1,5 @@ +{ + "dependencies": { + "inngest": "^4.2.4" + } +} 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(), From beb6dca564f263a6df78a6f02f701b486aa427f1 Mon Sep 17 00:00:00 2001 From: Linell Bonnette Date: Wed, 29 Apr 2026 08:03:08 -0700 Subject: [PATCH 2/7] chore: tweaks per review --- .claude/settings.local.json | 3 ++- .../add-ons/inngest/assets/src/routes/demo/inngest.tsx | 9 +++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 1192fca8..731a1df1 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -17,7 +17,8 @@ "WebFetch(domain:raw.githubusercontent.com)", "WebFetch(domain:api.github.com)", "WebFetch(domain:tanstack.com)", - "WebFetch(domain:www.npmjs.com)" + "WebFetch(domain:www.npmjs.com)", + "Bash(gh api *)" ], "deny": [] } 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 index 2ac8c079..2f7c0805 100644 --- 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 @@ -5,7 +5,12 @@ import { inngest } from '../../inngest/client' import { helloWorld } from '../../inngest/functions' const sendHelloEvent = createServerFn({ method: 'POST' }) - .inputValidator((name: string) => name) + .inputValidator((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 }) => { const { ids } = await inngest.send(helloWorld.create({ name })) return { eventId: ids[0] } @@ -95,7 +100,7 @@ function RouteComponent() {
  • 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. + 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:{' '} From 9cc77d67c7b3bb648afcfb1241d8bbf215ad377a Mon Sep 17 00:00:00 2001 From: Linell Bonnette Date: Tue, 21 Jul 2026 11:20:02 -0700 Subject: [PATCH 3/7] chore: cleanup --- .claude/settings.local.json | 3 +-- .../inngest/assets/src/routes/demo/inngest.tsx | 13 ++++++++++--- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 731a1df1..1192fca8 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -17,8 +17,7 @@ "WebFetch(domain:raw.githubusercontent.com)", "WebFetch(domain:api.github.com)", "WebFetch(domain:tanstack.com)", - "WebFetch(domain:www.npmjs.com)", - "Bash(gh api *)" + "WebFetch(domain:www.npmjs.com)" ], "deny": [] } 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 index 2f7c0805..f16c2c9a 100644 --- 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 @@ -13,7 +13,9 @@ const sendHelloEvent = createServerFn({ method: 'POST' }) }) .handler(async ({ data: name }) => { const { ids } = await inngest.send(helloWorld.create({ name })) - return { eventId: ids[0] } + const eventId = ids[0] + if (!eventId) throw new Error('Inngest did not return an event ID') + return { eventId } }) export const Route = createFileRoute('/demo/inngest')({ @@ -87,7 +89,11 @@ function RouteComponent() { )} {error && ( -
    +
    {error}
    )} @@ -100,7 +106,8 @@ function RouteComponent() {
  • 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. + 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:{' '} From a1414da4cedd1e7144d91b0392e991374e773723 Mon Sep 17 00:00:00 2001 From: Linell Bonnette Date: Thu, 5 Nov 2026 07:26:29 -0800 Subject: [PATCH 4/7] fix(create): restrict the Inngest demo sender to development The demo server function could send events for any visitor once an app was deployed with an event key. It now rejects sends outside development, while the signed /api/inngest route keeps serving production. - Use the current `validator` server function API - Require inngest ^4.26.2, which drops the deprecated serialize-error-cjs - Add an e2e smoke test that checks the production build rejects the sender and accepts a signed callback, and that development sends reach a local mock of the Inngest API Co-Authored-By: Claude Opus 5.5 --- packages/cli/tests-e2e/helpers.ts | 14 +- packages/cli/tests-e2e/inngest-smoke.spec.ts | 128 ++++++++++++++++++ .../react/add-ons/inngest/README.md | 4 +- .../assets/src/routes/demo/inngest.tsx | 13 +- .../react/add-ons/inngest/package.json | 2 +- 5 files changed, 156 insertions(+), 5 deletions(-) create mode 100644 packages/cli/tests-e2e/inngest-smoke.spec.ts diff --git a/packages/cli/tests-e2e/helpers.ts b/packages/cli/tests-e2e/helpers.ts index b564f07d..f89bf04d 100644 --- a/packages/cli/tests-e2e/helpers.ts +++ b/packages/cli/tests-e2e/helpers.ts @@ -33,6 +33,10 @@ type CreateAppFixtureOptions = { afterCreate?: (appDir: string) => Promise skipDevServer?: boolean runQualityGatesChecks?: boolean + /** Serve the production build with the `preview` script instead of `dev`. */ + server?: 'dev' | 'preview' + /** Extra environment variables for the app server process. */ + serverEnv?: NodeJS.ProcessEnv } export type RuntimeGuards = { @@ -219,7 +223,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,7 +376,12 @@ export async function createAppFixture( bundler = 'vite', packageManager = 'pnpm', routerOnly = false, + server: serverMode = 'dev', + serverEnv, } = options + if (serverMode === 'preview' && !runQualityGatesChecks) { + throw new Error('server: "preview" requires runQualityGatesChecks to build the app') + } const appDir = join(rootDir, appName) const cleanup = async () => { await rm(rootDir, { recursive: true, force: true }) @@ -466,7 +475,7 @@ export async function createAppFixture( } } - const dev = getPackageManagerCommandForScript(packageManager, 'dev') + const dev = getPackageManagerCommandForScript(packageManager, serverMode) const devServerStartedAt = now() const server = spawn(dev.command, dev.args, { @@ -474,6 +483,7 @@ export async function createAppFixture( env: { ...process.env, CI: '1', + ...serverEnv, PATH: [dirname(process.execPath), process.env.PATH] .filter(Boolean) .join(delimiter), 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..02cb057b --- /dev/null +++ b/packages/cli/tests-e2e/inngest-smoke.spec.ts @@ -0,0 +1,128 @@ +import { createHmac } from 'node:crypto' +import { createServer } from 'node:http' +import type { AddressInfo } from 'node:net' +import { expect, test, type Locator } 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 hits: Array = [] + const server = createServer((req, res) => { + hits.push(`${req.method} ${req.url}`) + req.resume() + req.on('end', () => { + 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}`, + hits, + close: () => new Promise((resolve) => server.close(() => resolve())), + } +} + +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}` +} + +// Clicks before hydration are no-ops, so retry until the result shows up. +async function clickUntilHydrated(button: Locator, result: Locator) { + await expect(async () => { + await button.click() + await expect(result).toBeVisible({ timeout: 2_000 }) + }).toPass({ timeout: 30_000 }) +} + +test('@blocking production Inngest app rejects the demo sender and serves the signed callback', async ({ + page, +}) => { + const inngestApi = await startMockInngest() + const fixture = await createReactAppFixture({ + appName: 'react-inngest-prod-app', + addOns: ['inngest'], + runQualityGatesChecks: true, + server: 'preview', + serverEnv: { + INNGEST_DEV: '', + INNGEST_SIGNING_KEY: SIGNING_KEY, + INNGEST_EVENT_KEY: 'test-event-key', + INNGEST_BASE_URL: inngestApi.url, + INNGEST_EVENT_API_BASE_URL: inngestApi.url, + }, + }) + 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 expect( + page.getByText('Sending demo events is disabled in production builds.'), + ).toBeVisible() + // The button still calls the server function so the server-side guard is + // what rejects the send. + await clickUntilHydrated( + page.getByRole('button', { name: 'Send demo/hello.world' }), + page.getByRole('alert'), + ) + await expect(page.getByRole('alert')).toContainText( + 'The Inngest demo sender is disabled in production.', + ) + await expect(page.getByText('Event sent')).toHaveCount(0) + + expect(inngestApi.hits).toEqual([]) + } finally { + await fixture.stop() + await fixture.cleanup() + await inngestApi.close() + } +}) + +test('@blocking development Inngest app sends the demo event to the local Inngest server', async ({ + page, +}) => { + const inngestApi = await startMockInngest() + const fixture = await createReactAppFixture({ + appName: 'react-inngest-dev-app', + addOns: ['inngest'], + serverEnv: { + INNGEST_DEV: '1', + INNGEST_BASE_URL: inngestApi.url, + INNGEST_EVENT_API_BASE_URL: inngestApi.url, + }, + }) + try { + await optimizePageForFastE2E(page) + await page.goto(`${fixture.url}/demo/inngest`) + await clickUntilHydrated( + page.getByRole('button', { name: 'Send demo/hello.world' }), + page.getByText('01MOCKEVENTID'), + ) + expect(inngestApi.hits.length).toBeGreaterThan(0) + for (const hit of inngestApi.hits) { + expect(hit).toMatch(/^POST \/e\//) + } + } finally { + await fixture.stop() + await fixture.cleanup() + await inngestApi.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 index 42d8f393..073be7fe 100644 --- a/packages/create/src/frameworks/react/add-ons/inngest/README.md +++ b/packages/create/src/frameworks/react/add-ons/inngest/README.md @@ -25,7 +25,7 @@ Open [http://localhost:3000/demo/inngest](http://localhost:3000/demo/inngest), c | `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 | +| `src/routes/demo/inngest.tsx` | Demo page at `/demo/inngest` that sends the sample event (dev only) | | `.env.local` | Commented `INNGEST_DEV=1` and placeholders for production keys | ### Add your own function @@ -72,6 +72,8 @@ Open [http://localhost:3000/demo/inngest](http://localhost:3000/demo/inngest), c - `INNGEST_SIGNING_KEY` — lets the `serve()` handler verify requests from Inngest 3. Make sure `INNGEST_DEV` is **not** set. +The demo page's sender is public, so its server function only runs in development. Production builds reject it, while the signed `/api/inngest` route keeps serving Inngest. + > 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. 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 index f16c2c9a..9277ecd0 100644 --- 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 @@ -5,13 +5,18 @@ import { inngest } from '../../inngest/client' import { helloWorld } from '../../inngest/functions' const sendHelloEvent = createServerFn({ method: 'POST' }) - .inputValidator((name: unknown) => { + .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. The signed + // /api/inngest callback route stays available in production. + 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') @@ -79,6 +84,12 @@ function RouteComponent() { {sending ? 'Sending…' : 'Send demo/hello.world'} + {!import.meta.env.DEV && ( +

    + Sending demo events is disabled in production builds. +

    + )} + {result && (

    Event sent

    diff --git a/packages/create/src/frameworks/react/add-ons/inngest/package.json b/packages/create/src/frameworks/react/add-ons/inngest/package.json index 8c0db34a..3dab2842 100644 --- a/packages/create/src/frameworks/react/add-ons/inngest/package.json +++ b/packages/create/src/frameworks/react/add-ons/inngest/package.json @@ -1,5 +1,5 @@ { "dependencies": { - "inngest": "^4.2.4" + "inngest": "^4.26.2" } } From 25e7d08041436dc09260339c795fdcbc2b871991 Mon Sep 17 00:00:00 2001 From: Linell Bonnette Date: Thu, 5 Nov 2026 08:37:20 -0800 Subject: [PATCH 5/7] chore: simplify the Inngest demo guard and smoke test - Let the server error explain the production block instead of a duplicate notice - Click once like the other smoke tests and assert the exact event sent - Rename the fixture server options so they read correctly for preview - Add a changeset for the Inngest add-on Co-Authored-By: Claude Opus 5.5 --- .changeset/inngest-addon.md | 6 ++ packages/cli/tests-e2e/helpers.ts | 22 ++++---- packages/cli/tests-e2e/inngest-smoke.spec.ts | 56 +++++++------------ .../react/add-ons/inngest/README.md | 4 +- .../assets/src/routes/demo/inngest.tsx | 9 +-- 5 files changed, 38 insertions(+), 59 deletions(-) create mode 100644 .changeset/inngest-addon.md 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 f89bf04d..ed2ca112 100644 --- a/packages/cli/tests-e2e/helpers.ts +++ b/packages/cli/tests-e2e/helpers.ts @@ -33,10 +33,8 @@ type CreateAppFixtureOptions = { afterCreate?: (appDir: string) => Promise skipDevServer?: boolean runQualityGatesChecks?: boolean - /** Serve the production build with the `preview` script instead of `dev`. */ - server?: 'dev' | 'preview' - /** Extra environment variables for the app server process. */ - serverEnv?: NodeJS.ProcessEnv + serverScript?: 'dev' | 'preview' + serverEnv?: Record } export type RuntimeGuards = { @@ -376,12 +374,9 @@ export async function createAppFixture( bundler = 'vite', packageManager = 'pnpm', routerOnly = false, - server: serverMode = 'dev', + serverScript = 'dev', serverEnv, } = options - if (serverMode === 'preview' && !runQualityGatesChecks) { - throw new Error('server: "preview" requires runQualityGatesChecks to build the app') - } const appDir = join(rootDir, appName) const cleanup = async () => { await rm(rootDir, { recursive: true, force: true }) @@ -475,10 +470,13 @@ export async function createAppFixture( } } - const dev = getPackageManagerCommandForScript(packageManager, serverMode) + 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, @@ -506,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 index 02cb057b..d6081426 100644 --- a/packages/cli/tests-e2e/inngest-smoke.spec.ts +++ b/packages/cli/tests-e2e/inngest-smoke.spec.ts @@ -1,18 +1,19 @@ import { createHmac } from 'node:crypto' import { createServer } from 'node:http' import type { AddressInfo } from 'node:net' -import { expect, test, type Locator } from '@playwright/test' +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 hits: Array = [] + const requests: Array<{ method?: string; url?: string; body: string }> = [] const server = createServer((req, res) => { - hits.push(`${req.method} ${req.url}`) - req.resume() + 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 })) }) @@ -21,7 +22,7 @@ async function startMockInngest() { const { port } = server.address() as AddressInfo return { url: `http://127.0.0.1:${port}`, - hits, + requests, close: () => new Promise((resolve) => server.close(() => resolve())), } } @@ -33,14 +34,6 @@ function signRequest(body: string) { return `t=${ts}&s=${sig}` } -// Clicks before hydration are no-ops, so retry until the result shows up. -async function clickUntilHydrated(button: Locator, result: Locator) { - await expect(async () => { - await button.click() - await expect(result).toBeVisible({ timeout: 2_000 }) - }).toPass({ timeout: 30_000 }) -} - test('@blocking production Inngest app rejects the demo sender and serves the signed callback', async ({ page, }) => { @@ -49,13 +42,12 @@ test('@blocking production Inngest app rejects the demo sender and serves the si appName: 'react-inngest-prod-app', addOns: ['inngest'], runQualityGatesChecks: true, - server: 'preview', + serverScript: 'preview', serverEnv: { INNGEST_DEV: '', INNGEST_SIGNING_KEY: SIGNING_KEY, INNGEST_EVENT_KEY: 'test-event-key', INNGEST_BASE_URL: inngestApi.url, - INNGEST_EVENT_API_BASE_URL: inngestApi.url, }, }) try { @@ -74,21 +66,11 @@ test('@blocking production Inngest app rejects the demo sender and serves the si await optimizePageForFastE2E(page) await page.goto(`${fixture.url}/demo/inngest`) - await expect( - page.getByText('Sending demo events is disabled in production builds.'), - ).toBeVisible() - // The button still calls the server function so the server-side guard is - // what rejects the send. - await clickUntilHydrated( - page.getByRole('button', { name: 'Send demo/hello.world' }), - page.getByRole('alert'), - ) - await expect(page.getByRole('alert')).toContainText( + await page.getByRole('button', { name: 'Send demo/hello.world' }).click() + await expect(page.getByRole('alert')).toHaveText( 'The Inngest demo sender is disabled in production.', ) - await expect(page.getByText('Event sent')).toHaveCount(0) - - expect(inngestApi.hits).toEqual([]) + expect(inngestApi.requests).toEqual([]) } finally { await fixture.stop() await fixture.cleanup() @@ -106,20 +88,20 @@ test('@blocking development Inngest app sends the demo event to the local Innges serverEnv: { INNGEST_DEV: '1', INNGEST_BASE_URL: inngestApi.url, - INNGEST_EVENT_API_BASE_URL: inngestApi.url, }, }) try { await optimizePageForFastE2E(page) await page.goto(`${fixture.url}/demo/inngest`) - await clickUntilHydrated( - page.getByRole('button', { name: 'Send demo/hello.world' }), - page.getByText('01MOCKEVENTID'), - ) - expect(inngestApi.hits.length).toBeGreaterThan(0) - for (const hit of inngestApi.hits) { - expect(hit).toMatch(/^POST \/e\//) - } + await page.getByRole('button', { name: 'Send demo/hello.world' }).click() + await expect(page.getByText('01MOCKEVENTID')).toBeVisible() + expect(inngestApi.requests).toHaveLength(1) + const [sent] = inngestApi.requests + expect(sent.method).toBe('POST') + expect(sent.url).toMatch(/^\/e\//) + expect(JSON.parse(sent.body)).toMatchObject([ + { name: 'demo/hello.world', data: { name: 'world' } }, + ]) } finally { await fixture.stop() await fixture.cleanup() diff --git a/packages/create/src/frameworks/react/add-ons/inngest/README.md b/packages/create/src/frameworks/react/add-ons/inngest/README.md index 073be7fe..f023796f 100644 --- a/packages/create/src/frameworks/react/add-ons/inngest/README.md +++ b/packages/create/src/frameworks/react/add-ons/inngest/README.md @@ -25,7 +25,7 @@ Open [http://localhost:3000/demo/inngest](http://localhost:3000/demo/inngest), c | `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 (dev only) | +| `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 @@ -72,7 +72,7 @@ Open [http://localhost:3000/demo/inngest](http://localhost:3000/demo/inngest), c - `INNGEST_SIGNING_KEY` — lets the `serve()` handler verify requests from Inngest 3. Make sure `INNGEST_DEV` is **not** set. -The demo page's sender is public, so its server function only runs in development. Production builds reject it, while the signed `/api/inngest` route keeps serving Inngest. +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). 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 index 9277ecd0..60d6c597 100644 --- 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 @@ -12,8 +12,7 @@ const sendHelloEvent = createServerFn({ method: 'POST' }) return trimmed }) .handler(async ({ data: name }) => { - // This demo sender is public, so only allow it in development. The signed - // /api/inngest callback route stays available in production. + // 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.') } @@ -84,12 +83,6 @@ function RouteComponent() { {sending ? 'Sending…' : 'Send demo/hello.world'} - {!import.meta.env.DEV && ( -

    - Sending demo events is disabled in production builds. -

    - )} - {result && (

    Event sent

    From ce5165057b1e634c03431f15ab61c21fa3cbfd61 Mon Sep 17 00:00:00 2001 From: Linell Bonnette Date: Thu, 5 Nov 2026 08:57:31 -0800 Subject: [PATCH 6/7] chore: close the Inngest mock on fixture failure and fix the dev server command - Close the mock Inngest API even when the app fixture fails to start - Pass --ignore-scripts=false to npx so the Inngest CLI installs its binary when npm scripts are disabled Co-Authored-By: Claude Opus 5.5 --- packages/cli/tests-e2e/inngest-smoke.spec.ts | 112 +++++++++--------- .../react/add-ons/inngest/README.md | 2 +- .../inngest/assets/_dot_env.local.append | 2 +- .../assets/src/routes/demo/inngest.tsx | 2 +- 4 files changed, 62 insertions(+), 56 deletions(-) diff --git a/packages/cli/tests-e2e/inngest-smoke.spec.ts b/packages/cli/tests-e2e/inngest-smoke.spec.ts index d6081426..9e932835 100644 --- a/packages/cli/tests-e2e/inngest-smoke.spec.ts +++ b/packages/cli/tests-e2e/inngest-smoke.spec.ts @@ -38,42 +38,45 @@ test('@blocking production Inngest app rejects the demo sender and serves the si page, }) => { const inngestApi = await startMockInngest() - const fixture = await createReactAppFixture({ - appName: 'react-inngest-prod-app', - addOns: ['inngest'], - runQualityGatesChecks: true, - serverScript: 'preview', - serverEnv: { - INNGEST_DEV: '', - INNGEST_SIGNING_KEY: SIGNING_KEY, - INNGEST_EVENT_KEY: 'test-event-key', - INNGEST_BASE_URL: inngestApi.url, - }, - }) 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, + const fixture = await createReactAppFixture({ + appName: 'react-inngest-prod-app', + addOns: ['inngest'], + runQualityGatesChecks: true, + serverScript: 'preview', + serverEnv: { + INNGEST_DEV: '', + INNGEST_SIGNING_KEY: SIGNING_KEY, + INNGEST_EVENT_KEY: 'test-event-key', + INNGEST_BASE_URL: inngestApi.url, + }, }) + try { + const unsigned = await page.request.get(`${fixture.url}/api/inngest`) + expect(unsigned.status()).toBe(401) - 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([]) + 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 fixture.stop() - await fixture.cleanup() await inngestApi.close() } }) @@ -82,29 +85,32 @@ test('@blocking development Inngest app sends the demo event to the local Innges page, }) => { const inngestApi = await startMockInngest() - const fixture = await createReactAppFixture({ - appName: 'react-inngest-dev-app', - addOns: ['inngest'], - serverEnv: { - INNGEST_DEV: '1', - INNGEST_BASE_URL: inngestApi.url, - }, - }) try { - await optimizePageForFastE2E(page) - await page.goto(`${fixture.url}/demo/inngest`) - await page.getByRole('button', { name: 'Send demo/hello.world' }).click() - await expect(page.getByText('01MOCKEVENTID')).toBeVisible() - expect(inngestApi.requests).toHaveLength(1) - const [sent] = inngestApi.requests - expect(sent.method).toBe('POST') - expect(sent.url).toMatch(/^\/e\//) - expect(JSON.parse(sent.body)).toMatchObject([ - { name: 'demo/hello.world', data: { name: 'world' } }, - ]) + const fixture = await createReactAppFixture({ + appName: 'react-inngest-dev-app', + addOns: ['inngest'], + serverEnv: { + INNGEST_DEV: '1', + INNGEST_BASE_URL: inngestApi.url, + }, + }) + try { + await optimizePageForFastE2E(page) + await page.goto(`${fixture.url}/demo/inngest`) + await page.getByRole('button', { name: 'Send demo/hello.world' }).click() + await expect(page.getByText('01MOCKEVENTID')).toBeVisible() + expect(inngestApi.requests).toHaveLength(1) + const [sent] = inngestApi.requests + expect(sent.method).toBe('POST') + expect(sent.url).toMatch(/^\/e\//) + expect(JSON.parse(sent.body)).toMatchObject([ + { name: 'demo/hello.world', data: { name: 'world' } }, + ]) + } finally { + await fixture.stop() + await fixture.cleanup() + } } finally { - await fixture.stop() - await fixture.cleanup() await inngestApi.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 index f023796f..5bc2875d 100644 --- a/packages/create/src/frameworks/react/add-ons/inngest/README.md +++ b/packages/create/src/frameworks/react/add-ons/inngest/README.md @@ -13,7 +13,7 @@ First, uncomment `INNGEST_DEV=1` in `.env.local`. In v4 the SDK defaults to **cl npm run dev # or INNGEST_DEV=1 npm run dev # Terminal 2 — Inngest Dev Server (UI at http://localhost:8288) -npx inngest-cli@latest dev +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). 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 index 19c62c67..d88d608c 100644 --- 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 @@ -1,7 +1,7 @@ # Inngest # # In development, uncomment INNGEST_DEV=1 so the SDK connects to the local -# Inngest Dev Server (npx inngest-cli@latest dev on localhost:8288) instead +# 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 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 index 60d6c597..1dcd2980 100644 --- 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 @@ -115,7 +115,7 @@ function RouteComponent() {
  • Run the Dev Server in a second terminal:{' '} - npx inngest-cli@latest dev. + npx --ignore-scripts=false inngest-cli@latest dev.
  • View traces and runs at{' '} From b08f41a6a30fe88a8ec67f9737b9750a7d309735 Mon Sep 17 00:00:00 2001 From: Linell Bonnette Date: Thu, 5 Nov 2026 17:34:18 -0800 Subject: [PATCH 7/7] test(cli): isolate Inngest env in smoke tests and assert inherited URLs are ignored --- packages/cli/tests-e2e/inngest-smoke.spec.ts | 72 ++++++++++++++++---- 1 file changed, 57 insertions(+), 15 deletions(-) diff --git a/packages/cli/tests-e2e/inngest-smoke.spec.ts b/packages/cli/tests-e2e/inngest-smoke.spec.ts index 9e932835..41b2e1f9 100644 --- a/packages/cli/tests-e2e/inngest-smoke.spec.ts +++ b/packages/cli/tests-e2e/inngest-smoke.spec.ts @@ -27,10 +27,40 @@ async function startMockInngest() { } } +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') + const sig = createHmac('sha256', key) + .update(body + ts) + .digest('hex') return `t=${ts}&s=${sig}` } @@ -44,12 +74,7 @@ test('@blocking production Inngest app rejects the demo sender and serves the si addOns: ['inngest'], runQualityGatesChecks: true, serverScript: 'preview', - serverEnv: { - INNGEST_DEV: '', - INNGEST_SIGNING_KEY: SIGNING_KEY, - INNGEST_EVENT_KEY: 'test-event-key', - INNGEST_BASE_URL: inngestApi.url, - }, + serverEnv: inngestEnv(inngestApi.url, false), }) try { const unsigned = await page.request.get(`${fixture.url}/api/inngest`) @@ -81,28 +106,41 @@ test('@blocking production Inngest app rejects the demo sender and serves the si } }) -test('@blocking development Inngest app sends the demo event to the local Inngest server', async ({ +test('@blocking development Inngest app sends the demo event to the local Inngest server and ignores inherited Inngest URLs', async ({ page, }) => { - const inngestApi = await startMockInngest() + 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: { - INNGEST_DEV: '1', - INNGEST_BASE_URL: inngestApi.url, - }, + 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).toMatch(/^\/e\//) + expect(sent.url).toBe(`/e/${EVENT_KEY}`) expect(JSON.parse(sent.body)).toMatchObject([ { name: 'demo/hello.world', data: { name: 'world' } }, ]) @@ -111,6 +149,10 @@ test('@blocking development Inngest app sends the demo event to the local Innges await fixture.cleanup() } } finally { - await inngestApi.close() + 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()]) } })