inngest-realtime
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseInngest Realtime
Inngest Realtime
Stream updates from durable Inngest functions to live UIs. Use channels and topics to broadcast progress, render workflow execution as it happens, or build bi-directional human-in-the-loop flows.
These skills are focused on TypeScript. For Python or Go, refer to the Inngest documentation for language-specific guidance. Core concepts apply across all languages.
⚠ CRITICAL: v3 vs v4 package selectionRealtime in Inngest v4 lives at the SDK subpath. The standaloneinngest/realtimenpm package is a v3-era package and is NOT compatible with@inngest/realtime. If your project is on v4 (the npm default), do not installinngest@4.x. Use the imports below.@inngest/realtimeSymptoms of using the wrong package on v4:on everyTypeError: Cls is not a constructor, 401 on subscription tokens, type incompatibility onPUT /api/inngest. Verify yournew Inngest({ middleware: [...] })showspackage.jsonbefore reading further."inngest": "^4.x"
将持久化Inngest函数的更新流式传输到实时UI。使用通道和主题来广播进度、实时渲染工作流执行过程,或构建双向人机协作流程。
本文技能聚焦于TypeScript。若使用Python或Go,请参考Inngest官方文档获取语言专属指引。核心概念适用于所有语言。
⚠ 重要:v3与v4包选择说明Inngest v4的实时功能位于SDK子路径。独立包inngest/realtime是v3时代的包,与@inngest/realtime不兼容。若你的项目使用v4(npm默认版本),请勿安装inngest@4.x,请使用下文的导入方式。@inngest/realtime在v4中使用错误包的症状:每次调用时出现PUT /api/inngest、订阅令牌请求返回401、TypeError: Cls is not a constructor出现类型不兼容。继续阅读前,请确认你的new Inngest({ middleware: [...] })中package.json。"inngest": "^4.x"
Prerequisites
前置条件
- Inngest v4 SDK installed () — see the
npm install inngestskillinngest-setup - set in
INNGEST_DEV=1for local development (without it, the SDK demands cloud signing keys and 401s on token requests).env.local - Local Inngest dev server running ()
npx inngest-cli@latest dev - Optional: for schema validation on topics
zod
- 已安装Inngest v4 SDK()——参考
npm install inngest技能inngest-setup - 本地开发时需在中设置
.env.local(若未设置,SDK会要求云签名密钥,令牌请求会返回401)INNGEST_DEV=1 - 本地Inngest开发服务器已启动()
npx inngest-cli@latest dev - 可选:使用进行主题的 schema 验证
zod
When to use Realtime
实时功能适用场景
| Problem shape | Pattern |
|---|---|
| Order status page animates as durable workflow steps complete | Per-run channel, publish per step, client subscribes |
| AI agent streams tokens to a chat UI | Per-conversation channel, publish chunks, stream to browser |
| Log tail for a long-running job | Single channel, log topic, append to UI |
| Human-in-the-loop approval | Channel + waitForEvent, publish prompt, wait for response |
| Admin dashboard with live order list | Global admin channel, fan-out from each function |
| 场景类型 | 实现模式 |
|---|---|
| 订单状态页面随持久化工作流步骤完成动态更新 | 每个运行实例对应一个通道,按步骤发布消息,客户端订阅 |
| AI Agent向聊天UI流式传输令牌 | 每个对话对应一个通道,发布数据块,流式传输到浏览器 |
| 长期运行任务的日志追踪 | 单个通道+日志主题,追加到UI |
| 人机协作审批流程 | 通道+waitForEvent,发布提示,等待响应 |
| 包含实时订单列表的管理后台 | 全局管理通道,从每个函数进行消息广播 |
Architecture
架构组成
Three pieces:
- Channel definition — a typed contract for what gets published. Lives in shared module so both server and client can reference the same channel name.
- Publishing — call between steps to wrap a durable publish, or
step.realtime.publishinsideinngest.realtime.publishbecause you're already inside a memoized step. See "Which publish method to use" below.step.run - Subscribing — server action mints a subscription token; React client uses the hook (or the lower-level
useRealtimeAPI for non-React consumers).subscribe()
分为三部分:
- 通道定义——消息发布的类型化契约。需定义在共享模块中,以便服务端和客户端引用相同的通道名称。
- 消息发布——在步骤之间调用来实现持久化发布;若在
step.realtime.publish内部,则调用step.run(因为已处于 memoized 步骤中)。详见下文的「选择合适的发布方法」。inngest.realtime.publish - 消息订阅——服务端操作生成订阅令牌;React客户端使用钩子(非React消费者可使用底层
useRealtimeAPI)。subscribe()
Step 1: Define a channel
步骤1:定义通道
Channels are pure data — no class hierarchy, no zod runtime required (but recommended for type safety). Define them once and import where needed.
typescript
// src/inngest/channels.ts
import { channel } from 'inngest/realtime';
import { z } from 'zod';
// Per-run channel: each fulfill-order run publishes step updates to its own channel.
export const orderChannel = channel({
name: (orderId: string) => `order:${orderId}`,
topics: {
step: {
schema: z.object({
name: z.string(),
status: z.enum(['running', 'complete', 'failed']),
output: z.record(z.string(), z.unknown()).optional(),
ts: z.number(),
}),
},
},
});
// Global admin channel: fan-out for cross-cutting visibility.
export const adminChannel = channel({
name: 'admin',
topics: {
order: {
schema: z.object({
orderId: z.string(),
step: z.string(),
status: z.enum(['running', 'complete', 'failed']),
ts: z.number(),
}),
},
},
});Two channel name shapes:
- — static channel, accessed as
name: 'admin'(topic ref)adminChannel.order - — parametric, accessed as
name: (id) => 'channel:${id}'(call the channel def with the id, then access topic)orderChannel(id).step
通道为纯数据结构——无需类继承,也不需要zod运行时(但推荐使用以保证类型安全)。只需定义一次,在需要的地方导入即可。
typescript
// src/inngest/channels.ts
import { channel } from 'inngest/realtime';
import { z } from 'zod';
// 每个运行实例对应一个通道:每个订单履约流程会将步骤更新发布到专属通道。
export const orderChannel = channel({
name: (orderId: string) => `order:${orderId}`,
topics: {
step: {
schema: z.object({
name: z.string(),
status: z.enum(['running', 'complete', 'failed']),
output: z.record(z.string(), z.unknown()).optional(),
ts: z.number(),
}),
},
},
});
// 全局管理通道:用于跨场景的消息广播。
export const adminChannel = channel({
name: 'admin',
topics: {
order: {
schema: z.object({
orderId: z.string(),
step: z.string(),
status: z.enum(['running', 'complete', 'failed']),
ts: z.number(),
}),
},
},
});两种通道名称形式:
- ——静态通道,通过
name: 'admin'访问(主题引用)adminChannel.order - ——参数化通道,通过
name: (id) => 'channel:${id}'访问(传入ID调用通道定义,再访问主题)orderChannel(id).step
Step 2: Publish from inside a function
步骤2:在函数内部发布消息
Inngest v4 ships realtime support natively — no middleware required. But where you call matters: it determines whether the publish is durable, and it's the most common place to get realtime wrong.
publishInngest v4原生支持实时功能——无需中间件。但调用的位置很重要:它决定了发布是否具备持久性,这也是使用实时功能时最容易出错的地方。
publishWhich publish method to use
选择合适的发布方法
| Where you are | Use this | Why |
|---|---|---|
Outside a step (top-level handler code, between | | Wraps the publish in its own step so it's durable, deduplicated by |
Inside a step (inside the callback passed to | | You're already inside a memoized step. |
| Outside a function (one-off route, script, etc.) | | Allowed, but not retry-safe — your client receiver must handle duplicates. |
The 90% rule: if you're writing handler code and you reach for , use . If you're writing code inside a block and you reach for , use .
publishstep.realtime.publishstep.runpublishinngest.realtime.publish| 代码位置 | 使用方法 | 原因 |
|---|---|---|
步骤之外(顶级处理器代码、 | | 将发布操作包装在独立步骤中,确保持久化、通过 |
步骤内部( | | 已处于memoized步骤中, |
| 函数之外(一次性路由、脚本等) | | 允许使用,但不支持重试——客户端接收器必须处理重复消息。 |
90%场景规则:若在处理器代码中调用,使用;若在代码块中调用,使用。
publishstep.realtime.publishstep.runpublishinngest.realtime.publishExample: both patterns in one function
示例:同一函数中使用两种模式
typescript
// src/inngest/functions/fulfill-order.ts
import { inngest } from '../client';
import { orderChannel, adminChannel } from '../channels';
export const fulfillOrder = inngest.createFunction(
{
id: 'fulfill-order',
retries: 3,
triggers: [{ event: 'store/order.placed' }],
},
async ({ event, step }) => {
const { orderId, customerEmail, lineItems } = event.data;
// Outside any step.run — use step.realtime.publish for a durable wrapper.
const emit = async (
name: string,
status: 'running' | 'complete' | 'failed',
output?: Record<string, unknown>,
) => {
const ts = Date.now();
await step.realtime.publish(
`emit-order-${name}-${status}`,
orderChannel(orderId).step,
{ name, status, output, ts },
);
await step.realtime.publish(
`emit-admin-${name}-${status}`,
adminChannel.order,
{ orderId, step: name, status, ts },
);
};
await emit('capture-payment', 'running');
// Inside step.run — use inngest.realtime.publish (already in a memoized step).
const payment = await step.run('capture-payment', async () => {
const intent = await stripe.paymentIntents.create({ /* ... */ });
// Stream a partial update mid-step. No step-in-step wrapping needed.
await inngest.realtime.publish(orderChannel(orderId).step, {
name: 'capture-payment',
status: 'running',
output: { stage: 'intent-created', intentId: intent.id },
ts: Date.now(),
});
return await stripe.paymentIntents.confirm(intent.id);
});
await emit('capture-payment', 'complete', payment);
await emit('reserve-inventory', 'running');
const inventory = await step.run('reserve-inventory', async () => {
// ...
});
await emit('reserve-inventory', 'complete', inventory);
// ...
},
);Why no middleware: Earlier versions used 's to inject a arg into the handler. v4 puts it on and directly.
@inngest/realtimerealtimeMiddleware()publishstep.realtimeinngest.realtimetypescript
// src/inngest/functions/fulfill-order.ts
import { inngest } from '../client';
import { orderChannel, adminChannel } from '../channels';
export const fulfillOrder = inngest.createFunction(
{
id: 'fulfill-order',
retries: 3,
triggers: [{ event: 'store/order.placed' }],
},
async ({ event, step }) => {
const { orderId, customerEmail, lineItems } = event.data;
// 处于任意step.run之外——使用step.realtime.publish实现持久化包装。
const emit = async (
name: string,
status: 'running' | 'complete' | 'failed',
output?: Record<string, unknown>,
) => {
const ts = Date.now();
await step.realtime.publish(
`emit-order-${name}-${status}`,
orderChannel(orderId).step,
{ name, status, output, ts },
);
await step.realtime.publish(
`emit-admin-${name}-${status}`,
adminChannel.order,
{ orderId, step: name, status, ts },
);
};
await emit('capture-payment', 'running');
// 处于step.run内部——使用inngest.realtime.publish(已处于memoized步骤中)。
const payment = await step.run('capture-payment', async () => {
const intent = await stripe.paymentIntents.create({ /* ... */ });
// 在步骤中流式传输部分更新,无需步骤嵌套包装。
await inngest.realtime.publish(orderChannel(orderId).step, {
name: 'capture-payment',
status: 'running',
output: { stage: 'intent-created', intentId: intent.id },
ts: Date.now(),
});
return await stripe.paymentIntents.confirm(intent.id);
});
await emit('capture-payment', 'complete', payment);
await emit('reserve-inventory', 'running');
const inventory = await step.run('reserve-inventory', async () => {
// ...
});
await emit('reserve-inventory', 'complete', inventory);
// ...
},
);**为何无需中间件:**早期版本使用的向处理器注入参数。v4则直接将其放在和上。
@inngest/realtimerealtimeMiddleware()publishstep.realtimeinngest.realtimeStep 3: Mint a subscription token (server action)
步骤3:生成订阅令牌(服务端操作)
In Next.js App Router, use a Server Action to securely mint a short-lived token for the React hook in Step 4. Without a token, clients can't subscribe.
typescript
// src/app/orders/[orderId]/actions.ts
'use server';
import { getClientSubscriptionToken } from 'inngest/react';
import { inngest } from '@/inngest/client';
import { orderChannel } from '@/inngest/channels';
export async function fetchOrderSubscriptionToken(orderId: string) {
// ⚠ AUTHORIZATION GATE: verify the current user owns this orderId
// before minting a token. Channels are addressable by ID, so without
// an ownership check, anyone can subscribe to any order's stream by
// guessing IDs.
//
// const session = await getServerSession();
// if (!session) throw new Error('Unauthenticated');
// const order = await db.order.findUnique({ where: { id: orderId } });
// if (order?.userId !== session.userId) throw new Error('Forbidden');
return getClientSubscriptionToken(inngest, {
channel: orderChannel(orderId),
topics: ['step'],
});
}getClientSubscriptionTokeninngest/reactuseRealtimegetSubscriptionTokensubscribe()在Next.js App Router中,使用Server Action安全生成短期令牌,供步骤4中的React钩子使用。没有令牌,客户端无法订阅。
typescript
// src/app/orders/[orderId]/actions.ts
'use server';
import { getClientSubscriptionToken } from 'inngest/react';
import { inngest } from '@/inngest/client';
import { orderChannel } from '@/inngest/channels';
export async function fetchOrderSubscriptionToken(orderId: string) {
// ⚠ 授权校验:生成令牌前,验证当前用户拥有该orderId
// 通道可通过ID访问,若不做所有权校验,任何人通过猜测ID即可订阅任意订单的数据流。
//
// const session = await getServerSession();
// if (!session) throw new Error('未认证');
// const order = await db.order.findUnique({ where: { id: orderId } });
// if (order?.userId !== session.userId) throw new Error('无权限');
return getClientSubscriptionToken(inngest, {
channel: orderChannel(orderId),
topics: ['step'],
});
}inngest/reactgetClientSubscriptionTokenuseRealtimegetSubscriptionTokensubscribe()Step 4: Subscribe with the useRealtime
hook
useRealtime步骤4:使用useRealtime
钩子订阅
useRealtimeThe recommended consumer for React/Next.js is the hook from . It handles the subscription lifecycle, reconnect, type narrowing per topic, and cleanup.
useRealtimeinngest/reacttypescript
// src/components/OrderStatusClient.tsx
'use client';
import { useRealtime } from 'inngest/react';
import { orderChannel } from '@/inngest/channels';
import { fetchOrderSubscriptionToken } from '@/app/orders/[orderId]/actions';
export function OrderStatusClient({ orderId }: { orderId: string }) {
const { messages, connectionStatus, error } = useRealtime({
channel: orderChannel(orderId),
topics: ['step'] as const,
token: () => fetchOrderSubscriptionToken(orderId),
});
if (error) return <div>Error: {error.message}</div>;
return (
<div>
<div>Status: {connectionStatus}</div>
<ul>
{messages.all.map((m, i) => (
<li key={i}>
{(m.data as { name: string }).name}: {(m.data as { status: string }).status}
</li>
))}
</ul>
</div>
);
}Useful options on the hook:
| Option | Default | Use it when |
|---|---|---|
| | Delay the subscription until you have an ID (e.g., |
| | Batch updates from a fast stream so React doesn't re-render per message. |
| | Pause the stream when the tab isn't visible (saves bandwidth). |
| | Disconnect when the run completes — turn off to keep the stream open for fan-out channels. |
| unbounded | Cap how many messages are retained in |
The hook returns (latest per topic), (full history), (most recent), and (new since last render).
messages.byTopicmessages.allmessages.lastmessages.deltaReact/Next.js推荐使用中的钩子。它会处理订阅生命周期、重连、按主题进行类型收窄以及清理工作。
inngest/reactuseRealtimetypescript
// src/components/OrderStatusClient.tsx
'use client';
import { useRealtime } from 'inngest/react';
import { orderChannel } from '@/inngest/channels';
import { fetchOrderSubscriptionToken } from '@/app/orders/[orderId]/actions';
export function OrderStatusClient({ orderId }: { orderId: string }) {
const { messages, connectionStatus, error } = useRealtime({
channel: orderChannel(orderId),
topics: ['step'] as const,
token: () => fetchOrderSubscriptionToken(orderId),
});
if (error) return <div>错误:{error.message}</div>;
return (
<div>
<div>连接状态:{connectionStatus}</div>
<ul>
{messages.all.map((m, i) => (
<li key={i}>
{(m.data as { name: string }).name}:{(m.data as { status: string }).status}
</li>
))}
</ul>
</div>
);
}钩子的实用配置项:
| 配置项 | 默认值 | 适用场景 |
|---|---|---|
| | 延迟订阅直到获取到ID(例如 |
| | 批量处理高速数据流的更新,避免React因每条消息重新渲染。 |
| | 标签页隐藏时暂停数据流(节省带宽)。 |
| | 运行完成后断开连接——若要保持广播通道的数据流,可关闭该配置。 |
| 无限制 | 限制 |
钩子返回(每个主题的最新消息)、(完整历史)、(最新消息)和(上次渲染后新增的消息)。
messages.byTopicmessages.allmessages.lastmessages.deltaPattern: Manual subscribe (non-React or custom transport)
模式:手动订阅(非React或自定义传输)
The hook covers the React case. If you're not using React, or you need a custom subscription lifecycle (server-side streaming, background workers, custom protocols), use the lower-level API directly.
useRealtimesubscribe()useRealtimesubscribe()Server action: mint a token with the lower-level helper
服务端操作:使用底层工具生成令牌
typescript
// src/app/orders/[orderId]/actions.ts
'use server';
import { getSubscriptionToken } from 'inngest/realtime';
import { inngest } from '@/inngest/client';
import { orderChannel } from '@/inngest/channels';
export async function fetchOrderSubscriptionTokenLowLevel(orderId: string) {
// ⚠ AUTHORIZATION GATE: same as Step 3 — verify ownership before minting.
const token = await getSubscriptionToken(inngest, {
channel: orderChannel(orderId),
topics: ['step'],
});
// ⚠ CRITICAL: strip the ChannelInstance from the response.
// getSubscriptionToken returns { channel: ChannelInstance, ... } where
// ChannelInstance contains zod schema methods (a class with prototypes).
// Next.js refuses to serialize classes across the server-action → client-component
// boundary, so return ONLY primitives.
return {
channel: orderChannel(orderId).name as string,
topics: ['step'] as const,
key: token.key,
apiBaseUrl: token.apiBaseUrl,
};
}typescript
// src/app/orders/[orderId]/actions.ts
'use server';
import { getSubscriptionToken } from 'inngest/realtime';
import { inngest } from '@/inngest/client';
import { orderChannel } from '@/inngest/channels';
export async function fetchOrderSubscriptionTokenLowLevel(orderId: string) {
// ⚠ 授权校验:与步骤3相同——生成令牌前验证所有权。
const token = await getSubscriptionToken(inngest, {
channel: orderChannel(orderId),
topics: ['step'],
});
// ⚠ 重要:从响应中剥离ChannelInstance。
// getSubscriptionToken返回{ channel: ChannelInstance, ... },其中
// ChannelInstance包含zod schema方法(带有原型的类)。
// Next.js拒绝在server-action → client-component边界序列化类,因此仅返回原始类型。
return {
channel: orderChannel(orderId).name as string,
topics: ['step'] as const,
key: token.key,
apiBaseUrl: token.apiBaseUrl,
};
}Manual client subscription
客户端手动订阅
typescript
// src/components/OrderStatusManual.tsx
'use client';
import * as React from 'react';
import { subscribe } from 'inngest/realtime';
import { fetchOrderSubscriptionTokenLowLevel } from '@/app/orders/[orderId]/actions';
export function OrderStatusManual({ orderId }: { orderId: string }) {
const [messages, setMessages] = React.useState<unknown[]>([]);
React.useEffect(() => {
let cancelled = false;
let sub: { close?: (reason?: string) => void } | undefined;
(async () => {
const token = await fetchOrderSubscriptionTokenLowLevel(orderId);
if (cancelled) return;
sub = await subscribe(
{
channel: token.channel,
topics: [...token.topics],
key: token.key,
apiBaseUrl: token.apiBaseUrl,
},
(message) => {
if (cancelled) return;
setMessages((prev) => [...prev, message.data]);
},
);
})();
return () => {
cancelled = true;
sub?.close?.('unmount');
};
}, [orderId]);
// ... render ...
}typescript
// src/components/OrderStatusManual.tsx
'use client';
import * as React from 'react';
import { subscribe } from 'inngest/realtime';
import { fetchOrderSubscriptionTokenLowLevel } from '@/app/orders/[orderId]/actions';
export function OrderStatusManual({ orderId }: { orderId: string }) {
const [messages, setMessages] = React.useState<unknown[]>([]);
React.useEffect(() => {
let cancelled = false;
let sub: { close?: (reason?: string) => void } | undefined;
(async () => {
const token = await fetchOrderSubscriptionTokenLowLevel(orderId);
if (cancelled) return;
sub = await subscribe(
{
channel: token.channel,
topics: [...token.topics],
key: token.key,
apiBaseUrl: token.apiBaseUrl,
},
(message) => {
if (cancelled) return;
setMessages((prev) => [...prev, message.data]);
},
);
})();
return () => {
cancelled = true;
sub?.close?.('组件卸载');
};
}, [orderId]);
// ... 渲染逻辑 ...
}SSE streaming from a route handler
通过路由处理器实现SSE流式传输
Subscribe inside a Next.js API route and pipe the stream to the client via SSE:
typescript
// src/app/api/orders/[orderId]/stream/route.ts
import { inngest } from '@/inngest/client';
import { subscribe } from 'inngest/realtime';
import { orderChannel } from '@/inngest/channels';
export async function GET(req: Request, { params }: { params: { orderId: string } }) {
// ⚠ AUTHORIZATION GATE: same rule as the server-action token mint above.
// Authenticate the request and confirm the caller owns params.orderId
// before opening the SSE stream. Skipping this leaks every order's
// step events to anyone with a URL.
const stream = await subscribe({
app: inngest,
channel: orderChannel(params.orderId),
topics: ['step'],
});
return new Response(stream.getEncodedStream(), {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
},
});
}Client consumes via rather than the callback. Use this when you want the SSE behavior or when the client-side API doesn't fit your component lifecycle.
fetch().getReader()subscribe()subscribe()在Next.js API路由中订阅,并通过SSE将数据流传输到客户端:
typescript
// src/app/api/orders/[orderId]/stream/route.ts
import { inngest } from '@/inngest/client';
import { subscribe } from 'inngest/realtime';
import { orderChannel } from '@/inngest/channels';
export async function GET(req: Request, { params }: { params: { orderId: string } }) {
// ⚠ 授权校验:与服务端令牌生成规则相同。
// 在打开SSE流之前,验证请求身份并确认调用者拥有params.orderId。
// 跳过此步骤会导致所有订单的步骤事件泄露给任何拥有URL的人。
const stream = await subscribe({
app: inngest,
channel: orderChannel(params.orderId),
topics: ['step'],
});
return new Response(stream.getEncodedStream(), {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
},
});
}客户端通过消费,而非回调。当需要SSE行为或客户端 API不适合组件生命周期时,可使用此方式。
fetch().getReader()subscribe()subscribe()Pattern: Human-in-the-loop
模式:人机协作流程
Combine with :
step.realtime.publishstep.waitForEventtypescript
import crypto from 'crypto';
export const reviewWorkflow = inngest.createFunction(
{ id: 'review-workflow', triggers: [{ event: 'review/start' }] },
async ({ event, step }) => {
const confirmationId = await step.run('gen-id', () => crypto.randomUUID());
// Publish a prompt — the client subscribes and renders an approval UI
await step.realtime.publish(
'publish-prompt',
reviewChannel.message,
{ message: 'Confirm to proceed?', confirmationId },
);
// Wait up to 15 minutes for the user to send the matching event back
const confirmation = await step.waitForEvent('await-confirmation', {
event: 'review/confirmation',
timeout: '15m',
if: `async.data.confirmationId == "${confirmationId}"`,
});
if (!confirmation) {
// user didn't respond — abort or escalate
return { decision: 'timed_out' };
}
// continue workflow...
},
);The links the published prompt to the matching reply, so the workflow knows which response to act on.
confirmationId结合与:
step.realtime.publishstep.waitForEventtypescript
import crypto from 'crypto';
export const reviewWorkflow = inngest.createFunction(
{ id: 'review-workflow', triggers: [{ event: 'review/start' }] },
async ({ event, step }) => {
const confirmationId = await step.run('gen-id', () => crypto.randomUUID());
// 发布提示——客户端订阅并渲染审批UI
await step.realtime.publish(
'publish-prompt',
reviewChannel.message,
{ message: '确认继续?', confirmationId },
);
// 等待用户返回匹配事件,最长等待15分钟
const confirmation = await step.waitForEvent('await-confirmation', {
event: 'review/confirmation',
timeout: '15m',
if: `async.data.confirmationId == "${confirmationId}"`,
});
if (!confirmation) {
// 用户未响应——终止流程或升级处理
return { decision: '超时' };
}
// 继续工作流...
},
);confirmationIdCommon pitfalls
常见陷阱
Don't use @inngest/realtime
on v4
@inngest/realtime请勿在v4中使用@inngest/realtime
@inngest/realtimeThe standalone package is for Inngest v3 only. On v4, all realtime APIs are in the SDK subpath . Mixing them produces:
@inngest/realtimeinngest/realtime- on
TypeError: Cls is not a constructor(v3 middleware class signature mismatch)PUT /api/inngest - 401 Unauthorized on subscription tokens
- TypeScript errors casting middleware
Verify with: — if it's , use . Period.
grep '"inngest"' package.json^4.xinngest/realtime独立包仅适用于Inngest v3。在v4中,所有实时API都位于SDK子路径。混用两者会导致:
@inngest/realtimeinngest/realtime- 调用时出现
PUT /api/inngest(v3中间件类签名不匹配)TypeError: Cls is not a constructor - 订阅令牌请求返回401未授权
- TypeScript类型转换错误
**验证方式:**执行——若版本为,请使用。
grep '"inngest"' package.json^4.xinngest/realtimeDon't return ChannelInstance from a Next.js server action (manual subscribe path only)
手动订阅路径下,请勿从Next.js服务端操作返回ChannelInstance
getSubscriptionToken{ channel: ChannelInstance, ... }This gotcha does not apply when you use from (Step 3 — the recommended path). That helper returns a serialization-safe shape directly.
getClientSubscriptionTokeninngest/reactgetSubscriptionToken{ channel: ChannelInstance, ... }该问题不适用于使用中的场景(步骤3——推荐路径)。该工具会直接返回可安全序列化的结构。
inngest/reactgetClientSubscriptionTokenINNGEST_DEV=1
is required for local dev
INNGEST_DEV=1本地开发必须设置INNGEST_DEV=1
INNGEST_DEV=1Without it, the SDK assumes cloud mode and demands + . All realtime operations 401 / 500. Add to . Hard restart the dev server (Next.js does not hot-reload changes).
INNGEST_SIGNING_KEYINNGEST_EVENT_KEY.env.local.env.local若未设置,SDK会默认使用云模式,要求和。所有实时操作会返回401/500。请将其添加到中,并重启开发服务器(Next.js不会热重载的更改)。
INNGEST_SIGNING_KEYINNGEST_EVENT_KEY.env.local.env.localChannel topic schemas validate on publish, not on consume
通道主题schema在发布时校验,而非消费时
If your published payload doesn't match the zod schema, the publish fails server-side. Subscriber receives nothing. Catch publish errors during step execution, or run with in if you have a reason to skip schema validation client-side.
validate: falsesubscribe()若发布的 payload 不符合zod schema,发布会在服务端失败,订阅者不会收到任何消息。请在步骤执行时捕获发布错误;若有理由跳过客户端schema校验,可在中设置。
subscribe()validate: falseReference
参考资料
- v4 entry points:
- — channel definitions
import { channel } from 'inngest/realtime' - — React hook + matching token helper (Step 3 + Step 4)
import { useRealtime, getClientSubscriptionToken } from 'inngest/react' - — lower-level helpers for non-React or custom transport
import { getSubscriptionToken, subscribe } from 'inngest/realtime'
- Publish methods:
- Outside a step: — wraps in a durable step
step.realtime.publish(id, topicRef, data) - Inside :
step.run— already inside a memoized step, no wrapping neededinngest.realtime.publish(topicRef, data) - Outside a function: — allowed but not retry-safe
inngest.realtime.publish(topicRef, data)
- Outside a step:
- Subscribe overloads: returns a stream;
subscribe(token)invokes callback per messagesubscribe(token, callback) - Next.js Server Action gotcha (manual path only): strip → return
ChannelInstance. Not needed with{ channel: string, topics, key, apiBaseUrl }.getClientSubscriptionToken
- v4入口:
- ——通道定义
import { channel } from 'inngest/realtime' - ——React钩子+配套令牌工具(步骤3+步骤4)
import { useRealtime, getClientSubscriptionToken } from 'inngest/react' - ——非React或自定义传输场景的底层工具
import { getSubscriptionToken, subscribe } from 'inngest/realtime'
- 发布方法:
- 步骤之外:——包装为持久化步骤
step.realtime.publish(id, topicRef, data) - 内部:
step.run——已处于memoized步骤,无需包装inngest.realtime.publish(topicRef, data) - 函数之外:——允许使用但不支持重试
inngest.realtime.publish(topicRef, data)
- 步骤之外:
- 订阅重载:返回数据流;
subscribe(token)每条消息触发回调subscribe(token, callback) - Next.js Server Action陷阱(仅手动路径):剥离→返回
ChannelInstance。使用{ channel: string, topics, key, apiBaseUrl }时无需此操作。getClientSubscriptionToken