inngest-realtime

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Inngest 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 selection
Realtime in Inngest v4 lives at the SDK subpath
inngest/realtime
. The standalone
@inngest/realtime
npm package is a v3-era package and is NOT compatible with
inngest@4.x
. If your project is on v4 (the npm default), do not install
@inngest/realtime
. Use the imports below.
Symptoms of using the wrong package on v4:
TypeError: Cls is not a constructor
on every
PUT /api/inngest
, 401 on subscription tokens, type incompatibility on
new Inngest({ middleware: [...] })
. Verify your
package.json
shows
"inngest": "^4.x"
before reading further.
将持久化Inngest函数的更新流式传输到实时UI。使用通道和主题来广播进度、实时渲染工作流执行过程,或构建双向人机协作流程。
本文技能聚焦于TypeScript。若使用Python或Go,请参考Inngest官方文档获取语言专属指引。核心概念适用于所有语言。
⚠ 重要:v3与v4包选择说明
Inngest v4的实时功能位于SDK子路径
inngest/realtime
。独立包
@inngest/realtime
v3时代的包
inngest@4.x
不兼容
。若你的项目使用v4(npm默认版本),请勿安装
@inngest/realtime
,请使用下文的导入方式。
在v4中使用错误包的症状:每次调用
PUT /api/inngest
时出现
TypeError: Cls is not a constructor
、订阅令牌请求返回401、
new Inngest({ middleware: [...] })
出现类型不兼容。继续阅读前,请确认你的
package.json
"inngest": "^4.x"

Prerequisites

前置条件

  • Inngest v4 SDK installed (
    npm install inngest
    ) — see the
    inngest-setup
    skill
  • INNGEST_DEV=1
    set in
    .env.local
    for local development (without it, the SDK demands cloud signing keys and 401s on token requests)
  • Local Inngest dev server running (
    npx inngest-cli@latest dev
    )
  • Optional:
    zod
    for schema validation on topics
  • 已安装Inngest v4 SDK(
    npm install inngest
    )——参考
    inngest-setup
    技能
  • 本地开发时需在
    .env.local
    中设置
    INNGEST_DEV=1
    (若未设置,SDK会要求云签名密钥,令牌请求会返回401)
  • 本地Inngest开发服务器已启动(
    npx inngest-cli@latest dev
  • 可选:使用
    zod
    进行主题的 schema 验证

When to use Realtime

实时功能适用场景

Problem shapePattern
Order status page animates as durable workflow steps completePer-run channel, publish per step, client subscribes
AI agent streams tokens to a chat UIPer-conversation channel, publish chunks, stream to browser
Log tail for a long-running jobSingle channel, log topic, append to UI
Human-in-the-loop approvalChannel + waitForEvent, publish prompt, wait for response
Admin dashboard with live order listGlobal admin channel, fan-out from each function
场景类型实现模式
订单状态页面随持久化工作流步骤完成动态更新每个运行实例对应一个通道,按步骤发布消息,客户端订阅
AI Agent向聊天UI流式传输令牌每个对话对应一个通道,发布数据块,流式传输到浏览器
长期运行任务的日志追踪单个通道+日志主题,追加到UI
人机协作审批流程通道+waitForEvent,发布提示,等待响应
包含实时订单列表的管理后台全局管理通道,从每个函数进行消息广播

Architecture

架构组成

Three pieces:
  1. Channel definition — a typed contract for what gets published. Lives in shared module so both server and client can reference the same channel name.
  2. Publishing — call
    step.realtime.publish
    between steps to wrap a durable publish, or
    inngest.realtime.publish
    inside
    step.run
    because you're already inside a memoized step. See "Which publish method to use" below.
  3. Subscribing — server action mints a subscription token; React client uses the
    useRealtime
    hook (or the lower-level
    subscribe()
    API for non-React consumers).
分为三部分:
  1. 通道定义——消息发布的类型化契约。需定义在共享模块中,以便服务端和客户端引用相同的通道名称。
  2. 消息发布——在步骤之间调用
    step.realtime.publish
    来实现持久化发布;若在
    step.run
    内部,则调用
    inngest.realtime.publish
    (因为已处于 memoized 步骤中)。详见下文的「选择合适的发布方法」。
  3. 消息订阅——服务端操作生成订阅令牌;React客户端使用
    useRealtime
    钩子(非React消费者可使用底层
    subscribe()
    API)。

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:
  • name: 'admin'
    — static channel, accessed as
    adminChannel.order
    (topic ref)
  • name: (id) => 'channel:${id}'
    — parametric, accessed as
    orderChannel(id).step
    (call the channel def with the id, then access topic)
通道为纯数据结构——无需类继承,也不需要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}'
    ——参数化通道,通过
    orderChannel(id).step
    访问(传入ID调用通道定义,再访问主题)

