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.

615 lines (613 loc) • 19.6 kB
import { defaultDevServerHost, defaultInngestApiBaseUrl, defaultInngestEventBaseUrl, dummyEventKey, envKeys, headerKeys } from "../helpers/consts.js"; import { allProcessEnv, getFetch, inngestHeaders, normalizeUrl, parseAsBoolean } from "../helpers/env.js"; import { fixEventKeyMissingSteps } from "../helpers/errors.js"; import { sendEventResponseSchema } from "../types.js"; import { formatLogMessage } from "../helpers/log.js"; import { retryWithBackoff } from "../helpers/promises.js"; import { stringify } from "../helpers/strings.js"; import { getAsyncCtx } from "./execution/als.js"; import { InngestApi } from "../api/api.js"; import { createEntropy } from "../helpers/crypto.js"; import { ConsoleLogger, ProxyLogger } from "../middleware/logger.js"; import { InngestFunction } from "./InngestFunction.js"; import { Middleware } from "./middleware/middleware.js"; import { UnscopedMetadataBuilder } from "./InngestMetadata.js"; import { step } from "./InngestStepTools.js"; import { buildWrapSendEventChain } from "./middleware/utils.js"; import { getSubscriptionToken, subscribe } from "./realtime/subscribe/index.js"; import { isValidatable } from "./triggers/typeHelpers.js"; //#region src/components/Inngest.ts /** * A client used to interact with the Inngest API by sending or reacting to * events. * * ```ts * const inngest = new Inngest({ id: "my-app" }); * ``` * * @public */ /** * Symbol for accessing the SDK's internal logger. Not part of the public API. * @internal */ const internalLoggerSymbol = Symbol.for("inngest.internalLogger"); var Inngest = class Inngest { get [Symbol.toStringTag]() { return Inngest.Tag; } /** * The ID of this instance, most commonly a reference to the application it * resides in. * * The ID of your client should remain the same for its lifetime; if you'd * like to change the name of your client as it appears in the Inngest UI, * change the `name` property instead. */ id; /** * Stores the options so we can remember explicit settings the user has * provided. */ options; inngestApi; _userProvidedFetch; _cachedFetch; _logger; /** * Logger for SDK internal messages. Falls back to the user's `logger` if * `internalLogger` is not provided in client options. * * @internal */ [internalLoggerSymbol]; localFns = []; /** * Middleware instances that provide simpler hooks. */ middleware; _env = {}; _appVersion; /** * @internal * Flag set by metadataMiddleware to enable step.metadata() */ experimentalMetadataEnabled = false; /** * A dummy Inngest function used in Durable Endpoints. This is necessary * because the vast majority of middleware hooks require the Inngest function. * But for Durable Endpoints, there is no Inngest function. So we need some * placeholder. */ dummyDurableEndpointFunction = null; getDummyDurableEndpointFunction() { if (this.dummyDurableEndpointFunction) return this.dummyDurableEndpointFunction; this.dummyDurableEndpointFunction = new InngestFunction(this, { id: "__proxy__", triggers: [] }, async () => {}); return this.dummyDurableEndpointFunction; } /** * Try to parse the `INNGEST_DEV` environment variable as a URL. * Returns the URL if valid, otherwise `undefined`. */ get explicitDevUrl() { const devEnvValue = this._env[envKeys.InngestDevMode]; if (typeof devEnvValue !== "string" || !devEnvValue) return; if (parseAsBoolean(devEnvValue) !== void 0) return; try { return new URL(normalizeUrl(devEnvValue)); } catch { return; } } /** * Given a default cloud URL, return the appropriate URL based on the * current mode and environment variables. * * If `INNGEST_DEV` is set to a URL, that URL is used. Otherwise, we use * the default cloud URL in cloud mode or the default dev server host in * dev mode. */ resolveDefaultUrl(cloudUrl) { const explicitDevUrl = this.explicitDevUrl; if (explicitDevUrl) return explicitDevUrl.href; return this.mode === "cloud" ? cloudUrl : defaultDevServerHost; } get apiBaseUrl() { return this.options.baseUrl || this._env[envKeys.InngestApiBaseUrl] || this._env[envKeys.InngestBaseUrl] || this.resolveDefaultUrl(defaultInngestApiBaseUrl); } get eventBaseUrl() { return this.options.baseUrl || this._env[envKeys.InngestEventApiBaseUrl] || this._env[envKeys.InngestBaseUrl] || this.resolveDefaultUrl(defaultInngestEventBaseUrl); } get eventKey() { return this.options.eventKey || this._env[envKeys.InngestEventKey] || void 0; } get fetch() { if (!this._cachedFetch) this._cachedFetch = this._userProvidedFetch ? getFetch(this[internalLoggerSymbol], this._userProvidedFetch) : getFetch(this[internalLoggerSymbol], globalThis.fetch); return this._cachedFetch; } get signingKey() { return this.options.signingKey || this._env[envKeys.InngestSigningKey]; } get signingKeyFallback() { return this.options.signingKeyFallback || this._env[envKeys.InngestSigningKeyFallback]; } get headers() { return inngestHeaders({ inngestEnv: this.options.env, env: this._env }); } /** * The base logger for this client. Passed to user functions as `ctx.logger`. */ get logger() { return this._logger; } get env() { return this.headers[headerKeys.Environment] ?? null; } get appVersion() { return this._appVersion; } /** * Access the metadata builder for updating run and step metadata. * * @example * ```ts * // Update metadata for the current run * await inngest.metadata.update({ status: "processing" }); * * // Update metadata for a different run * await inngest.metadata.run(otherRunId).update({ key: "val" }); * * ``` */ get metadata() { if (!this.experimentalMetadataEnabled) throw new Error("inngest.metadata is experimental. Enable it by adding metadataMiddleware() from \"inngest/experimental\" to your client middleware."); return new UnscopedMetadataBuilder(this); } /** * A client used to interact with the Inngest API by sending or reacting to * events. * * ```ts * const inngest = new Inngest({ id: "my-app" }); * ``` */ constructor(options) { this.options = options; const { id, logger, middleware, appVersion } = this.options; if (!id) throw new Error("An `id` must be passed to create an Inngest instance."); this.id = id; this._env = { ...allProcessEnv() }; this._userProvidedFetch = options.fetch; this.inngestApi = new InngestApi({ baseUrl: () => this.apiBaseUrl, signingKey: () => this.signingKey, signingKeyFallback: () => this.signingKeyFallback, fetch: () => this.fetch }); this._logger = logger ?? new ConsoleLogger(); this[internalLoggerSymbol] = this.options.internalLogger ?? this._logger; this.middleware = [...builtInMiddleware(this._logger), ...middleware ?? []]; for (const mw of this.middleware) mw.onRegister?.({ client: this, fn: null }); this._appVersion = appVersion; } /** * Returns a `Promise` that resolves when the app is ready and all middleware * has been initialized. */ get ready() { return Promise.resolve(); } /** * Set the environment variables for this client. This is useful if you are * passed environment variables at runtime instead of as globals and need to * update the client with those values as requests come in. */ setEnvVars(env = allProcessEnv()) { this._env = { ...this._env, ...env }; return this; } get mode() { if (typeof this.options.isDev === "boolean") return this.options.isDev ? "dev" : "cloud"; const envIsDev = parseAsBoolean(this._env[envKeys.InngestDevMode]); if (typeof envIsDev === "boolean") return envIsDev ? "dev" : "cloud"; if (this.explicitDevUrl) return "dev"; return "cloud"; } /** * Given a response from Inngest, relay the error to the caller. */ async getResponseError(response, rawBody, foundErr = "Unknown error") { let errorMessage = foundErr; if (errorMessage === "Unknown error") switch (response.status) { case 401: errorMessage = "Event key Not Found"; break; case 400: errorMessage = "Cannot process event payload"; break; case 403: errorMessage = "Forbidden"; break; case 404: errorMessage = "Event key not found"; break; case 406: errorMessage = `${JSON.stringify(await rawBody)}`; break; case 409: case 412: errorMessage = "Event transformation failed"; break; case 413: errorMessage = "Event payload too large"; break; case 500: errorMessage = "Internal server error"; break; default: try { errorMessage = await response.text(); } catch (_err) { errorMessage = `${JSON.stringify(await rawBody)}`; } break; } return /* @__PURE__ */ new Error(`Inngest API Error: ${response.status} ${errorMessage}`); } eventKeySet() { return this.eventKey !== void 0; } /** * EXPERIMENTAL: This API is not yet stable and may change in the future * without a major version bump. * * Send a Signal to Inngest. */ async sendSignal({ signal, data, env }) { const headers = { ...env ? { [headerKeys.Environment]: env } : {} }; return this._sendSignal({ signal, data, headers }); } async _sendSignal({ signal, data, headers }) { const res = await this.inngestApi.sendSignal({ signal, data }, { ...this.headers, ...headers }); if (res.ok) return res.value; throw new Error(`Failed to send signal: ${res.error?.error || "Unknown error"}`); } async updateMetadata({ target, metadata, headers }) { const res = await this.inngestApi.updateMetadata({ target, metadata }, { headers }); if (res.ok) return res.value; throw new Error(`Failed to update metadata: ${res.error?.error || "Unknown error"}`); } async warnMetadata(target, kind, log) { const fields = {}; if (log.code) fields.code = log.code; if (log.explanation) fields.explanation = log.explanation; if (log.action) fields.action = log.action; if (log.docs) fields.docs = log.docs; if (Object.keys(fields).length > 0) this[internalLoggerSymbol].warn(fields, log.message); else this[internalLoggerSymbol].warn(log.message); if (!this.experimentalMetadataEnabled) return; await this.updateMetadata({ target, metadata: [{ kind: "inngest.warnings", op: "merge", values: { [`sdk.${kind}`]: formatLogMessage(log) } }] }); } /** * Realtime-related functionality for this Inngest client. */ realtime = { publish: async (topicRef, data) => { const topicConfig = topicRef.config; if (topicConfig && "schema" in topicConfig && topicConfig.schema) { if ((await topicConfig.schema["~standard"].validate(data)).issues) throw new Error(`Schema validation failed for topic "${topicRef.topic}"`); } const runId = (await getAsyncCtx())?.execution?.ctx.runId; const res = await this.inngestApi.publish({ channel: topicRef.channel, topics: [topicRef.topic], runId }, data); if (!res.ok) throw new Error(`Failed to publish to realtime: ${res.error?.error || "Unknown error"}`); }, subscribe: async (opts) => { return subscribe({ ...opts, app: this }); }, token: async (opts) => { return getSubscriptionToken(this, opts); } }; endpoint(handler) { if (!this.options.endpointAdapter) throw new Error("No endpoint adapter configured for this Inngest client."); return this.options.endpointAdapter({ client: this })(handler); } /** * Creates a proxy handler that polls Inngest for durable endpoint results. * * The proxy: * - Extracts `runId` and `token` from query params * - Fetches the result from Inngest API * - Runs the response through middleware (e.g., decryption) * - Adds CORS headers * * Use this in combination with the `asyncRedirectUrl` option on your * endpoint adapter to redirect users to your own proxy endpoint instead * of directly to Inngest. * * @example * ```ts * import { Inngest } from "inngest"; * import { endpointAdapter } from "inngest/edge"; * * const inngest = new Inngest({ * id: "my-app", * endpointAdapter: endpointAdapter.withOptions({ * asyncRedirectUrl: "/api/inngest/poll", * }), * }); * * // Durable endpoint * export const GET = inngest.endpoint(async (req) => { * const result = await step.run("work", () => "done"); * return new Response(result); * }); * * // Proxy endpoint at /api/inngest/poll * export const GET = inngest.endpointProxy(); * ``` */ endpointProxy() { if (!this.options.endpointAdapter) throw new Error("No endpoint adapter configured for this Inngest client."); if (!this.options.endpointAdapter.createProxyHandler) throw new Error("The configured endpoint adapter does not support proxy handlers."); return this.options.endpointAdapter.createProxyHandler({ client: this }); } /** * Decrypt a proxy response using the client's middleware stack. * * Runs `transformFunctionInput` on each middleware instance to decrypt * step data (used by encryption middleware). * * Uses type assertions because we're creating a minimal "fake" execution * context just to run the decryption middleware hooks - not a full execution. * * @internal */ async decryptProxyResult(result) { if (!result.data) return result; const mwInstances = this.middleware.map((Cls) => { return new Cls({ client: this }); }); const dummyEvent = { name: "__proxy__", data: {} }; let transformArgs = { ctx: { event: dummyEvent, events: [dummyEvent], runId: "__proxy__", attempt: 0, step }, fn: this.getDummyDurableEndpointFunction(), steps: { __result__: { type: "data", data: result.data } } }; for (const mw of mwInstances) if (mw.transformFunctionInput) transformArgs = await mw.transformFunctionInput(transformArgs); const decryptedStep = transformArgs.steps?.__result__; let decryptedData = result.data; if (decryptedStep && "data" in decryptedStep) decryptedData = decryptedStep.data; return { ...result, data: decryptedData }; } /** * Send one or many events to Inngest. Takes an entire payload (including * name) as each input. * * ```ts * await inngest.send({ name: "app/user.created", data: { id: 123 } }); * ``` * * Returns a promise that will resolve if the event(s) were sent successfully, * else throws with an error explaining what went wrong. */ async send(payload, options) { const headers = { ...options?.env ? { [headerKeys.Environment]: options.env } : {} }; return this._send({ payload, headers, fnMiddleware: [], fn: null }); } /** * Internal method for sending an event, used to allow Inngest internals to * further customize the request sent to an Inngest Server. */ async _send({ payload, headers, fn, fnMiddleware }) { const nowMillis = (/* @__PURE__ */ new Date()).getTime(); let maxAttempts = 5; try { const entropy = createEntropy(10); const entropyBase64 = Buffer.from(entropy).toString("base64"); headers = { ...headers, [headerKeys.EventIdSeed]: `${nowMillis},${entropyBase64}` }; } catch (err) { this[internalLoggerSymbol].debug({ err }, "Event-sending retries disabled"); maxAttempts = 1; } let payloads = Array.isArray(payload) ? payload : payload ? [payload] : []; const mwInstances = [...this.middleware, ...fnMiddleware].map((Cls) => new Cls({ client: this })); for (const mw of mwInstances) if (mw?.transformSendEvent) { const transformed = await mw.transformSendEvent({ events: payloads, fn: fn ?? null }); if (transformed !== void 0) payloads = transformed.events; } for (const payload$1 of payloads) if (isValidatable(payload$1)) await payload$1.validate(); payloads = payloads.map((p) => { return { ...p, id: p.id, ts: p.ts || nowMillis, data: p.data || {} }; }); /** * It can be valid for a user to send an empty list of events; if this * happens, show a warning that this may not be intended, but don't throw. */ if (!payloads.length) { this[internalLoggerSymbol].warn("inngest.send() called with no events; the returned promise will resolve, but no events have been sent"); return { ids: [] }; } /** * If in prod mode and key is not present, fail now. */ if (this.mode === "cloud" && !this.eventKeySet()) throw new Error(formatLogMessage({ message: "Failed to send event", explanation: "Your event or events were not sent to Inngest. We couldn't find an event key to use to send events to Inngest.", action: fixEventKeyMissingSteps.join("; ") })); const innerHandler = async () => { return { ids: (await retryWithBackoff(async () => { let rawBody; let body; const url = new URL(`e/${this.eventKey ?? dummyEventKey}`, this.eventBaseUrl); const response = await this.fetch(url.href, { method: "POST", body: stringify(payloads), headers: { ...this.headers, ...headers } }); try { rawBody = await response.json(); body = await sendEventResponseSchema.parseAsync(rawBody); } catch (_err) { throw await this.getResponseError(response, rawBody); } if (body.status !== 200 || body.error) throw await this.getResponseError(response, rawBody, body.error); return body; }, { maxAttempts, baseDelay: 100 })).ids }; }; return await buildWrapSendEventChain(mwInstances, innerHandler, payloads, fn)(); } createFunction = (rawOptions, handler) => { const fn = this._createFunction(rawOptions, handler); for (const mw of fn.opts.middleware ?? []) mw.onRegister?.({ client: this, fn }); this.localFns.push(fn); return fn; }; get funcs() { return this.localFns; } _createFunction = (rawOptions, handler) => { if (typeof handler !== "function") throw new Error(`"createFunction" expected a handler function as the second argument. Triggers belong in the first argument: createFunction({ id, triggers: { event: "..." } }, handler)`); const options = { ...rawOptions, triggers: this.sanitizeTriggers(rawOptions.triggers) }; return new InngestFunction(this, options, handler); }; /** * Runtime-only validation. */ sanitizeTriggers(triggers) { if (triggers === void 0) return []; if (!Array.isArray(triggers)) return [triggers]; return triggers; } }; /** * Default middleware that is included in every client, placed before the user's * middleware. Returns new-style `Middleware.Class` constructors. Uses a closure * so the no-arg constructors can capture the base logger. */ function builtInMiddleware(baseLogger) { return [class LoggerMiddleware extends Middleware.BaseMiddleware { id = "inngest:logger"; proxyLogger = new ProxyLogger(baseLogger); transformFunctionInput(arg) { let logger = baseLogger; if ("child" in logger) try { logger = logger.child({ runID: arg.ctx.runId, eventName: arg.ctx.event.name }); } catch (err) { logger.error({ err }, "failed to create \"childLogger\" with error"); } this.proxyLogger = new ProxyLogger(logger); return { ...arg, ctx: Object.assign({}, arg.ctx, { logger: this.proxyLogger }) }; } onMemoizationEnd() { this.proxyLogger.enable(); } onStepError(arg) { this.proxyLogger.error({ err: arg.error }, "Inngest step error"); } wrapFunctionHandler({ next }) { return next().catch((err) => { this.proxyLogger.error({ err }, "Inngest function error"); throw err; }); } wrapRequest({ next }) { return next().finally(() => this.proxyLogger.flush()); } }]; } (function(_Inngest) { _Inngest.Tag = "Inngest.App"; })(Inngest || (Inngest = {})); //#endregion export { Inngest, internalLoggerSymbol }; //# sourceMappingURL=Inngest.js.map