inngest
Version:
Official SDK for Inngest.com. Inngest is the reliability layer for modern applications. Inngest combines durable execution, events, and queues into a zero-infra platform with built-in observability.
1 lines • 139 kB
Source Map (JSON)
{"version":3,"file":"engine.cjs","names":["hashjs","isRecord","createExecutionEngine: InngestExecutionFactory","headers: Record<string, string>","InngestExecution","ExecutionVersion","options: InngestExecutionOptions","StepMode","Stream","MiddlewareManager","internalLoggerSymbol","trace","version","getAsyncLocalStorage","headerKeys","createDeferredPromise","retryWithBackoff","defaultMaxRetries","buildSseMetadataEvent","prependToStream","resultData: unknown","streamingResult: ExecutionResult","StepOpCode","commonCheckpointHandler: CheckpointHandlers[StepMode][\"\"]","serializeError","allSteps: OutgoingOp[]","syncHandlers: CheckpointHandlers[StepMode.Sync]","sseResponse: SseResponse","asyncHandlers: CheckpointHandlers[StepMode.Async]","asyncCheckpointingHandlers: CheckpointHandlers[StepMode.AsyncCheckpointing]","step","UnreachableError","outgoingOp: OutgoingOp","getAsyncCtx","interval: GoInterval | undefined","runAsPromise","goIntervalTiming","innerHandler: () => Promise<unknown>","err: Error","NonRetriableError","StepError","RetryAfterError","validateEvents","isDeferredFunction","undefinedToNull","steps: OutgoingOp[]","createDeferredPromiseWithStack","loop: ExecutionState[\"loop\"]","LazyOps","experimentStepRunSymbol","createGroupTools","z","jsonErrorSchema","deserializeError","foundStepsToReport: Map<string, FoundStep>","unhandledFoundStepsToReport: Map<string, FoundStep>","expectedNextStepIndexes: Map<string, number>","remainingStepCompletionOrder: string[]","foundStepsReportPromise: Promise<void> | undefined","ErrCode","extensionPromise: Promise<void>","resolveNextTick","resolveAfterPending","stepHandler: StepHandler","getStepOptions","isLazyOp","hashedId","extraOpts: Record<string, unknown> | undefined","step: FoundStep","createStepTools","input: unknown","createTimeoutPromise","STEP_INDEXING_SUFFIX"],"sources":["../../../src/components/execution/engine.ts"],"sourcesContent":["import { trace } from \"@opentelemetry/api\";\nimport hashjs from \"hash.js\";\nimport ms, { type StringValue } from \"ms\";\nimport { z } from \"zod/v3\";\n\nimport {\n defaultMaxRetries,\n ExecutionVersion,\n headerKeys,\n internalEvents,\n} from \"../../helpers/consts.ts\";\n\nimport {\n deserializeError,\n ErrCode,\n serializeError,\n} from \"../../helpers/errors.js\";\nimport { undefinedToNull } from \"../../helpers/functions.js\";\nimport { isDeferredFunction } from \"../../helpers/marker.ts\";\nimport {\n createDeferredPromise,\n createDeferredPromiseWithStack,\n createTimeoutPromise,\n type DeferredPromiseReturn,\n type GoInterval,\n goIntervalTiming,\n resolveAfterPending,\n resolveNextTick,\n retryWithBackoff,\n runAsPromise,\n} from \"../../helpers/promises.ts\";\nimport * as Temporal from \"../../helpers/temporal.ts\";\nimport {\n isRecord,\n type MaybePromise,\n type Simplify,\n} from \"../../helpers/types.ts\";\nimport {\n type APIStepPayload,\n type Context,\n type DeferFn,\n type EventPayload,\n type FailureEventArgs,\n type Handler,\n type HashedOp,\n jsonErrorSchema,\n type OutgoingOp,\n StepMode,\n StepOpCode,\n} from \"../../types.ts\";\nimport { version } from \"../../version.ts\";\nimport { internalLoggerSymbol } from \"../Inngest.ts\";\nimport { createGroupTools } from \"../InngestGroupTools.ts\";\nimport type {\n MetadataKind,\n MetadataOpcode,\n MetadataScope,\n MetadataUpdate,\n} from \"../InngestMetadata.ts\";\nimport {\n createStepTools,\n type ExperimentStepTools,\n experimentStepRunSymbol,\n type FoundStep,\n getStepOptions,\n STEP_INDEXING_SUFFIX,\n type StepHandler,\n} from \"../InngestStepTools.ts\";\nimport { MiddlewareManager } from \"../middleware/index.ts\";\nimport type { Middleware } from \"../middleware/middleware.ts\";\nimport { UnreachableError } from \"../middleware/utils.ts\";\nimport { NonRetriableError } from \"../NonRetriableError.ts\";\nimport { RetryAfterError } from \"../RetryAfterError.ts\";\nimport { StepError } from \"../StepError.ts\";\nimport { Stream } from \"../StreamTools.ts\";\nimport { validateEvents } from \"../triggers/utils.js\";\nimport { getAsyncCtx, getAsyncLocalStorage } from \"./als.ts\";\nimport {\n type BasicFoundStep,\n type ExecutionResult,\n type IInngestExecution,\n InngestExecution,\n type InngestExecutionFactory,\n type InngestExecutionOptions,\n type MemoizedOp,\n} from \"./InngestExecution.ts\";\nimport { isLazyOp, LazyOps } from \"./lazyOps.ts\";\nimport { clientProcessorMap } from \"./otel/access.ts\";\nimport {\n buildSseMetadataEvent,\n prependToStream,\n type SseResponse,\n} from \"./streaming.ts\";\n\nconst { sha1 } = hashjs;\n\n/**\n * Retry configuration for checkpoint operations.\n *\n * Checkpoint calls use exponential backoff with jitter to handle transient\n * network failures (e.g., dev server temporarily down, cloud hiccup). If\n * retries exhaust, the error propagates up - for Sync mode this results in a\n * 500 error, for AsyncCheckpointing the caller handles fallback.\n */\nconst CHECKPOINT_RETRY_OPTIONS = { maxAttempts: 5, baseDelay: 100 };\n\nfunction errorMessage(error: unknown): string {\n if (error instanceof Error) {\n return error.message;\n }\n if (isRecord(error) && typeof error.message === \"string\") {\n return error.message;\n }\n return String(error);\n}\n\n/**\n * Placeholder step ID used when completing a checkpointed run.\n */\nconst RUN_COMPLETE_STEP_ID = \"complete\";\n\nconst STEP_NOT_FOUND_MAX_FOUND_STEPS = 25;\n\nexport const createExecutionEngine: InngestExecutionFactory = (options) => {\n return new InngestExecutionEngine(options);\n};\n\nfunction extractSseResponse(response: Response, body: string): SseResponse {\n const headers: Record<string, string> = {};\n response.headers.forEach((value, key) => {\n headers[key] = value;\n });\n return { body, statusCode: response.status, headers };\n}\n\nfunction defaultSseResponse(data: unknown): SseResponse {\n return {\n body: JSON.stringify(data),\n statusCode: 200,\n headers: { \"content-type\": \"application/json\" },\n };\n}\n\nfunction jsonResponse(data: unknown, status = 200): Response {\n return new Response(JSON.stringify(data), {\n status,\n headers: { \"Content-Type\": \"application/json\" },\n });\n}\n\nclass InngestExecutionEngine\n extends InngestExecution\n implements IInngestExecution\n{\n public version = ExecutionVersion.V2;\n\n private state: ExecutionState;\n private fnArg: Context.Any;\n private checkpointHandlers: CheckpointHandlers;\n private timeoutDuration = 1000 * 10;\n private execution: Promise<ExecutionResult> | undefined;\n private userFnToRun: Handler.Any;\n private middlewareManager: MiddlewareManager;\n /**\n * Close the stream via {@link streamCloseSucceeded}, {@link streamCloseFailed},\n * or {@link streamEnd} — never call `streamTools.close*`/`end` directly, as\n * the wrappers ensure the redirect event is flushed first.\n */\n private streamTools: Stream;\n\n /**\n * Resolved when `stream.push()`/`pipe()` is first called in sync mode,\n * allowing `_start()` to return the SSE Response to the HTTP layer while\n * the core loop continues executing steps in the background.\n */\n private earlyStreamResponse:\n | DeferredPromiseReturn<ExecutionResult>\n | undefined;\n\n /**\n * Whether the `inngest.redirect_info` SSE event has already been sent.\n * Prevents duplicate redirect events.\n */\n private redirectSent = false;\n\n /**\n * Promise that resolves once the redirect event has been written (or the\n * attempt completes). Stored so that `checkpointAndSwitchToAsync` can\n * await it before closing the writer.\n */\n private redirectPromise: Promise<void> = Promise.resolve();\n\n /**\n * If we're supposed to run a particular step via `requestedRunStep`, this\n * will be a `Promise` that resolves after no steps have been found for\n * `timeoutDuration` milliseconds.\n *\n * If we're not supposed to run a particular step, this will be `undefined`.\n */\n private timeout?: ReturnType<typeof createTimeoutPromise>;\n private rootSpanId?: string;\n\n /**\n * If we're checkpointing and have been given a maximum runtime, this will be\n * a `Promise` that resolves after that duration has elapsed, allowing us to\n * ensure that we end the execution in good time, especially in serverless\n * environments.\n */\n private checkpointingMaxRuntimeTimer?: ReturnType<\n typeof createTimeoutPromise\n >;\n\n /**\n * If we're checkpointing and have been given a maximum buffer interval, this\n * will be a `Promise` that resolves after that duration has elapsed, allowing\n * us to periodically checkpoint even if the step buffer hasn't filled.\n */\n private checkpointingMaxBufferIntervalTimer?: ReturnType<\n typeof createTimeoutPromise\n >;\n\n constructor(rawOptions: InngestExecutionOptions) {\n const options: InngestExecutionOptions = {\n ...rawOptions,\n stepMode: rawOptions.stepMode ?? StepMode.Async,\n };\n\n super(options);\n\n /**\n * Check we have everything we need for checkpointing\n */\n if (this.options.stepMode === StepMode.Sync) {\n if (!this.options.createResponse) {\n throw new Error(\"createResponse is required for sync step mode\");\n }\n }\n\n this.userFnToRun = this.getUserFnToRun();\n this.streamTools = new Stream({\n onActivated: () => this.handleStreamActivated(),\n onWriteError: (err) =>\n this.devDebug(\n \"stream write error (client may have disconnected):\",\n err,\n ),\n });\n this.state = this.createExecutionState();\n this.fnArg = this.createFnArg();\n\n // Setup middleware\n const mwInstances =\n this.options.middlewareInstances ??\n (this.options.client.middleware || []).map((Cls) => {\n return new Cls({ client: this.options.client });\n });\n this.middlewareManager = new MiddlewareManager(\n this.fnArg,\n () => this.state.stepState,\n mwInstances,\n this.options.fn,\n this.options.client[internalLoggerSymbol],\n );\n\n this.checkpointHandlers = this.createCheckpointHandlers();\n this.initializeTimer(this.state);\n this.initializeCheckpointRuntimeTimer(this.state);\n\n this.devDebug(\n \"created new V1 execution for run;\",\n this.options.requestedRunStep\n ? `wanting to run step \"${this.options.requestedRunStep}\"`\n : \"discovering steps\",\n );\n\n this.devDebug(\"existing state keys:\", Object.keys(this.state.stepState));\n }\n\n /**\n * Idempotently start the execution of the user's function.\n */\n public start() {\n if (!this.execution) {\n this.devDebug(\"starting V1 execution\");\n\n const tracer = trace.getTracer(\"inngest\", version);\n\n this.execution = getAsyncLocalStorage().then((als) => {\n return als.run(\n {\n app: this.options.client,\n execution: {\n ctx: this.fnArg,\n instance: this,\n stream: this.streamTools,\n },\n },\n async () => {\n return tracer.startActiveSpan(\"inngest.execution\", (span) => {\n this.rootSpanId = span.spanContext().spanId;\n clientProcessorMap.get(this.options.client)?.declareStartingSpan({\n span,\n runId: this.options.runId,\n traceparent: this.options.headers[headerKeys.TraceParent],\n tracestate: this.options.headers[headerKeys.TraceState],\n });\n\n return this._start()\n .then((result) => {\n this.devDebug(\"result:\", result);\n return result;\n })\n .finally(() => {\n span.end();\n });\n });\n },\n );\n });\n }\n\n return this.execution;\n }\n\n public addMetadata(\n stepId: string,\n kind: MetadataKind,\n scope: MetadataScope,\n op: MetadataOpcode,\n values: Record<string, unknown>,\n ) {\n if (!this.state.metadata) {\n this.state.metadata = new Map();\n }\n\n const updates = this.state.metadata.get(stepId) ?? [];\n updates.push({ kind, scope, op, values });\n this.state.metadata.set(stepId, updates);\n\n return true;\n }\n\n /**\n * Starts execution of the user's function and the core loop.\n */\n private async _start(): Promise<ExecutionResult> {\n // Set up a deferred promise that handleStreamActivated resolves to return\n // the SSE Response early while the core loop keeps running.\n if (this.options.stepMode === StepMode.Sync && this.options.acceptsSse) {\n this.earlyStreamResponse = createDeferredPromise<ExecutionResult>();\n }\n\n const coreLoop = this.runCoreLoop();\n\n if (this.earlyStreamResponse) {\n // Suppress: if earlyStreamResponse wins the race and coreLoop later\n // rejects, the rejection must not go unhandled.\n coreLoop.catch((err) => {\n this.options.client[internalLoggerSymbol].error(\n { err },\n \"Core loop rejected after early stream response was sent\",\n );\n });\n\n return Promise.race([this.earlyStreamResponse.promise, coreLoop]);\n }\n\n return coreLoop;\n }\n\n /**\n * The core checkpoint loop: processes checkpoints until a handler returns\n * a result.\n */\n private async runCoreLoop(): Promise<ExecutionResult> {\n try {\n const allCheckpointHandler = this.getCheckpointHandler(\"\");\n await this.startExecution();\n\n let i = 0;\n\n for await (const checkpoint of this.state.loop) {\n await allCheckpointHandler(checkpoint, i);\n\n const handler = this.getCheckpointHandler(checkpoint.type);\n const result = await handler(checkpoint, i++);\n\n if (result) {\n return result;\n }\n }\n } catch (error) {\n // If earlyStreamResponse was set up, close the stream with an error event\n // and resolve (not reject) with a well-formed result so the caller gets a\n // structured response instead of a raw Error.\n if (this.earlyStreamResponse) {\n await this.streamCloseFailed(\"Internal execution error\");\n const result = this.transformOutput({ error });\n this.earlyStreamResponse.resolve(result);\n return result;\n }\n\n return this.transformOutput({ error });\n } finally {\n void this.state.loop.return();\n }\n\n /**\n * If we're here, the generator somehow finished without returning a value.\n * This should never happen.\n */\n throw new Error(\"Core loop finished without returning a value\");\n }\n\n private async checkpoint(steps: OutgoingOp[]): Promise<void> {\n const lazyOps = this.state.lazyOps.drain();\n if (lazyOps.length > 0) {\n steps = [...steps, ...lazyOps];\n }\n\n if (this.options.stepMode === StepMode.Sync) {\n if (!this.state.checkpointedRun) {\n // We have to start the run\n const res = await retryWithBackoff(\n () =>\n this.options.client[\"inngestApi\"].checkpointNewRun({\n runId: this.fnArg.runId,\n event: this.fnArg.event as APIStepPayload,\n steps,\n executionVersion: this.version,\n retries: this.fnArg.maxAttempts ?? defaultMaxRetries,\n }),\n CHECKPOINT_RETRY_OPTIONS,\n );\n\n this.state.checkpointedRun = {\n appId: res.data.app_id,\n fnId: res.data.fn_id,\n token: res.data.token,\n realtimeToken: res.data.realtime_token,\n };\n\n // Try sending redirect (no-op if stream isn't activated yet).\n this.sendRedirectIfReady();\n } else {\n await retryWithBackoff(\n () =>\n this.options.client[\"inngestApi\"].checkpointSteps({\n appId: this.state.checkpointedRun!.appId,\n fnId: this.state.checkpointedRun!.fnId,\n runId: this.fnArg.runId,\n steps,\n }),\n CHECKPOINT_RETRY_OPTIONS,\n );\n }\n } else if (this.options.stepMode === StepMode.AsyncCheckpointing) {\n const { internalFnId, queueItemId } = this.options;\n if (!queueItemId) {\n throw new Error(\n \"Missing queueItemId for async checkpointing. This is a bug in the Inngest SDK.\",\n );\n }\n\n if (!internalFnId) {\n throw new Error(\n \"Missing internalFnId for async checkpointing. This is a bug in the Inngest SDK.\",\n );\n }\n\n await retryWithBackoff(\n () =>\n this.options.client[\"inngestApi\"].checkpointStepsAsync({\n runId: this.fnArg.runId,\n fnId: internalFnId,\n queueItemId,\n steps,\n }),\n CHECKPOINT_RETRY_OPTIONS,\n );\n } else {\n throw new Error(\n \"Checkpointing is only supported in Sync and AsyncCheckpointing step modes. This is a bug in the Inngest SDK.\",\n );\n }\n }\n\n private async checkpointAndSwitchToAsync(\n steps: OutgoingOp[],\n stepError?: unknown,\n ): Promise<ExecutionResult> {\n await this.checkpoint(steps);\n\n if (!this.state.checkpointedRun?.token) {\n throw new Error(\"Failed to checkpoint and switch to async mode\");\n }\n\n const token = this.state.checkpointedRun.token;\n\n if (this.streamTools.activated) {\n if (stepError && !this.retriability(stepError)) {\n // Permanent failure — close with a failed event so the client\n // sees the error.\n await this.streamCloseFailed(errorMessage(stepError));\n } else {\n // End stream without a result event — client uses redirect_info\n // to reconnect.\n await this.streamEnd();\n }\n } else if (this.options.acceptsSse) {\n // Stream was never activated (no stream.push/pipe), but the client\n // accepts SSE. Close and return the SSE response so the client gets\n // metadata + redirect_info events and can follow the redirect.\n await this.streamEnd();\n return {\n type: \"function-resolved\",\n ctx: this.fnArg,\n ops: this.ops,\n data: this.buildSyncSseResponse(),\n };\n }\n\n return {\n type: \"change-mode\",\n ctx: this.fnArg,\n ops: this.ops,\n to: StepMode.Async,\n token,\n };\n }\n\n /**\n * Prepend the `inngest.metadata` SSE event to the stream's readable side.\n * The returned stream can be used as a fetch body or Response body.\n *\n * NOTE: `this.streamTools.readable` can only be consumed once, so only one\n * of `buildSyncSseResponse` or `postCheckpointStream` may be called per\n * execution.\n */\n private buildMetadataPrefixedStream(): ReadableStream<Uint8Array> {\n const metadataEvent = buildSseMetadataEvent(this.fnArg.runId);\n return prependToStream(\n new TextEncoder().encode(metadataEvent),\n this.streamTools.readable,\n );\n }\n\n /**\n * Build the initial SSE `Response` that marks the start of streaming to the\n * client. Only used in sync mode. In async mode, the stream is POSTed to the\n * Inngest Server via {@link postCheckpointStream} instead.\n *\n * The response body is the stream's readable side, prefixed with the\n * `inngest.metadata` SSE event.\n */\n private buildSyncSseResponse(): Response {\n return new Response(this.buildMetadataPrefixedStream(), {\n status: 200,\n headers: {\n \"Content-Type\": \"text/event-stream\",\n \"Cache-Control\": \"no-cache\",\n },\n });\n }\n\n /**\n * Wraps a plain return value as an SSE Response.\n *\n * Used when the client sent `Accept: text/event-stream` but\n * `stream.push()`/`pipe()` was NOT called during execution. The\n * checkpointable data is the function's return value. The SSE events are just\n * a delivery mechanism.\n */\n private async wrapResultAsSse(\n checkpoint: Simplify<\n { type: \"function-resolved\" } & Checkpoints[\"function-resolved\"]\n >,\n sseResponse: SseResponse,\n ): Promise<ExecutionResult> {\n const resultData: unknown = checkpoint.data;\n\n // Close the stream with a terminal succeeded event\n await this.streamCloseSucceeded(sseResponse);\n\n const clientResponse = this.buildSyncSseResponse();\n\n // Run transformOutput to fire middleware hooks\n const result = this.transformOutput({ data: resultData });\n\n const streamingResult: ExecutionResult = {\n ...result,\n data: clientResponse,\n } as ExecutionResult;\n\n // Background: checkpoint the return value (not the stream bytes).\n void this.checkpointReturnValue(resultData);\n\n return streamingResult;\n }\n\n /**\n * Called when `stream.push()`/`pipe()` is first invoked during sync\n * execution. Resolves {@link earlyStreamResponse} so that `_start()` can\n * return the SSE Response to the HTTP layer immediately, while the core\n * checkpoint loop keeps running steps in the background.\n */\n private handleStreamActivated(): undefined {\n if (this.earlyStreamResponse) {\n // \"function-resolved\" tells the HTTP layer to send the Response,\n // even though the function is still running.\n this.earlyStreamResponse.resolve({\n type: \"function-resolved\",\n ctx: this.fnArg,\n ops: this.ops,\n data: this.buildSyncSseResponse(),\n });\n\n // Checkpoint may have already provided the realtime token — try redirect now.\n this.sendRedirectIfReady();\n\n return undefined;\n }\n\n // Async/AsyncCheckpointing mode: start the streaming POST immediately.\n // In sync mode without SSE, the stream has no consumer — skip the POST.\n if (this.options.stepMode !== StepMode.Sync) {\n this.postCheckpointStream();\n }\n this.sendRedirectIfReady();\n return undefined;\n }\n\n /**\n * Sends the `inngest.redirect_info` SSE event when both conditions are met:\n * 1. The client accepts SSE (so there's a stream to write the event to)\n * 2. We have a realtime token (first checkpoint has completed)\n *\n * Called after the first checkpoint AND on stream activation, whichever\n * comes second, so the redirect is sent as early as possible.\n */\n private sendRedirectIfReady(): void {\n if (this.redirectSent) {\n return;\n }\n\n if (!this.options.acceptsSse) {\n return;\n }\n\n if (!this.state.checkpointedRun) {\n // This is part of the happy path. We may not have checkpointed the run\n // yet, which happens after the first step ends\n return;\n }\n\n this.redirectSent = true;\n\n const { realtimeToken } = this.state.checkpointedRun;\n\n this.redirectPromise = (async () => {\n try {\n const redirect =\n await this.options.client[\"inngestApi\"].getRealtimeStreamRedirect(\n realtimeToken,\n );\n\n this.streamTools.sendRedirectInfo({\n runId: this.fnArg.runId,\n url: redirect.url,\n });\n } catch (err) {\n this.options.client[internalLoggerSymbol].warn(\n { err },\n \"Failed to fetch realtime stream redirect URL\",\n );\n }\n })();\n }\n\n /**\n * Await the pending redirect-info fetch, then close the stream with a\n * succeeded result. Awaiting first guarantees the redirect event is\n * enqueued on the write chain before the close event.\n */\n private async streamCloseSucceeded(response: SseResponse): Promise<void> {\n await this.redirectPromise;\n this.streamTools.closeSucceeded(response);\n }\n\n /**\n * Await the pending redirect-info fetch, then close the stream with a\n * failed result.\n */\n private async streamCloseFailed(error: string): Promise<void> {\n await this.redirectPromise;\n this.streamTools.closeFailed(error);\n }\n\n /**\n * Await the pending redirect-info fetch, then close the stream without\n * a result event.\n */\n private async streamEnd(): Promise<void> {\n await this.redirectPromise;\n this.streamTools.end();\n }\n\n /**\n * POST stream data to the checkpoint stream ingest endpoint.\n *\n * Called eagerly from handleStreamActivated so chunks flow in\n * real-time, or after completion if stream.push() was never called.\n */\n private postCheckpointStream(): void {\n try {\n // Fire and forget — completes when the stream closes.\n void this.options.client[\"inngestApi\"]\n .checkpointStream({\n runId: this.fnArg.runId,\n body: this.buildMetadataPrefixedStream(),\n })\n .catch((err: unknown) => {\n this.devDebug(\"checkpoint stream POST error:\", err);\n });\n } catch (err) {\n this.devDebug(\"checkpoint stream POST error:\", err);\n }\n }\n\n /**\n * Checkpoints the return value of a function that was delivered via SSE.\n * Runs in the background so it doesn't block the client stream.\n */\n private async checkpointReturnValue(data: unknown): Promise<void> {\n try {\n if (this.options.createResponse) {\n await this.checkpoint([\n {\n op: StepOpCode.RunComplete,\n id: hashId(RUN_COMPLETE_STEP_ID),\n data: await this.options.createResponse(jsonResponse(data)),\n },\n ]);\n }\n } catch (err) {\n this.devDebug(\n \"error during background checkpoint of SSE result, client stream unaffected:\",\n err,\n );\n }\n }\n\n /**\n * Creates a handler for every checkpoint type, defining what to do when we\n * reach that checkpoint in the core loop.\n */\n private createCheckpointHandlers(): CheckpointHandlers {\n const commonCheckpointHandler: CheckpointHandlers[StepMode][\"\"] = (\n checkpoint,\n ) => {\n this.devDebug(`${this.options.stepMode} checkpoint:`, checkpoint);\n };\n\n const stepRanHandler = async (\n stepResult: OutgoingOp,\n ): Promise<ExecutionResult> => {\n const transformResult = await this.transformOutput(stepResult);\n\n /**\n * Transforming output will always return either function rejection or\n * resolution. In most cases, this can be immediately returned, but in\n * this particular case we want to handle it differently.\n */\n if (transformResult.type === \"function-resolved\") {\n return {\n type: \"step-ran\",\n ctx: transformResult.ctx,\n ops: transformResult.ops,\n step: {\n ...stepResult,\n data: transformResult.data,\n },\n };\n } else if (transformResult.type === \"function-rejected\") {\n const stepForResponse = {\n ...stepResult,\n error: transformResult.error,\n };\n\n if (stepResult.op === StepOpCode.StepFailed) {\n const ser = serializeError(transformResult.error);\n stepForResponse.data = {\n __serialized: true,\n name: ser.name,\n message: ser.message,\n stack: \"\",\n };\n }\n\n return {\n type: \"step-ran\",\n ctx: transformResult.ctx,\n ops: transformResult.ops,\n retriable: transformResult.retriable,\n step: stepForResponse,\n };\n }\n\n return transformResult;\n };\n\n const maybeReturnNewSteps = async (): Promise<\n ExecutionResult | undefined\n > => {\n const newSteps = await this.filterNewSteps(\n Array.from(this.state.steps.values()),\n );\n\n const allSteps: OutgoingOp[] = [\n ...(newSteps ?? []),\n ...this.state.lazyOps.drain(),\n ];\n if (allSteps.length === 0) {\n return;\n }\n\n return {\n type: \"steps-found\",\n ctx: this.fnArg,\n ops: this.ops,\n steps: allSteps as [OutgoingOp, ...OutgoingOp[]],\n };\n };\n\n const attemptCheckpointAndResume = async (\n stepResult?: OutgoingOp,\n resume = true,\n force = false,\n ): Promise<ExecutionResult | undefined> => {\n // If we're here, we successfully ran a step, so we may now need\n // to checkpoint it depending on the step buffer configured.\n if (stepResult) {\n const stepToResume = this.resumeStepWithResult(stepResult, resume);\n\n // Clear `executingStep` immediately after resuming, before any await.\n // `resumeStepWithResult` resolves the step's promise, queuing a\n // microtask for the function to continue. Any subsequent await (e.g.\n // the `transformOutput` hook) yields and lets that microtask run, so\n // `executingStep` must already be cleared to avoid a false positive\n // NESTING_STEPS warning in the next step's handler.\n delete this.state.executingStep;\n\n // Buffer a copy with transformed data for checkpointing\n this.state.checkpointingStepBuffer.push({\n ...stepToResume,\n data: stepResult.data,\n });\n }\n\n if (\n force ||\n !this.options.checkpointingConfig?.bufferedSteps ||\n this.state.checkpointingStepBuffer.length >=\n this.options.checkpointingConfig.bufferedSteps\n ) {\n this.devDebug(\"checkpointing and resuming execution after step run\");\n\n try {\n this.devDebug(\n `checkpointing all buffered steps:`,\n this.state.checkpointingStepBuffer\n .map((op) => op.displayName || op.id)\n .join(\", \"),\n );\n\n return void (await this.checkpoint(\n this.state.checkpointingStepBuffer,\n ));\n } catch (err) {\n // If checkpointing fails for any reason, fall back to returning\n // ALL buffered steps to the executor via the normal async flow.\n // The executor persists completed steps and rediscovers any\n // parallel/errored steps on the next invocation.\n this.devDebug(\n \"error checkpointing after step run, so falling back to async\",\n err,\n );\n\n const buffered = this.state.checkpointingStepBuffer;\n\n if (buffered.length) {\n return {\n type: \"steps-found\" as const,\n ctx: this.fnArg,\n ops: this.ops,\n steps: buffered as [OutgoingOp, ...OutgoingOp[]],\n };\n }\n\n return;\n } finally {\n // Clear the checkpointing buffer\n this.state.checkpointingStepBuffer = [];\n }\n } else {\n this.devDebug(\n `not checkpointing yet, continuing execution as we haven't reached buffered step limit of ${this.options.checkpointingConfig?.bufferedSteps}`,\n );\n }\n\n return;\n };\n\n const syncHandlers: CheckpointHandlers[StepMode.Sync] = {\n /**\n * Run for all checkpoints. Best used for logging or common actions.\n * Use other handlers to return values and interrupt the core loop.\n */\n \"\": commonCheckpointHandler,\n\n \"function-resolved\": async (checkpoint) => {\n const usingSseStream = !!this.earlyStreamResponse;\n\n if (this.streamTools.activated) {\n let resultData: unknown = checkpoint.data;\n let sseResponse: SseResponse;\n if (checkpoint.data instanceof Response) {\n // Clone when not SSE so the Response body stays intact for passthrough.\n const body = await (usingSseStream\n ? checkpoint.data.text()\n : checkpoint.data.clone().text());\n sseResponse = extractSseResponse(checkpoint.data, body);\n resultData = body;\n } else {\n sseResponse = defaultSseResponse(resultData);\n }\n\n // Always close the stream — either the SSE client or the\n // server-side checkpoint POST needs the terminal result event.\n await this.streamCloseSucceeded(sseResponse);\n\n if (usingSseStream) {\n // SSE path: response already sent; checkpoint in background.\n void this.checkpointReturnValue(resultData);\n return this.transformOutput({ data: resultData });\n }\n\n // Non-SSE: fall through to normal response handling.\n }\n\n // If the client accepts SSE (but stream.push() was NOT called), build\n // the SSE Response now. The SSE envelope is always needed when the\n // client requests it because it carries inngest.metadata and\n // inngest.redirect_info. Without these the client can't reconnect if a\n // future execution goes async.\n if (this.options.acceptsSse) {\n let sseResponse: SseResponse;\n if (checkpoint.data instanceof Response) {\n const body = await checkpoint.data.text();\n sseResponse = extractSseResponse(checkpoint.data, body);\n checkpoint = { ...checkpoint, data: body };\n } else {\n sseResponse = defaultSseResponse(checkpoint.data);\n }\n return this.wrapResultAsSse(checkpoint, sseResponse);\n }\n\n // Response pass-through: deliver as-is. Checkpoint null because the\n // Response body (ReadableStream) can only be consumed once.\n if (checkpoint.data instanceof Response) {\n void this.checkpointReturnValue(null);\n return this.transformOutput({ data: checkpoint.data });\n }\n\n // Non-streaming path\n await this.checkpoint([\n {\n op: StepOpCode.RunComplete,\n id: hashId(RUN_COMPLETE_STEP_ID),\n data: await this.options.createResponse!(\n jsonResponse(checkpoint.data),\n ),\n },\n ]);\n\n // Apply middleware transformation before returning\n return this.transformOutput({ data: checkpoint.data });\n },\n\n \"function-rejected\": async (checkpoint) => {\n const usingSseStream = !!this.earlyStreamResponse;\n const isFinal = !this.retriability(checkpoint.error);\n\n if (this.streamTools.activated && usingSseStream) {\n // SSE path: checkpoint the error, then close the stream.\n // The checkpoint triggers sendRedirectIfReady which starts\n // the (near-instant) redirect URL resolution. Chaining the\n // close as a continuation ensures the redirect event is\n // written first, without blocking the handler's return on\n // the checkpoint's retry budget.\n void (async () => {\n try {\n await this.checkpoint([\n {\n id: hashId(RUN_COMPLETE_STEP_ID),\n op: isFinal ? StepOpCode.StepFailed : StepOpCode.StepError,\n error: checkpoint.error,\n },\n ]);\n } catch (err) {\n this.options.client[internalLoggerSymbol].warn(\n { err },\n \"Failed to checkpoint function error\",\n );\n }\n\n if (isFinal) {\n await this.streamCloseFailed(errorMessage(checkpoint.error));\n } else {\n await this.streamEnd();\n }\n })();\n\n return this.transformOutput({ error: checkpoint.error });\n }\n\n // If the function throws during sync execution, we want to switch to\n // async mode so that we can retry. The exception is that we're already\n // at max attempts, in which case we do actually want to reject.\n if (isFinal) {\n return this.transformOutput({ error: checkpoint.error });\n }\n\n // Retryable — checkpoint the error and switch to async mode.\n // checkpointAndSwitchToAsync handles closing the stream.\n return this.checkpointAndSwitchToAsync([\n {\n id: hashId(RUN_COMPLETE_STEP_ID),\n op: StepOpCode.StepError,\n error: checkpoint.error,\n },\n ]);\n },\n\n \"step-not-found\": () => {\n return {\n type: \"function-rejected\",\n ctx: this.fnArg,\n error: new Error(\n \"Step not found when checkpointing; this should never happen\",\n ),\n ops: this.ops,\n retriable: false,\n };\n },\n\n \"steps-found\": async ({ steps }) => {\n // If we're entering parallelism or async mode, checkpoint and switch\n // to async.\n if (steps.length !== 1 || steps[0].mode !== StepMode.Sync) {\n return this.checkpointAndSwitchToAsync(\n steps.map((step) => ({ ...step, id: step.hashedId })),\n );\n }\n\n // Otherwise we're good to start executing things right now.\n const result = await this.executeStep(steps[0]);\n\n const transformed = await stepRanHandler(result);\n if (transformed.type !== \"step-ran\") {\n throw new Error(\n \"Unexpected checkpoint handler result type after running step in sync mode\",\n );\n }\n\n if (result.error) {\n return this.checkpointAndSwitchToAsync(\n [transformed.step],\n result.error,\n );\n }\n\n // Resume the step with original data for user code\n //\n // Note: We should likely also pass this through `transformOutput` and\n // then `transformInput` to mimic the entire middleware cycle, to ensure\n // that any transformations that purposefully skew the resulting type\n // are supported.\n const stepToResume = this.resumeStepWithResult(result);\n\n // Clear executingStep now that the step result has been processed.\n // Without this, the next step discovery would see stale state and\n // emit a false positive NESTING_STEPS warning.\n delete this.state.executingStep;\n\n // Checkpoint with transformed data from middleware\n const stepForCheckpoint = {\n ...stepToResume,\n data: transformed.step.data,\n };\n\n return void (await this.checkpoint([stepForCheckpoint]));\n },\n\n \"checkpointing-runtime-reached\": () => {\n return this.checkpointAndSwitchToAsync([\n {\n op: StepOpCode.DiscoveryRequest,\n\n // Append with time because we don't want Executor-side\n // idempotency to dedupe. There may have been a previous\n // discovery request.\n id: _internals.hashId(`discovery-request-${Date.now()}`),\n },\n ]);\n },\n\n \"checkpointing-buffer-interval-reached\": () => {\n return attemptCheckpointAndResume(undefined, false, true);\n },\n };\n\n const asyncHandlers: CheckpointHandlers[StepMode.Async] = {\n /**\n * Run for all checkpoints. Best used for logging or common actions.\n * Use other handlers to return values and interrupt the core loop.\n */\n \"\": commonCheckpointHandler,\n\n /**\n * The user's function has completed and returned a value.\n */\n \"function-resolved\": async ({ data }) => {\n let resultData: unknown = data;\n let sseResponse: SseResponse;\n if (data instanceof Response) {\n const body = await data.text();\n sseResponse = extractSseResponse(data, body);\n resultData = body;\n } else {\n sseResponse = defaultSseResponse(resultData);\n }\n\n // Real new steps (e.g. from `Promise.race` where the winning branch\n // completed before losing branches reported). The function isn't truly\n // done: the executor needs to memoize these and call back. Don't close\n // the stream. `filterNewSteps` excludes lazy ops, so `attachLazyOps`\n // is what folds them in here.\n const newSteps = await this.filterNewSteps(\n Array.from(this.state.steps.values()),\n );\n\n if (newSteps?.length) {\n return this.attachLazyOps({\n type: \"steps-found\",\n ctx: this.fnArg,\n ops: this.ops,\n steps: newSteps as [OutgoingOp, ...OutgoingOp[]],\n });\n }\n\n // Function is truly done. Close the stream with a terminal\n // succeeded event.\n await this.streamCloseSucceeded(sseResponse);\n\n // If stream was never activated, start the POST now so the\n // client waiting at the GET endpoint gets the result event.\n if (!this.streamTools.activated) {\n this.postCheckpointStream();\n }\n\n // We need to do this even here for async, as we could be returning\n // data from an API endpoint, even if we were triggered async.\n if (this.options.createResponse) {\n data = await this.options.createResponse(jsonResponse(resultData));\n }\n\n // Terminal. `attachLazyOps` bundles any buffered ops with\n // `RunComplete` so the executor finalizes in one round-trip.\n return this.attachLazyOps(this.transformOutput({ data }));\n },\n\n /**\n * The user's function has thrown an error.\n */\n \"function-rejected\": async (checkpoint) => {\n const isFinal = !this.retriability(checkpoint.error);\n\n if (isFinal) {\n await this.streamCloseFailed(errorMessage(checkpoint.error));\n } else {\n // Retryable error — suppress the result event; the run will retry.\n await this.streamEnd();\n }\n\n if (!this.streamTools.activated) {\n this.postCheckpointStream();\n }\n\n // Buffered defer ops were recorded by user code that ran successfully\n // before the throw, so they should ship even when the function\n // ultimately errors. `attachLazyOps` bundles them alongside the\n // terminal error op (mirroring the `function-resolved` bundling for\n // `RunComplete`). The asyncCheckpointing rejection path achieves the\n // same intent via a final checkpoint call; pure-async has no\n // checkpoint channel, so we ship via the response body.\n return this.attachLazyOps(\n this.transformOutput({ error: checkpoint.error }),\n );\n },\n\n /**\n * We've found one or more steps. Here we may want to run a step or report\n * them back to Inngest.\n */\n \"steps-found\": async ({ steps }) => {\n const stepResult = await this.tryExecuteStep(steps);\n if (!stepResult) {\n return maybeReturnNewSteps();\n }\n\n // If the step's handler buffered any lazy ops (e.g. `defer` calls),\n // ship them alongside the step result instead of losing them in the\n // `step-ran` → single-op response.\n if (this.state.lazyOps.length === 0) {\n return stepRanHandler(stepResult);\n }\n\n const transformed = await stepRanHandler(stepResult);\n if (transformed.type !== \"step-ran\") {\n return transformed;\n }\n\n return this.attachLazyOps({\n type: \"steps-found\",\n ctx: transformed.ctx,\n ops: transformed.ops,\n steps: [transformed.step],\n });\n },\n\n /**\n * While trying to find a step that Inngest has told us to run, we've\n * timed out or have otherwise decided that it doesn't exist.\n */\n \"step-not-found\": ({ step }) => {\n const { foundSteps, totalFoundSteps } = this.getStepNotFoundDetails();\n return {\n type: \"step-not-found\",\n ctx: this.fnArg,\n ops: this.ops,\n step,\n foundSteps,\n totalFoundSteps,\n };\n },\n\n \"checkpointing-runtime-reached\": () => {\n throw new Error(\n \"Checkpointing maximum runtime reached, but this is not in a checkpointing step mode. This is a bug in the Inngest SDK.\",\n );\n },\n\n \"checkpointing-buffer-interval-reached\": () => {\n throw new Error(\n \"Checkpointing maximum buffer interval reached, but this is not in a checkpointing step mode. This is a bug in the Inngest SDK.\",\n );\n },\n };\n\n const asyncCheckpointingHandlers: CheckpointHandlers[StepMode.AsyncCheckpointing] =\n {\n \"\": commonCheckpointHandler,\n \"function-resolved\": async (checkpoint, i) => {\n // Capture buffered lazy ops up front so the underlying handler's\n // `maybeReturnNewSteps` doesn't ship them as a standalone\n // `steps-found` batch — we want to bundle them with the final\n // response (alongside `RunComplete` or any newly-discovered\n // real steps) so the executor doesn't need an extra round-trip.\n const lazyOps = this.state.lazyOps.drain();\n\n const output = await asyncHandlers[\"function-resolved\"](\n checkpoint,\n i,\n );\n\n if (output?.type === \"function-resolved\") {\n const steps = [\n ...this.state.checkpointingStepBuffer,\n ...lazyOps,\n {\n op: StepOpCode.RunComplete,\n id: hashId(RUN_COMPLETE_STEP_ID),\n data: output.data,\n },\n ];\n\n if (isNonEmpty(steps)) {\n return {\n type: \"steps-found\",\n ctx: output.ctx,\n ops: output.ops,\n steps,\n };\n }\n }\n\n if (output?.type === \"steps-found\" && lazyOps.length) {\n return {\n ...output,\n steps: [...output.steps, ...lazyOps] as [\n OutgoingOp,\n ...OutgoingOp[],\n ],\n };\n }\n\n return output;\n },\n \"function-rejected\": async (checkpoint) => {\n // If we have buffered steps — either from step results or from\n // lazy ops (e.g. `defer` called before the error) — attempt\n // checkpointing them first. The buffered defer ops were recorded\n // by user code that ran successfully, so they should ship even\n // when the function ultimately errors.\n if (\n this.state.checkpointingStepBuffer.length ||\n this.state.lazyOps.length > 0\n ) {\n const fallback = await attemptCheckpointAndResume(\n undefined,\n false,\n true,\n );\n if (fallback) {\n return fallback;\n }\n }\n\n return await this.transformOutput({ error: checkpoint.error });\n },\n \"step-not-found\": asyncHandlers[\"step-not-found\"],\n \"steps-found\": async ({ steps }) => {\n // Note that if we have a requested run step, we'll never be\n // checkpointing, as that's an async parallel execution mode.\n\n // Break found steps in to { stepsToResume, newSteps }\n const { stepsToResume, newSteps } = steps.reduce(\n (acc, step) => {\n if (!step.hasStepState) {\n acc.newSteps.push(step);\n } else if (!step.fulfilled) {\n acc.stepsToResume.push(step);\n }\n\n return acc;\n },\n { stepsToResume: [], newSteps: [] } as {\n stepsToResume: FoundStep[];\n newSteps: FoundStep[];\n },\n );\n\n this.devDebug(\"split found steps in to:\", {\n stepsToResume: stepsToResume.length,\n newSteps: newSteps.length,\n });\n\n // Got new steps? Exit early.\n if (!this.options.requestedRunStep && newSteps.length) {\n // If the checkpointing runtime has been exceeded, don't execute new\n // steps in-process. Flush any buffered steps and return the new\n // steps to the executor so it can schedule them.\n if (this.state.checkpointingRuntimeExceeded) {\n if (this.state.checkpointingStepBuffer.length) {\n const fallback = await attemptCheckpointAndResume(\n undefined,\n false,\n true,\n );\n if (fallback) {\n return fallback;\n }\n }\n\n return maybeReturnNewSteps();\n }\n\n const stepResult = await this.tryExecuteStep(newSteps);\n if (stepResult) {\n this.devDebug(`executed step \"${stepResult.id}\" successfully`);\n\n // We executed a step!\n //\n // We know that because we're in this mode, we're always free to\n // checkpoint and continue if we ran a step and it was successful.\n if (stepResult.error) {\n // Flush buffered steps before falling back to async,\n // so previously-successful steps aren't lost.\n if (this.state.checkpointingStepBuffer.length) {\n const fallback = await attemptCheckpointAndResume(\n undefined,\n false,\n true,\n );\n if (fallback) {\n return fallback;\n