Step 2: Publish from inside a function

步骤2:在函数内部发布消息

Inngest v4 ships realtime support natively — no middleware required. But where you call
publish
matters: it determines whether the publish is durable, and it's the most common place to get realtime wrong.
Inngest v4原生支持实时功能——无需中间件。但调用
publish
的位置很重要:它决定了发布是否具备持久性,这也是使用实时功能时最容易出错的地方。

Which publish method to use

选择合适的发布方法

Where you areUse thisWhy
Outside a step (top-level handler code, between
step.run
calls)
step.realtime.publish(id, topicRef, data)
Wraps the publish in its own step so it's durable, deduplicated by
id
, and retry-safe.
Inside a step (inside the callback passed to
step.run
)
inngest.realtime.publish(topicRef, data)
You're already inside a memoized step.
step.realtime.publish
would create a step inside a step. The bare client publish is the right call here.
Outside a function (one-off route, script, etc.)
inngest.realtime.publish(topicRef, data)
Allowed, but not retry-safe — your client receiver must handle duplicates.
The 90% rule: if you're writing handler code and you reach for
publish
, use
step.realtime.publish
. If you're writing code inside a
step.run
block and you reach for
publish
, use
inngest.realtime.publish
.
代码位置使用方法原因
步骤之外(顶级处理器代码、
step.run
调用之间)
step.realtime.publish(id, topicRef, data)
将发布操作包装在独立步骤中,确保持久化、通过
id
去重,且支持重试。
步骤内部
step.run
传入的回调函数中)
inngest.realtime.publish(topicRef, data)
已处于memoized步骤中,
step.realtime.publish
会导致步骤嵌套,直接调用客户端发布方法即可。
函数之外(一次性路由、脚本等)
inngest.realtime.publish(topicRef, data)
允许使用,但不支持重试——客户端接收器必须处理重复消息。
90%场景规则:若在处理器代码中调用
publish
,使用
step.realtime.publish
;若在
step.run
代码块中调用
publish
,使用
inngest.realtime.publish

Example: 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
@inngest/realtime
's
realtimeMiddleware()
to inject a
publish
arg into the handler. v4 puts it on
step.realtime
and
inngest.realtime
directly.
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;

    // 处于任意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);

    // ...
  },
);
**为何无需中间件:**早期版本使用
@inngest/realtime
realtimeMiddleware()
向处理器注入
publish
参数。v4则直接将其放在
step.realtime
inngest.realtime
上。

Step 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'],
  });
}
getClientSubscriptionToken
from
inngest/react
returns a token shape that the
useRealtime
hook in Step 4 consumes directly. No ChannelInstance stripping needed — that gotcha only applies to the lower-level
getSubscriptionToken
+ manual
subscribe()
path (see "Pattern: Manual subscribe" below).
在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/react
中的
getClientSubscriptionToken
会返回
useRealtime
钩子可直接消费的令牌结构。无需剥离ChannelInstance——该问题仅适用于底层
getSubscriptionToken
+手动
subscribe()
的路径(详见下文「模式:手动订阅」)。

Step 4: Subscribe with the
useRealtime
hook

步骤4:使用
useRealtime
钩子订阅

