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.
407 lines (405 loc) • 14.3 kB
JavaScript
import { NonRetriableError } from "./NonRetriableError.js";
import { StepMode, StepOpCode } from "../types.js";
import { timeStr } from "../helpers/strings.js";
import { getAsyncCtx, getAsyncCtxSync } from "./execution/als.js";
import { getISOString, isTemporalDuration } from "../helpers/temporal.js";
import { InngestFunction } from "./InngestFunction.js";
import { UnscopedMetadataBuilder, metadataSymbol } from "./InngestMetadata.js";
import { fetch } from "./Fetch.js";
import { InngestFunctionReference } from "./InngestFunctionReference.js";
import { internalLoggerSymbol } from "./Inngest.js";
import { z } from "zod/v3";
import { models } from "@inngest/ai";
//#region src/components/InngestStepTools.ts
const getStepOptions = (options) => {
if (typeof options === "string") return { id: options };
return options;
};
/**
* Suffix used to namespace steps that are automatically indexed.
*/
const STEP_INDEXING_SUFFIX = ":";
const createStepTools = (client, execution, stepHandler) => {
/**
* A local helper used to create tools that can be used to submit an op.
*
* When using this function, a generic type should be provided which is the
* function signature exposed to the user.
*/
const createTool = (matchOp, opts) => {
const wrappedMatchOp = (stepOptions, ...rest) => {
const op = matchOp(stepOptions, ...rest);
const alsCtx = getAsyncCtxSync()?.execution;
if (alsCtx?.insideExperimentSelect) throw new NonRetriableError("Step tools (step.run, step.sleep, etc.) cannot be called inside an experiment select() callback. Move step calls into variant callbacks instead.");
const parallelMode = stepOptions.parallelMode ?? alsCtx?.parallelMode;
if (parallelMode) op.opts = {
...op.opts,
parallelMode
};
const experimentContext = alsCtx?.experimentContext;
if (experimentContext) op.opts = {
...op.opts,
...experimentContext
};
const tracker = alsCtx?.experimentStepTracker;
if (tracker) tracker.found = true;
return op;
};
return (async (...args) => {
return stepHandler({
args,
matchOp: wrappedMatchOp,
opts
});
});
};
/**
* Create a new step run tool that can be used to run a step function using
* `step.run()` as a shim.
*/
const createStepRun = (type) => {
return createTool(({ id, name }, _fn, ...input) => {
const opts = {
...input.length ? { input } : {},
...type ? { type } : {}
};
return {
id,
mode: StepMode.Sync,
op: StepOpCode.StepPlanned,
name: id,
displayName: name ?? id,
...Object.keys(opts).length ? { opts } : {},
userland: { id }
};
}, { fn: (_, __, fn, ...input) => fn(...input) });
};
/**
* Creates a metadata builder wrapper for step.metadata("id").
* Uses MetadataBuilder for config accumulation, but wraps .update() in tools.run() for memoization.
*/
const createStepMetadataWrapper = (memoizationId, builder) => {
if (!client["experimentalMetadataEnabled"]) throw new Error("step.metadata() is experimental. Enable it by adding metadataMiddleware() from \"inngest/experimental\" to your client middleware.");
const withBuilder = (next) => createStepMetadataWrapper(memoizationId, next);
if (!builder) builder = new UnscopedMetadataBuilder(client).run();
return {
run: (runId) => withBuilder(builder.run(runId)),
step: (stepId, index) => withBuilder(builder.step(stepId, index)),
attempt: (attemptIndex) => withBuilder(builder.attempt(attemptIndex)),
span: (spanId) => withBuilder(builder.span(spanId)),
update: async (values, kind = "default") => {
await tools.run(memoizationId, async () => {
await builder.update(values, kind);
});
},
do: async (fn) => {
await tools.run(memoizationId, async () => {
await fn(builder);
});
}
};
};
/**
* Define the set of tools the user has access to for their step functions.
*
* Each key is the function name and is expected to run `createTool` and pass
* a generic type for that function as it will appear in the user's code.
*/
const tools = {
sendEvent: createTool(({ id, name }) => {
return {
id,
mode: StepMode.Sync,
op: StepOpCode.StepPlanned,
name: "sendEvent",
displayName: name ?? id,
opts: { type: "step.sendEvent" },
userland: { id }
};
}, { fn: (_ctx, _idOrOptions, payload) => {
const fn = execution["options"]["fn"];
return client["_send"]({
payload,
headers: execution["options"]["headers"],
fnMiddleware: fn.opts.middleware ?? [],
fn
});
} }),
waitForSignal: createTool(({ id, name }, opts) => {
return {
id,
mode: StepMode.Async,
op: StepOpCode.WaitForSignal,
name: opts.signal,
displayName: name ?? id,
opts: {
signal: opts.signal,
timeout: timeStr(opts.timeout),
conflict: opts.onConflict
},
userland: { id }
};
}),
realtime: { publish: createTool(({ id, name }) => {
return {
id,
mode: StepMode.Sync,
op: StepOpCode.StepPlanned,
displayName: name ?? id,
opts: { type: "step.realtime.publish" },
userland: { id }
};
}, { fn: async (ctx, _idOrOptions, 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 res = await client["inngestApi"].publish({
topics: [topicRef.topic],
channel: topicRef.channel,
runId: ctx.runId
}, data);
if (!res.ok) throw new Error(`Failed to publish to realtime: ${res.error?.error || "Unknown error"}`);
return data;
} }) },
sendSignal: createTool(({ id, name }, opts) => {
return {
id,
mode: StepMode.Sync,
op: StepOpCode.StepPlanned,
name: "sendSignal",
displayName: name ?? id,
opts: {
type: "step.sendSignal",
signal: opts.signal
},
userland: { id }
};
}, { fn: (_ctx, _idOrOptions, opts) => {
return client["_sendSignal"]({
signal: opts.signal,
data: opts.data,
headers: execution["options"]["headers"]
});
} }),
waitForEvent: createTool(({ id, name }, opts) => {
const matchOpts = { timeout: timeStr(typeof opts === "string" ? opts : opts.timeout) };
if (typeof opts !== "string") {
if (opts?.match) matchOpts.if = `event.${opts.match} == async.${opts.match}`;
else if (opts?.if) matchOpts.if = opts.if;
}
const eventName = typeof opts.event === "string" ? opts.event : opts.event.name;
return {
id,
mode: StepMode.Async,
op: StepOpCode.WaitForEvent,
name: eventName,
opts: matchOpts,
displayName: name ?? id,
userland: { id }
};
}),
run: createStepRun(),
ai: {
infer: createTool(({ id, name }, options) => {
const { model, body, ...rest } = options;
const modelCopy = { ...model };
options.model.onCall?.(modelCopy, options.body);
return {
id,
mode: StepMode.Async,
op: StepOpCode.AiGateway,
displayName: name ?? id,
opts: {
type: "step.ai.infer",
url: modelCopy.url,
headers: modelCopy.headers,
auth_key: modelCopy.authKey,
format: modelCopy.format,
body,
...rest
},
userland: { id }
};
}),
wrap: createStepRun("step.ai.wrap"),
models: { ...models }
},
sleep: createTool(({ id, name }, time) => {
/**
* The presence of this operation in the returned stack indicates that the
* sleep is over and we should continue execution.
*/
const msTimeStr = timeStr(isTemporalDuration(time) ? time.total({ unit: "milliseconds" }) : time);
return {
id,
mode: StepMode.Async,
op: StepOpCode.Sleep,
name: msTimeStr,
displayName: name ?? id,
userland: { id }
};
}),
sleepUntil: createTool(({ id, name }, time) => {
try {
const iso = getISOString(time);
/**
* The presence of this operation in the returned stack indicates that the
* sleep is over and we should continue execution.
*/
return {
id,
mode: StepMode.Async,
op: StepOpCode.Sleep,
name: iso,
displayName: name ?? id,
userland: { id }
};
} catch (err) {
/**
* If we're here, it's because the date is invalid. We'll throw a custom
* error here to standardise this response.
*/
client[internalLoggerSymbol].warn({ err }, "Invalid `Date`, date string, `Temporal.Instant`, or `Temporal.ZonedDateTime` passed to sleepUntil");
throw new Error(`Invalid \`Date\`, date string, \`Temporal.Instant\`, or \`Temporal.ZonedDateTime\` passed to sleepUntil: ${time}`);
}
}),
invoke: createTool(({ id, name }, invokeOpts) => {
const optsSchema = invokePayloadSchema.extend({ timeout: z.union([
z.number(),
z.string(),
z.date()
]).optional() });
const parsedFnOpts = optsSchema.extend({
_type: z.literal("fnInstance").optional().default("fnInstance"),
function: z.instanceof(InngestFunction)
}).or(optsSchema.extend({
_type: z.literal("refInstance").optional().default("refInstance"),
function: z.instanceof(InngestFunctionReference)
})).safeParse(invokeOpts);
if (!parsedFnOpts.success) throw new Error(`Invalid invocation options passed to invoke; must include a function instance or referenceFunction().`);
const { _type, function: fn, data, v, timeout } = parsedFnOpts.data;
const opts = {
payload: {
data,
v
},
function_id: "",
timeout: typeof timeout === "undefined" ? void 0 : timeStr(timeout)
};
switch (_type) {
case "fnInstance":
opts.function_id = fn.id(fn["client"].id);
break;
case "refInstance":
opts.function_id = [fn.opts.appId || client.id, fn.opts.functionId].filter(Boolean).join("-");
break;
}
return {
id,
mode: StepMode.Async,
op: StepOpCode.InvokeFunction,
displayName: name ?? id,
opts,
userland: { id }
};
}),
fetch
};
tools[metadataSymbol] = (memoizationId) => createStepMetadataWrapper(memoizationId);
tools[experimentStepRunSymbol] = createStepRun("group.experiment");
tools[gatewaySymbol] = createTool(({ id, name }, input, init) => {
const url = input instanceof Request ? input.url : input.toString();
const headers = {};
if (input instanceof Request) input.headers.forEach((value, key) => {
headers[key] = value;
});
else if (init?.headers) new Headers(init.headers).forEach((value, key) => {
headers[key] = value;
});
return {
id,
mode: StepMode.Async,
op: StepOpCode.Gateway,
displayName: name ?? id,
opts: {
url,
method: init?.method ?? "GET",
headers,
body: init?.body
},
userland: { id }
};
});
return tools;
};
const gatewaySymbol = Symbol.for("inngest.step.gateway");
const experimentStepRunSymbol = Symbol.for("inngest.group.experiment");
/**
* A generic set of step tools that can be used without typing information about
* the client used to create them.
*
* These tools use AsyncLocalStorage to track the context in which they are
* used, and will throw an error if used outside of an Inngest context.
*
* The intention of these high-level tools is to allow usage of Inngest step
* tools within API endpoints, though they can still be used within regular
* Inngest functions as well.
*/
const step = {
fetch: null,
ai: {
infer: (...args) => getDeferredStepTooling().then((tools) => tools.ai.infer(...args)),
wrap: (...args) => getDeferredStepTooling().then((tools) => tools.ai.wrap(...args)),
models: { ...models }
},
invoke: (...args) => getDeferredStepTooling().then((tools) => tools.invoke(...args)),
run: (...args) => getDeferredStepTooling().then((tools) => tools.run(...args)),
sendEvent: (...args) => getDeferredStepTooling().then((tools) => tools.sendEvent(...args)),
sendSignal: (...args) => getDeferredStepTooling().then((tools) => tools.sendSignal(...args)),
sleep: (...args) => getDeferredStepTooling().then((tools) => tools.sleep(...args)),
sleepUntil: (...args) => getDeferredStepTooling().then((tools) => tools.sleepUntil(...args)),
waitForEvent: (...args) => getDeferredStepTooling().then((tools) => tools.waitForEvent(...args)),
waitForSignal: (...args) => getDeferredStepTooling().then((tools) => tools.waitForSignal(...args)),
realtime: { publish: (...args) => getDeferredStepTooling().then((tools) => tools.realtime.publish(...args)) }
};
/**
* An internal function used to retrieve or create step tooling for the current
* execution context.
*
* Note that this requires an existing context to create the step tooling;
* something must declare the Inngest execution context before this can be used.
*/
const getDeferredStepTooling = async () => {
const ctx = await getAsyncCtx();
if (!ctx) throw new Error("`step` tools can only be used within Inngest function executions; no context was found");
if (!ctx.app) throw new Error("`step` tools can only be used within Inngest function executions; no Inngest client was found in the execution context");
if (!ctx.execution) throw new Error("`step` tools can only be used within Inngest function executions; no execution context was found");
return ctx.execution.ctx.step;
};
const getDeferredGroupTooling = async () => {
const ctx = await getAsyncCtx();
if (!ctx) throw new Error("`group` tools can only be used within Inngest function executions; no context was found");
if (!ctx.execution) throw new Error("`group` tools can only be used within Inngest function executions; no execution context was found");
return ctx.execution.ctx.group;
};
/**
* A deferred proxy for `group` tools that delegates through ALS context.
*
* @public
*/
const group = {
parallel: (...args) => getDeferredGroupTooling().then((tools) => tools.parallel(...args)),
experiment: (...args) => getDeferredGroupTooling().then((tools) => tools.experiment(...args))
};
/**
* The event payload portion of the options for `step.invoke()`. This does not
* include non-payload options like `timeout` or the function to invoke.
*/
const invokePayloadSchema = z.object({
data: z.record(z.any()).optional(),
v: z.string().optional()
});
//#endregion
export { STEP_INDEXING_SUFFIX, createStepTools, experimentStepRunSymbol, gatewaySymbol, getStepOptions, group, step };
//# sourceMappingURL=InngestStepTools.js.map