UNPKG

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,442 lines (1,440 loc) • 49.8 kB
import { ExecutionVersion, defaultMaxRetries, envKeys, forwardedHeaders, headerKeys, internalEvents, logPrefix, probe, queryKeys, syncKind } from "../helpers/consts.js"; import { enumFromValue } from "../helpers/enum.js"; import { version } from "../version.js"; import { allProcessEnv, checkModeConfiguration, getPlatformName, inngestHeaders, parseAsBoolean } from "../helpers/env.js"; import { rethrowError, serializeError } from "../helpers/errors.js"; import { AsyncResponseType, StepMode, StepOpCode, functionConfigSchema, inBandSyncRequestBodySchema } from "../types.js"; import { PREFERRED_ASYNC_EXECUTION_VERSION } from "./execution/InngestExecution.js"; import { isRecord } from "../helpers/types.js"; import { warnOnce } from "../helpers/log.js"; import { createVersionSchema, fetchAllFnData, parseFnData, undefinedToNull } from "../helpers/functions.js"; import { fetchWithAuthFallback, signDataWithKey } from "../helpers/net.js"; import { runAsPromise } from "../helpers/promises.js"; import { ServerTiming } from "../helpers/ServerTiming.js"; import { hashEventKey, hashSigningKey, stringify } from "../helpers/strings.js"; import { createStream } from "../helpers/stream.js"; import { getAsyncCtx } from "./execution/als.js"; import { InngestFunction } from "./InngestFunction.js"; import { buildWrapRequestChain } from "./middleware/utils.js"; import { internalLoggerSymbol } from "./Inngest.js"; import { _internals } from "./execution/engine.js"; import { z } from "zod/v3"; //#region src/components/InngestCommHandler.ts const internalServerErrorResponse = { body: stringify({ code: "internal_server_error" }), headers: { "Content-Type": "application/json" }, status: 500, version: void 0 }; /** * A schema for the response from Inngest when registering. */ const registerResSchema = z.object({ status: z.number().default(200), skipped: z.boolean().optional().default(false), modified: z.boolean().optional().default(false), error: z.string().default("Successfully registered") }); /** * `InngestCommHandler` is a class for handling incoming requests from Inngest (or * Inngest's tooling such as the dev server or CLI) and taking appropriate * action for any served functions. * * All handlers (Next.js, RedwoodJS, Remix, Deno Fresh, etc.) are created using * this class; the exposed `serve` function will - most commonly - create an * instance of `InngestCommHandler` and then return `instance.createHandler()`. * * See individual parameter details for more information, or see the * source code for an existing handler, e.g. * {@link https://github.com/inngest/inngest-js/blob/main/src/next.ts} * * @example * ``` * // my-custom-handler.ts * import { * InngestCommHandler, * type ServeHandlerOptions, * } from "./components/InngestCommHandler"; * * export const serve = (options: ServeHandlerOptions) => { * const handler = new InngestCommHandler({ * frameworkName: "my-custom-handler", * ...options, * handler: (req: Request) => { * return { * body: () => req.json(), * headers: (key) => req.headers.get(key), * method: () => req.method, * url: () => new URL(req.url, `https://${req.headers.get("host") || ""}`), * transformResponse: ({ body, status, headers }) => { * return new Response(body, { status, headers }); * }, * }; * }, * }); * * return handler.createHandler(); * }; * ``` * * @public */ var InngestCommHandler = class { /** * The handler specified during instantiation of the class. */ handler; /** * The URL of the Inngest function registration endpoint. */ inngestRegisterUrl; /** * The name of the framework this handler is designed for. Should be * lowercase, alphanumeric characters inclusive of `-` and `/`. */ frameworkName; /** * The origin used to access the Inngest serve endpoint, e.g.: * * "https://myapp.com" or "https://myapp.com:1234" * * By default, the library will try to infer this using request details such * as the "Host" header and request path, but sometimes this isn't possible * (e.g. when running in a more controlled environments such as AWS Lambda or * when dealing with proxies/redirects). * * Provide the custom origin here to ensure that the path is reported * correctly when registering functions with Inngest. * * To also provide a custom path, use `servePath`. */ _serveOrigin; /** * The path to the Inngest serve endpoint. e.g.: * * "/some/long/path/to/inngest/endpoint" * * By default, the library will try to infer this using request details such * as the "Host" header and request path, but sometimes this isn't possible * (e.g. when running in a more controlled environments such as AWS Lambda or * when dealing with proxies/redirects). * * Provide the custom path (excluding the hostname) here to ensure that the * path is reported correctly when registering functions with Inngest. * * To also provide a custom hostname, use `serveOrigin`. */ _servePath; streaming; /** * A private collection of just Inngest functions, as they have been passed * when instantiating the class. */ rawFns; client; /** * A private collection of functions that are being served. This map is used * to find and register functions when interacting with Inngest Cloud. */ fns = {}; env = allProcessEnv(); allowExpiredSignatures; _options; skipSignatureValidation; constructor(options) { this._options = options; /** * v2 -> v3 migration error. * TODO: do we need to handle people going from v2->v4? * * If a serve handler is passed a client as the first argument, it'll be * spread in to these options. We should be able to detect this by picking * up a unique property on the object. */ if (Object.hasOwn(options, "eventKey")) throw new Error(`${logPrefix} You've passed an Inngest client as the first argument to your serve handler. This is no longer supported in v3; please pass the Inngest client as the \`client\` property of an options object instead. See https://www.inngest.com/docs/sdk/migration`); this.frameworkName = options.frameworkName; this.client = options.client; this.handler = options.handler; /** * Provide a hidden option to allow expired signatures to be accepted during * testing. */ this.allowExpiredSignatures = Boolean(arguments["0"]?.__testingAllowExpiredSignatures); this.rawFns = options.functions?.filter(Boolean) ?? []; if (this.rawFns.length !== (options.functions ?? []).length) this.client[internalLoggerSymbol].warn(`Some functions passed to serve() are undefined and misconfigured. Please check your imports.`); this.fns = this.rawFns.reduce((acc, fn) => { const configs = fn["getConfig"]({ baseUrl: new URL("https://example.com"), appPrefix: this.client.id }); const fns = configs.reduce((acc$1, { id }, index) => { return { ...acc$1, [id]: { fn, onFailure: Boolean(index) } }; }, {}); configs.forEach(({ id }) => { if (acc[id]) throw new Error(`Duplicate function ID "${id}"; please change a function's name or provide an explicit ID to avoid conflicts.`); }); return { ...acc, ...fns }; }, {}); this.inngestRegisterUrl = new URL("/fn/register", this.client.apiBaseUrl); this._serveOrigin = options.serveOrigin || this.env[envKeys.InngestServeOrigin]; this._servePath = options.servePath || this.env[envKeys.InngestServePath]; this.skipSignatureValidation = options.skipSignatureValidation || false; const defaultStreamingOption = false; this.streaming = z.boolean().default(defaultStreamingOption).catch((ctx) => { this.client[internalLoggerSymbol].warn({ input: ctx.input, default: defaultStreamingOption }, "Unknown streaming option; using default"); return defaultStreamingOption; }).parse(options.streaming || parseAsBoolean(this.env[envKeys.InngestStreaming])); this.client.setEnvVars(this.env); } /** * The origin used to access the Inngest serve endpoint, e.g.: * * "https://myapp.com" * * By default, the library will try to infer this using request details such * as the "Host" header and request path, but sometimes this isn't possible * (e.g. when running in a more controlled environments such as AWS Lambda or * when dealing with proxies/redirects). * * Provide the custom origin here to ensure that the path is reported * correctly when registering functions with Inngest. * * To also provide a custom path, use `servePath`. */ get serveOrigin() { if (this._serveOrigin) return this._serveOrigin; const envOrigin = this.env[envKeys.InngestServeOrigin]; if (envOrigin) return envOrigin; const envHost = this.env[envKeys.InngestServeHost]; if (envHost) { warnOnce(this.client[internalLoggerSymbol], "serve-host-deprecated", "INNGEST_SERVE_HOST is deprecated; use INNGEST_SERVE_ORIGIN instead"); return envHost; } } /** * The path to the Inngest serve endpoint. e.g.: * * "/some/long/path/to/inngest/endpoint" * * By default, the library will try to infer this using request details such * as the "Host" header and request path, but sometimes this isn't possible * (e.g. when running in a more controlled environments such as AWS Lambda or * when dealing with proxies/redirects). * * Provide the custom path (excluding the hostname) here to ensure that the * path is reported correctly when registering functions with Inngest. * * To also provide a custom hostname, use `serveOrigin`. * * This is a getter to encourage checking the environment for the serve path * each time it's accessed, as it may change during execution. */ get servePath() { return this._servePath || this.env[envKeys.InngestServePath]; } get hashedEventKey() { if (!this.client.eventKey) return; return hashEventKey(this.client.eventKey); } get hashedSigningKey() { if (!this.client.signingKey) return; return hashSigningKey(this.client.signingKey); } get hashedSigningKeyFallback() { if (!this.client.signingKeyFallback) return; return hashSigningKey(this.client.signingKeyFallback); } /** * Returns a `boolean` representing whether this handler will stream responses * or not. Takes into account the user's preference and the platform's * capabilities. */ async shouldStream(actions) { if (await actions.queryStringWithDefaults("testing for probe", queryKeys.Probe) !== void 0) return false; const envStreaming = this.env[envKeys.InngestStreaming]; if (envStreaming === "allow" || envStreaming === "force") warnOnce(this.client[internalLoggerSymbol], "streaming-allow-force-deprecated", { value: envStreaming }, `INNGEST_STREAMING="${envStreaming}" is deprecated; set INNGEST_STREAMING=true instead`); const streamingRequested = this.streaming === true || parseAsBoolean(this.env[envKeys.InngestStreaming]) === true || envStreaming === "allow" || envStreaming === "force"; if (!actions.transformStreamingResponse) { if (streamingRequested) throw new Error(`${logPrefix} Streaming has been forced but the serve handler does not support streaming. Please either remove the streaming option or use a serve handler that supports streaming.`); return false; } return streamingRequested; } async isInngestReq(actions) { const reqMessage = `checking if this is an Inngest request`; const [runId, signature] = await Promise.all([actions.headers(reqMessage, headerKeys.InngestRunId), actions.headers(reqMessage, headerKeys.Signature)]); return Boolean(runId && typeof signature === "string"); } /** * Start handling a request, setting up environments, modes, and returning * some helpers. */ async initRequest(...args) { const timer = new ServerTiming(this.client[internalLoggerSymbol]); const actions = await this.getActions(timer, ...args); const [env, expectedServerKind] = await Promise.all([actions.env?.("starting to handle request"), actions.headers("checking expected server kind", headerKeys.InngestServerKind)]); this.env = { ...allProcessEnv(), ...env }; this.client.setEnvVars(this.env); const headerPromises = forwardedHeaders.map(async (header) => { return { header, value: await actions.headers(`fetching ${header} for forwarding`, header) }; }); const headersToForwardP = Promise.all(headerPromises).then((fetchedHeaders) => { return fetchedHeaders.reduce((acc, { header, value }) => { if (value) acc[header] = value; return acc; }, {}); }); const getHeaders = async () => ({ ...inngestHeaders({ env: this.env, framework: this.frameworkName, client: this.client, expectedServerKind: expectedServerKind || void 0, extras: { "Server-Timing": timer.getHeader() } }), ...await headersToForwardP }); return { timer, actions, getHeaders }; } /** * `createSyncHandler` should be used to return a type-equivalent version of * the `handler` specified during instantiation. */ createSyncHandler() { return (handler) => { return this.wrapHandler((async (...args) => { const reqInit = await this.initRequest(...args); const fn = new InngestFunction(this.client, { id: this._options.syncOptions?.functionId ?? "", retries: this._options.syncOptions?.retries ?? defaultMaxRetries }, () => handler(...args)); if (await this.isInngestReq(reqInit.actions)) return this.handleAsyncRequest({ ...reqInit, forceExecution: true, args, fns: [fn] }); return this.handleSyncRequest({ ...reqInit, args, asyncMode: this._options.syncOptions?.asyncResponse ?? AsyncResponseType.Redirect, asyncRedirectUrl: this._options.syncOptions?.asyncRedirectUrl, fn }); })); }; } /** * `createHandler` should be used to return a type-equivalent version of the * `handler` specified during instantiation. * * @example * ``` * // my-custom-handler.ts * import { * InngestCommHandler, * type ServeHandlerOptions, * } from "./components/InngestCommHandler"; * * export const serve = (options: ServeHandlerOptions) => { * const handler = new InngestCommHandler({ * frameworkName: "my-custom-handler", * ...options, * handler: (req: Request) => { * return { * body: () => req.json(), * headers: (key) => req.headers.get(key), * method: () => req.method, * url: () => new URL(req.url, `https://${req.headers.get("host") || ""}`), * transformResponse: ({ body, status, headers }) => { * return new Response(body, { status, headers }); * }, * }; * }, * }); * * return handler.createHandler(); * }; * ``` */ createHandler() { return this.wrapHandler((async (...args) => { return this.handleAsyncRequest({ ...await this.initRequest(...args), args }); })); } /** * Given a set of actions that let us access the incoming request, create an * event that repesents a run starting from an HTTP request. */ async createHttpEvent(actions, fn) { const reason = "creating sync event"; const contentTypePromise = actions.headers(reason, headerKeys.ContentType).then((v) => v ?? ""); const ipPromise = actions.headers(reason, headerKeys.ForwardedFor).then((v) => { if (v) return v; return actions.headers(reason, headerKeys.RealIp).then((v$1) => v$1 ?? ""); }); const methodPromise = actions.method(reason); const urlPromise = actions.url(reason).then((v) => this.reqUrl(v)); const domainPromise = urlPromise.then((url) => `${url.protocol}//${url.host}`); const pathPromise = urlPromise.then((url) => url.pathname); const queryParamsPromise = urlPromise.then((url) => url.searchParams.toString()); const bodyPromise = actions.body(reason).then((body$1) => { return typeof body$1 === "string" ? body$1 : stringify(body$1); }); const [contentType, domain, ip, method, path, queryParams, body] = await Promise.all([ contentTypePromise, domainPromise, ipPromise, methodPromise, pathPromise, queryParamsPromise, bodyPromise ]); return { name: internalEvents.HttpRequest, data: { content_type: contentType, domain, ip, method, path, query_params: queryParams, body, fn: fn.id() } }; } async handleSyncRequest({ timer, actions, fn, asyncMode, asyncRedirectUrl, args }) { if (!actions.experimentalTransformSyncResponse) throw new Error("This platform does not support synchronous Inngest function executions."); if (await getAsyncCtx()) throw new Error("We already seem to be in the context of an Inngest execution, but didn't expect to be. Did you already wrap this handler?"); const { ulid } = await import("ulid"); const runId = ulid(); const event = await this.createHttpEvent(actions, fn); const exeVersion = ExecutionVersion.V2; const result = await fn["createExecution"]({ partialOptions: { client: this.client, data: { runId, event, attempt: 0, events: [event], maxAttempts: fn.opts.retries ?? defaultMaxRetries }, runId, headers: {}, reqArgs: args, stepCompletionOrder: [], stepState: {}, disableImmediateExecution: false, isFailureHandler: false, timer, createResponse: (data) => actions.experimentalTransformSyncResponse("creating sync execution", data).then((res) => ({ ...res, version: exeVersion })), stepMode: StepMode.Sync } }).start(); const resultHandler = { "step-not-found": () => { throw new Error("We should not get the result 'step-not-found' when checkpointing. This is a bug in the `inngest` SDK"); }, "steps-found": () => { throw new Error("We should not get the result 'steps-found' when checkpointing. This is a bug in the `inngest` SDK"); }, "step-ran": () => { throw new Error("We should not get the result 'step-ran' when checkpointing. This is a bug in the `inngest` SDK"); }, "function-rejected": (result$1) => { return actions.transformResponse("creating sync error response", { status: result$1.retriable ? 500 : 400, headers: { "Content-Type": "application/json", [headerKeys.NoRetry]: result$1.retriable ? "false" : "true", ...typeof result$1.retriable === "string" ? { [headerKeys.RetryAfter]: result$1.retriable } : {} }, version: exeVersion, body: stringify(undefinedToNull(result$1.error)) }); }, "function-resolved": ({ data }) => { return data; }, "change-mode": async ({ token }) => { switch (asyncMode) { case AsyncResponseType.Redirect: { let redirectUrl; if (asyncRedirectUrl) if (typeof asyncRedirectUrl === "function") redirectUrl = await asyncRedirectUrl({ runId, token }); else { const baseUrl = await actions.url("getting request origin"); const url = new URL(asyncRedirectUrl, baseUrl.origin); url.searchParams.set("runId", runId); url.searchParams.set("token", token); redirectUrl = url.toString(); } else redirectUrl = await this.client["inngestApi"]["getTargetUrl"](`/v1/http/runs/${runId}/output?token=${token}`).then((url) => url.toString()); return actions.transformResponse("creating sync->async redirect response", { status: 302, headers: { [headerKeys.Location]: redirectUrl }, version: exeVersion, body: "" }); } case AsyncResponseType.Token: return actions.transformResponse("creating sync->async token response", { status: 200, headers: {}, version: exeVersion, body: stringify({ run_id: runId, token }) }); default: break; } throw new Error("Not implemented: change-mode"); } }[result.type]; if (!resultHandler) throw new Error(`No handler for execution result type: ${result.type}. This is a bug in the \`inngest\` SDK`); return resultHandler(result); } async handleAsyncRequest({ timer, actions, args, getHeaders, forceExecution, fns }) { if (forceExecution && !actions.experimentalTransformSyncResponse) throw new Error("This platform does not support async executions in Inngest for APIs."); const methodP = actions.method("starting to handle request"); const [signature, method, body] = await Promise.all([ actions.headers("checking signature for request", headerKeys.Signature).then((headerSignature) => { return headerSignature ?? void 0; }), methodP, methodP.then(async (method$1) => { if (method$1 === "POST" || method$1 === "PUT") { const body$1 = await actions.body(`checking body for request signing as method is ${method$1}`); if (!body$1) return ""; if (typeof body$1 === "string") return JSON.parse(body$1); return body$1; } return ""; }) ]); const signatureValidation = this.validateSignature(signature, body); const mwInstances = this.client.middleware.map((Cls) => new Cls({ client: this.client })); /** * Prepares an action response by merging returned data to provide * trailing information such as `Server-Timing` headers. * * It should always prioritize the headers returned by the action, as they * may contain important information such as `Content-Type`. */ const prepareActionRes = async (res) => { const headers = { ...await getHeaders(), ...res.headers, ...res.version === null ? {} : { [headerKeys.RequestVersion]: (res.version ?? PREFERRED_ASYNC_EXECUTION_VERSION).toString() } }; let signature$1; try { signature$1 = await signatureValidation.then(async (result) => { if (!result.success || !result.keyUsed) return; return await this.getResponseSignature(result.keyUsed, res.body); }); } catch (err) { return { ...res, headers, body: stringify(serializeError(err)), status: 500 }; } if (signature$1) headers[headerKeys.Signature] = signature$1; return { ...res, headers }; }; let actionResponseVersion; const handleAndPrepare = async () => { const rawRes = await timer.wrap("action", () => this.handleAction({ actions, timer, getHeaders, reqArgs: args, signatureValidation, body, method, forceExecution: Boolean(forceExecution), fns, mwInstances })); actionResponseVersion = rawRes.version; return prepareActionRes(rawRes); }; let chainResult; if (method === "POST") { const url = await actions.url("building requestInfo for middleware"); const fnId = url.searchParams.get(queryKeys.FnId); const matchedFn = fnId ? this.fns[fnId] : void 0; const fnMw = matchedFn?.fn?.opts?.middleware ?? []; mwInstances.push(...fnMw.map((Cls) => { return new Cls({ client: this.client }); })); const fn = matchedFn?.fn ?? null; const requestInfo = { headers: Object.freeze({ ...await getHeaders() }), method, url, body: () => Promise.resolve(body) }; let runId = ""; if (isRecord(body) && isRecord(body.ctx) && body.ctx.run_id && typeof body.ctx.run_id === "string") runId = body.ctx.run_id; const innerHandler = async () => { const prepared = await handleAndPrepare(); return { status: prepared.status, headers: prepared.headers, body: prepared.body }; }; chainResult = buildWrapRequestChain({ fn, handler: innerHandler, middleware: mwInstances, requestArgs: args, requestInfo, runId })(); } else chainResult = handleAndPrepare().then((prepared) => ({ status: prepared.status, headers: prepared.headers, body: prepared.body })); const safeChainResult = chainResult.catch((err) => ({ status: 500, headers: { "Content-Type": "application/json" }, body: stringify({ type: "internal", ...serializeError(err) }) })); let shouldStream; try { shouldStream = await this.shouldStream(actions); } catch (err) { return actions.transformResponse("sending back response", { status: 500, headers: { ...await getHeaders(), "Content-Type": "application/json" }, body: stringify(serializeError(err)), version: void 0 }); } if (shouldStream) { if (await actions.method("starting streaming response") === "POST") { const { stream, finalize } = await createStream(); /** * Errors are handled by `handleAction` here to ensure that an * appropriate response is always given. */ safeChainResult.then((res) => { return finalize(Promise.resolve({ ...res, version: actionResponseVersion })); }); return timer.wrap("res", async () => { return actions.transformStreamingResponse?.("starting streaming response", { status: 201, headers: await getHeaders(), body: stream, version: null }); }); } } return timer.wrap("res", async () => { return safeChainResult.then((res) => { return actions.transformResponse("sending back response", { ...res, version: actionResponseVersion }); }); }); } async getActions(timer, ...args) { /** * Used for testing, allow setting action overrides externally when * calling the handler. Always search the final argument. */ const lastArg = args[args.length - 1]; const actionOverrides = typeof lastArg === "object" && lastArg !== null && "actionOverrides" in lastArg && typeof lastArg["actionOverrides"] === "object" && lastArg["actionOverrides"] !== null ? lastArg["actionOverrides"] : {}; /** * We purposefully `await` the handler, as it could be either sync or * async. */ const rawActions = { ...await timer.wrap("handler", () => this.handler(...args)).catch(rethrowError("Serve handler failed to run")), ...actionOverrides }; /** * Mapped promisified handlers from userland `serve()` function mixed in * with some helpers. */ const actions = { ...Object.entries(rawActions).reduce((acc, [key, value]) => { if (typeof value !== "function") return acc; return { ...acc, [key]: (reason, ...args$1) => { const errMessage = [`Failed calling \`${key}\` from serve handler`, reason].filter(Boolean).join(" when "); const fn = () => value(...args$1); return runAsPromise(fn).catch(rethrowError(errMessage)).catch((err) => { this.client[internalLoggerSymbol].error({ err }, errMessage); throw err; }); } }; }, {}), queryStringWithDefaults: async (reason, key) => { const url = await actions.url(reason); return await actions.queryString?.(reason, key, url) || url.searchParams.get(key) || void 0; }, ...actionOverrides }; return actions; } wrapHandler(handler) { /** * Some platforms check (at runtime) the length of the function being used * to handle an endpoint. If this is a variadic function, it will fail that * check. * * Therefore, we expect the arguments accepted to be the same length as the * `handler` function passed internally. * * We also set a name to avoid a common useless name in tracing such as * `"anonymous"` or `"bound function"`. * * https://github.com/getsentry/sentry-javascript/issues/3284 */ Object.defineProperties(handler, { name: { value: "InngestHandler" }, length: { value: this.handler.length } }); return handler; } /** * Given a set of functions to check if an action is available from the * instance's handler, enact any action that is found. * * This method can fetch varying payloads of data, but ultimately is the place * where _decisions_ are made regarding functionality. * * For example, if we find that we should be viewing the UI, this function * will decide whether the UI should be visible based on the payload it has * found (e.g. env vars, options, etc). */ async handleAction({ actions, timer, getHeaders, reqArgs, signatureValidation, body: rawBody, method, forceExecution, fns, mwInstances }) { if (!this.checkModeConfiguration()) return internalServerErrorResponse; const isMissingBody = !rawBody; let body = rawBody; try { let url = await actions.url("starting to handle request"); if (method === "POST" || forceExecution) { if (!forceExecution && isMissingBody) { this.client[internalLoggerSymbol].error("Missing body when executing, possibly due to missing request body middleware"); return { status: 500, headers: { "Content-Type": "application/json" }, body: stringify(serializeError(/* @__PURE__ */ new Error("Missing request body when executing, possibly due to missing request body middleware"))), version: void 0 }; } const validationResult = await signatureValidation; if (!validationResult.success) return { status: 401, headers: { "Content-Type": "application/json" }, body: stringify(serializeError(validationResult.err)), version: void 0 }; let fn; let fnId; if (forceExecution) { fn = fns?.length && fns[0] ? { fn: fns[0], onFailure: false } : Object.values(this.fns)[0]; fnId = fn?.fn.id(); let die = false; const dieHeader = await actions.headers("getting step plan force control for forced execution", headerKeys.InngestForceStepPlan); if (dieHeader) { const parsed = parseAsBoolean(dieHeader); if (typeof parsed === "boolean") die = parsed; else this.client[internalLoggerSymbol].warn({ header: headerKeys.InngestForceStepPlan, value: dieHeader }, "Invalid boolean header value; defaulting to false"); } body = { event: {}, events: [], steps: {}, version: PREFERRED_ASYNC_EXECUTION_VERSION, sdkDecided: true, ctx: { attempt: 0, disable_immediate_execution: die, use_api: true, max_attempts: Infinity, run_id: await actions.headers("getting run ID for forced execution", headerKeys.InngestRunId), stack: { stack: [], current: 0 } } }; } else { const rawProbe = await actions.queryStringWithDefaults("testing for probe", queryKeys.Probe); if (rawProbe) { const probe$1 = enumFromValue(probe, rawProbe); if (!probe$1) return { status: 400, headers: { "Content-Type": "application/json" }, body: stringify(serializeError(/* @__PURE__ */ new Error(`Unknown probe "${rawProbe}"`))), version: void 0 }; return { [probe.Trust]: () => ({ status: 200, headers: { "Content-Type": "application/json" }, body: "", version: void 0 }) }[probe$1](); } fnId = await actions.queryStringWithDefaults("processing run request", queryKeys.FnId); if (!fnId) throw new Error("No function ID found in async request"); fn = this.fns[fnId]; } if (typeof fnId === "undefined" || !fn) throw new Error("No function ID found in request"); const stepId = await actions.queryStringWithDefaults("processing run request", queryKeys.StepId) || await actions.headers("processing run request", headerKeys.InngestStepId) || null; let headerReqVersion; try { const rawVersionHeader = await actions.headers("processing run request", headerKeys.RequestVersion); if (rawVersionHeader && Number.isFinite(Number(rawVersionHeader))) { const res = createVersionSchema(this.client[internalLoggerSymbol]).parse(Number(rawVersionHeader)); if (!res.sdkDecided) headerReqVersion = res.version; } } catch {} const resolvedHeaders = await getHeaders(); const { version: version$1, result } = this.runStep({ functionId: fnId, data: body, stepId, timer, reqArgs, headers: resolvedHeaders, fn, forceExecution, actions, headerReqVersion, requestInfo: { headers: Object.freeze({ ...resolvedHeaders }), method, url, body: () => Promise.resolve(body) }, mwInstances }); const stepOutput = await result; /** * Functions can return `undefined`, but we'll always convert this to * `null`, as this is appropriately serializable by JSON. */ const opDataUndefinedToNull = (op) => { op.data = undefinedToNull(op.data); return op; }; const handler = { "function-rejected": (result$1) => { return { status: result$1.retriable ? 500 : 400, headers: { "Content-Type": "application/json", [headerKeys.NoRetry]: result$1.retriable ? "false" : "true", ...typeof result$1.retriable === "string" ? { [headerKeys.RetryAfter]: result$1.retriable } : {} }, body: stringify(undefinedToNull(result$1.error)), version: version$1 }; }, "function-resolved": (result$1) => { if (forceExecution) { const runCompleteOp = { id: _internals.hashId("complete"), op: StepOpCode.RunComplete, data: undefinedToNull(result$1.data) }; return { status: 206, headers: { "Content-Type": "application/json" }, body: stringify(runCompleteOp), version: version$1 }; } return { status: 200, headers: { "Content-Type": "application/json" }, body: stringify(undefinedToNull(result$1.data)), version: version$1 }; }, "step-not-found": (result$1) => { let error = `Could not find step "${result$1.step.displayName || result$1.step.id}" to run; timed out.`; if (result$1.foundSteps.length > 0) { const foundStepsSummary = result$1.foundSteps.map((step) => { return `${step.displayName || step.id} (${step.id})`; }).join("\n"); error = `${error} Found new steps: \n${foundStepsSummary}.`; } if (result$1.totalFoundSteps > result$1.foundSteps.length) error = `${error} (showing ${result$1.foundSteps.length} of ${result$1.totalFoundSteps})`; return { status: 500, headers: { "Content-Type": "application/json", [headerKeys.NoRetry]: "false" }, body: stringify({ error, requestedStep: result$1.step.id, foundSteps: result$1.foundSteps, totalFoundSteps: result$1.totalFoundSteps }), version: version$1 }; }, "step-ran": (result$1) => { const step = opDataUndefinedToNull(result$1.step); return { status: 206, headers: { "Content-Type": "application/json", ...typeof result$1.retriable !== "undefined" ? { [headerKeys.NoRetry]: result$1.retriable ? "false" : "true", ...typeof result$1.retriable === "string" ? { [headerKeys.RetryAfter]: result$1.retriable } : {} } : {} }, body: stringify([step]), version: version$1 }; }, "steps-found": (result$1) => { const steps = result$1.steps.map(opDataUndefinedToNull); return { status: 206, headers: { "Content-Type": "application/json" }, body: stringify(steps), version: version$1 }; }, "change-mode": (result$1) => { return { status: 500, headers: { "Content-Type": "application/json", [headerKeys.NoRetry]: "true" }, body: stringify({ error: `We wanted to change mode to "${result$1.to}", but this is not supported within the InngestCommHandler. This is a bug in the Inngest SDK.` }), version: version$1 }; } }[stepOutput.type]; try { return await handler(stepOutput); } catch (err) { this.client[internalLoggerSymbol].error({ err }, "Error handling execution result"); throw err; } } const env = (await getHeaders())[headerKeys.Environment] ?? null; if (method === "GET") return { status: 200, body: stringify(await this.introspectionBody({ actions, env, signatureValidation, url })), headers: { "Content-Type": "application/json" }, version: void 0 }; if (method === "PUT") { const [deployId, inBandSyncRequested] = await Promise.all([actions.queryStringWithDefaults("processing deployment request", queryKeys.DeployId).then((deployId$1) => { return deployId$1 === "undefined" ? void 0 : deployId$1; }), Promise.resolve(parseAsBoolean(this.env[envKeys.InngestAllowInBandSync])).then((allowInBandSync) => { if (allowInBandSync !== void 0 && !allowInBandSync) return syncKind.OutOfBand; return actions.headers("processing deployment request", headerKeys.InngestSyncKind); }).then((kind) => { return kind === syncKind.InBand; })]); if (inBandSyncRequested) { if (isMissingBody) { this.client[internalLoggerSymbol].error("Missing body when syncing, possibly due to missing request body middleware"); return { status: 500, headers: { "Content-Type": "application/json" }, body: stringify(serializeError(/* @__PURE__ */ new Error("Missing request body when syncing, possibly due to missing request body middleware"))), version: void 0 }; } if (!(await signatureValidation).success) return { status: 401, body: stringify({ code: "sig_verification_failed" }), headers: { "Content-Type": "application/json" }, version: void 0 }; const res = inBandSyncRequestBodySchema.safeParse(body); if (!res.success) return { status: 400, body: stringify({ code: "invalid_request", message: res.error.message }), headers: { "Content-Type": "application/json" }, version: void 0 }; url = this.reqUrl(new URL(res.data.url)); return { status: 200, body: stringify(await this.inBandRegisterBody({ actions, deployId, env, signatureValidation, url })), headers: { "Content-Type": "application/json", [headerKeys.InngestSyncKind]: syncKind.InBand }, version: void 0 }; } const { status, message, modified } = await this.register(this.reqUrl(url), deployId, getHeaders); return { status, body: stringify({ message, modified }), headers: { "Content-Type": "application/json", [headerKeys.InngestSyncKind]: syncKind.OutOfBand }, version: void 0 }; } } catch (err) { return { status: 500, body: stringify({ type: "internal", ...serializeError(err) }), headers: { "Content-Type": "application/json" }, version: void 0 }; } this.client[internalLoggerSymbol].error({ method }, "Received unhandled HTTP method; expected POST, PUT, or GET"); return { status: 405, body: JSON.stringify({ message: `No action found; expected POST, PUT, or GET but received "${method}"`, mode: this.client.mode }), headers: {}, version: void 0 }; } runStep({ actions, functionId, stepId, data, timer, reqArgs, headers, fn, forceExecution, headerReqVersion, requestInfo, mwInstances }) { if (!fn) throw new Error(`Could not find function with ID "${functionId}"`); const immediateFnData = parseFnData(data, headerReqVersion, this.client[internalLoggerSymbol]); const { sdkDecided } = immediateFnData; let version$1 = ExecutionVersion.V2; if (version$1 === ExecutionVersion.V2 && sdkDecided && fn.fn["shouldOptimizeParallelism"]?.() === false) version$1 = ExecutionVersion.V1; const result = runAsPromise(async () => { const anyFnData = await fetchAllFnData({ data: immediateFnData, api: this.client["inngestApi"], logger: this.client[internalLoggerSymbol] }); if (!anyFnData.ok) throw new Error(anyFnData.error); const createResponse = forceExecution && actions.experimentalTransformSyncResponse ? (data$1) => actions.experimentalTransformSyncResponse("created sync->async response", data$1).then((res) => ({ ...res, version: version$1 })) : void 0; const { event, events, steps, ctx } = anyFnData.value; const stepState = Object.entries(steps ?? {}).reduce((acc, [id, result$1]) => { return { ...acc, [id]: result$1.type === "data" ? { id, data: result$1.data } : result$1.type === "input" ? { id, input: result$1.input } : { id, error: result$1.error } }; }, {}); const requestedRunStep = stepId === "step" ? void 0 : stepId || void 0; const checkpointingConfig = fn.fn["shouldAsyncCheckpoint"](requestedRunStep, ctx?.fn_id, Boolean(ctx?.disable_immediate_execution)); const executionOptions = { partialOptions: { client: this.client, runId: ctx?.run_id || "", stepMode: checkpointingConfig ? StepMode.AsyncCheckpointing : StepMode.Async, checkpointingConfig, data: { event, events, runId: ctx?.run_id || "", attempt: ctx?.attempt ?? 0, maxAttempts: ctx?.max_attempts }, internalFnId: ctx?.fn_id, queueItemId: ctx?.qi_id, stepState, requestedRunStep, timer, isFailureHandler: fn.onFailure, disableImmediateExecution: ctx?.disable_immediate_execution, stepCompletionOrder: ctx?.stack?.stack ?? [], reqArgs, headers, createResponse, requestInfo, middlewareInstances: mwInstances } }; return fn.fn["createExecution"](executionOptions).start(); }); return { version: version$1, result }; } configs(url) { const configs = Object.values(this.rawFns).reduce((acc, fn) => [...acc, ...fn["getConfig"]({ baseUrl: url, appPrefix: this.client.id })], []); for (const config of configs) { const check = functionConfigSchema.safeParse(config); if (!check.success) { const errors = check.error.errors.map((err) => err.message).join("; "); this.client[internalLoggerSymbol].warn({ functionId: config.id, errors }, "Invalid function config"); } } return configs; } /** * Return an Inngest serve endpoint URL given a potential `path` and `host`. * * Will automatically use the `serveOrigin` and `servePath` if they have been * set when registering. */ reqUrl(url) { let ret = new URL(url); const servePath = this.servePath || this.env[envKeys.InngestServePath]; if (servePath) ret.pathname = servePath; if (this.serveOrigin) ret = new URL(ret.pathname + ret.search, this.serveOrigin); return ret; } registerBody({ url, deployId }) { return { url: url.href, deployType: "ping", framework: this.frameworkName, appName: this.client.id, functions: this.configs(url), sdk: `js:v${version}`, v: "0.1", deployId: deployId || void 0, capabilities: { trust_probe: "v1", connect: "v1" }, appVersion: this.client.appVersion }; } async inBandRegisterBody({ actions, deployId, env, signatureValidation, url }) { const registerBody = this.registerBody({ deployId, url }); const introspectionBody = await this.introspectionBody({ actions, env, signatureValidation, url }); const body = { app_id: this.client.id, appVersion: this.client.appVersion, capabilities: registerBody.capabilities, env, framework: registerBody.framework, functions: registerBody.functions, inspection: introspectionBody, platform: getPlatformName({ ...allProcessEnv(), ...this.env }), sdk_author: "inngest", sdk_language: "", sdk_version: "", sdk: registerBody.sdk, url: registerBody.url }; if ("authentication_succeeded" in introspectionBody && introspectionBody.authentication_succeeded) { body.sdk_language = introspectionBody.sdk_language; body.sdk_version = introspectionBody.sdk_version; } return body; } async introspectionBody({ actions, env, signatureValidation, url }) { const registerBody = this.registerBody({ url: this.reqUrl(url), deployId: null }); if (!this.client.mode) throw new Error("No mode set; cannot introspect without mode"); let introspection = { extra: { native_crypto: globalThis.crypto?.subtle ? true : false }, has_event_key: this.client["eventKeySet"](), has_signing_key: Boolean(this.client.signingKey), function_count: registerBody.functions.length, mode: this.client.mode, schema_version: "2024-05-24" }; if (this.client.mode === "cloud") try { if (!(await signatureValidation).success) throw new Error("Signature validation failed"); introspection = { ...introspection, authentication_succeeded: true, api_origin: this.client.apiBaseUrl, app_id: this.client.id, capabilities: { trust_probe: "v1", connect: "v1" }, env, event_api_origin: this.client.eventBaseUrl, event_key_hash: this.hashedEventKey ?? null, extra: { ...introspection.extra, is_streaming: await this.shouldStream(actions), native_crypto: globalThis.crypto?.subtle ? true : false }, framework: this.frameworkName, sdk_language: "js", sdk_version: version, serve_origin: this.serveOrigin ?? null, serve_path: this.servePath ?? null, signing_key_fallback_hash: this.hashedSigningKeyFallback ?? null, signing_key_hash: this.hashedSigningKey ?? null }; } catch { introspection = { ...introspection }; } return introspection; } async register(url, deployId, getHeaders) { const body = this.registerBody({ url, deployId }); let res; const registerUrl = new URL(this.inngestRegisterUrl.href); if (deployId) registerUrl.searchParams.set(queryKeys.DeployId, deployId); try { res = await fetchWithAuthFallback({ authToken: this.hashedSigningKey, authTokenFallback: this.hashedSigningKeyFallback, fetch: this.client.fetch, url: registerUrl.href, options: { method: "POST", body: stringify(body), headers: { ...await getHeaders(), [headerKeys.InngestSyncKind]: syncKind.OutOfBand }, redirect: "follow" } }); } catch (err) { this.client[internalLoggerSymbol].error({ err }, "Failed to register"); return { status: 500, message: `Failed to register${err instanceof Error ? `; ${err.message}` : ""}`, modified: false }; } const raw = await res.text(); let data = {}; try { data = JSON.parse(raw); } catch (err) { this.client[internalLoggerSymbol].warn({ err }, "Couldn't unpack register response"); let message = "Failed to register"; if (err instanceof Error) message += `; ${err.message}`; message += `; status code: ${res.status}`; return { status: 500, message, modified: false }; } let status; let error; let skipped; let modified; try { ({status, error, skipped, modified} = registerResSchema.parse(data)); } catch (err) { this.client[internalLoggerSymbol].warn({ err }, "Invalid register response schema"); let message = "Failed to register"; if (err instanceof Error) message += `; ${err.message}`; message += `; status code: ${res.status}`; return { status: 500, message, modified: false }; } if (!skipped) this.client[internalLoggerSymbol].debug("Registered inngest functions"); return { status, message: error, modified }; } /** * Check that the current mode has the configuration it requires. * Returns `true` if valid, `false` if not. */ checkModeConfiguration() { this.client.setEnvVars(this.env); return checkModeConfiguration({ mode: this.client.mode, signingKey: this.client.signingKey, internalLogger: this.client[internalLoggerSymbol] }); } /** * Validate the signature of a request and return the signing key used to * validate it. */ async validateSignature(sig, body) { try { if (this.skipSignatureValidation) return { success: true, keyUsed: "" }; if (this.client.mode !== "cloud") return { success: true, keyUsed: "" }; if (!this.client.signingKey) throw new Error(`No signing key found in client options or ${envKeys.InngestSigningKey} env var. Find your keys at https://app.inngest.com/env/production/manage/signing-key`); if (!sig) throw new Error(`No ${headerKeys.Signature} provided`); return { success: true, keyUsed: await new RequestSignature(sig).verifySignature({ body, allowExpiredSignatures: this.allowExpiredSignatures, signingKey: this.client.signingKey, signingKeyFallback: this.client.signingKeyFallback, logger: this.client[internalLoggerSymbol] }) }; } catch (err) { return { success: false, err }; } } async getResponseSignature(key, body) { const now = Date.now(); return `t=${now}&s=${await signDataWithKey(body, key, now.toString(), this.client[internalLoggerSymbol])}`; } }; var RequestSignature = class { timestamp; signature; constructor(sig) { const params = new URLSearchParams(sig); this.timestamp = params.get("t") || ""; this.signature = params.get("s") || ""; if (!this.timestamp || !this.signature) throw new Error(`Invalid ${headerKeys.Signature} provided`); } hasExpired(allowExpiredSignatures) { if (allowExpiredSignatures) return false; return Date.now() - (/* @__PURE__ */ new Date(Number.parseInt(this.timestamp) * 1e3)).valueOf() > 1e3 * 60 * 5; } async #verifySignature({ body, signingKey, allowExpiredSignatures, logger }) { if (this.hasExpired(allowExpiredSignatures)) throw new Error("Signature has expired"); if (await signDataWithKey(body, signingKey, this.timestamp, logger) !== this.signature) throw new Error("Invalid signature"); } async verifySignature({ body, signingKey, signingKeyFallback, allowExpiredSignatures, logger }) { try { await this.#verifySignature({ body, signingKey, allowExpiredSignatures, logger }); return signingKey; } catch (err) { if (!signingKeyFallback) throw err; await this.#verifySignature({ body, signingKey: signingKeyFallback, allowExpiredSignatures, logger }); return signingKeyFallback; } } }; //#endregion export { InngestCommHandler }; //# sourceMappingURL=InngestCommHandler.js.map