The recommended consumer for React/Next.js is the
useRealtime
hook from
inngest/react
. It handles the subscription lifecycle, reconnect, type narrowing per topic, and cleanup.
typescript
// 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:
OptionDefaultUse it when
enabled
true
Delay the subscription until you have an ID (e.g.,
enabled: !!runId
).
bufferInterval
0
Batch updates from a fast stream so React doesn't re-render per message.
pauseOnHidden
false
Pause the stream when the tab isn't visible (saves bandwidth).
autoCloseOnTerminal
true
Disconnect when the run completes — turn off to keep the stream open for fan-out channels.
historyLimit
unboundedCap how many messages are retained in
messages.all
.
The hook returns
messages.byTopic
(latest per topic),
messages.all
(full history),
messages.last
(most recent), and
messages.delta
(new since last render).
React/Next.js推荐使用
inngest/react
中的
useRealtime
钩子。它会处理订阅生命周期、重连、按主题进行类型收窄以及清理工作。
typescript
// 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>
  );
}
钩子的实用配置项:
配置项默认值适用场景
enabled
true
延迟订阅直到获取到ID(例如
enabled: !!runId
)。
bufferInterval
0
批量处理高速数据流的更新,避免React因每条消息重新渲染。
pauseOnHidden
false
标签页隐藏时暂停数据流(节省带宽)。
autoCloseOnTerminal
true
运行完成后断开连接——若要保持广播通道的数据流,可关闭该配置。
historyLimit
无限制限制
messages.all
中保留的消息数量。
钩子返回
messages.byTopic
(每个主题的最新消息)、
messages.all
(完整历史)、
messages.last
(最新消息)和
messages.delta
(上次渲染后新增的消息)。

Pattern: Manual subscribe (non-React or custom transport)

模式:手动订阅(非React或自定义传输)

The
useRealtime
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
subscribe()
API directly.
useRealtime
钩子适用于React场景。若不使用React,或需要自定义订阅生命周期(服务端流式传输、后台任务、自定义协议),可直接使用底层
subscribe()
API。

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
fetch().getReader()
rather than the
subscribe()
callback. Use this when you want the SSE behavior or when the client-side
subscribe()
API doesn't fit your component lifecycle.
在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',
    },
  });
}
客户端通过
fetch().getReader()
消费,而非
subscribe()
回调。当需要SSE行为或客户端
subscribe()
API不适合组件生命周期时,可使用此方式。

Pattern: Human-in-the-loop

模式:人机协作流程

Combine
step.realtime.publish
with
step.waitForEvent
:
typescript
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
confirmationId
links the published prompt to the matching reply, so the workflow knows which response to act on.
结合
step.realtime.publish
step.waitForEvent
typescript
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: '超时' };
    }
    // 继续工作流...
  },
);
confirmationId
将发布的提示与对应的回复关联,确保工作流知道要响应哪个回复。

Common pitfalls

常见陷阱

Don't use
@inngest/realtime
on v4

请勿在v4中使用
@inngest/realtime

The standalone
@inngest/realtime
package is for Inngest v3 only. On v4, all realtime APIs are in the SDK subpath
inngest/realtime
. Mixing them produces:
  • TypeError: Cls is not a constructor
    on
    PUT /api/inngest
    (v3 middleware class signature mismatch)
  • 401 Unauthorized on subscription tokens
  • TypeScript errors casting middleware
Verify with:
grep '"inngest"' package.json
— if it's
^4.x
, use
inngest/realtime
. Period.
独立包
@inngest/realtime
仅适用于Inngest v3。在v4中,所有实时API都位于SDK子路径
inngest/realtime
。混用两者会导致:
  • 调用
    PUT /api/inngest
    时出现
    TypeError: Cls is not a constructor
    (v3中间件类签名不匹配)
  • 订阅令牌请求返回401未授权
  • TypeScript类型转换错误
**验证方式:**执行
grep '"inngest"' package.json
——若版本为
^4.x
,请使用
inngest/realtime

Don't return ChannelInstance from a Next.js server action (manual subscribe path only)

手动订阅路径下,请勿从Next.js服务端操作返回ChannelInstance

getSubscriptionToken
returns
{ channel: ChannelInstance, ... }
where ChannelInstance has zod schema methods (a class). Next.js refuses to serialize classes across the server-action → client-component boundary. Strip to primitives before returning. See "Pattern: Manual subscribe" above.
This gotcha does not apply when you use
getClientSubscriptionToken
from
inngest/react
(Step 3 — the recommended path). That helper returns a serialization-safe shape directly.
getSubscriptionToken
返回
{ channel: ChannelInstance, ... }
,其中ChannelInstance包含zod schema方法(类)。Next.js拒绝在server-action → client-component边界序列化类。返回前需剥离为原始类型。详见上文「模式:手动订阅」。
该问题不适用于使用
inngest/react
getClientSubscriptionToken
的场景(步骤3——推荐路径)。该工具会直接返回可安全序列化的结构。

INNGEST_DEV=1
is required for local dev

本地开发必须设置
INNGEST_DEV=1

Without it, the SDK assumes cloud mode and demands
INNGEST_SIGNING_KEY
+
INNGEST_EVENT_KEY
. All realtime operations 401 / 500. Add to
.env.local
. Hard restart the dev server (Next.js does not hot-reload
.env.local
changes).
若未设置,SDK会默认使用云模式,要求
INNGEST_SIGNING_KEY
INNGEST_EVENT_KEY
。所有实时操作会返回401/500。请将其添加到
.env.local
中,并重启开发服务器(Next.js不会热重载
.env.local
的更改)。

Channel 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
validate: false
in
subscribe()
if you have a reason to skip schema validation client-side.
若发布的 payload 不符合zod schema,发布会在服务端失败,订阅者不会收到任何消息。请在步骤执行时捕获发布错误;若有理由跳过客户端schema校验,可在
subscribe()
中设置
validate: false

Reference

参考资料

  • v4 entry points:
    • import { channel } from 'inngest/realtime'
      — channel definitions
    • import { useRealtime, getClientSubscriptionToken } from 'inngest/react'
      — React hook + matching token helper (Step 3 + Step 4)
    • import { getSubscriptionToken, subscribe } from 'inngest/realtime'
      — lower-level helpers for non-React or custom transport
  • Publish methods:
    • Outside a step:
      step.realtime.publish(id, topicRef, data)
      — wraps in a durable step
    • Inside
      step.run
      :
      inngest.realtime.publish(topicRef, data)
      — already inside a memoized step, no wrapping needed
    • Outside a function:
      inngest.realtime.publish(topicRef, data)
      — allowed but not retry-safe
  • Subscribe overloads:
    subscribe(token)
    returns a stream;
    subscribe(token, callback)
    invokes callback per message
  • Next.js Server Action gotcha (manual path only): strip
    ChannelInstance
    → return
    { channel: string, topics, key, apiBaseUrl }
    . Not needed with
    getClientSubscriptionToken
    .
  • v4入口:
    • import { channel } from 'inngest/realtime'
      ——通道定义
    • import { useRealtime, getClientSubscriptionToken } from 'inngest/react'
      ——React钩子+配套令牌工具(步骤3+步骤4)
    • import { getSubscriptionToken, subscribe } from 'inngest/realtime'
      ——非React或自定义传输场景的底层工具
  • 发布方法:
    • 步骤之外
      step.realtime.publish(id, topicRef, data)
      ——包装为持久化步骤
    • step.run
      内部
      inngest.realtime.publish(topicRef, data)
      ——已处于memoized步骤,无需包装
    • 函数之外
      inngest.realtime.publish(topicRef, data)
      ——允许使用但不支持重试
  • 订阅重载:
    subscribe(token)
    返回数据流;
    subscribe(token, callback)
    每条消息触发回调
  • Next.js Server Action陷阱(仅手动路径):剥离
    ChannelInstance
    →返回
    { channel: string, topics, key, apiBaseUrl }
    。使用
    getClientSubscriptionToken
    时无需此操作。