diff --git a/.changeset/client-fn-ids.md b/.changeset/client-fn-ids.md new file mode 100644 index 000000000..b22983c8b --- /dev/null +++ b/.changeset/client-fn-ids.md @@ -0,0 +1,11 @@ +--- +"inngest": major +--- + +Clients and functions now require IDs + +When instantiating a client using `new Inngest()` or creating a function via `inngest.createFunction()`, it's now required to pass an `id` instead of a `name`. + +Previously only `name` was required, but this implied that the value was safe to change. Internally, we used this name to _produce_ an ID which was used during deployments and executions. + +See the [v3 migration guide](https://www.inngest.com/docs/sdk/migration). diff --git a/.changeset/env-vars.md b/.changeset/env-vars.md new file mode 100644 index 000000000..1e17f09de --- /dev/null +++ b/.changeset/env-vars.md @@ -0,0 +1,9 @@ +--- +"inngest": major +--- + +Refactored available environment variables and configuration + +The arrangement of environment variables available has shifted a lot over the course of v2, so in v3 we've streamlined what's available and how they're used. + +See the [v3 migration guide](https://www.inngest.com/docs/sdk/migration). diff --git a/.changeset/fns-option-removed.md b/.changeset/fns-option-removed.md new file mode 100644 index 000000000..854f33ca4 --- /dev/null +++ b/.changeset/fns-option-removed.md @@ -0,0 +1,9 @@ +--- +"inngest": major +--- + +In v2, providing a `fns` option when creating a function -- an object of functions -- would wrap those passed functions in `step.run()`, meaning you can run code inside your function without the `step.run()` boilerplate. + +This wasn't a very well advertised feature and had some drawbacks, so we're instead replacing it with some optional middleware. + +See the [v3 migration guide](https://www.inngest.com/docs/sdk/migration). diff --git a/.changeset/serve-handlers-refactored.md b/.changeset/serve-handlers-refactored.md new file mode 100644 index 000000000..5d8a38f42 --- /dev/null +++ b/.changeset/serve-handlers-refactored.md @@ -0,0 +1,7 @@ +--- +"inngest": major +--- + +Serving functions could become a bit unwieldy with the format we had, so we've slightly altered how you serve your functions to ensure proper discoverability of options and aid in readability when revisiting the code. + +See the [v3 migration guide](https://www.inngest.com/docs/sdk/migration). diff --git a/.changeset/shorthand-fn-creation.md b/.changeset/shorthand-fn-creation.md new file mode 100644 index 000000000..72a88858b --- /dev/null +++ b/.changeset/shorthand-fn-creation.md @@ -0,0 +1,9 @@ +--- +"inngest": major +--- + +Shorthand function creation removed + +`inngest.createFunction()` can no longer take a string as the first or second arguments; an object is now required to aid in the discoverability of options and configuration. + +See the [v3 migration guide](https://www.inngest.com/docs/sdk/migration). diff --git a/.changeset/steps-require-ids.md b/.changeset/steps-require-ids.md new file mode 100644 index 000000000..3758aac5e --- /dev/null +++ b/.changeset/steps-require-ids.md @@ -0,0 +1,11 @@ +--- +"inngest": major +--- + +All steps require IDs + +When using any step.* tool, an ID is now required to ensure that determinism across changes to a function is easier to reason about for the user and the underlying engine. + +The addition of these IDs allows you to deploy hotfixes and logic changes to long-running functions without fear of errors, failures, or panics. Beforehand, any changes to a function resulted in an irrecoverable error if step definitions changed. With this, changes to a function are smartly applied by default. + +See the [v3 migration guide](https://www.inngest.com/docs/sdk/migration). diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index e163dd1bc..2af9993e9 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -52,6 +52,7 @@ jobs: - 'latest' - 'next' - 'beta' + - '~5.2.0' - '~5.1.0' - '~5.0.0' - '~4.9.0' @@ -141,11 +142,7 @@ jobs: # Install dependencies in the example repo # Don't use "npm ci", "--immutable" etc., as example repos won't be # shipped with lock files. - - name: Install example dependencies - run: npm install - working-directory: examples/${{ matrix.example }} - - - name: Add local SDK to example + - name: Add local SDK to example with dependencies working-directory: examples/${{ matrix.example }} run: npm install ./inngest.tgz diff --git a/examples/framework-fastify/inngest/client.ts b/examples/framework-fastify/inngest/client.ts index f53b7c2c6..053eddf2e 100644 --- a/examples/framework-fastify/inngest/client.ts +++ b/examples/framework-fastify/inngest/client.ts @@ -1,4 +1,4 @@ import { Inngest } from "inngest"; import { schemas } from "./types"; -export const inngest = new Inngest({ name: "My Fastify app", schemas }); +export const inngest = new Inngest({ id: "my-fastify-app", schemas }); diff --git a/examples/framework-fastify/inngest/helloWorld.ts b/examples/framework-fastify/inngest/helloWorld.ts index 503ef390e..7e083dbde 100644 --- a/examples/framework-fastify/inngest/helloWorld.ts +++ b/examples/framework-fastify/inngest/helloWorld.ts @@ -1,7 +1,7 @@ import { inngest } from "./client"; export default inngest.createFunction( - { name: "Hello World" }, + { id: "hello-world" }, { event: "demo/event.sent" }, async ({ event, step }) => { return { diff --git a/examples/framework-fastify/package.json b/examples/framework-fastify/package.json index de4f89f50..6acbd0b2e 100644 --- a/examples/framework-fastify/package.json +++ b/examples/framework-fastify/package.json @@ -12,7 +12,7 @@ "license": "ISC", "dependencies": { "fastify": "^4.21.0", - "inngest": "^2.0.0" + "inngest": "^3.0.0" }, "devDependencies": { "@types/node": "^20.5.2", diff --git a/examples/framework-nextjs/inngest/client.ts b/examples/framework-nextjs/inngest/client.ts index 351f31260..b05693234 100644 --- a/examples/framework-nextjs/inngest/client.ts +++ b/examples/framework-nextjs/inngest/client.ts @@ -1,4 +1,4 @@ import { Inngest } from "inngest"; import { schemas } from "./types"; -export const inngest = new Inngest({ name: "My Next.js app", schemas }); +export const inngest = new Inngest({ id: "my-nextjs-app", schemas }); diff --git a/examples/framework-nextjs/inngest/helloWorld.ts b/examples/framework-nextjs/inngest/helloWorld.ts index 7de77e041..7e083dbde 100644 --- a/examples/framework-nextjs/inngest/helloWorld.ts +++ b/examples/framework-nextjs/inngest/helloWorld.ts @@ -1,9 +1,9 @@ import { inngest } from "./client"; export default inngest.createFunction( - { name: "Hello World" }, + { id: "hello-world" }, { event: "demo/event.sent" }, - ({ event, step }) => { + async ({ event, step }) => { return { message: `Hello ${event.name}!`, }; diff --git a/examples/framework-nextjs/package.json b/examples/framework-nextjs/package.json index aec06a44e..1b691659c 100644 --- a/examples/framework-nextjs/package.json +++ b/examples/framework-nextjs/package.json @@ -8,7 +8,7 @@ "lint": "next lint" }, "dependencies": { - "inngest": "^2.0.0", + "inngest": "^3.0.0", "next": "12.3.1", "react": "18.2.0", "react-dom": "18.2.0" diff --git a/examples/framework-nextjs/pages/api/inngest.ts b/examples/framework-nextjs/pages/api/inngest.ts index 2d6fca922..9bbb06006 100644 --- a/examples/framework-nextjs/pages/api/inngest.ts +++ b/examples/framework-nextjs/pages/api/inngest.ts @@ -1,4 +1,4 @@ import { serve } from "inngest/next"; -import { inngest, functions } from "../../inngest"; +import { functions, inngest } from "../../inngest"; -export default serve(inngest, functions); +export default serve({ client: inngest, functions }); diff --git a/examples/framework-nuxt/inngest/client.ts b/examples/framework-nuxt/inngest/client.ts index 9c49d600e..d9d86a2e9 100644 --- a/examples/framework-nuxt/inngest/client.ts +++ b/examples/framework-nuxt/inngest/client.ts @@ -1,4 +1,4 @@ import { Inngest } from "inngest"; import { schemas } from "./types"; -export const inngest = new Inngest({ name: "My Nuxt app", schemas }); +export const inngest = new Inngest({ id: "my-nuxt-app", schemas }); diff --git a/examples/framework-nuxt/inngest/helloWorld.ts b/examples/framework-nuxt/inngest/helloWorld.ts index 7de77e041..7e083dbde 100644 --- a/examples/framework-nuxt/inngest/helloWorld.ts +++ b/examples/framework-nuxt/inngest/helloWorld.ts @@ -1,9 +1,9 @@ import { inngest } from "./client"; export default inngest.createFunction( - { name: "Hello World" }, + { id: "hello-world" }, { event: "demo/event.sent" }, - ({ event, step }) => { + async ({ event, step }) => { return { message: `Hello ${event.name}!`, }; diff --git a/examples/framework-nuxt/package.json b/examples/framework-nuxt/package.json index 2c8cff74b..506c1e122 100644 --- a/examples/framework-nuxt/package.json +++ b/examples/framework-nuxt/package.json @@ -12,7 +12,7 @@ "nuxt": "^3.0.0" }, "dependencies": { - "inngest": "^2.0.0" + "inngest": "^3.0.0" }, "volta": { "node": "18.16.1", diff --git a/examples/framework-nuxt/server/api/inngest.ts b/examples/framework-nuxt/server/api/inngest.ts index 50e002d94..92d9b98a7 100644 --- a/examples/framework-nuxt/server/api/inngest.ts +++ b/examples/framework-nuxt/server/api/inngest.ts @@ -1,4 +1,4 @@ import { serve } from "inngest/nuxt"; -import { inngest, functions } from "~~/inngest"; +import { functions, inngest } from "~~/inngest"; -export default defineEventHandler(serve(inngest, functions)); +export default defineEventHandler(serve({ client: inngest, functions })); diff --git a/examples/framework-remix/app/inngest/client.ts b/examples/framework-remix/app/inngest/client.ts index 167349f81..6eebc930b 100644 --- a/examples/framework-remix/app/inngest/client.ts +++ b/examples/framework-remix/app/inngest/client.ts @@ -1,4 +1,4 @@ import { Inngest } from "inngest"; import { schemas } from "./types"; -export const inngest = new Inngest({ name: "My Remix app", schemas }); +export const inngest = new Inngest({ id: "my-remix-app", schemas }); diff --git a/examples/framework-remix/app/inngest/helloWorld.ts b/examples/framework-remix/app/inngest/helloWorld.ts index 7de77e041..7e083dbde 100644 --- a/examples/framework-remix/app/inngest/helloWorld.ts +++ b/examples/framework-remix/app/inngest/helloWorld.ts @@ -1,9 +1,9 @@ import { inngest } from "./client"; export default inngest.createFunction( - { name: "Hello World" }, + { id: "hello-world" }, { event: "demo/event.sent" }, - ({ event, step }) => { + async ({ event, step }) => { return { message: `Hello ${event.name}!`, }; diff --git a/examples/framework-remix/app/routes/api/inngest.ts b/examples/framework-remix/app/routes/api/inngest.ts index da7463e57..420229309 100644 --- a/examples/framework-remix/app/routes/api/inngest.ts +++ b/examples/framework-remix/app/routes/api/inngest.ts @@ -1,6 +1,6 @@ import { serve } from "inngest/remix"; -import { inngest, functions } from "~/inngest"; +import { functions, inngest } from "~/inngest"; -const handler = serve(inngest, functions); +const handler = serve({ client: inngest, functions }); -export { handler as loader, handler as action }; +export { handler as action, handler as loader }; diff --git a/examples/framework-remix/package.json b/examples/framework-remix/package.json index 99a48b9bb..9fc8977c0 100644 --- a/examples/framework-remix/package.json +++ b/examples/framework-remix/package.json @@ -10,7 +10,7 @@ "@remix-run/react": "^1.8.0", "@remix-run/vercel": "^1.8.0", "@vercel/node": "^2.6.2", - "inngest": "^2.0.0", + "inngest": "^3.0.0", "react": "^18.2.0", "react-dom": "^18.2.0" }, diff --git a/packages/inngest/etc/inngest.api.md b/packages/inngest/etc/inngest.api.md index c314705e8..9df8807da 100644 --- a/packages/inngest/etc/inngest.api.md +++ b/packages/inngest/etc/inngest.api.md @@ -10,17 +10,17 @@ import { z } from 'zod'; // @public export interface ClientOptions { + baseUrl?: string; env?: string; eventKey?: string; fetch?: typeof fetch; - inngestBaseUrl?: string; + id: string; // Warning: (ae-forgotten-export) The symbol "Logger" needs to be exported by the entry point index.d.ts logger?: Logger; // Warning: (ae-forgotten-export) The symbol "MiddlewareStack" needs to be exported by the entry point index.d.ts // // (undocumented) middleware?: MiddlewareStack; - name: string; schemas?: EventSchemas>; } @@ -54,12 +54,11 @@ export class EventSchemas> { name: K; }>; }>>; // Warning: (ae-forgotten-export) The symbol "LiteralZodEventSchemas" needs to be exported by the entry point index.d.ts - // Warning: (ae-forgotten-export) The symbol "ExcludeEmptyZodLiterals" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "ZodToStandardSchema" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "PickLiterals" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "GetName" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "InferZodShape" needs to be exported by the entry point index.d.ts - fromZod(schemas: ExcludeEmptyZodLiterals): EventSchemas]: InferZodShape; } : never : T extends ZodEventSchemas ? T : never>>>>; + fromZod(schemas: T): EventSchemas]: InferZodShape; } : T extends ZodEventSchemas ? T : never>>>>; } // @public @@ -96,12 +95,10 @@ export interface FunctionOptions, Ev limit: number; key?: string; }; - // (undocumented) - fns?: Record; - id?: string; + id: string; idempotency?: string; middleware?: MiddlewareStack; - name: string; + name?: string; // (undocumented) onFailure?: (...args: unknown[]) => unknown; rateLimit?: { @@ -112,8 +109,10 @@ export interface FunctionOptions, Ev retries?: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20; } +// Warning: (ae-forgotten-export) The symbol "AnyInngest" needs to be exported by the entry point index.d.ts +// // @public -export type GetEvents> = T extends Inngest ? EventsFromOpts : never; +export type GetEvents = T extends Inngest ? EventsFromOpts : never; // @public export enum headerKeys { @@ -126,6 +125,10 @@ export enum headerKeys { // (undocumented) Platform = "x-inngest-platform", // (undocumented) + RequestVersion = "x-inngest-req-version", + // (undocumented) + RetryAfter = "retry-after", + // (undocumented) SdkVersion = "x-inngest-sdk", // (undocumented) Signature = "x-inngest-signature" @@ -133,8 +136,7 @@ export enum headerKeys { // @public export class Inngest { - constructor({ name, eventKey, inngestBaseUrl, fetch, env, logger, middleware, }: TOpts); - // Warning: (ae-forgotten-export) The symbol "ShimmedFns" needs to be exported by the entry point index.d.ts + constructor({ id, eventKey, baseUrl, fetch, env, logger, middleware, }: TOpts); // Warning: (ae-forgotten-export) The symbol "ExclusiveKeys" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "Handler" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "ExtendWithMiddleware" needs to be exported by the entry point index.d.ts @@ -143,52 +145,44 @@ export class Inngest { // Warning: (ae-forgotten-export) The symbol "FunctionTrigger" needs to be exported by the entry point index.d.ts // // (undocumented) - createFunction, TMiddleware extends MiddlewareStack, TTrigger extends TriggerOptions & string>, TShimmedFns extends Record any> = ShimmedFns, TTriggerName extends keyof EventsFromOpts & string = EventNameFromTrigger, TTrigger>>(nameOrOpts: string | ExclusiveKeys, TTriggerName>, "fns" | "onFailure" | "middleware"> & { - fns?: TFns; - onFailure?: Handler, TTriggerName, TShimmedFns, ExtendWithMiddleware<[ + createFunction & string>, TTriggerName extends keyof EventsFromOpts & string = EventNameFromTrigger, TTrigger>>(options: ExclusiveKeys, TTriggerName>, "onFailure" | "middleware"> & { + onFailure?: Handler, TTriggerName, ExtendWithMiddleware<[ typeof builtInMiddleware, NonNullable, TMiddleware ], FailureEventArgs[TTriggerName]>>>; middleware?: TMiddleware; - }, "batchEvents", "cancelOn" | "rateLimit">, trigger: TTrigger, handler: Handler, TTriggerName, TShimmedFns, ExtendWithMiddleware<[ + }, "batchEvents", "cancelOn" | "rateLimit">, trigger: TTrigger, handler: Handler, TTriggerName, ExtendWithMiddleware<[ typeof builtInMiddleware, NonNullable, TMiddleware ]>>): InngestFunction, FunctionTrigger & string>, FunctionOptions, keyof EventsFromOpts & string>>; - readonly inngestBaseUrl: URL; - readonly name: string; + readonly id: string; // Warning: (ae-forgotten-export) The symbol "SendEventPayload" needs to be exported by the entry point index.d.ts - send>>(payload: Payload): Promise; + // Warning: (ae-forgotten-export) The symbol "SendEventOutput" needs to be exported by the entry point index.d.ts + send>>(payload: Payload): Promise>; setEventKey( eventKey: string): void; } -// Warning: (ae-forgotten-export) The symbol "Handler_2" needs to be exported by the entry point index.d.ts -// Warning: (ae-forgotten-export) The symbol "ActionResponse" needs to be exported by the entry point index.d.ts -// // @public -export class InngestCommHandler, ...args: Parameters) => any, TStreamTransform extends (res: ActionResponse, ...args: Parameters) => any> { - constructor( - frameworkName: string, - client: Inngest, - functions: InngestFunction[], options: RegisterOptions | undefined, - handler: H, - transformRes: TResTransform, - streamTransformRes?: TStreamTransform); +export class InngestCommHandler { + // Warning: (ae-forgotten-export) The symbol "InngestCommHandlerOptions" needs to be exported by the entry point index.d.ts + constructor(options: InngestCommHandlerOptions); // Warning: (ae-forgotten-export) The symbol "FunctionConfig" needs to be exported by the entry point index.d.ts // // (undocumented) protected configs(url: URL): FunctionConfig[]; - createHandler(): (...args: Parameters) => Promise>>; + createHandler(): (...args: Input) => Promise>; protected readonly frameworkName: string; - readonly handler: H; + // Warning: (ae-forgotten-export) The symbol "Handler_2" needs to be exported by the entry point index.d.ts + readonly handler: Handler_2; + readonly id: string; protected _isProd: boolean; protected log(level: LogLevel, ...args: unknown[]): void; protected readonly logLevel: LogLevel; - readonly name: string; // (undocumented) - protected register(url: URL, devServerHost: string | undefined, deployId: string | undefined | null, getHeaders: () => Record): Promise<{ + protected register(url: URL, deployId: string | undefined | null, getHeaders: () => Record): Promise<{ status: number; message: string; modified: boolean; @@ -199,10 +193,12 @@ export class InngestCommHandler; + protected runStep(functionId: string, stepId: string | null, data: unknown, timer: ServerTiming): { + version: ExecutionVersion; + result: Promise; + }; protected readonly serveHost: string | undefined; protected readonly servePath: string | undefined; protected signingKey: string | undefined; @@ -212,10 +208,7 @@ export class InngestCommHandler): void; + protected validateSignature(sig: string | undefined, body: unknown): void; } // @public @@ -245,8 +238,10 @@ export type LiteralZodEventSchema = z.ZodObject<{ // @public export type LogArg = unknown; +// Warning: (ae-forgotten-export) The symbol "logLevels" needs to be exported by the entry point index.d.ts +// // @public -export type LogLevel = "fatal" | "error" | "warn" | "info" | "debug" | "silent"; +export type LogLevel = (typeof logLevels)[number]; // @public export interface MiddlewareOptions { @@ -258,8 +253,8 @@ export interface MiddlewareOptions { // // @public (undocumented) export type MiddlewareRegisterFn = (ctx: { - client: Inngest; - fn?: InngestFunction; + client: AnyInngest; + fn?: AnyInngestFunction; }) => MaybePromise; // @public (undocumented) @@ -313,19 +308,15 @@ export enum queryKeys { // (undocumented) FnId = "fnId", // (undocumented) - Introspect = "introspect", - // (undocumented) StepId = "stepId" } // @public export interface RegisterOptions { + baseUrl?: string; fetch?: typeof fetch; - inngestRegisterUrl?: string; - // @deprecated - landingPage?: boolean; + id?: string; logLevel?: LogLevel; - name?: string; serveHost?: string; servePath?: string; signingKey?: string; @@ -333,22 +324,23 @@ export interface RegisterOptions { } // @public -export type ServeHandler = ( -client: Inngest, -functions: InngestFunction[], -opts?: RegisterOptions -/** -* This `any` return is appropriate. -* -* While we can infer the signature of the returned value, we cannot guarantee -* that we have used the same types as the framework we are integrating with, -* which sometimes can cause frustrating collisions for a user that result in -* `as unknown as X` casts. -* -* Instead, we will use `any` here and have the user be able to place it -* anywhere they need. -*/ -) => any; +export class RetryAfterError extends Error { + constructor(message: string, + retryAfter: number | string | Date, options?: { + cause?: unknown; + }); + readonly cause?: unknown; + readonly retryAfter: string; +} + +// @public +export interface ServeHandlerOptions extends RegisterOptions { + client: AnyInngest; + functions: readonly AnyInngestFunction[]; +} + +// @public +export const slugify: (str: string) => string; // @public export type StandardEventSchemas = Record; @@ -364,6 +356,15 @@ export type StandardEventSchemaToPayload = Simplify<{ }; }>; +// @public +export interface StepOptions { + id: string; + name?: string; +} + +// @public +export type StepOptionsOrId = StepOptions["id"] | StepOptions; + // @public export type StrictUnion = StrictUnionHelper; @@ -374,7 +375,7 @@ export type StrictUnionHelper = T extends any ? T & Partial = T | StrictUnion<{ +export type TriggerOptions = StrictUnion<{ event: T; if?: string; } | { @@ -392,14 +393,17 @@ export type ZodEventSchemas = Record { @@ -79,9 +79,9 @@ export class InngestApi { const data: unknown = await resp.json(); if (resp.ok) { - return ok(BatchSchema.parse(data)); + return ok(batchSchema.parse(data)); } else { - return err(ErrorSchema.parse(data)); + return err(errorSchema.parse(data)); } }) .catch((error) => { diff --git a/packages/inngest/src/api/schema.ts b/packages/inngest/src/api/schema.ts index ff280ea48..1472cbbeb 100644 --- a/packages/inngest/src/api/schema.ts +++ b/packages/inngest/src/api/schema.ts @@ -1,19 +1,33 @@ import { z } from "zod"; -import { type EventPayload } from "../types"; +import { failureEventErrorSchema, type EventPayload } from "../types"; -export const ErrorSchema = z.object({ +export const errorSchema = z.object({ error: z.string(), status: z.number(), }); -export type ErrorResponse = z.infer; +export type ErrorResponse = z.infer; -export const StepsSchema = z.object({}).passthrough().default({}); -export type StepsResponse = z.infer; +export const stepsSchema = z + .record( + z + .object({ + type: z.literal("data").optional().default("data"), + data: z.any().refine((v) => typeof v !== "undefined", { + message: "Data in steps must be defined", + }), + }) + .or( + z.object({ + type: z.literal("error").optional().default("error"), + error: failureEventErrorSchema, + }) + ) + ) + .default({}); -export const BatchSchema = z.array( - z - .object({}) - .passthrough() - .transform((v) => v as EventPayload) +export type StepsResponse = z.infer; + +export const batchSchema = z.array( + z.record(z.any()).transform((v) => v as EventPayload) ); -export type BatchResponse = z.infer; +export type BatchResponse = z.infer; diff --git a/packages/inngest/src/cloudflare.ts b/packages/inngest/src/cloudflare.ts index 1f247e21d..21a2761af 100644 --- a/packages/inngest/src/cloudflare.ts +++ b/packages/inngest/src/cloudflare.ts @@ -1,11 +1,10 @@ import { InngestCommHandler, - type ServeHandler, + type ServeHandlerOptions, } from "./components/InngestCommHandler"; -import { headerKeys, queryKeys } from "./helpers/consts"; import { type SupportedFrameworkName } from "./types"; -export const name: SupportedFrameworkName = "cloudflare-pages"; +export const frameworkName: SupportedFrameworkName = "cloudflare-pages"; /** * In Cloudflare, serve and register any declared functions with Inngest, making @@ -13,65 +12,39 @@ export const name: SupportedFrameworkName = "cloudflare-pages"; * * @public */ -export const serve: ServeHandler = (nameOrInngest, fns, opts) => { - const handler = new InngestCommHandler( - name, - nameOrInngest, - fns, - { - /** - * Assume that we want to override the `fetch` implementation with the one - * globally available in the Cloudflare env. Specifying it here will - * ensure we avoid trying to load a Node-compatible version later. - */ - fetch: fetch.bind(globalThis), - ...opts, - }, - ({ +export const serve = (options: ServeHandlerOptions) => { + const handler = new InngestCommHandler({ + frameworkName, + + /** + * Assume that we want to override the `fetch` implementation with the one + * globally available in the Cloudflare env. Specifying it here will + * ensure we avoid trying to load a Node-compatible version later. + */ + fetch: fetch.bind(globalThis), + ...options, + handler: ({ request: req, env, }: { request: Request; env: Record; }) => { - const url = new URL(req.url, `https://${req.headers.get("host") || ""}`); - return { - env, - url, - view: () => { - if (req.method === "GET") { - return { - isIntrospection: url.searchParams.has(queryKeys.Introspect), - }; - } - }, - register: () => { - if (req.method === "PUT") { - return { - deployId: url.searchParams.get(queryKeys.DeployId), - }; - } - }, - run: async () => { - if (req.method === "POST") { - return { - fnId: url.searchParams.get(queryKeys.FnId) as string, - stepId: url.searchParams.get(queryKeys.StepId) as string, - data: (await req.json()) as Record, - signature: req.headers.get(headerKeys.Signature) || undefined, - }; - } + body: () => req.json(), + headers: (key) => req.headers.get(key), + method: () => req.method, + env: () => env, + url: () => new URL(req.url, `https://${req.headers.get("host") || ""}`), + transformResponse: ({ body, status, headers }) => { + return new Response(body, { + status, + headers, + }); }, }; }, - ({ body, status, headers }): Response => { - return new Response(body, { - status, - headers, - }); - } - ); + }); return handler.createHandler(); }; diff --git a/packages/inngest/src/components/EventSchemas.test.ts b/packages/inngest/src/components/EventSchemas.test.ts index d095ecb17..000315a8f 100644 --- a/packages/inngest/src/components/EventSchemas.test.ts +++ b/packages/inngest/src/components/EventSchemas.test.ts @@ -7,7 +7,7 @@ import { z } from "zod"; // eslint-disable-next-line @typescript-eslint/no-explicit-any type Schemas> = GetEvents< - Inngest<{ name: "test"; schemas: T }> + Inngest<{ id: "test"; schemas: T }> >; describe("EventSchemas", () => { @@ -382,18 +382,20 @@ describe("EventSchemas", () => { test("can overwrite types with multiple calls", () => { const schemas = new EventSchemas() - .fromZod({ - "test.event": { + .fromZod([ + z.object({ + name: z.literal("test.event"), data: z.object({ a: z.string() }), user: z.object({ b: z.number() }), - }, - }) - .fromZod({ - "test.event": { + }), + ]) + .fromZod([ + z.object({ + name: z.literal("test.event"), data: z.object({ c: z.string() }), user: z.object({ d: z.number() }), - }, - }); + }), + ]); assertType["test.event"]["name"]>("test.event"); assertType["test.event"]["data"]>({ c: "" }); @@ -544,13 +546,13 @@ describe("EventSchemas", () => { }>(); const inngest = new Inngest({ - name: "test", + id: "test", schemas, eventKey: "test-key-123", }); inngest.createFunction( - { name: "test" }, + { id: "test" }, { event: "test.event" }, ({ event }) => { assertType<"test.event">(event.name); @@ -567,13 +569,13 @@ describe("EventSchemas", () => { }>(); const inngest = new Inngest({ - name: "test", + id: "test", schemas, eventKey: "test-key-123", }); inngest.createFunction( - { name: "test" }, + { id: "test" }, { event: "test.event" }, ({ event }) => { assertType<"test.event">(event.name); @@ -592,19 +594,50 @@ describe("EventSchemas", () => { }>(); const inngest = new Inngest({ - name: "test", + id: "test", + schemas, + eventKey: "test-key-123", + }); + + inngest.createFunction( + { + id: "test", + cancelOn: [{ event: "test.event2", match: "data.foo" }], + }, + { event: "test.event" }, + ({ step }) => { + void step.waitForEvent("id", { + event: "test.event2", + match: "data.foo", + timeout: "1h", + }); + } + ); + }); + + test("cannot match between two events without shared properties", () => { + const schemas = new EventSchemas().fromRecord<{ + "test.event": { data: { foo: string } }; + "test.event2": { data: { bar: boolean } }; + }>(); + + const inngest = new Inngest({ + id: "test", schemas, eventKey: "test-key-123", }); inngest.createFunction( { - name: "test", + id: "test", + // @ts-expect-error - `"data.foo"` is not assignable cancelOn: [{ event: "test.event2", match: "data.foo" }], }, { event: "test.event" }, ({ step }) => { - void step.waitForEvent("test.event2", { + void step.waitForEvent("id", { + event: "test.event2", + // @ts-expect-error - `"data.foo"` is not assignable match: "data.foo", timeout: "1h", }); @@ -620,19 +653,20 @@ describe("EventSchemas", () => { }>(); const inngest = new Inngest({ - name: "test", + id: "test", schemas, eventKey: "test-key-123", }); inngest.createFunction( { - name: "test", + id: "test", cancelOn: [{ event: "test.event2", match: "data.foo" }], }, { event: "test.event" }, ({ step }) => { - void step.waitForEvent("test.event2", { + void step.waitForEvent("id", { + event: "test.event2", match: "data.foo", timeout: "1h", }); @@ -648,19 +682,20 @@ describe("EventSchemas", () => { }>(); const inngest = new Inngest({ - name: "test", + id: "test", schemas, eventKey: "test-key-123", }); inngest.createFunction( { - name: "test", + id: "test", cancelOn: [{ event: "test.event2", match: "data.foo" }], }, { event: "test.event" }, ({ step }) => { - void step.waitForEvent("test.event2", { + void step.waitForEvent("id", { + event: "test.event", match: "data.foo", timeout: "1h", }); diff --git a/packages/inngest/src/components/EventSchemas.ts b/packages/inngest/src/components/EventSchemas.ts index bd51a2c3a..7fb67689b 100644 --- a/packages/inngest/src/components/EventSchemas.ts +++ b/packages/inngest/src/components/EventSchemas.ts @@ -24,22 +24,6 @@ export type StandardEventSchema = { */ export type StandardEventSchemas = Record; -/** - * A helper type that ensures users cannot declare a literal Zod schema with - * an empty string as the event name. - * - * @public - */ -export type ExcludeEmptyZodLiterals = T extends LiteralZodEventSchemas - ? { - [I in keyof T]: T[I] extends z.ZodObject - ? U extends { name: z.ZodLiteral<""> } - ? "ERROR: Empty event names are now allowed." - : T[I] - : never; - } - : T; - /** * A literal Zod schema, which is a Zod schema that has a literal string as the * event name. This can be used to create correct Zod schemas outside of the @@ -285,7 +269,7 @@ export class EventSchemas> { */ public fromZod( // eslint-disable-next-line @typescript-eslint/no-unused-vars - schemas: ExcludeEmptyZodLiterals + schemas: T ) { return new EventSchemas< Combine< diff --git a/packages/inngest/src/components/Inngest.test.ts b/packages/inngest/src/components/Inngest.test.ts index f5b12b947..f0215c5dc 100644 --- a/packages/inngest/src/components/Inngest.test.ts +++ b/packages/inngest/src/components/Inngest.test.ts @@ -16,7 +16,7 @@ const testEventKey = "foo-bar-baz-test"; describe("instantiation", () => { describe("event key warnings", () => { let warnSpy: jest.SpyInstance; - const originalEnvEventKey = process.env[envKeys.EventKey]; + const originalEnvEventKey = process.env[envKeys.InngestEventKey]; beforeEach(() => { warnSpy = jest.spyOn(console, "warn"); @@ -27,27 +27,27 @@ describe("instantiation", () => { warnSpy.mockRestore(); if (originalEnvEventKey) { - process.env[envKeys.EventKey] = originalEnvEventKey; + process.env[envKeys.InngestEventKey] = originalEnvEventKey; } else { - delete process.env[envKeys.EventKey]; + delete process.env[envKeys.InngestEventKey]; } }); test("should log a warning if event key not specified", () => { - createClient({ name: "test" }); + createClient({ id: "test" }); expect(warnSpy).toHaveBeenCalledWith( expect.stringContaining("Could not find event key") ); }); test("should not log a warning if event key is specified", () => { - createClient({ name: "test", eventKey: testEventKey }); + createClient({ id: "test", eventKey: testEventKey }); expect(warnSpy).not.toHaveBeenCalled(); }); test("should not log a warning if event key is specified in env", () => { - process.env[envKeys.EventKey] = testEventKey; - createClient({ name: "test" }); + process.env[envKeys.InngestEventKey] = testEventKey; + createClient({ id: "test" }); expect(warnSpy).not.toHaveBeenCalled(); }); }); @@ -99,7 +99,7 @@ describe("send", () => { }); test("should fail to send if event key not specified at instantiation", async () => { - const inngest = createClient({ name: "test" }); + const inngest = createClient({ id: "test" }); await expect(() => inngest.send(testEvent)).rejects.toThrowError( "Failed to send event" @@ -107,9 +107,11 @@ describe("send", () => { }); test("should succeed if event key specified at instantiation", async () => { - const inngest = createClient({ name: "test", eventKey: testEventKey }); + const inngest = createClient({ id: "test", eventKey: testEventKey }); - await expect(inngest.send(testEvent)).resolves.toBeUndefined(); + await expect(inngest.send(testEvent)).resolves.toMatchObject({ + ids: Array(1).fill(expect.any(String)), + }); expect(global.fetch).toHaveBeenCalledWith( expect.stringContaining(`/e/${testEventKey}`), @@ -121,10 +123,12 @@ describe("send", () => { }); test("should succeed if event key specified in env", async () => { - process.env[envKeys.EventKey] = testEventKey; - const inngest = createClient({ name: "test" }); + process.env[envKeys.InngestEventKey] = testEventKey; + const inngest = createClient({ id: "test" }); - await expect(inngest.send(testEvent)).resolves.toBeUndefined(); + await expect(inngest.send(testEvent)).resolves.toMatchObject({ + ids: Array(1).fill(expect.any(String)), + }); expect(global.fetch).toHaveBeenCalledWith( expect.stringContaining(`/e/${testEventKey}`), @@ -136,10 +140,12 @@ describe("send", () => { }); test("should succeed if event key given at runtime", async () => { - const inngest = createClient({ name: "test" }); + const inngest = createClient({ id: "test" }); inngest.setEventKey(testEventKey); - await expect(inngest.send(testEvent)).resolves.toBeUndefined(); + await expect(inngest.send(testEvent)).resolves.toMatchObject({ + ids: Array(1).fill(expect.any(String)), + }); expect(global.fetch).toHaveBeenCalledWith( expect.stringContaining(`/e/${testEventKey}`), @@ -151,21 +157,25 @@ describe("send", () => { }); test("should succeed if an empty list of payloads is given", async () => { - const inngest = createClient({ name: "test" }); + const inngest = createClient({ id: "test" }); inngest.setEventKey(testEventKey); - await expect(inngest.send([])).resolves.toBeUndefined(); + await expect(inngest.send([])).resolves.toMatchObject({ + ids: Array(0).fill(expect.any(String)), + }); expect(global.fetch).not.toHaveBeenCalled(); }); test("should send env:foo if explicitly set", async () => { const inngest = createClient({ - name: "test", + id: "test", eventKey: testEventKey, env: "foo", }); - await expect(inngest.send(testEvent)).resolves.toBeUndefined(); + await expect(inngest.send(testEvent)).resolves.toMatchObject({ + ids: Array(1).fill(expect.any(String)), + }); expect(global.fetch).toHaveBeenCalledWith( expect.stringContaining(`/e/${testEventKey}`), expect.objectContaining({ @@ -179,14 +189,16 @@ describe("send", () => { }); test("should send env:foo if set in INNGEST_ENV", async () => { - process.env[envKeys.Environment] = "foo"; + process.env[envKeys.InngestEnvironment] = "foo"; const inngest = createClient({ - name: "test", + id: "test", eventKey: testEventKey, }); - await expect(inngest.send(testEvent)).resolves.toBeUndefined(); + await expect(inngest.send(testEvent)).resolves.toMatchObject({ + ids: Array(1).fill(expect.any(String)), + }); expect(global.fetch).toHaveBeenCalledWith( expect.stringContaining(`/e/${testEventKey}`), expect.objectContaining({ @@ -200,15 +212,17 @@ describe("send", () => { }); test("should send explicit env:foo over env var if set in both", async () => { - process.env[envKeys.Environment] = "bar"; + process.env[envKeys.InngestEnvironment] = "bar"; const inngest = createClient({ - name: "test", + id: "test", eventKey: testEventKey, env: "foo", }); - await expect(inngest.send(testEvent)).resolves.toBeUndefined(); + await expect(inngest.send(testEvent)).resolves.toMatchObject({ + ids: Array(1).fill(expect.any(String)), + }); expect(global.fetch).toHaveBeenCalledWith( expect.stringContaining(`/e/${testEventKey}`), expect.objectContaining({ @@ -225,11 +239,13 @@ describe("send", () => { process.env[envKeys.VercelBranch] = "foo"; const inngest = createClient({ - name: "test", + id: "test", eventKey: testEventKey, }); - await expect(inngest.send(testEvent)).resolves.toBeUndefined(); + await expect(inngest.send(testEvent)).resolves.toMatchObject({ + ids: Array(1).fill(expect.any(String)), + }); expect(global.fetch).toHaveBeenCalledWith( expect.stringContaining(`/e/${testEventKey}`), expect.objectContaining({ @@ -243,7 +259,7 @@ describe("send", () => { }); test("should insert `ts` timestamp ", async () => { - const inngest = createClient({ name: "test" }); + const inngest = createClient({ id: "test" }); inngest.setEventKey(testEventKey); const testEventWithoutTs = { @@ -253,7 +269,9 @@ describe("send", () => { const mockedFetch = jest.mocked(global.fetch); - await expect(inngest.send(testEventWithoutTs)).resolves.toBeUndefined(); + await expect(inngest.send(testEventWithoutTs)).resolves.toMatchObject({ + ids: Array(1).fill(expect.any(String)), + }); expect(mockedFetch).toHaveBeenCalledTimes(2); // 2nd for dev server check expect(mockedFetch.mock.calls[1]).toHaveLength(2); @@ -273,7 +291,7 @@ describe("send", () => { }); test("should insert blank `data` if none given", async () => { - const inngest = createClient({ name: "test" }); + const inngest = createClient({ id: "test" }); inngest.setEventKey(testEventKey); const testEventWithoutData = { @@ -282,7 +300,9 @@ describe("send", () => { const mockedFetch = jest.mocked(global.fetch); - await expect(inngest.send(testEventWithoutData)).resolves.toBeUndefined(); + await expect(inngest.send(testEventWithoutData)).resolves.toMatchObject({ + ids: Array(1).fill(expect.any(String)), + }); expect(mockedFetch).toHaveBeenCalledTimes(2); // 2nd for dev server check expect(mockedFetch.mock.calls[1]).toHaveLength(2); @@ -302,7 +322,7 @@ describe("send", () => { test("should allow middleware to mutate input", async () => { const inngest = createClient({ - name: "test", + id: "test", eventKey: testEventKey, middleware: [ new InngestMiddleware({ @@ -333,7 +353,9 @@ describe("send", () => { await expect( inngest.send({ ...testEvent, data: { foo: true } }) - ).resolves.toBeUndefined(); + ).resolves.toMatchObject({ + ids: Array(1).fill(expect.any(String)), + }); expect(global.fetch).toHaveBeenCalledWith( expect.stringContaining(`/e/${testEventKey}`), @@ -346,23 +368,54 @@ describe("send", () => { ); }); + test("should allow middleware to mutate output", async () => { + const inngest = createClient({ + id: "test", + eventKey: testEventKey, + middleware: [ + new InngestMiddleware({ + name: "Test", + init() { + return { + onSendEvent() { + return { + transformOutput({ result }) { + return { + result: { + ids: result.ids.map((id) => `${id}-bar`), + }, + }; + }, + }; + }, + }; + }, + }), + ], + }); + + await expect(inngest.send(testEvent)).resolves.toMatchObject({ + ids: Array(1).fill(expect.stringMatching(/-bar$/)), + }); + }); + test("should return error from Inngest if parsed", () => { global.fetch = setFetch({ status: 400, error: "Test Error" }); - const inngest = createClient({ name: "test", eventKey: testEventKey }); + const inngest = createClient({ id: "test", eventKey: testEventKey }); return expect(inngest.send(testEvent)).rejects.toThrowError("Test Error"); }); test("should return error from Inngest if parsed even for 200", () => { global.fetch = setFetch({ status: 200, error: "Test Error" }); - const inngest = createClient({ name: "test", eventKey: testEventKey }); + const inngest = createClient({ id: "test", eventKey: testEventKey }); return expect(inngest.send(testEvent)).rejects.toThrowError("Test Error"); }); test("should return error if bad status code with no error string", () => { global.fetch = setFetch({ status: 400 }); - const inngest = createClient({ name: "test", eventKey: testEventKey }); + const inngest = createClient({ id: "test", eventKey: testEventKey }); return expect(inngest.send(testEvent)).rejects.toThrowError( "Cannot process event payload" @@ -371,7 +424,7 @@ describe("send", () => { test("should return unknown error from response text if very bad status code", () => { global.fetch = setFetch({ status: 600 }); - const inngest = createClient({ name: "test", eventKey: testEventKey }); + const inngest = createClient({ id: "test", eventKey: testEventKey }); return expect(inngest.send(testEvent)).rejects.toThrowError("600"); }); @@ -379,7 +432,7 @@ describe("send", () => { describe("types", () => { describe("no custom types", () => { - const inngest = createClient({ name: "test", eventKey: testEventKey }); + const inngest = createClient({ id: "test", eventKey: testEventKey }); test("allows sending a single event with an object", () => { const _fn = () => inngest.send({ name: "anything", data: "foo" }); @@ -397,7 +450,7 @@ describe("send", () => { describe("multiple custom types", () => { const inngest = createClient({ - name: "test", + id: "test", eventKey: testEventKey, schemas: new EventSchemas().fromRecord<{ foo: { @@ -496,12 +549,12 @@ describe("send", () => { describe("createFunction", () => { describe("types", () => { describe("function input", () => { - const inngest = createClient({ name: "test" }); + const inngest = createClient({ id: "test" }); test("has attempt number", () => { inngest.createFunction( { - name: "test", + id: "test", onFailure: ({ attempt }) => { assertType(attempt); }, @@ -515,18 +568,11 @@ describe("createFunction", () => { }); describe("no custom types", () => { - const inngest = createClient({ name: "test" }); - - test("allows name to be a string", () => { - inngest.createFunction("test", { event: "test" }, ({ event }) => { - assertType(event.name); - assertType>(true); - }); - }); + const inngest = createClient({ id: "test" }); test("allows name to be an object", () => { inngest.createFunction( - { name: "test" }, + { id: "test" }, { event: "test" }, ({ event }) => { assertType(event.name); @@ -549,10 +595,10 @@ describe("createFunction", () => { test("disallows specifying cancellation with batching", () => { inngest.createFunction( + // @ts-expect-error Cannot specify cancellation with batching { - name: "test", + id: "test", batchEvents: { maxSize: 5, timeout: "5s" }, - // @ts-expect-error Cannot specify cancellation with batching cancelOn: [{ event: "test2" }], }, { event: "test" }, @@ -564,10 +610,10 @@ describe("createFunction", () => { test("disallows specifying rate limit with batching", () => { inngest.createFunction( + // @ts-expect-error Cannot specify rate limit with batching { - name: "test", + id: "test", batchEvents: { maxSize: 5, timeout: "5s" }, - // @ts-expect-error Cannot specify rate limit with batching rateLimit: { limit: 5, period: "5s" }, }, { event: "test" }, @@ -577,30 +623,31 @@ describe("createFunction", () => { ); }); - test("allows trigger to be a string", () => { - inngest.createFunction("test", "test", ({ event }) => { - assertType(event.name); - assertType>(true); - }); - }); - test("allows trigger to be an object with an event property", () => { - inngest.createFunction("test", { event: "test" }, ({ event }) => { - assertType(event.name); - assertType>(true); - }); + inngest.createFunction( + { id: "test" }, + { event: "test" }, + ({ event }) => { + assertType(event.name); + assertType>(true); + } + ); }); test("allows trigger to be an object with a cron property", () => { - inngest.createFunction("test", { cron: "test" }, ({ event }) => { - assertType(event.name); - assertType>(true); - }); + inngest.createFunction( + { id: "test" }, + { cron: "test" }, + ({ event }) => { + assertType(event.name); + assertType>(true); + } + ); }); test("disallows trigger with unknown properties", () => { // @ts-expect-error Unknown property - inngest.createFunction("test", { foo: "bar" }, ({ event }) => { + inngest.createFunction({ id: "test" }, { foo: "bar" }, ({ event }) => { assertType(event.name); assertType>(true); }); @@ -608,7 +655,7 @@ describe("createFunction", () => { test("disallows trigger with both event and cron properties", () => { inngest.createFunction( - "test", + { id: "test" }, // @ts-expect-error Both event and cron { event: "test", cron: "test" }, ({ event }) => { @@ -621,7 +668,7 @@ describe("createFunction", () => { describe("multiple custom types", () => { const inngest = createClient({ - name: "test", + id: "test", schemas: new EventSchemas().fromRecord<{ foo: { name: "foo"; @@ -648,16 +695,9 @@ describe("createFunction", () => { }); }); - test("allows name to be a string", () => { - inngest.createFunction("test", { event: "foo" }, ({ event }) => { - assertType<"foo">(event.name); - assertType<{ title: string }>(event.data); - }); - }); - test("allows name to be an object", () => { inngest.createFunction( - { name: "test" }, + { id: "test" }, { event: "bar" }, ({ event }) => { assertType<"bar">(event.name); @@ -678,24 +718,25 @@ describe("createFunction", () => { ); }); - test("allows trigger to be a string", () => { - inngest.createFunction("test", "bar", ({ event }) => { - assertType<"bar">(event.name); - assertType<{ message: string }>(event.data); - }); - }); - test("allows trigger to be an object with an event property", () => { - inngest.createFunction("test", { event: "foo" }, ({ event }) => { - assertType<"foo">(event.name); - assertType<{ title: string }>(event.data); - }); + inngest.createFunction( + { id: "test" }, + { event: "foo" }, + ({ event }) => { + assertType<"foo">(event.name); + assertType<{ title: string }>(event.data); + } + ); }); test("allows trigger to be an object with a cron property", () => { - inngest.createFunction("test", { cron: "test" }, ({ event }) => { - assertType(event); - }); + inngest.createFunction( + { id: "test" }, + { cron: "test" }, + ({ event }) => { + assertType(event); + } + ); }); test("disallows trigger with unknown properties", () => { @@ -707,7 +748,7 @@ describe("createFunction", () => { test("disallows trigger with both event and cron properties", () => { inngest.createFunction( - "test", + { id: "test" }, // @ts-expect-error Both event and cron { event: "foo", cron: "test" }, ({ event }) => { diff --git a/packages/inngest/src/components/Inngest.ts b/packages/inngest/src/components/Inngest.ts index ecc1a0efe..8b580cc6c 100644 --- a/packages/inngest/src/components/Inngest.ts +++ b/packages/inngest/src/components/Inngest.ts @@ -1,5 +1,10 @@ import { InngestApi } from "../api/api"; -import { envKeys } from "../helpers/consts"; +import { + defaultInngestBaseUrl, + defaultInngestEventBaseUrl, + envKeys, + logPrefix, +} from "../helpers/consts"; import { devServerAvailable, devServerUrl } from "../helpers/devserver"; import { devServerHost, @@ -14,6 +19,7 @@ import { type ExclusiveKeys, type SendEventPayload } from "../helpers/types"; import { DefaultLogger, ProxyLogger, type Logger } from "../middleware/logger"; import { sendEventResponseSchema, + type AnyHandler, type ClientOptions, type EventNameFromTrigger, type EventPayload, @@ -22,8 +28,8 @@ import { type FunctionTrigger, type Handler, type MiddlewareStack, + type SendEventOutput, type SendEventResponse, - type ShimmedFns, type TriggerOptions, } from "../types"; import { type EventSchemas } from "./EventSchemas"; @@ -35,6 +41,7 @@ import { type MiddlewareOptions, type MiddlewareRegisterFn, type MiddlewareRegisterReturn, + type SendEventHookStack, } from "./InngestMiddleware"; /** @@ -53,6 +60,9 @@ export type EventsFromOpts = ? U : Record; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type AnyInngest = Inngest; + /** * A client used to interact with the Inngest API by sending or reacting to * events. @@ -77,27 +87,31 @@ export type EventsFromOpts = */ export class Inngest { /** - * The name of this instance, most commonly the name of the application it + * 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. */ - public readonly name: string; + public readonly id: string; /** * Inngest event key, used to send events to Inngest Cloud. */ private eventKey = ""; - /** - * Base URL for Inngest Cloud. - */ - public readonly inngestBaseUrl: URL; + private readonly baseUrl: string | undefined; private readonly inngestApi: InngestApi; /** * The absolute URL of the Inngest Cloud API. */ - private inngestApiUrl: URL = new URL(`e/${this.eventKey}`, "https://inn.gs/"); + private sendEventUrl: URL = new URL( + `e/${this.eventKey}`, + defaultInngestEventBaseUrl + ); private readonly headers: Record; @@ -132,22 +146,24 @@ export class Inngest { * ``` */ constructor({ - name, + id, eventKey, - inngestBaseUrl = "https://inn.gs/", + baseUrl, fetch, env, logger = new DefaultLogger(), middleware, }: TOpts) { - if (!name) { + if (!id) { // TODO PrettyError - throw new Error("A name must be passed to create an Inngest instance."); + throw new Error("An `id` must be passed to create an Inngest instance."); } - this.name = name; - this.inngestBaseUrl = new URL(inngestBaseUrl); - this.setEventKey(eventKey || processEnv(envKeys.EventKey) || ""); + this.id = id; + + this.baseUrl = baseUrl || processEnv(envKeys.InngestBaseUrl); + + this.setEventKey(eventKey || processEnv(envKeys.InngestEventKey) || ""); if (!this.eventKey) { console.warn( @@ -167,13 +183,12 @@ export class Inngest { this.headers = inngestHeaders({ inngestEnv: env, }); + this.fetch = getFetch(fetch); - const signingKey = processEnv(envKeys.SigningKey) || ""; this.inngestApi = new InngestApi({ - baseUrl: - processEnv(envKeys.InngestApiBaseUrl) || "https://api.inngest.com", - signingKey: signingKey, + baseUrl: this.baseUrl || defaultInngestBaseUrl, + signingKey: processEnv(envKeys.InngestSigningKey) || "", fetch: this.fetch, }); @@ -275,7 +290,11 @@ export class Inngest { eventKey: string ): void { this.eventKey = eventKey; - this.inngestApiUrl = new URL(`e/${this.eventKey}`, this.inngestBaseUrl); + + this.sendEventUrl = new URL( + `e/${this.eventKey}`, + this.baseUrl || defaultInngestEventBaseUrl + ); } /** @@ -307,7 +326,7 @@ export class Inngest { */ public async send>>( payload: Payload - ): Promise { + ): Promise> { const hooks = await getHookStack( this.middleware, "onSendEvent", @@ -316,8 +335,10 @@ export class Inngest { transformInput: (prev, output) => { return { ...prev, ...output }; }, - transformOutput: (prev, _output) => { - return prev; + transformOutput(prev, output) { + return { + result: { ...prev.result, ...output?.result }, + }; }, } ); @@ -358,12 +379,23 @@ export class Inngest { }; }); + const applyHookToOutput = async ( + arg: Parameters>[0] + ): Promise> => { + const hookOutput = await hooks.transformOutput?.(arg); + return { + ...arg.result, + ...hookOutput?.result, + // 🤮 + } as unknown as SendEventOutput; + }; + /** * 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) { - return console.warn( + console.warn( prettyError({ type: "warn", whatHappened: "`inngest.send()` called with no events", @@ -374,11 +406,13 @@ export class Inngest { stack: true, }) ); + + return await applyHookToOutput({ result: { ids: [] } }); } // When sending events, check if the dev server is available. If so, use the // dev server. - let url = this.inngestApiUrl.href; + let url = this.sendEventUrl.href; if (!skipDevServer()) { const host = devServerHost(); @@ -409,99 +443,74 @@ export class Inngest { throw await this.#getResponseError(response, body.error); } - return void (await hooks.transformOutput?.({ payloads: [...payloads] })); + return await applyHookToOutput({ result: { ids: body.ids } }); } public createFunction< - TFns extends Record, TMiddleware extends MiddlewareStack, TTrigger extends TriggerOptions & string>, - TShimmedFns extends Record< - string, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (...args: any[]) => any - > = ShimmedFns, TTriggerName extends keyof EventsFromOpts & string = EventNameFromTrigger, TTrigger> >( - nameOrOpts: - | string - | ExclusiveKeys< - Omit< - FunctionOptions, TTriggerName>, - "fns" | "onFailure" | "middleware" - > & { - /** - * Pass in an object of functions that will be wrapped in Inngest - * tooling and passes to your handler. This wrapping ensures that each - * function is automatically separated and retried. - * - * @example - * - * Both examples behave the same; it's preference as to which you - * prefer. - * - * ```ts - * import { userDb } from "./db"; - * - * // Specify `fns` and be able to use them in your Inngest function - * inngest.createFunction( - * { name: "Create user from PR", fns: { ...userDb } }, - * { event: "github/pull_request" }, - * async ({ fns: { createUser } }) => { - * await createUser("Alice"); - * } - * ); - * - * // Or always use `run()` to run inline steps and use them directly - * inngest.createFunction( - * { name: "Create user from PR" }, - * { event: "github/pull_request" }, - * async ({ step: { run } }) => { - * await run("createUser", () => userDb.createUser("Alice")); - * } - * ); - * ``` - */ - fns?: TFns; - - /** - * Provide a function to be called if your function fails, meaning - * that it ran out of retries and was unable to complete successfully. - * - * This is useful for sending warning notifications or cleaning up - * after a failure and supports all the same functionality as a - * regular handler. - */ - onFailure?: Handler< - TOpts, - EventsFromOpts, - TTriggerName, - TShimmedFns, - ExtendWithMiddleware< - [ - typeof builtInMiddleware, - NonNullable, - TMiddleware - ], - FailureEventArgs[TTriggerName]> - > - >; - - /** - * TODO - */ - middleware?: TMiddleware; - }, - "batchEvents", - "cancelOn" | "rateLimit" - >, + options: ExclusiveKeys< + Omit< + FunctionOptions, TTriggerName>, + "onFailure" | "middleware" + > & { + /** + * Provide a function to be called if your function fails, meaning + * that it ran out of retries and was unable to complete successfully. + * + * This is useful for sending warning notifications or cleaning up + * after a failure and supports all the same functionality as a + * regular handler. + */ + onFailure?: Handler< + TOpts, + EventsFromOpts, + TTriggerName, + ExtendWithMiddleware< + [ + typeof builtInMiddleware, + NonNullable, + TMiddleware + ], + FailureEventArgs[TTriggerName]> + > + >; + + /** + * Define a set of middleware that can be registered to hook into + * various lifecycles of the SDK and affect input and output of + * Inngest functionality. + * + * See {@link https://innge.st/middleware} + * + * @example + * + * ```ts + * export const inngest = new Inngest({ + * middleware: [ + * new InngestMiddleware({ + * name: "My Middleware", + * init: () => { + * // ... + * } + * }) + * ] + * }); + * ``` + */ + middleware?: TMiddleware; + }, + "batchEvents", + "cancelOn" | "rateLimit" + >, trigger: TTrigger, handler: Handler< TOpts, EventsFromOpts, TTriggerName, - TShimmedFns, ExtendWithMiddleware< [ typeof builtInMiddleware, @@ -516,17 +525,33 @@ export class Inngest { FunctionTrigger & string>, FunctionOptions, keyof EventsFromOpts & string> > { - const sanitizedOpts = ( - typeof nameOrOpts === "string" ? { name: nameOrOpts } : nameOrOpts - ) as FunctionOptions< + let sanitizedOpts: FunctionOptions< EventsFromOpts, keyof EventsFromOpts & string >; + if (typeof options === "string") { + // v2 -> v3 runtime migraton warning + console.warn( + `${logPrefix} InngestFunction: Creating a function with a string as the first argument has been deprecated in v3; pass an object instead. See https://www.inngest.com/docs/sdk/migration` + ); + + sanitizedOpts = { id: options }; + } else { + sanitizedOpts = options as typeof sanitizedOpts; + } + let sanitizedTrigger: FunctionTrigger & string>; if (typeof trigger === "string") { - sanitizedTrigger = { event: trigger }; + // v2 -> v3 migration warning + console.warn( + `${logPrefix} InngestFunction: Creating a function with a string as the second argument has been deprecated in v3; pass an object instead. See https://www.inngest.com/docs/sdk/migration` + ); + + sanitizedTrigger = { + event: trigger, + }; } else if (trigger.event) { sanitizedTrigger = { event: trigger.event, @@ -536,12 +561,18 @@ export class Inngest { sanitizedTrigger = trigger; } + if (Object.prototype.hasOwnProperty.call(sanitizedOpts, "fns")) { + // v2 -> v3 migration warning + console.warn( + `${logPrefix} InngestFunction: \`fns\` option has been deprecated in v3; use \`middleware\` instead. See https://www.inngest.com/docs/sdk/migration` + ); + } + return new InngestFunction( this, sanitizedOpts, sanitizedTrigger, - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-explicit-any - handler as any + handler as AnyHandler ); } } @@ -557,7 +588,7 @@ export class Inngest { * If this is moved, please ensure that using this package in another project * can correctly access comments on mutated input and output. */ -const builtInMiddleware = ((m: T): T => m)([ +export const builtInMiddleware = ((m: T): T => m)([ new InngestMiddleware({ name: "Inngest: Logger", init({ client }) { diff --git a/packages/inngest/src/components/InngestCommHandler.test.ts b/packages/inngest/src/components/InngestCommHandler.test.ts index ab077feaa..b2c9be013 100644 --- a/packages/inngest/src/components/InngestCommHandler.test.ts +++ b/packages/inngest/src/components/InngestCommHandler.test.ts @@ -1,8 +1,5 @@ import { EventSchemas } from "@local"; -import { type ServeHandler } from "@local/components/InngestCommHandler"; -import { type IsAny } from "@local/helpers/types"; import { serve } from "@local/next"; -import { assertType } from "type-plus"; import { z } from "zod"; import { createClient } from "../test/helpers"; @@ -18,7 +15,7 @@ describe("#153", () => { type Json = Literal | { [key: string]: Json } | Json[]; const inngest = createClient({ - name: "My App", + id: "My App", schemas: new EventSchemas().fromRecord<{ foo: { name: "foo"; @@ -33,12 +30,38 @@ describe("#153", () => { * This would throw: * "Type instantiation is excessively deep and possibly infinite.ts(2589)" */ - serve(inngest, []); + serve({ client: inngest, functions: [] }); }); }); describe("ServeHandler", () => { - test("serve handlers return any", () => { - assertType>>(true); + describe("functions argument", () => { + test("types: allows mutable functions array", () => { + const inngest = createClient({ id: "test" }); + + const functions = [ + inngest.createFunction( + { id: "test" }, + { event: "demo/event.sent" }, + () => "test" + ), + ]; + + serve({ client: inngest, functions }); + }); + + test("types: allows readonly functions array", () => { + const inngest = createClient({ id: "test" }); + + const functions = [ + inngest.createFunction( + { id: "test" }, + { event: "demo/event.sent" }, + () => "test" + ), + ] as const; + + serve({ client: inngest, functions }); + }); }); }); diff --git a/packages/inngest/src/components/InngestCommHandler.ts b/packages/inngest/src/components/InngestCommHandler.ts index f19a7a430..7c41b758d 100644 --- a/packages/inngest/src/components/InngestCommHandler.ts +++ b/packages/inngest/src/components/InngestCommHandler.ts @@ -1,8 +1,16 @@ import canonicalize from "canonicalize"; +import debug from "debug"; import { hmac, sha256 } from "hash.js"; import { z } from "zod"; import { ServerTiming } from "../helpers/ServerTiming"; -import { envKeys, headerKeys, queryKeys } from "../helpers/consts"; +import { + debugPrefix, + defaultInngestBaseUrl, + envKeys, + headerKeys, + logPrefix, + queryKeys, +} from "../helpers/consts"; import { devServerAvailable, devServerUrl } from "../helpers/devserver"; import { allProcessEnv, @@ -12,90 +20,139 @@ import { isProd, platformSupportsStreaming, skipDevServer, + type Env, } from "../helpers/env"; -import { OutgoingResultError, serializeError } from "../helpers/errors"; -import { cacheFn, parseFnData } from "../helpers/functions"; -import { createStream } from "../helpers/stream"; +import { rethrowError, serializeError } from "../helpers/errors"; import { - hashSigningKey, - stringify, - stringifyUnknown, -} from "../helpers/strings"; + fetchAllFnData, + parseFnData, + undefinedToNull, + type FnData, +} from "../helpers/functions"; +import { runAsPromise } from "../helpers/promises"; +import { createStream } from "../helpers/stream"; +import { hashSigningKey, stringify } from "../helpers/strings"; import { type MaybePromise } from "../helpers/types"; import { + logLevels, + type EventPayload, type FunctionConfig, - type IncomingOp, - type InternalRegisterOptions, type IntrospectRequest, type LogLevel, + type OutgoingOp, type RegisterOptions, type RegisterRequest, - type StepRunResponse, type SupportedFrameworkName, } from "../types"; import { version } from "../version"; -import { type Inngest } from "./Inngest"; -import { type InngestFunction } from "./InngestFunction"; -import { NonRetriableError } from "./NonRetriableError"; +import { type AnyInngest } from "./Inngest"; +import { + type AnyInngestFunction, + type CreateExecutionOptions, + type InngestFunction, +} from "./InngestFunction"; +import { + ExecutionVersion, + PREFERRED_EXECUTION_VERSION, + type ExecutionResult, + type ExecutionResultHandler, + type ExecutionResultHandlers, + type InngestExecutionOptions, +} from "./execution/InngestExecution"; /** - * A handler for serving Inngest functions. This type should be used - * whenever a handler for a new framework is being added to enforce that the - * registration process is always the same for the user. - * - * @example - * ``` - * // my-custom-handler.ts - * import { InngestCommHandler, ServeHandler } from "inngest"; - * - * export const serve: ServeHandler = (nameOrInngest, fns, opts) => { - * const handler = new InngestCommHandler( - * "my-custom-handler", - * nameOrInngest, - * fns, - * opts, - * () => { ... }, - * () => { ... } - * ); - * - * return handler.createHandler(); - * }; - * ``` + * A set of options that can be passed to a serve handler, intended to be used + * by internal and custom serve handlers to provide a consistent interface. * * @public */ -export type ServeHandler = ( +export interface ServeHandlerOptions extends RegisterOptions { /** - * The name of this app, used to scope and group Inngest functions, or - * the `Inngest` instance used to declare all functions. + * The `Inngest` instance used to declare all functions. */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - client: Inngest, + client: AnyInngest; /** * An array of the functions to serve and register with Inngest. */ + functions: readonly AnyInngestFunction[]; +} + +export interface InternalServeHandlerOptions extends ServeHandlerOptions { + /** + * Can be used to override the framework name given to a particular serve + * handler. + */ + frameworkName?: string; +} + +interface InngestCommHandlerOptions< + // eslint-disable-next-line @typescript-eslint/no-explicit-any + Input extends any[] = any[], // eslint-disable-next-line @typescript-eslint/no-explicit-any - functions: InngestFunction[], + Output = any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + StreamOutput = any +> extends RegisterOptions { + /** + * The name of the framework this handler is designed for. Should be + * lowercase, alphanumeric characters inclusive of `-` and `/`. + * + * This should never be defined by the user; a {@link ServeHandler} should + * abstract this. + */ + frameworkName: string; + + /** + * The name of this serve handler, e.g. `"My App"`. It's recommended that this + * value represents the overarching app/service that this set of functions is + * being served from. + * + * This can also be an `Inngest` client, in which case the name given when + * instantiating the client is used. This is useful if you're sending and + * receiving events from the same service, as you can reuse a single + * definition of Inngest. + */ + client: AnyInngest; /** - * A set of options to further configure the registration of Inngest - * functions. + * An array of the functions to serve and register with Inngest. */ - opts?: RegisterOptions + functions: readonly AnyInngestFunction[]; + /** - * This `any` return is appropriate. + * The `handler` is the function your framework requires to handle a + * request. For example, this is most commonly a function that is given a + * `Request` and must return a `Response`. * - * While we can infer the signature of the returned value, we cannot guarantee - * that we have used the same types as the framework we are integrating with, - * which sometimes can cause frustrating collisions for a user that result in - * `as unknown as X` casts. + * The handler must map out any incoming parameters, then return a + * strictly-typed object to assess what kind of request is being made, + * collecting any relevant data as we go. * - * Instead, we will use `any` here and have the user be able to place it - * anywhere they need. + * @example + * ``` + * return { + * register: () => { ... }, + * run: () => { ... }, + * view: () => { ... } + * }; + * ``` + * + * Every key must be specified and must be a function that either returns + * a strictly-typed payload or `undefined` if the request is not for that + * purpose. + * + * This gives handlers freedom to choose how their platform of choice will + * trigger differing actions, whilst also ensuring all required information + * is given for each request type. + * + * See any existing handler for a full example. + * + * This should never be defined by the user; a {@link ServeHandler} should + * abstract this. */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any -) => any; + handler: Handler; +} /** * Capturing the global type of fetch so that we can reliably access it below. @@ -148,36 +205,24 @@ const registerResSchema = z.object({ * @public */ export class InngestCommHandler< - H extends Handler, - TResTransform extends ( - res: ActionResponse, - ...args: Parameters - ) => // eslint-disable-next-line @typescript-eslint/no-explicit-any - any, - TStreamTransform extends ( - res: ActionResponse, - ...args: Parameters - ) => // eslint-disable-next-line @typescript-eslint/no-explicit-any - any + // eslint-disable-next-line @typescript-eslint/no-explicit-any + Input extends any[] = any[], + // eslint-disable-next-line @typescript-eslint/no-explicit-any + Output = any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + StreamOutput = any > { /** - * The name of this serve handler, e.g. `"My App"`. It's recommended that this + * The ID of this serve handler, e.g. `"my-app"`. It's recommended that this * value represents the overarching app/service that this set of functions is * being served from. */ - public readonly name: string; + public readonly id: string; /** * The handler specified during instantiation of the class. */ - public readonly handler: H; - - /** - * The response transformer specified during instantiation of the class. - */ - public readonly transformRes: TResTransform; - - public readonly streamTransformRes: TStreamTransform | undefined; + public readonly handler: Handler; /** * The URL of the Inngest function registration endpoint. @@ -186,8 +231,7 @@ export class InngestCommHandler< /** * The name of the framework this handler is designed for. Should be - * lowercase, alphanumeric characters inclusive of `-` and `/`. This should - * never be defined by the user; a {@link ServeHandler} should abstract this. + * lowercase, alphanumeric characters inclusive of `-` and `/`. */ protected readonly frameworkName: string; @@ -263,11 +307,9 @@ export class InngestCommHandler< * A private collection of just Inngest functions, as they have been passed * when instantiating the class. */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - private readonly rawFns: InngestFunction[]; + private readonly rawFns: AnyInngestFunction[]; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - private readonly client: Inngest; + private readonly client: AnyInngest; /** * A private collection of functions that are being served. This map is used @@ -278,124 +320,29 @@ export class InngestCommHandler< { fn: InngestFunction; onFailure: boolean } > = {}; - private allowExpiredSignatures: boolean; - - constructor( - /** - * The name of the framework this handler is designed for. Should be - * lowercase, alphanumeric characters inclusive of `-` and `/`. - * - * This should never be defined by the user; a {@link ServeHandler} should - * abstract this. - */ - frameworkName: string, - - /** - * The name of this serve handler, e.g. `"My App"`. It's recommended that this - * value represents the overarching app/service that this set of functions is - * being served from. - * - * This can also be an `Inngest` client, in which case the name given when - * instantiating the client is used. This is useful if you're sending and - * receiving events from the same service, as you can reuse a single - * definition of Inngest. - */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - client: Inngest, - - /** - * An array of the functions to serve and register with Inngest. - */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - functions: InngestFunction[], - options: RegisterOptions = {}, + private env: Env = allProcessEnv(); - /** - * The `handler` is the function your framework requires to handle a - * request. For example, this is most commonly a function that is given a - * `Request` and must return a `Response`. - * - * The handler must map out any incoming parameters, then return a - * strictly-typed object to assess what kind of request is being made, - * collecting any relevant data as we go. - * - * @example - * ``` - * return { - * register: () => { ... }, - * run: () => { ... }, - * view: () => { ... } - * }; - * ``` - * - * Every key must be specified and must be a function that either returns - * a strictly-typed payload or `undefined` if the request is not for that - * purpose. - * - * This gives handlers freedom to choose how their platform of choice will - * trigger differing actions, whilst also ensuring all required information - * is given for each request type. - * - * See any existing handler for a full example. - * - * This should never be defined by the user; a {@link ServeHandler} should - * abstract this. - */ - handler: H, + private allowExpiredSignatures: boolean; + constructor(options: InngestCommHandlerOptions) { /** - * The `transformRes` function receives the output of the Inngest SDK and - * can decide how to package up that information to appropriately return the - * information to Inngest. + * v2 -> v3 migration error. * - * Mostly, this is taking the given parameters and returning a new - * `Response`. - * - * The function is passed an {@link ActionResponse} (an object containing a - * `status` code, a `headers` object, and a stringified `body`), as well as - * every parameter passed to the given `handler` function. This ensures you - * can appropriately handle the response, including use of any required - * parameters such as `res` in Express-/Connect-like frameworks. - * - * This should never be defined by the user; a {@link ServeHandler} should - * abstract this. + * 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. */ - transformRes: TResTransform, + if (Object.prototype.hasOwnProperty.call(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` + ); + } - /** - * The `streamTransformRes` function, if defined, declares that this handler - * supports streaming responses back to Inngest. This is useful for - * functions that are expected to take a long time, as edge streaming can - * often circumvent restrictive request timeouts and other limitations. - * - * If your handler does not support streaming, do not define this function. - * - * It receives the output of the Inngest SDK and can decide how to package - * up that information to appropriately return the information in a stream - * to Inngest. - * - * Mostly, this is taking the given parameters and returning a new - * `Response`. - * - * The function is passed an {@link ActionResponse} (an object containing a - * `status` code, a `headers` object, and `body`, a `ReadableStream`), as - * well as every parameter passed to the given `handler` function. This - * ensures you can appropriately handle the response, including use of any - * required parameters such as `res` in Express-/Connect-like frameworks. - * - * This should never be defined by the user; a {@link ServeHandler} should - * abstract this. - */ - streamTransformRes?: TStreamTransform - ) { - this.frameworkName = - (options as InternalRegisterOptions)?.frameworkName || frameworkName; - this.client = client; - this.name = options.name || this.client.name; + this.frameworkName = options.frameworkName; + this.client = options.client; + this.id = options.id || this.client.id; - this.handler = handler; - this.transformRes = transformRes; - this.streamTransformRes = streamTransformRes; + this.handler = options.handler as Handler; /** * Provide a hidden option to allow expired signatures to be accepted during @@ -403,13 +350,13 @@ export class InngestCommHandler< */ this.allowExpiredSignatures = Boolean( // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, prefer-rest-params - arguments["3"]?.__testingAllowExpiredSignatures + arguments["0"]?.__testingAllowExpiredSignatures ); // Ensure we filter any undefined functions in case of missing imports. - this.rawFns = functions.filter(Boolean); + this.rawFns = options.functions.filter(Boolean); - if (this.rawFns.length !== functions.length) { + if (this.rawFns.length !== options.functions.length) { // TODO PrettyError console.warn( `Some functions passed to serve() are undefined and misconfigured. Please check your imports.` @@ -419,10 +366,7 @@ export class InngestCommHandler< this.fns = this.rawFns.reduce< Record >((acc, fn) => { - const configs = fn["getConfig"]( - new URL("https://example.com"), - this.name - ); + const configs = fn["getConfig"](new URL("https://example.com"), this.id); const fns = configs.reduce((acc, { id }, index) => { return { ...acc, [id]: { fn, onFailure: Boolean(index) } }; @@ -444,14 +388,52 @@ export class InngestCommHandler< }, {}); this.inngestRegisterUrl = new URL( - options.inngestRegisterUrl || "https://api.inngest.com/fn/register" + "/fn/register", + options.baseUrl || + this.env[envKeys.InngestBaseUrl] || + this.client["baseUrl"] || + defaultInngestBaseUrl ); this.signingKey = options.signingKey; - this.serveHost = options.serveHost; - this.servePath = options.servePath; - this.logLevel = options.logLevel ?? "info"; - this.streaming = options.streaming ?? false; + this.serveHost = options.serveHost || this.env[envKeys.InngestServeHost]; + this.servePath = options.servePath || this.env[envKeys.InngestServePath]; + + const defaultLogLevel: typeof this.logLevel = "info"; + this.logLevel = z + .enum(logLevels) + .default(defaultLogLevel) + .catch((ctx) => { + this.log( + "warn", + `Unknown log level passed: ${String( + ctx.input + )}; defaulting to ${defaultLogLevel}` + ); + + return defaultLogLevel; + }) + .parse(options.logLevel || this.env[envKeys.InngestLogLevel]); + + if (this.logLevel === "debug") { + debug.enable(`${debugPrefix}:*`); + } + + const defaultStreamingOption: typeof this.streaming = false; + this.streaming = z + .union([z.enum(["allow", "force"]), z.literal(false)]) + .default(defaultStreamingOption) + .catch((ctx) => { + this.log( + "warn", + `Unknown streaming option passed: ${String( + ctx.input + )}; defaulting to ${String(defaultStreamingOption)}` + ); + + return defaultStreamingOption; + }) + .parse(options.streaming || this.env[envKeys.InngestStreaming]); this.fetch = getFetch(options.fetch || this.client["fetch"]); } @@ -485,40 +467,62 @@ export class InngestCommHandler< * }; * ``` */ - public createHandler(): ( - ...args: Parameters - ) => Promise>> { - return async (...args: Parameters) => { + public createHandler(): (...args: Input) => Promise> { + return async (...args: Input) => { const timer = new ServerTiming(); /** * We purposefully `await` the handler, as it could be either sync or * async. */ - // eslint-disable-next-line @typescript-eslint/await-thenable - const rawActions = await timer.wrap("handler", () => - this.handler(...args) - ); + const rawActions = await timer + .wrap("handler", () => this.handler(...args)) + .catch(rethrowError("Serve handler failed to run")); /** - * For each function within the actions returned, ensure that its value - * caches when run. This ensures that the function is only run once, even - * if it's called multiple times throughout this handler's invocation. + * Map over every `action` in `rawActions` and create a new `actions` + * object where each function is safely promisifed with each access + * requiring a reason. * - * Many frameworks have issues with multiple calls to req/res objects; - * reading a request's body multiple times is a common example. This makes - * sure to handle this without having to pass around references. + * This helps us provide high quality errors about what's going wrong for + * each access without having to wrap every access in a try/catch. */ - const actions = Object.fromEntries( - Object.entries(rawActions).map(([key, val]) => [ - key, - typeof val === "function" ? cacheFn(val) : val, - ]) - ) as typeof rawActions; - - const getHeaders = (): Record => + const actions: HandlerResponseWithErrors = Object.entries( + rawActions + ).reduce((acc, [key, value]) => { + if (typeof value !== "function") { + return acc; + } + + return { + ...acc, + [key]: (reason: string, ...args: unknown[]) => { + const errMessage = [ + `Failed calling \`${key}\` from serve handler`, + reason, + ] + .filter(Boolean) + .join(" when "); + + const fn = () => + (value as (...args: unknown[]) => unknown)(...args); + + return runAsPromise(fn) + .catch(rethrowError(errMessage)) + .catch((err) => { + this.log("error", err); + throw err; + }); + }, + }; + }, {} as HandlerResponseWithErrors); + + this.env = + (await actions.env?.("starting to handle request")) ?? allProcessEnv(); + + const getInngestHeaders = (): Record => inngestHeaders({ - env: actions.env as Record, + env: this.env, framework: this.frameworkName, client: this.client, extras: { @@ -527,7 +531,7 @@ export class InngestCommHandler< }); const actionRes = timer.wrap("action", () => - this.handleAction(actions as ReturnType>, timer) + this.handleAction(actions, timer, getInngestHeaders) ); /** @@ -540,8 +544,15 @@ export class InngestCommHandler< const prepareActionRes = (res: ActionResponse): ActionResponse => ({ ...res, headers: { - ...getHeaders(), + ...getInngestHeaders(), ...res.headers, + ...(res.version === null + ? {} + : { + [headerKeys.RequestVersion]: ( + res.version ?? PREFERRED_EXECUTION_VERSION + ).toString(), + }), }, }); @@ -550,38 +561,41 @@ export class InngestCommHandler< (this.streaming === "allow" && platformSupportsStreaming( this.frameworkName as SupportedFrameworkName, - actions.env as Record + this.env )); - if (wantToStream && this.streamTransformRes) { - const runRes = await actions.run(); - if (runRes) { + if (wantToStream && actions.transformStreamingResponse) { + const method = await actions.method("starting streaming response"); + + if (method === "POST") { const { stream, finalize } = await createStream(); /** * Errors are handled by `handleAction` here to ensure that an * appropriate response is always given. */ - void actionRes.then((res) => finalize(prepareActionRes(res))); + void actionRes.then((res) => { + return finalize(prepareActionRes(res)); + }); - return timer.wrap("res", () => - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - this.streamTransformRes?.( + return timer.wrap("res", () => { + return actions.transformStreamingResponse?.( + "starting streaming response", { status: 201, - headers: getHeaders(), + headers: getInngestHeaders(), body: stream, - }, - ...args - ) - ); + version: null, + } + ); + }); } } return timer.wrap("res", async () => { - return actionRes.then((res) => { + return actionRes.then(prepareActionRes).then((actionRes) => { // eslint-disable-next-line @typescript-eslint/no-unsafe-return - return this.transformRes(prepareActionRes(res), ...args); + return actions.transformResponse("sending back response", actionRes); }); }); }; @@ -599,82 +613,151 @@ export class InngestCommHandler< * found (e.g. env vars, options, etc). */ private async handleAction( - actions: ReturnType, - timer: ServerTiming + actions: HandlerResponseWithErrors, + timer: ServerTiming, + getInngestHeaders: () => Record ): Promise { - const env = actions.env ?? allProcessEnv(); - - const getHeaders = (): Record => - inngestHeaders({ - env: env as Record, - framework: this.frameworkName, - client: this.client, - extras: { - "Server-Timing": timer.getHeader(), - }, - }); - - this._isProd = actions.isProduction ?? isProd(env); + this._isProd = + (await actions.isProduction?.("starting to handle request")) ?? + isProd(this.env); /** * If we've been explicitly passed an Inngest dev sever URL, assume that * we shouldn't skip the dev server. */ - this._skipDevServer = devServerHost(env) + this._skipDevServer = devServerHost(this.env) ? false - : this._isProd ?? skipDevServer(env); + : this._isProd ?? skipDevServer(this.env); + + this.upsertKeysFromEnv(); try { - const runRes = await actions.run(); - - if (runRes) { - this.upsertKeysFromEnv(env); - this.validateSignature(runRes.signature, runRes.data); - this.client["inngestApi"].setSigningKey(this.signingKey); - - const stepRes = await this.runStep( - runRes.fnId, - runRes.stepId, - runRes.data, - timer + const url = await actions.url("starting to handle request"); + const method = await actions.method("starting to handle request"); + + const getQuerystring = async ( + reason: string, + key: string + ): Promise => { + const ret = + (await actions.queryString?.(reason, key, url)) || + url.searchParams.get(key) || + undefined; + + return ret; + }; + + if (method === "POST") { + const signature = await actions.headers( + "checking signature for run request", + headerKeys.Signature ); - if (stepRes.status === 500 || stepRes.status === 400) { - const headers: Record = { - "Content-Type": "application/json", - }; + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const body = await actions.body("processing run request"); + this.validateSignature(signature ?? undefined, body); + + const fnId = await getQuerystring( + "processing run request", + queryKeys.FnId + ); + if (!fnId) { + // TODO PrettyError + throw new Error("No function ID found in request"); + } + + const stepId = + (await getQuerystring("processing run request", queryKeys.StepId)) || + null; - if (stepRes.status === 400) { - headers[headerKeys.NoRetry] = "true"; + const { version, result } = this.runStep(fnId, stepId, body, timer); + 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: OutgoingOp) => { + const opData = z.object({ data: z.any() }).safeParse(op.data); + + if (opData.success) { + (op.data as { data: unknown }).data = undefinedToNull( + opData.data.data + ); } - return { - status: stepRes.status, - body: stringify( - stepRes.error || - serializeError( - new Error( - "Unknown error; function failed but no error was returned" - ) - ) - ), - headers, - }; - } + return op; + }; - return { - status: stepRes.status, - body: stringify(stepRes.body), - headers: { - "Content-Type": "application/json", + const resultHandlers: ExecutionResultHandlers = { + "function-rejected": (result) => { + return { + status: result.retriable ? 500 : 400, + headers: { + "Content-Type": "application/json", + [headerKeys.NoRetry]: result.retriable ? "false" : "true", + ...(typeof result.retriable === "string" + ? { [headerKeys.RetryAfter]: result.retriable } + : {}), + }, + body: stringify(undefinedToNull(result.error)), + version, + }; + }, + "function-resolved": (result) => { + return { + status: 200, + headers: { + "Content-Type": "application/json", + }, + body: stringify(undefinedToNull(result.data)), + version, + }; + }, + "step-not-found": (result) => { + return { + status: 500, + headers: { + "Content-Type": "application/json", + [headerKeys.NoRetry]: "false", + }, + body: stringify({ + error: `Could not find step "${result.step.id}" to run; timed out`, + }), + version, + }; + }, + "step-ran": (result) => { + const step = opDataUndefinedToNull(result.step); + + return { + status: 206, + headers: { "Content-Type": "application/json" }, + body: stringify([step]), + version, + }; + }, + "steps-found": (result) => { + const steps = result.steps.map(opDataUndefinedToNull); + + return { + status: 206, + headers: { "Content-Type": "application/json" }, + body: stringify(steps), + version, + }; }, }; + + const handler = resultHandlers[ + stepOutput.type + ] as ExecutionResultHandler; + + return await handler(stepOutput); } - const viewRes = await actions.view(); - if (viewRes) { - this.upsertKeysFromEnv(env); - const registerBody = this.registerBody(this.reqUrl(actions.url)); + if (method === "GET") { + const registerBody = this.registerBody(this.reqUrl(url)); const introspection: IntrospectRequest = { message: "Inngest endpoint configured correctly.", @@ -689,18 +772,20 @@ export class InngestCommHandler< headers: { "Content-Type": "application/json", }, + version: undefined, }; } - const registerRes = await actions.register(); - if (registerRes) { - this.upsertKeysFromEnv(env); + if (method === "PUT") { + const deployId = await getQuerystring( + "processing deployment request", + queryKeys.DeployId + ); const { status, message, modified } = await this.register( - this.reqUrl(actions.url), - stringifyUnknown(env[envKeys.DevServerUrl]), - registerRes.deployId, - getHeaders + this.reqUrl(url), + deployId, + getInngestHeaders ); return { @@ -709,6 +794,7 @@ export class InngestCommHandler< headers: { "Content-Type": "application/json", }, + version: undefined, }; } } catch (err) { @@ -721,6 +807,7 @@ export class InngestCommHandler< headers: { "Content-Type": "application/json", }, + version: undefined, }; } @@ -732,132 +819,130 @@ export class InngestCommHandler< skipDevServer: this._skipDevServer, }), headers: {}, + version: undefined, }; } - protected async runStep( + protected runStep( functionId: string, stepId: string | null, data: unknown, timer: ServerTiming - ): Promise { - try { - const fn = this.fns[functionId]; - if (!fn) { - // TODO PrettyError - throw new Error(`Could not find function with ID "${functionId}"`); - } + ): { version: ExecutionVersion; result: Promise } { + const fn = this.fns[functionId]; + if (!fn) { + // TODO PrettyError + throw new Error(`Could not find function with ID "${functionId}"`); + } - const fndata = await parseFnData(data, this.client["inngestApi"]); - if (!fndata.ok) { - throw new Error(fndata.error); - } - const { event, events, steps, ctx } = fndata.value; + const immediateFnData = parseFnData(data); + const { version } = immediateFnData; - /** - * TODO When the executor does support per-step errors, this map will need - * to adjust to ensure we're not double-stacking the op inside `data`. - */ - const opStack = - ctx?.stack?.stack - .slice(0, ctx.stack.current) - .map((opId) => { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const step = steps?.[opId]; - if (typeof step === "undefined") { - // TODO PrettyError - throw new Error(`Could not find step with ID "${opId}"`); - } - - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - return { id: opId, data: step }; - }) ?? []; - - const ret = await fn.fn["runFn"]( - { event, events, runId: ctx?.run_id, attempt: ctx?.attempt }, - opStack, - /** - * TODO The executor is sending `"step"` as the step ID when it is not - * wanting to run a specific step. This is not needed and we should - * remove this on the executor side. - */ - stepId === "step" ? null : stepId || null, - timer, - fn.onFailure + const result = runAsPromise(async () => { + const anyFnData = await fetchAllFnData( + immediateFnData, + this.client["inngestApi"] ); - - if (ret[0] === "complete") { - return { - status: 200, - body: ret[1], - }; + if (!anyFnData.ok) { + throw new Error(anyFnData.error); } - /** - * If the function has run user code and is intending to return an error, - * interrupt this flow and instead throw a 500 to Inngest. - * - * The executor doesn't yet support per-step errors, so returning an - * `error` key here would cause the executor to misunderstand what is - * happening. - * - * TODO When the executor does support per-step errors, we can remove this - * comment and check and functionality should resume as normal. - */ - if (ret[0] === "run" && ret[1].error) { - /** - * We throw the `data` here instead of the `error` because we expect - * `data` to be a prepared version of the error which may have been - * altered by middleware, whereas `error` is the initial triggering - * error. - */ - throw new OutgoingResultError({ - data: ret[1].data, - error: ret[1].error, - }); - } + type ExecutionStarter = ( + fnData: V extends ExecutionVersion + ? Extract + : FnData + ) => MaybePromise; - return { - status: 206, - body: Array.isArray(ret[1]) ? ret[1] : [ret[1]], + type GenericExecutionStarters = Record< + ExecutionVersion, + ExecutionStarter + >; + + type ExecutionStarters = { + [V in ExecutionVersion]: ExecutionStarter; }; - } catch (unserializedErr) { - /** - * Always serialize the error before sending it back to Inngest. Errors, - * by default, do not niceley serialize to JSON, so we use the a package - * to do this. - * - * See {@link https://www.npmjs.com/package/serialize-error} - */ - const isOutgoingOpError = unserializedErr instanceof OutgoingResultError; - const error = stringify( - serializeError( - isOutgoingOpError ? unserializedErr.result.data : unserializedErr - ) + const executionStarters = ((s: ExecutionStarters) => + s as GenericExecutionStarters)({ + [ExecutionVersion.V0]: ({ event, events, steps, ctx, version }) => { + const stepState = Object.entries(steps ?? {}).reduce< + InngestExecutionOptions["stepState"] + >((acc, [id, data]) => { + return { + ...acc, + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + [id]: { id, data }, + }; + }, {}); + + return { + version, + partialOptions: { + runId: ctx?.run_id || "", + data: { + event: event as EventPayload, + events: events as [EventPayload, ...EventPayload[]], + runId: ctx?.run_id || "", + attempt: ctx?.attempt ?? 0, + }, + stepState, + requestedRunStep: + stepId === "step" ? undefined : stepId || undefined, + timer, + isFailureHandler: fn.onFailure, + stepCompletionOrder: ctx?.stack?.stack ?? [], + }, + }; + }, + [ExecutionVersion.V1]: ({ event, events, steps, ctx, version }) => { + const stepState = Object.entries(steps ?? {}).reduce< + InngestExecutionOptions["stepState"] + >((acc, [id, result]) => { + return { + ...acc, + [id]: + result.type === "data" + ? // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + { id, data: result.data } + : { id, error: result.error }, + }; + }, {}); + + return { + version, + partialOptions: { + runId: ctx?.run_id || "", + data: { + event: event as EventPayload, + events: events as [EventPayload, ...EventPayload[]], + runId: ctx?.run_id || "", + attempt: ctx?.attempt ?? 0, + }, + stepState, + requestedRunStep: + stepId === "step" ? undefined : stepId || undefined, + timer, + isFailureHandler: fn.onFailure, + disableImmediateExecution: ctx?.disable_immediate_execution, + stepCompletionOrder: ctx?.stack?.stack ?? [], + }, + }; + }, + }); + + const executionOptions = await executionStarters[anyFnData.value.version]( + anyFnData.value ); - const isNonRetriableError = isOutgoingOpError - ? unserializedErr.result.error instanceof NonRetriableError - : unserializedErr instanceof NonRetriableError; + return fn.fn["createExecution"](executionOptions).start(); + }); - /** - * If we've caught a non-retriable error, we'll return a 400 to Inngest - * to indicate that the error is not transient and should not be retried. - * - * The errors caught here are caught from the main function as well as - * inside individual steps, so this safely catches all areas. - */ - return { - status: isNonRetriableError ? 400 : 500, - error, - }; - } + return { version, result }; } protected configs(url: URL): FunctionConfig[] { return Object.values(this.rawFns).reduce( - (acc, fn) => [...acc, ...fn["getConfig"](url, this.name)], + (acc, fn) => [...acc, ...fn["getConfig"](url, this.id)], [] ); } @@ -871,14 +956,16 @@ export class InngestCommHandler< protected reqUrl(url: URL): URL { let ret = new URL(url); - if (this.servePath) ret.pathname = this.servePath; - if (this.serveHost) - ret = new URL(ret.pathname + ret.search, this.serveHost); + const serveHost = this.serveHost || this.env[envKeys.InngestServeHost]; + const servePath = this.servePath || this.env[envKeys.InngestServePath]; - /** - * Remove any introspection query strings. - */ - ret.searchParams.delete(queryKeys.Introspect); + if (servePath) { + ret.pathname = servePath; + } + + if (serveHost) { + ret = new URL(ret.pathname + ret.search, serveHost); + } return ret; } @@ -888,7 +975,7 @@ export class InngestCommHandler< url: url.href, deployType: "ping", framework: this.frameworkName, - appName: this.name, + appName: this.id, functions: this.configs(url), sdk: `js:v${version}`, v: "0.1", @@ -901,7 +988,6 @@ export class InngestCommHandler< protected async register( url: URL, - devServerHost: string | undefined, deployId: string | undefined | null, getHeaders: () => Record ): Promise<{ status: number; message: string; modified: boolean }> { @@ -914,13 +1000,14 @@ export class InngestCommHandler< let registerURL = this.inngestRegisterUrl; if (!this._skipDevServer) { - const hasDevServer = await devServerAvailable(devServerHost, this.fetch); + const host = devServerHost(this.env); + const hasDevServer = await devServerAvailable(host, this.fetch); if (hasDevServer) { - registerURL = devServerUrl(devServerHost, "/fn/register"); + registerURL = devServerUrl(host, "/fn/register"); } } - if (deployId) { + if (deployId && deployId !== "undefined") { registerURL.searchParams.set(queryKeys.DeployId, deployId); } @@ -984,20 +1071,36 @@ export class InngestCommHandler< * situations where environment variables are passed directly to handlers or * are otherwise difficult to access during initialization. */ - private upsertKeysFromEnv(env: Record) { - if (!this.signingKey && env[envKeys.SigningKey]) { - this.signingKey = String(env[envKeys.SigningKey]); + private upsertKeysFromEnv() { + if (this.env[envKeys.InngestSigningKey]) { + if (!this.signingKey) { + this.signingKey = String(this.env[envKeys.InngestSigningKey]); + } + + this.client["inngestApi"].setSigningKey(this.signingKey); } - if (!this.client["eventKey"] && env[envKeys.EventKey]) { - this.client.setEventKey(String(env[envKeys.EventKey])); + if (!this.client["eventKey"] && this.env[envKeys.InngestEventKey]) { + this.client.setEventKey(String(this.env[envKeys.InngestEventKey])); + } + + // v2 -> v3 migration warnings + if (this.env[envKeys.InngestDevServerUrl]) { + this.log( + "warn", + `Use of ${envKeys.InngestDevServerUrl} has been deprecated in v3; please use ${envKeys.InngestBaseUrl} instead. See https://www.inngest.com/docs/sdk/migration` + ); + } + + if (this.env[envKeys.InngestApiBaseUrl]) { + this.log( + "warn", + `Use of ${envKeys.InngestApiBaseUrl} has been deprecated in v3; please use ${envKeys.InngestBaseUrl} instead. See https://www.inngest.com/docs/sdk/migration` + ); } } - protected validateSignature( - sig: string | undefined, - body: Record - ) { + protected validateSignature(sig: string | undefined, body: unknown) { // Never validate signatures in development. if (!this.isProd) { // In dev, warning users about signing keys ensures that it's considered @@ -1015,7 +1118,7 @@ export class InngestCommHandler< if (!this.signingKey) { // TODO PrettyError throw new Error( - `No signing key found in client options or ${envKeys.SigningKey} env var. Find your keys at https://app.inngest.com/secrets` + `No signing key found in client options or ${envKeys.InngestSigningKey} env var. Find your keys at https://app.inngest.com/secrets` ); } @@ -1061,11 +1164,11 @@ export class InngestCommHandler< if (currentLevel >= logLevelSetting) { let logger = console.log; - if (Object.hasOwnProperty.call(console, level)) { + if (Object.prototype.hasOwnProperty.call(console, level)) { logger = console[level as keyof typeof console] as typeof logger; } - logger(`inngest ${level as string}: `, ...args); + logger(`${logPrefix} ${level as string} -`, ...args); } } } @@ -1133,18 +1236,75 @@ class RequestSignature { * The broad definition of a handler passed when instantiating an * {@link InngestCommHandler} instance. */ +export type Handler< + // eslint-disable-next-line @typescript-eslint/no-explicit-any + Input extends any[] = any[], + // eslint-disable-next-line @typescript-eslint/no-explicit-any + Output = any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + StreamOutput = any +> = (...args: Input) => HandlerResponse; + // eslint-disable-next-line @typescript-eslint/no-explicit-any -type Handler = (...args: any[]) => { - env?: Record; - isProduction?: boolean; - url: URL; -} & { - [K in Extract< - HandlerAction, - { action: "run" | "register" | "view" } - >["action"]]: () => MaybePromise< - Omit, "action"> | undefined - >; +export type HandlerResponse = { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + body: () => MaybePromise; + env?: () => MaybePromise; + headers: (key: string) => MaybePromise; + + /** + * Whether the current environment is production. This is used to determine + * some functionality like whether to connect to the dev server or whether to + * show debug logging. + * + * If this is not provided--or is provided and returns `undefined`--we'll try + * to automatically detect whether we're in production by checking various + * environment variables. + */ + isProduction?: () => MaybePromise; + method: () => MaybePromise; + queryString?: ( + key: string, + url: URL + ) => MaybePromise; + url: () => MaybePromise; + + /** + * The `transformResponse` function receives the output of the Inngest SDK and + * can decide how to package up that information to appropriately return the + * information to Inngest. + * + * Mostly, this is taking the given parameters and returning a new `Response`. + * + * The function is passed an {@link ActionResponse}, an object containing a + * `status` code, a `headers` object, and a stringified `body`. This ensures + * you can appropriately handle the response, including use of any required + * parameters such as `res` in Express-/Connect-like frameworks. + */ + transformResponse: (res: ActionResponse) => Output; + + /** + * The `transformStreamingResponse` function, if defined, declares that this + * handler supports streaming responses back to Inngest. This is useful for + * functions that are expected to take a long time, as edge streaming can + * often circumvent restrictive request timeouts and other limitations. + * + * If your handler does not support streaming, do not define this function. + * + * It receives the output of the Inngest SDK and can decide how to package + * up that information to appropriately return the information in a stream + * to Inngest. + * + * Mostly, this is taking the given parameters and returning a new `Response`. + * + * The function is passed an {@link ActionResponse}, an object containing a + * `status` code, a `headers` object, and `body`, a `ReadableStream`. This + * ensures you can appropriately handle the response, including use of any + * required parameters such as `res` in Express-/Connect-like frameworks. + */ + transformStreamingResponse?: ( + res: ActionResponse + ) => StreamOutput; }; /** @@ -1168,32 +1328,32 @@ export interface ActionResponse< * A stringified body to return. */ body: TBody; + + /** + * The version of the execution engine that was used to run this action. + * + * If the action didn't use the execution engine (for example, a GET request + * as a health check), this will be `undefined`. + * + * If the version should be entirely omitted from the response (for example, + * when sending preliminary headers when streaming), this will be `null`. + */ + version: ExecutionVersion | null | undefined; } /** - * A set of actions the SDK is aware of, including any payloads they require - * when requesting them. + * A version of {@link HandlerResponse} where each function is safely + * promisified and requires a reason for each access. + * + * This enables us to provide accurate errors for each access without having to + * wrap every access in a try/catch. */ -type HandlerAction = - | { - action: "error"; - data: Record; - } - | { - action: "view"; - isIntrospection: boolean; - } - | { - action: "register"; - deployId?: null | string; - } - | { - action: "run"; - fnId: string; - stepId: string | null; - data: Record; - signature: string | undefined; - } - | { - action: "bad-method"; - }; +type HandlerResponseWithErrors = { + [K in keyof HandlerResponse]: NonNullable extends ( + ...args: infer Args + ) => infer R + ? R extends MaybePromise + ? (errMessage: string, ...args: Args) => Promise + : (errMessage: string, ...args: Args) => Promise + : HandlerResponse[K]; +}; diff --git a/packages/inngest/src/components/InngestFunction.test.ts b/packages/inngest/src/components/InngestFunction.test.ts index f959b03b5..b4a2fbf43 100644 --- a/packages/inngest/src/components/InngestFunction.test.ts +++ b/packages/inngest/src/components/InngestFunction.test.ts @@ -1,17 +1,34 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/require-await */ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ /* eslint-disable @typescript-eslint/no-unsafe-return */ /* eslint-disable @typescript-eslint/no-unsafe-call */ /* eslint-disable @typescript-eslint/no-unsafe-member-access */ import { jest } from "@jest/globals"; -import { EventSchemas, type EventPayload } from "@local"; +import { + EventSchemas, + InngestMiddleware, + NonRetriableError, + type EventPayload, +} from "@local"; import { InngestFunction } from "@local/components/InngestFunction"; +import { STEP_INDEXING_SUFFIX } from "@local/components/InngestStepTools"; import { - _internals, - type UnhashedOp, -} from "@local/components/InngestStepTools"; -import { NonRetriableError } from "@local/components/NonRetriableError"; + ExecutionVersion, + PREFERRED_EXECUTION_VERSION, + type ExecutionResult, + type ExecutionResults, + type InngestExecutionOptions, +} from "@local/components/execution/InngestExecution"; +import { _internals } from "@local/components/execution/v1"; import { ServerTiming } from "@local/helpers/ServerTiming"; import { internalEvents } from "@local/helpers/consts"; -import { ErrCode, OutgoingResultError } from "@local/helpers/errors"; +import { + ErrCode, + OutgoingResultError, + serializeError, +} from "@local/helpers/errors"; import { DefaultLogger, ProxyLogger, @@ -21,8 +38,9 @@ import { StepOpCode, type ClientOptions, type FailureEventPayload, - type OpStack, + type OutgoingOp, } from "@local/types"; +import { fromPartial } from "@total-typescript/shoehorn"; import { type IsEqual } from "type-fest"; import { assertType } from "type-plus"; import { createClient } from "../test/helpers"; @@ -36,34 +54,64 @@ type TestEvents = { const schemas = new EventSchemas().fromRecord(); const opts = ((x: T): T => x)({ - name: "test", + id: "test", eventKey: "event-key-123", schemas, + /** + * Create some test middleware that purposefully takes time for every hook. + * This ensures that the engine accounts for the potential time taken by + * middleware to run. + */ + middleware: [ + new InngestMiddleware({ + name: "Mock", + init: () => { + const mockHook = () => + new Promise((resolve) => setTimeout(() => setTimeout(resolve))); + + return { + onFunctionRun: () => { + return { + afterExecution: mockHook, + afterMemoization: mockHook, + beforeExecution: mockHook, + beforeMemoization: mockHook, + beforeResponse: mockHook, + transformInput: mockHook, + transformOutput: mockHook, + }; + }, + onSendEvent: () => { + return { + transformInput: mockHook, + transformOutput: mockHook, + }; + }, + }; + }, + }), + ], }); const inngest = createClient(opts); const timer = new ServerTiming(); -describe("#generateID", () => { - it("Returns a correct name", () => { - const fn = () => - new InngestFunction( - createClient({ name: "test" }), - { name: "HELLO 👋 there mr Wolf 🥳!" }, - { event: "test/event.name" }, - () => undefined - ); - expect(fn().id("MY MAGIC APP 🥳!")).toEqual( - "my-magic-app-hello-there-mr-wolf" - ); - expect(fn().id()).toEqual("hello-there-mr-wolf"); +const matchError = (err: any) => { + const serializedErr = serializeError(err); + return expect.objectContaining({ + ...serializedErr, + stack: expect.any(String), }); +}; + +describe("ID restrictions", () => { + it.todo("does not allow characters outside of the character set"); }); describe("runFn", () => { describe("single-step function", () => { - const stepRet = "step done"; + const stepRet = { someProperty: "step done" }; const stepErr = new Error("step error"); [ @@ -85,7 +133,7 @@ describe("runFn", () => { describe(`${type} function`, () => { describe("success", () => { let fn: InngestFunction; - let ret: Awaited>; + let ret: ExecutionResult; let flush: jest.SpiedFunction<() => void>; beforeAll(async () => { @@ -98,26 +146,34 @@ describe("runFn", () => { fn = new InngestFunction( createClient(opts), - { name: "Foo" }, + { id: "Foo" }, { event: "foo" }, flowFn ); - ret = await fn["runFn"]( - { event: { name: "foo", data: { foo: "foo" } } }, - [], - null, - timer, - false - ); + const execution = fn["createExecution"]({ + version: PREFERRED_EXECUTION_VERSION, + partialOptions: { + data: fromPartial({ + event: { name: "foo", data: { foo: "foo" } }, + }), + runId: "run", + stepState: {}, + stepCompletionOrder: [], + }, + }); + + ret = await execution.start(); }); test("returns is not op on success", () => { - expect(ret[0]).toBe("complete"); + expect(ret.type).toBe("function-resolved"); }); test("returns data on success", () => { - expect(ret[1]).toBe(stepRet); + expect((ret as ExecutionResults["function-resolved"]).data).toBe( + stepRet + ); }); test("should attempt to flush logs", () => { @@ -132,28 +188,32 @@ describe("runFn", () => { beforeAll(() => { fn = new InngestFunction( createClient(opts), - { name: "Foo" }, + { id: "Foo" }, { event: "foo" }, badFlowFn ); }); test("wrap thrown error", async () => { - await expect( - fn["runFn"]( - { event: { name: "foo", data: { foo: "foo" } } }, - [], - null, - timer, - false - ) - ).rejects.toThrow( - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - expect.objectContaining({ - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - result: expect.objectContaining({ error: stepErr }), - }) - ); + const execution = fn["createExecution"]({ + version: PREFERRED_EXECUTION_VERSION, + partialOptions: { + data: fromPartial({ + event: { name: "foo", data: { foo: "foo" } }, + }), + stepState: {}, + runId: "run", + stepCompletionOrder: [], + }, + }); + + const ret = await execution.start(); + + expect(ret.type).toBe("function-rejected"); + expect(ret).toMatchObject({ + error: matchError(stepErr), + retriable: true, + }); }); }); }); @@ -164,23 +224,44 @@ describe("runFn", () => { const runFnWithStack = ( // eslint-disable-next-line @typescript-eslint/no-explicit-any fn: InngestFunction, - stack: OpStack, + stepState: InngestExecutionOptions["stepState"], opts?: { + executionVersion?: ExecutionVersion; runStep?: string; onFailure?: boolean; event?: EventPayload; + stackOrder?: InngestExecutionOptions["stepCompletionOrder"]; + disableImmediateExecution?: boolean; } ) => { - return fn["runFn"]( - { event: opts?.event || { name: "foo", data: {} } }, - stack, - opts?.runStep || null, - timer, - Boolean(opts?.onFailure) - ); + const execution = fn["createExecution"]({ + version: opts?.executionVersion ?? PREFERRED_EXECUTION_VERSION, + partialOptions: { + data: fromPartial({ + event: opts?.event || { name: "foo", data: {} }, + }), + runId: "run", + stepState, + stepCompletionOrder: opts?.stackOrder ?? Object.keys(stepState), + isFailureHandler: Boolean(opts?.onFailure), + requestedRunStep: opts?.runStep, + timer, + disableImmediateExecution: opts?.disableImmediateExecution, + }, + }); + + return execution.start(); }; - const getHashDataSpy = () => jest.spyOn(_internals, "hashData"); + const getHashDataSpy = () => jest.spyOn(_internals, "hashOp"); + const getWarningSpy = () => jest.spyOn(console, "warn"); + const getErrorSpy = () => jest.spyOn(console, "error"); + + const executionIdHashes: Partial< + Record string> + > = { + [ExecutionVersion.V1]: _internals.hashId, + }; const testFn = < T extends { @@ -200,95 +281,182 @@ describe("runFn", () => { >( fnName: string, createTools: () => T, - hashes: U, - tests: (hashes: U) => Record< - string, + executionTests: Record< + ExecutionVersion, { - stack?: OpStack; - onFailure?: boolean; - runStep?: string; - expectedReturn?: Awaited>; - expectedThrowMessage?: string; - expectedHashOps?: UnhashedOp[]; - expectedStepsRun?: (keyof T["steps"])[]; - event?: EventPayload; - customTests?: () => void; - } + hashes: U; + tests: (hashes: U) => Record< + string, + { + stack?: InngestExecutionOptions["stepState"]; + stackOrder?: InngestExecutionOptions["stepCompletionOrder"]; + onFailure?: boolean; + runStep?: string; + expectedReturn?: Awaited>; + expectedThrowMessage?: string; + expectedHashOps?: OutgoingOp[]; + expectedStepsRun?: (keyof T["steps"])[]; + event?: EventPayload; + customTests?: () => void; + disableImmediateExecution?: boolean; + expectedWarnings?: string[]; + expectedErrors?: string[]; + } + >; + } | null > ) => { - describe(fnName, () => { - Object.entries(tests(hashes)).forEach(([name, t]) => { - describe(name, () => { - let hashDataSpy: ReturnType; - let tools: T; - let ret: Awaited> | undefined; - let retErr: Error | undefined; - let flush: jest.SpiedFunction<() => void>; - - beforeAll(() => { - jest.restoreAllMocks(); - flush = jest - .spyOn(ProxyLogger.prototype, "flush") - .mockImplementation(async () => { - /* noop */ + Object.entries(executionTests).forEach(([version, specs]) => { + if (!specs) return; + const { hashes, tests } = specs; + + const executionVersion = version as unknown as ExecutionVersion; + + describe(`${fnName} (V${executionVersion})`, () => { + const hashId = executionIdHashes[executionVersion]; + + const processedHashes = hashId + ? (Object.fromEntries( + Object.entries(hashes).map(([key, value]) => { + return [key, hashId(value)]; + }) + ) as typeof hashes) + : hashes; + + Object.entries(tests(processedHashes)).forEach(([name, t]) => { + describe(name, () => { + let hashDataSpy: ReturnType; + let warningSpy: ReturnType; + let errorSpy: ReturnType; + let tools: T; + let ret: Awaited> | undefined; + let retErr: Error | undefined; + let flush: jest.SpiedFunction<() => void>; + + beforeAll(() => { + jest.restoreAllMocks(); + flush = jest + .spyOn(ProxyLogger.prototype, "flush") + .mockImplementation(async () => { + /* noop */ + }); + hashDataSpy = getHashDataSpy(); + warningSpy = getWarningSpy(); + errorSpy = getErrorSpy(); + tools = createTools(); + }); + + t.customTests?.(); + + beforeAll(async () => { + ret = await runFnWithStack(tools.fn, t.stack || {}, { + executionVersion, + stackOrder: t.stackOrder, + runStep: t.runStep, + onFailure: t.onFailure || tools.onFailure, + event: t.event || tools.event, + disableImmediateExecution: t.disableImmediateExecution, + }).catch((err: Error) => { + retErr = err; + return undefined; }); - hashDataSpy = getHashDataSpy(); - tools = createTools(); - }); + }); - t.customTests?.(); + if (t.expectedThrowMessage) { + test("throws expected error", () => { + expect( + retErr instanceof OutgoingResultError + ? (retErr.result.error as Error)?.message + : retErr?.message ?? "" + ).toContain(t.expectedThrowMessage); + }); + } else { + test("returns expected value", () => { + expect(ret).toEqual(t.expectedReturn); + }); + } - beforeAll(async () => { - ret = await runFnWithStack(tools.fn, t.stack || [], { - runStep: t.runStep, - onFailure: t.onFailure || tools.onFailure, - event: t.event || tools.event, - }).catch((err: Error) => { - retErr = err; - return undefined; - }); - }); + if (t.expectedHashOps?.length) { + test("hashes expected ops", () => { + t.expectedHashOps?.forEach((h) => { + expect(hashDataSpy).toHaveBeenCalledWith(h); + }); + }); + } + + if (t.expectedWarnings?.length) { + describe("warning logs", () => { + t.expectedWarnings?.forEach((warning, i) => { + test(`warning log #${i + 1} includes "${warning}"`, () => { + expect(warningSpy).toHaveBeenNthCalledWith( + i + 1, + expect.stringContaining(warning) + ); + }); + }); + }); + } else { + test("no warning logs", () => { + expect(warningSpy).not.toHaveBeenCalled(); + }); + } + + if (t.expectedErrors?.length) { + describe("error logs", () => { + t.expectedErrors?.forEach((error, i) => { + test(`error log #${i + 1} includes "${error}"`, () => { + const call = errorSpy.mock.calls[i]; + const stringifiedArgs = call?.map((arg) => { + return arg instanceof Error ? serializeError(arg) : arg; + }); + + expect(JSON.stringify(stringifiedArgs)).toContain(error); + }); + }); + }); + } else { + test("no error logs", () => { + expect(errorSpy).not.toHaveBeenCalled(); + }); + } - if (t.expectedThrowMessage) { - test("throws expected error", () => { - expect( - retErr instanceof OutgoingResultError - ? (retErr.result.error as Error)?.message - : retErr?.message ?? "" - ).toContain(t.expectedThrowMessage); - }); - } else { - test("returns expected value", () => { - expect(ret).toEqual(t.expectedReturn); - }); - } + test("runs expected steps", () => { + Object.keys(tools.steps).forEach((k) => { + const step = tools.steps[k]; - if (t.expectedHashOps?.length) { - test("hashes expected ops", () => { - t.expectedHashOps?.forEach((h) => { - expect(hashDataSpy).toHaveBeenCalledWith(h); + if (t.expectedStepsRun?.includes(k)) { + expect(step).toHaveBeenCalled(); + } else { + expect(step).not.toHaveBeenCalled(); + } }); }); - } - - test("runs expected steps", () => { - Object.keys(tools.steps).forEach((k) => { - const step = tools.steps[k]; - if (t.expectedStepsRun?.includes(k)) { - expect(step).toHaveBeenCalled(); - } else { - expect(step).not.toHaveBeenCalled(); - } + test("should attempt to flush logs", () => { + // could be flushed multiple times so no specifying counts + expect(flush).toHaveBeenCalled(); }); - }); - test("should attempt to flush logs", () => { - // could be flushed multiple times so no specifying counts - expect(flush).toHaveBeenCalled(); - }); + if ( + ret && + (ret.type === "step-ran" || ret.type === "steps-found") + ) { + test("output hashes match expected shape", () => { + const outgoingOps: OutgoingOp[] = + ret!.type === "step-ran" + ? [ret!.step] + : ret!.type === "steps-found" + ? ret!.steps + : []; + + outgoingOps.forEach((op) => { + expect(op.id).toMatch(/^[a-f0-9]{40}$/i); + }); + }); + } - t.customTests?.(); + t.customTests?.(); + }); }); }); }); @@ -301,8 +469,8 @@ describe("runFn", () => { const B = jest.fn(() => "B"); const fn = inngest.createFunction( - "name", - "foo", + { id: "name" }, + { event: "foo" }, async ({ step: { run } }) => { await run("A", A); await run("B", B); @@ -312,54 +480,105 @@ describe("runFn", () => { return { fn, steps: { A, B } }; }, { - A: "c0a4028e0b48a2eeff383fa7186fd2d3763f5412", - B: "b494def3936f5c59986e81bc29443609bfc2384a", - }, - ({ A, B }) => ({ - "first run runs A step": { - expectedReturn: [ - "run", - expect.objectContaining({ - id: A, - name: "A", - op: StepOpCode.RunStep, - data: "A", - }), - ], - expectedStepsRun: ["A"], - }, - "request with A in stack runs B step": { - stack: [ - { - id: A, - data: "A", - }, - ], - expectedReturn: [ - "run", - expect.objectContaining({ - id: B, - name: "B", - op: StepOpCode.RunStep, - data: "B", - }), - ], - expectedStepsRun: ["B"], + [ExecutionVersion.V0]: { + hashes: { + A: "c0a4028e0b48a2eeff383fa7186fd2d3763f5412", + B: "b494def3936f5c59986e81bc29443609bfc2384a", + }, + tests: ({ A, B }) => ({ + "first run runs A step": { + expectedReturn: { + type: "step-ran", + step: expect.objectContaining({ + id: A, + name: "A", + op: StepOpCode.RunStep, + data: "A", + }), + }, + expectedStepsRun: ["A"], + }, + "request with A in stack runs B step": { + stack: { [A]: { id: A, data: "A" } }, + expectedReturn: { + type: "step-ran", + step: expect.objectContaining({ + id: B, + name: "B", + op: StepOpCode.RunStep, + data: "B", + }), + }, + expectedStepsRun: ["B"], + }, + "final request returns empty response": { + stack: { + [A]: { id: A, data: "A" }, + [B]: { id: B, data: "B" }, + }, + stackOrder: [A, B], + expectedReturn: { + type: "function-resolved", + data: null, + }, + }, + }), }, - "final request returns empty response": { - stack: [ - { - id: A, - data: "A", + [ExecutionVersion.V1]: { + hashes: { + A: "A", + B: "B", + }, + tests: ({ A, B }) => ({ + "first run runs A step": { + expectedReturn: { + type: "step-ran", + step: expect.objectContaining({ + id: A, + name: "A", + op: StepOpCode.RunStep, + data: { data: "A" }, + }), + }, + expectedStepsRun: ["A"], }, - { - id: B, - data: "B", + "request with A in stack runs B step": { + stack: { + [A]: { + id: A, + data: "A", + }, + }, + expectedReturn: { + type: "step-ran", + step: expect.objectContaining({ + id: B, + name: "B", + op: StepOpCode.RunStep, + data: { data: "B" }, + }), + }, + expectedStepsRun: ["B"], }, - ], - expectedReturn: ["complete", undefined], + "final request returns empty response": { + stack: { + [A]: { + id: A, + data: "A", + }, + [B]: { + id: B, + data: "B", + }, + }, + expectedReturn: { + type: "function-resolved", + data: null, + }, + }, + }), }, - }) + } ); testFn( @@ -369,10 +588,13 @@ describe("runFn", () => { const B = jest.fn(() => "B"); const fn = inngest.createFunction( - "name", - "foo", + { id: "name" }, + { event: "foo" }, async ({ step: { waitForEvent, run } }) => { - const foo = await waitForEvent("foo", "2h"); + const foo = await waitForEvent("wait-id", { + event: "foo", + timeout: "2h", + }); if (foo?.data.foo === "foo") { await run("A", A); @@ -385,63 +607,139 @@ describe("runFn", () => { return { fn, steps: { A, B } }; }, { - foo: "715347facf54baa82ad66dafed5ed6f1f84eaf8a", - A: "cfae9b35319fd155051a76b9208840185cecdc07", - B: "1352bc51e5732952742e6d103747c954c16570f5", - }, - ({ foo, A, B }) => ({ - "first run reports waitForEvent": { - expectedReturn: [ - "discovery", - [ - expect.objectContaining({ - op: StepOpCode.WaitForEvent, - name: "foo", - id: foo, - }), - ], - ], - }, - "request with event foo.data.foo:foo runs A step": { - stack: [{ id: foo, data: { data: { foo: "foo" } } }], - expectedReturn: [ - "run", - expect.objectContaining({ - id: A, - name: "A", - op: StepOpCode.RunStep, - data: "A", - }), - ], - expectedStepsRun: ["A"], - }, - "request with event foo.data.foo:bar runs B step": { - stack: [{ id: foo, data: { data: { foo: "bar" } } }], - expectedReturn: [ - "run", - expect.objectContaining({ - id: B, - name: "B", - op: StepOpCode.RunStep, - data: "B", - }), - ], - expectedStepsRun: ["B"], + [ExecutionVersion.V0]: { + hashes: { + foo: "715347facf54baa82ad66dafed5ed6f1f84eaf8a", + A: "cfae9b35319fd155051a76b9208840185cecdc07", + B: "1352bc51e5732952742e6d103747c954c16570f5", + }, + tests: ({ foo, A, B }) => ({ + "first run reports waitForEvent": { + expectedReturn: { + type: "steps-found", + steps: [ + expect.objectContaining({ + op: StepOpCode.WaitForEvent, + name: "foo", + id: foo, + }), + ], + }, + }, + "request with event foo.data.foo:foo runs A step": { + stack: { + [foo]: { id: foo, data: { data: { foo: "foo" } } }, + }, + expectedReturn: { + type: "step-ran", + step: expect.objectContaining({ + id: A, + name: "A", + op: StepOpCode.RunStep, + data: "A", + }), + }, + expectedStepsRun: ["A"], + }, + "request with event foo.data.foo:bar runs B step": { + stack: { + [foo]: { id: foo, data: { data: { foo: "bar" } } }, + }, + expectedReturn: { + type: "step-ran", + step: expect.objectContaining({ + id: B, + name: "B", + op: StepOpCode.RunStep, + data: "B", + }), + }, + + expectedStepsRun: ["B"], + }, + "final request returns empty response": { + stack: { + [foo]: { + id: foo, + data: { data: { foo: "bar" } }, + }, + [B]: { + id: B, + data: "B", + }, + }, + stackOrder: [foo, B], + expectedReturn: { + type: "function-resolved", + data: null, + }, + }, + }), }, - "final request returns empty response": { - stack: [ - { - id: foo, - data: { data: { foo: "bar" } }, + [ExecutionVersion.V1]: { + hashes: { + foo: "wait-id", + A: "A", + B: "B", + }, + tests: ({ foo, A, B }) => ({ + "first run reports waitForEvent": { + expectedReturn: { + type: "steps-found", + steps: [ + expect.objectContaining({ + op: StepOpCode.WaitForEvent, + name: "foo", + id: foo, + }), + ], + }, }, - { - id: B, - data: "B", + "request with event foo.data.foo:foo runs A step": { + stack: { [foo]: { id: foo, data: { data: { foo: "foo" } } } }, + expectedReturn: { + type: "step-ran", + step: expect.objectContaining({ + id: A, + name: "A", + op: StepOpCode.RunStep, + data: { data: "A" }, + }), + }, + expectedStepsRun: ["A"], + }, + "request with event foo.data.foo:bar runs B step": { + stack: { [foo]: { id: foo, data: { data: { foo: "bar" } } } }, + expectedReturn: { + type: "step-ran", + step: expect.objectContaining({ + id: B, + name: "B", + op: StepOpCode.RunStep, + data: { data: "B" }, + }), + }, + expectedStepsRun: ["B"], }, - ], - expectedReturn: ["complete", undefined], + "final request returns empty response": { + stack: { + [foo]: { + id: foo, + data: { data: { foo: "bar" } }, + }, + [B]: { + id: B, + data: { data: "B" }, + }, + }, + expectedReturn: { + type: "function-resolved", + data: null, + }, + }, + }), }, - }) + } ); testFn( @@ -452,8 +750,8 @@ describe("runFn", () => { const C = jest.fn(() => "C"); const fn = inngest.createFunction( - "name", - "foo", + { id: "name" }, + { event: "foo" }, async ({ step: { run } }) => { await Promise.all([run("A", A), run("B", B)]); await run("C", C); @@ -463,108 +761,267 @@ describe("runFn", () => { return { fn, steps: { A, B, C } }; }, { - A: "c0a4028e0b48a2eeff383fa7186fd2d3763f5412", - B: "1b724c1e706194ce9fa9aa57c0fb1c5075c7f7f4", - C: "b9996145f3de0c6073d3526ec18bb73be43e8bd6", - }, - ({ A, B, C }) => ({ - "first run reports A and B steps": { - expectedReturn: [ - "discovery", - [ - expect.objectContaining({ - id: A, - name: "A", - op: StepOpCode.StepPlanned, - }), - expect.objectContaining({ - id: B, - name: "B", - op: StepOpCode.StepPlanned, - }), - ], - ], - }, + [ExecutionVersion.V0]: { + hashes: { + A: "c0a4028e0b48a2eeff383fa7186fd2d3763f5412", + B: "1b724c1e706194ce9fa9aa57c0fb1c5075c7f7f4", + C: "b9996145f3de0c6073d3526ec18bb73be43e8bd6", + }, + tests: ({ A, B, C }) => ({ + "first run reports A and B steps": { + expectedReturn: { + type: "steps-found", + steps: [ + expect.objectContaining({ + id: A, + name: "A", + op: StepOpCode.StepPlanned, + }), + expect.objectContaining({ + id: B, + name: "B", + op: StepOpCode.StepPlanned, + }), + ], + }, + }, - "requesting to run B runs B": { - runStep: B, - expectedReturn: [ - "run", - expect.objectContaining({ - id: B, - name: "B", - op: StepOpCode.RunStep, - data: "B", - }), - ], - expectedStepsRun: ["B"], - }, + "requesting to run B runs B": { + runStep: B, + expectedReturn: { + type: "step-ran", + step: expect.objectContaining({ + id: B, + name: "B", + op: StepOpCode.RunStep, + data: "B", + }), + }, + expectedStepsRun: ["B"], + }, - "request following B returns empty response": { - stack: [ - { - id: B, - data: "B", + "request following B returns empty response": { + stack: { + [B]: { + id: B, + data: "B", + }, + }, + expectedReturn: { + type: "steps-found", + steps: [] as unknown as [OutgoingOp, ...OutgoingOp[]], + }, }, - ], - expectedReturn: ["discovery", []], - }, - "requesting to run A runs A": { - runStep: A, - expectedReturn: [ - "run", - expect.objectContaining({ - id: A, - name: "A", - op: StepOpCode.RunStep, - data: "A", - }), - ], - expectedStepsRun: ["A"], - }, + "requesting to run A runs A": { + runStep: A, + expectedReturn: { + type: "step-ran", + step: expect.objectContaining({ + id: A, + name: "A", + op: StepOpCode.RunStep, + data: "A", + }), + }, + expectedStepsRun: ["A"], + }, - "request with B,A order runs C step": { - stack: [ - { - id: B, - data: "B", + "request with B,A order runs C step": { + stack: { + [B]: { + id: B, + data: "B", + }, + [A]: { + id: A, + data: "A", + }, + }, + stackOrder: [B, A], + expectedReturn: { + type: "step-ran", + step: expect.objectContaining({ + id: C, + name: "C", + op: StepOpCode.RunStep, + data: "C", + }), + }, + expectedStepsRun: ["C"], }, - { - id: A, - data: "A", - }, - ], - expectedReturn: [ - "run", - expect.objectContaining({ - id: C, - name: "C", - op: StepOpCode.RunStep, - data: "C", - }), - ], - expectedStepsRun: ["C"], + + "final request returns empty response": { + stack: { + [B]: { + id: B, + data: "B", + }, + [A]: { + id: A, + data: "A", + }, + [C]: { + id: C, + data: "C", + }, + }, + stackOrder: [B, A, C], + expectedReturn: { + type: "function-resolved", + data: null, + }, + }, + }), }, + [ExecutionVersion.V1]: { + hashes: { + A: "A", + B: "B", + C: "C", + }, + tests: ({ A, B, C }) => ({ + "first run reports A and B steps": { + expectedReturn: { + type: "steps-found", + steps: [ + expect.objectContaining({ + id: A, + name: "A", + op: StepOpCode.StepPlanned, + }), + expect.objectContaining({ + id: B, + name: "B", + op: StepOpCode.StepPlanned, + }), + ], + }, + }, - "final request returns empty response": { - stack: [ - { - id: B, - data: "B", + "requesting to run B runs B": { + disableImmediateExecution: true, + runStep: B, + expectedReturn: { + type: "step-ran", + step: expect.objectContaining({ + id: B, + name: "B", + op: StepOpCode.RunStep, + data: { data: "B" }, + }), + }, + expectedStepsRun: ["B"], }, - { - id: A, - data: "A", + + "request with only B state returns discovery of A": { + disableImmediateExecution: true, + stack: { + [B]: { + id: B, + data: "B", + }, + }, + expectedReturn: { + type: "steps-found", + steps: [ + expect.objectContaining({ + id: A, + name: "A", + op: StepOpCode.StepPlanned, + }), + ], + }, }, - { - id: C, - data: "C", + + "requesting to run A runs A": { + disableImmediateExecution: true, + runStep: A, + expectedReturn: { + type: "step-ran", + step: expect.objectContaining({ + id: A, + name: "A", + op: StepOpCode.RunStep, + data: { data: "A" }, + }), + }, + expectedStepsRun: ["A"], }, - ], - expectedReturn: ["complete", undefined], + + "request with B,A state discovers C step": { + disableImmediateExecution: true, + stack: { + [B]: { + id: B, + data: "B", + }, + [A]: { + id: A, + data: "A", + }, + }, + expectedReturn: { + type: "steps-found", + steps: [ + expect.objectContaining({ + id: C, + name: "C", + op: StepOpCode.StepPlanned, + }), + ], + }, + }, + + "requesting to run C runs C": { + disableImmediateExecution: true, + stack: { + [B]: { + id: B, + data: "B", + }, + [A]: { + id: A, + data: "A", + }, + }, + runStep: C, + expectedReturn: { + type: "step-ran", + step: expect.objectContaining({ + id: C, + name: "C", + op: StepOpCode.RunStep, + data: { data: "C" }, + }), + }, + expectedStepsRun: ["C"], + }, + + "final request returns empty response": { + disableImmediateExecution: true, + stack: { + [B]: { + id: B, + data: "B", + }, + [A]: { + id: A, + data: "A", + }, + [C]: { + id: C, + data: "C", + }, + }, + expectedReturn: { + type: "function-resolved", + data: null, + }, + }, + }), }, - }) + } ); testFn( @@ -576,8 +1033,8 @@ describe("runFn", () => { const BWins = jest.fn(() => Promise.resolve("B wins")); const fn = inngest.createFunction( - "name", - "foo", + { id: "name" }, + { event: "foo" }, async ({ step: { run } }) => { const winner = await Promise.race([run("A", A), run("B", B)]); @@ -589,94 +1046,615 @@ describe("runFn", () => { } ); - return { fn, steps: { A, B, AWins, BWins } }; + return { fn, steps: { A, B, AWins, BWins } }; + }, + { + [ExecutionVersion.V0]: { + hashes: { + A: "c0a4028e0b48a2eeff383fa7186fd2d3763f5412", + B: "1b724c1e706194ce9fa9aa57c0fb1c5075c7f7f4", + AWins: "", + BWins: "bfdc2902cd708525bec677c1ad15fffff4bdccca", + }, + tests: ({ A, B, BWins }) => ({ + "first run reports A and B steps": { + expectedReturn: { + type: "steps-found", + steps: [ + expect.objectContaining({ + id: A, + name: "A", + op: StepOpCode.StepPlanned, + }), + expect.objectContaining({ + id: B, + name: "B", + op: StepOpCode.StepPlanned, + }), + ], + }, + }, + + "requesting to run B runs B": { + runStep: B, + expectedReturn: { + type: "step-ran", + step: expect.objectContaining({ + id: B, + name: "B", + op: StepOpCode.RunStep, + data: "B", + }), + }, + expectedStepsRun: ["B"], + }, + + "request following B runs 'B wins' step": { + stack: { [B]: { id: B, data: "B" } }, + expectedReturn: { + type: "step-ran", + step: expect.objectContaining({ + id: BWins, + name: "B wins", + op: StepOpCode.RunStep, + data: "B wins", + }), + }, + expectedStepsRun: ["BWins"], + }, + + "requesting to run A runs A": { + runStep: A, + expectedReturn: { + type: "step-ran", + step: expect.objectContaining({ + id: A, + name: "A", + op: StepOpCode.RunStep, + data: "A", + }), + }, + expectedStepsRun: ["A"], + }, + + "request following A returns empty response": { + stack: { + [B]: { id: B, data: "B" }, + [A]: { id: A, data: "A" }, + }, + stackOrder: [B, A], + expectedReturn: { + type: "steps-found", + steps: [] as unknown as [OutgoingOp, ...OutgoingOp[]], + }, + }, + }), + }, + [ExecutionVersion.V1]: { + hashes: { + A: "A", + B: "B", + AWins: "A wins", + BWins: "B wins", + }, + tests: ({ A, B, AWins, BWins }) => ({ + "first run reports A and B steps": { + expectedReturn: { + type: "steps-found", + steps: [ + expect.objectContaining({ + id: A, + name: "A", + op: StepOpCode.StepPlanned, + }), + expect.objectContaining({ + id: B, + name: "B", + op: StepOpCode.StepPlanned, + }), + ], + }, + }, + + "requesting to run B runs B": { + runStep: B, + expectedReturn: { + type: "step-ran", + step: expect.objectContaining({ + id: B, + name: "B", + op: StepOpCode.RunStep, + data: { data: "B" }, + }), + }, + expectedStepsRun: ["B"], + disableImmediateExecution: true, + }, + + "request following B reports 'A' and 'B wins' steps": { + stack: { [B]: { id: B, data: "B" } }, + expectedReturn: { + type: "steps-found", + steps: [ + expect.objectContaining({ + id: A, + name: "A", + op: StepOpCode.StepPlanned, + }), + expect.objectContaining({ + id: BWins, + name: "B wins", + op: StepOpCode.StepPlanned, + }), + ], + }, + disableImmediateExecution: true, + }, + + "requesting to run A runs A": { + runStep: A, + expectedReturn: { + type: "step-ran", + step: expect.objectContaining({ + id: A, + name: "A", + op: StepOpCode.RunStep, + data: { data: "A" }, + }), + }, + expectedStepsRun: ["A"], + disableImmediateExecution: true, + }, + + "request following 'B wins' re-reports missing 'A' step": { + stack: { + [B]: { id: B, data: "B" }, + [BWins]: { id: BWins, data: "B wins" }, + }, + stackOrder: [B, BWins], + expectedReturn: { + type: "steps-found", + steps: [ + expect.objectContaining({ + id: A, + name: "A", + op: StepOpCode.StepPlanned, + }), + ], + }, + disableImmediateExecution: true, + }, + + "request following A completion resolves": { + stack: { + [A]: { id: A, data: "A" }, + [B]: { id: B, data: "B" }, + [BWins]: { id: BWins, data: "B wins" }, + }, + stackOrder: [B, BWins, A], + expectedReturn: { type: "function-resolved", data: null }, + disableImmediateExecution: true, + }, + + "request if 'A' is complete reports 'B' and 'A wins' steps": { + stack: { [A]: { id: A, data: "A" } }, + expectedReturn: { + type: "steps-found", + steps: [ + expect.objectContaining({ + id: B, + name: "B", + op: StepOpCode.StepPlanned, + }), + expect.objectContaining({ + id: AWins, + name: "A wins", + op: StepOpCode.StepPlanned, + }), + ], + }, + disableImmediateExecution: true, + }, + }), + }, + } + ); + + testFn( + "Deep Promise.race", + () => { + const A = jest.fn(() => Promise.resolve("A")); + const B = jest.fn(() => Promise.resolve("B")); + const B2 = jest.fn(() => Promise.resolve("B2")); + const AWins = jest.fn(() => Promise.resolve("A wins")); + const BWins = jest.fn(() => Promise.resolve("B wins")); + + const fn = inngest.createFunction( + { id: "name" }, + { event: "foo" }, + async ({ step: { run } }) => { + const winner = await Promise.race([ + run("A", A), + run("B", B).then(() => run("B2", B2)), + ]); + + if (winner === "A") { + await run("A wins", AWins); + } else if (winner === "B2") { + await run("B wins", BWins); + } + } + ); + + return { fn, steps: { A, B, B2, AWins, BWins } }; + }, + { + [ExecutionVersion.V0]: { + hashes: { + A: "c0a4028e0b48a2eeff383fa7186fd2d3763f5412", + B: "1b724c1e706194ce9fa9aa57c0fb1c5075c7f7f4", + B2: "e363452a9ca7762e772c235cf97ced4e7db51bd6", + AWins: "", + BWins: "c2592f0bf963b94c594c24431460a66bae8fa60f", + }, + tests: ({ A, B, B2, BWins }) => ({ + "if B chain wins without 'A', runs 'B wins' step": { + stack: { + [B]: { id: B, data: "B" }, + [B2]: { id: B2, data: "B2" }, + }, + stackOrder: [B, B2], + expectedReturn: { + type: "step-ran", + step: expect.objectContaining({ + id: BWins, + name: "B wins", + op: StepOpCode.RunStep, + data: "B wins", + }), + }, + expectedStepsRun: ["BWins"], + }, + "if B chain wins with 'A' afterwards, reports no steps to run": { + stack: { + [B]: { id: B, data: "B" }, + [B2]: { id: B2, data: "B2" }, + [A]: { id: A, data: "A" }, + }, + stackOrder: [B, B2, A], + expectedReturn: { + type: "steps-found", + steps: [] as unknown as [OutgoingOp, ...OutgoingOp[]], + }, + }, + }), + }, + [ExecutionVersion.V1]: { + hashes: { + A: "A", + B: "B", + B2: "B2", + AWins: "A wins", + BWins: "B wins", + }, + tests: ({ A, B, B2, BWins }) => ({ + "if B chain wins without 'A', reports 'A' and 'B wins' steps": { + stack: { + [B]: { id: B, data: "B" }, + [B2]: { id: B2, data: "B2" }, + }, + expectedReturn: { + type: "steps-found", + steps: [ + expect.objectContaining({ + id: A, + name: "A", + op: StepOpCode.StepPlanned, + }), + expect.objectContaining({ + id: BWins, + name: "B wins", + op: StepOpCode.StepPlanned, + }), + ], + }, + disableImmediateExecution: true, + }, + "if B chain wins after with 'A' afterwards, reports 'B wins' step": + { + stack: { + [B]: { id: B, data: "B" }, + [B2]: { id: B2, data: "B2" }, + [A]: { id: A, data: "A" }, + }, + stackOrder: [B, B2, A], + expectedReturn: { + type: "steps-found", + steps: [ + expect.objectContaining({ + id: BWins, + name: "B wins", + op: StepOpCode.StepPlanned, + }), + ], + }, + disableImmediateExecution: true, + }, + }), + }, + } + ); + + testFn( + "step indexing in sequence", + () => { + const A = jest.fn(() => "A"); + const B = jest.fn(() => "B"); + const C = jest.fn(() => "C"); + + const id = "A"; + + const fn = inngest.createFunction( + { id: "name" }, + { event: "foo" }, + async ({ step: { run } }) => { + await run(id, A); + await run(id, B); + await run(id, C); + } + ); + + return { fn, steps: { A, B, C } }; + }, + { + // This is not performed in v0 executions. + [ExecutionVersion.V0]: null, + [ExecutionVersion.V1]: { + hashes: { + A: "A", + B: `A${STEP_INDEXING_SUFFIX}1`, + C: `A${STEP_INDEXING_SUFFIX}2`, + }, + tests: ({ A, B, C }) => ({ + "first run runs A step": { + expectedReturn: { + type: "step-ran", + step: expect.objectContaining({ + id: A, + name: "A", + op: StepOpCode.RunStep, + data: { data: "A" }, + }), + }, + expectedStepsRun: ["A"], + }, + "request with A in stack runs B step": { + stack: { + [A]: { + id: A, + data: "A", + }, + }, + expectedReturn: { + type: "step-ran", + step: expect.objectContaining({ + id: B, + name: "A", + op: StepOpCode.RunStep, + data: { data: "B" }, + }), + }, + expectedStepsRun: ["B"], + }, + "request with B in stack runs C step": { + stack: { + [A]: { + id: A, + data: "A", + }, + [B]: { + id: B, + data: "B", + }, + }, + expectedReturn: { + type: "step-ran", + step: expect.objectContaining({ + id: C, + name: "A", + op: StepOpCode.RunStep, + data: { data: "C" }, + }), + }, + expectedStepsRun: ["C"], + }, + }), + }, + } + ); + + testFn( + "step indexing synchronously", + () => { + const A = jest.fn(() => "A"); + const B = jest.fn(() => "B"); + const C = jest.fn(() => "C"); + + const id = "A"; + + const fn = inngest.createFunction( + { id: "name" }, + { event: "foo" }, + async ({ step: { run } }) => { + await Promise.all([run(id, A), run(id, B), run(id, C)]); + } + ); + + return { fn, steps: { A, B, C } }; + }, + { + // This is not performed in v0 executions. + [ExecutionVersion.V0]: null, + [ExecutionVersion.V1]: { + hashes: { + A: "A", + B: `A${STEP_INDEXING_SUFFIX}1`, + C: `A${STEP_INDEXING_SUFFIX}2`, + }, + tests: ({ A, B, C }) => ({ + "first run reports all steps": { + expectedReturn: { + type: "steps-found", + steps: [ + expect.objectContaining({ + id: A, + name: "A", + op: StepOpCode.StepPlanned, + }), + expect.objectContaining({ + id: B, + name: "A", + op: StepOpCode.StepPlanned, + }), + expect.objectContaining({ + id: C, + name: "A", + op: StepOpCode.StepPlanned, + }), + ], + }, + }, + }), + }, + } + ); + + testFn( + "step indexing in parallel", + () => { + const A = jest.fn(() => "A"); + const B = jest.fn(() => "B"); + + const id = "A"; + const wait = (ms: number) => new Promise((r) => setTimeout(r, ms)); + + const fn = inngest.createFunction( + { id: "name" }, + { event: "foo" }, + async ({ step: { run } }) => { + await run(id, A); + await wait(200); + await run(id, B); + } + ); + + return { fn, steps: { A, B } }; + }, + { + // This is not performed in v0 executions. + [ExecutionVersion.V0]: null, + [ExecutionVersion.V1]: { + hashes: { + A: "A", + B: `A${STEP_INDEXING_SUFFIX}1`, + C: `A${STEP_INDEXING_SUFFIX}2`, + }, + tests: ({ A, B }) => ({ + "first run runs A step": { + expectedReturn: { + type: "step-ran", + step: expect.objectContaining({ + id: A, + name: "A", + op: StepOpCode.RunStep, + data: { data: "A" }, + }), + }, + expectedStepsRun: ["A"], + }, + "request with A in stack runs B step": { + stack: { + [A]: { + id: A, + data: "A", + }, + }, + expectedReturn: { + type: "step-ran", + step: expect.objectContaining({ + id: B, + name: "A", + op: StepOpCode.RunStep, + data: { data: "B" }, + }), + }, + expectedStepsRun: ["B"], + expectedWarnings: [ErrCode.AUTOMATIC_PARALLEL_INDEXING], + }, + }), + }, + } + ); + + testFn( + "step indexing in parallel with separated indexes", + () => { + const A = jest.fn(() => "A"); + const B = jest.fn(() => "B"); + const C = jest.fn(() => "C"); + + const id = "A"; + const wait = (ms: number) => new Promise((r) => setTimeout(r, ms)); + + const fn = inngest.createFunction( + { id: "name" }, + { event: "foo" }, + async ({ step: { run } }) => { + await Promise.all([run(id, A), run(id, B)]); + await wait(200); + await run(id, C); + } + ); + + return { fn, steps: { A, B, C } }; }, { - A: "c0a4028e0b48a2eeff383fa7186fd2d3763f5412", - B: "1b724c1e706194ce9fa9aa57c0fb1c5075c7f7f4", - AWins: "", - BWins: "bfdc2902cd708525bec677c1ad15fffff4bdccca", - }, - ({ A, B, BWins }) => ({ - "first run reports A and B steps": { - expectedReturn: [ - "discovery", - [ - expect.objectContaining({ - id: A, - name: "A", - op: StepOpCode.StepPlanned, - }), - expect.objectContaining({ - id: B, - name: "B", - op: StepOpCode.StepPlanned, - }), - ], - ], - }, - - "requesting to run B runs B": { - runStep: B, - expectedReturn: [ - "run", - expect.objectContaining({ - id: B, - name: "B", - op: StepOpCode.RunStep, - data: "B", - }), - ], - expectedStepsRun: ["B"], - }, - - "request following B runs 'B wins' step": { - stack: [ - { - id: B, - data: "B", - }, - ], - expectedReturn: [ - "run", - expect.objectContaining({ - id: BWins, - name: "B wins", - op: StepOpCode.RunStep, - data: "B wins", - }), - ], - expectedStepsRun: ["BWins"], - }, - - "requesting to run A runs A": { - runStep: A, - expectedReturn: [ - "run", - expect.objectContaining({ - id: A, - name: "A", - op: StepOpCode.RunStep, - data: "A", - }), - ], - expectedStepsRun: ["A"], - }, - - "request following A returns empty response": { - stack: [ - { - id: B, - data: "B", - }, - { - id: A, - data: "A", + // This is not performed in v0 executions. + [ExecutionVersion.V0]: null, + [ExecutionVersion.V1]: { + hashes: { + A: "A", + B: `A${STEP_INDEXING_SUFFIX}1`, + C: `A${STEP_INDEXING_SUFFIX}2`, + }, + tests: ({ A, B, C }) => ({ + "request with A,B in stack reports C step": { + stack: { + [A]: { + id: A, + data: "A", + }, + [B]: { + id: B, + data: "B", + }, + }, + expectedReturn: { + type: "steps-found", + steps: [ + expect.objectContaining({ + id: C, + name: "A", + op: StepOpCode.StepPlanned, + }), + ], + }, + expectedWarnings: [ErrCode.AUTOMATIC_PARALLEL_INDEXING], + disableImmediateExecution: true, }, - ], - expectedReturn: ["discovery", []], + }), }, - }) + } ); testFn( @@ -684,13 +1662,13 @@ describe("runFn", () => { () => { const A = jest.fn(() => "A"); const B = jest.fn(() => { - throw "B"; + throw "B failed message"; }); const BFailed = jest.fn(() => "B failed"); const fn = inngest.createFunction( - "name", - "foo", + { id: "name" }, + { event: "foo" }, async ({ step: { run } }) => { return Promise.all([ run("A", A), @@ -702,284 +1680,412 @@ describe("runFn", () => { return { fn, steps: { A, B, BFailed } }; }, { - A: "c0a4028e0b48a2eeff383fa7186fd2d3763f5412", - B: "1b724c1e706194ce9fa9aa57c0fb1c5075c7f7f4", - BFailed: "0ccca8a0c6463bcf972afb233f1f0baa47d90cc3", - }, - ({ A, B, BFailed }) => ({ - "first run reports A and B steps": { - expectedReturn: [ - "discovery", - [ - expect.objectContaining({ - id: A, - name: "A", - op: StepOpCode.StepPlanned, - }), - expect.objectContaining({ - id: B, - name: "B", - op: StepOpCode.StepPlanned, - }), - ], - ], - }, + [ExecutionVersion.V0]: { + hashes: { + A: "c0a4028e0b48a2eeff383fa7186fd2d3763f5412", + B: "1b724c1e706194ce9fa9aa57c0fb1c5075c7f7f4", + BFailed: "0ccca8a0c6463bcf972afb233f1f0baa47d90cc3", + }, + tests: ({ A, B, BFailed }) => ({ + "first run reports A and B steps": { + expectedReturn: { + type: "steps-found", + steps: [ + expect.objectContaining({ + id: A, + name: "A", + op: StepOpCode.StepPlanned, + }), + expect.objectContaining({ + id: B, + name: "B", + op: StepOpCode.StepPlanned, + }), + ], + }, + }, - "requesting to run A runs A": { - runStep: A, - expectedReturn: [ - "run", - expect.objectContaining({ - id: A, - name: "A", - op: StepOpCode.RunStep, - data: "A", - }), - ], - expectedStepsRun: ["A"], - }, + "requesting to run A runs A": { + runStep: A, + expectedReturn: { + type: "step-ran", + step: expect.objectContaining({ + id: A, + name: "A", + op: StepOpCode.RunStep, + data: "A", + }), + }, + expectedStepsRun: ["A"], + }, - "request following A returns empty response": { - stack: [{ id: A, data: "A" }], - expectedReturn: ["discovery", []], - }, + "request following A returns empty response": { + stack: { [A]: { id: A, data: "A" } }, + expectedReturn: { + type: "steps-found", + steps: [] as unknown as [OutgoingOp, ...OutgoingOp[]], + }, + }, - "requesting to run B runs B, which fails": { - runStep: B, - expectedReturn: [ - "run", - expect.objectContaining({ - id: B, - name: "B", - op: StepOpCode.RunStep, - error: "B", - }), - ], - expectedStepsRun: ["B"], - }, + "requesting to run B runs B, which fails": { + runStep: B, + expectedReturn: { + type: "step-ran", + step: expect.objectContaining({ + id: B, + name: "B", + op: StepOpCode.RunStep, + error: matchError("B failed message"), + retriable: true, + }), + }, + expectedErrors: ["B failed message"], + expectedStepsRun: ["B"], + }, - "request following B runs 'B failed' step": { - stack: [ - { id: A, data: "A" }, - { id: B, error: "B" }, - ], - expectedReturn: [ - "run", - expect.objectContaining({ - id: BFailed, - name: "B failed", - op: StepOpCode.RunStep, - data: "B failed", - }), - ], - expectedStepsRun: ["BFailed"], - }, + "request following B runs 'B failed' step": { + stack: { + [A]: { id: A, data: "A" }, + [B]: { id: B, error: "B" }, + }, + stackOrder: [A, B], + expectedReturn: { + type: "step-ran", + step: expect.objectContaining({ + id: BFailed, + name: "B failed", + op: StepOpCode.RunStep, + data: "B failed", + }), + }, + expectedStepsRun: ["BFailed"], + }, - "final request returns empty response": { - stack: [ - { id: A, data: "A" }, - { id: B, error: "B" }, - { id: BFailed, data: "B failed" }, - ], - expectedReturn: ["complete", ["A", "B failed"]], + "final request returns return value": { + stack: { + [A]: { id: A, data: "A" }, + [B]: { id: B, error: "B" }, + [BFailed]: { id: BFailed, data: "B failed" }, + }, + stackOrder: [A, B, BFailed], + expectedReturn: { + type: "function-resolved", + data: ["A", "B failed"], + }, + }, + }), }, - }) - ); + [ExecutionVersion.V1]: { + hashes: { + A: "A", + B: "B", + BFailed: "B failed", + }, + tests: ({ A, B, BFailed }) => ({ + "first run reports A and B steps": { + expectedReturn: { + type: "steps-found", + steps: [ + expect.objectContaining({ + id: A, + name: "A", + op: StepOpCode.StepPlanned, + }), + expect.objectContaining({ + id: B, + name: "B", + op: StepOpCode.StepPlanned, + }), + ], + }, + }, - testFn( - "throw when a non-step fn becomes a step-fn", - () => { - const A = jest.fn(() => "A"); + "requesting to run A runs A": { + disableImmediateExecution: true, + runStep: A, + expectedReturn: { + type: "step-ran", + step: expect.objectContaining({ + id: A, + name: "A", + op: StepOpCode.RunStep, + data: { data: "A" }, + }), + }, + expectedStepsRun: ["A"], + }, - const fn = inngest.createFunction( - { name: "Foo" }, - "foo", - async ({ step: { run } }) => { - await new Promise((resolve) => setTimeout(resolve, 1000)); - await run("A", A); - } - ); + "request with only A state returns B found": { + disableImmediateExecution: true, + stack: { [A]: { id: A, data: "A" } }, + expectedReturn: { + type: "steps-found", + steps: [ + expect.objectContaining({ + id: B, + name: "B", + op: StepOpCode.StepPlanned, + }), + ], + }, + }, - return { fn, steps: { A } }; - }, - { - A: "", - }, - () => ({ - "first run throws, as we find a step late": { - expectedThrowMessage: ErrCode.STEP_USED_AFTER_ASYNC, + "requesting to run B runs B, which fails": { + disableImmediateExecution: true, + runStep: B, + expectedReturn: { + type: "function-rejected", + error: matchError("B failed message"), + retriable: true, + }, + expectedStepsRun: ["B"], + expectedErrors: ["B failed message"], + }, + + "request following B reports 'B failed' step": { + disableImmediateExecution: true, + stack: { + [A]: { id: A, data: "A" }, + [B]: { id: B, error: "B failed message" }, + }, + expectedReturn: { + type: "steps-found", + steps: [ + expect.objectContaining({ + id: BFailed, + name: "B failed", + op: StepOpCode.StepPlanned, + }), + ], + }, + }, + + "requesting to run 'B failed' runs 'B failed'": { + disableImmediateExecution: true, + stack: { + [A]: { id: A, data: "A" }, + [B]: { id: B, error: "B failed message" }, + }, + runStep: BFailed, + expectedReturn: { + type: "step-ran", + step: expect.objectContaining({ + id: BFailed, + name: "B failed", + op: StepOpCode.RunStep, + data: { data: "B failed" }, + }), + }, + expectedStepsRun: ["BFailed"], + }, + + "final request returns empty response": { + disableImmediateExecution: true, + stack: { + [A]: { id: A, data: "A" }, + [B]: { id: B, error: "B failed message" }, + [BFailed]: { id: BFailed, data: "B failed" }, + }, + expectedReturn: { + type: "function-resolved", + data: ["A", "B failed"], + }, + }, + }), }, - }) + } ); testFn( - "throw when a linear step-fn becomes a non-step fn", + "throws a NonRetriableError when one is thrown inside a step", () => { - const A = jest.fn(() => "A"); - const B = jest.fn(() => "B"); + const A = jest.fn(() => { + throw new NonRetriableError("A error message"); + }); const fn = inngest.createFunction( - { name: "Foo" }, - "foo", + { id: "Foo" }, + { event: "foo" }, async ({ step: { run } }) => { await run("A", A); - await new Promise((resolve) => setTimeout(resolve, 1000)); - await run("B", B); } ); - return { fn, steps: { A, B } }; + return { fn, steps: { A } }; }, { - A: "c0a4028e0b48a2eeff383fa7186fd2d3763f5412", - B: "", - }, - ({ A }) => ({ - "first run executes A, as it's the only found step": { - expectedReturn: [ - "run", - expect.objectContaining({ - id: A, - name: "A", - op: StepOpCode.RunStep, - data: "A", - }), - ], - expectedStepsRun: ["A"], + [ExecutionVersion.V0]: { + hashes: { + A: "c0a4028e0b48a2eeff383fa7186fd2d3763f5412", + }, + tests: ({ A }) => ({ + "first run executes A, which throws a NonRetriable error": { + expectedReturn: { + type: "step-ran", + step: expect.objectContaining({ + id: A, + name: "A", + op: StepOpCode.RunStep, + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + error: matchError(new NonRetriableError("A error message")), + }), + }, + expectedErrors: ["A error message"], + expectedStepsRun: ["A"], + }, + }), }, - "second run throws, as mixes async logic": { - stack: [{ id: A, data: "A" }], - expectedThrowMessage: ErrCode.ASYNC_DETECTED_AFTER_MEMOIZATION, + [ExecutionVersion.V1]: { + hashes: { + A: "A", + }, + tests: () => ({ + "first run executes A, which throws a NonRetriable error": { + expectedReturn: { + type: "function-rejected", + retriable: false, + error: matchError(new NonRetriableError("A error message")), + }, + expectedStepsRun: ["A"], + expectedErrors: ["A error message"], + }, + }), }, - }) + } ); testFn( - "throw when a parallel step-fn becomes a non-step fn", + "throws a NonRetriableError when thrown inside the main function body", () => { - const A = jest.fn(() => "A"); - const B = jest.fn(() => "B"); - const fn = inngest.createFunction( - { name: "Foo" }, - "foo", - async ({ step: { run } }) => { - await run("A", A); - await new Promise((resolve) => setTimeout(resolve, 1000)); - await run("B", B); + { id: "Foo" }, + { event: "foo" }, + async () => { + throw new NonRetriableError("Error message"); } ); - return { fn, steps: { A, B } }; + return { fn, steps: {} }; }, { - A: "c0a4028e0b48a2eeff383fa7186fd2d3763f5412", - B: "", - }, - ({ A }) => ({ - "first run executes A, as it's the only found step": { - expectedReturn: [ - "run", - expect.objectContaining({ - id: A, - name: "A", - op: StepOpCode.RunStep, - data: "A", - }), - ], - expectedStepsRun: ["A"], + [ExecutionVersion.V0]: { + hashes: { + A: "c0a4028e0b48a2eeff383fa7186fd2d3763f5412", + }, + tests: () => ({ + "throws a NonRetriableError": { + expectedReturn: { + type: "function-rejected", + retriable: false, + error: matchError(new NonRetriableError("Error message")), + }, + expectedErrors: ["Error message"], + }, + }), }, - "second run throws, as mixes async logic": { - stack: [{ id: A, data: "A" }], - expectedThrowMessage: ErrCode.ASYNC_DETECTED_AFTER_MEMOIZATION, + [ExecutionVersion.V1]: { + hashes: {}, + tests: () => ({ + "throws a NonRetriableError": { + expectedReturn: { + type: "function-rejected", + retriable: false, + error: matchError(new NonRetriableError("Error message")), + }, + expectedErrors: ["Error message"], + expectedStepsRun: [], + }, + }), }, - }) + } ); - let firstRun = true; - testFn( - "throw when a step-fn detects side effects during memoization", + "throws a retriable error when a string is thrown inside the main function body", () => { - const A = jest.fn(() => "A"); - const B = jest.fn(() => "B"); - const fn = inngest.createFunction( - { name: "Foo" }, - "foo", - async ({ step: { run } }) => { - if (firstRun) { - firstRun = false; - } else { - await new Promise((resolve) => setTimeout(resolve, 1000)); - } - - await run("A", A); - await run("B", B); + { id: "Foo" }, + { event: "foo" }, + async () => { + throw "foo"; } ); - return { fn, steps: { A, B } }; + return { fn, steps: {} }; }, { - A: "c0a4028e0b48a2eeff383fa7186fd2d3763f5412", - B: "", - }, - ({ A }) => ({ - "first run executes A, as it's the only found step": { - expectedReturn: [ - "run", - expect.objectContaining({ - id: A, - name: "A", - op: StepOpCode.RunStep, - data: "A", - }), - ], - expectedStepsRun: ["A"], + [ExecutionVersion.V0]: { + hashes: {}, + tests: () => ({ + "throws a retriable error": { + expectedReturn: { + type: "function-rejected", + retriable: true, + error: matchError("foo"), + }, + expectedErrors: ["foo"], + }, + }), }, - "second run throws, as we find async logic during memoization": { - stack: [{ id: A, data: "A" }], - expectedThrowMessage: ErrCode.NON_DETERMINISTIC_FUNCTION, + [ExecutionVersion.V1]: { + hashes: {}, + tests: () => ({ + "throws a retriable error": { + expectedReturn: { + type: "function-rejected", + retriable: true, + error: matchError("foo"), + }, + expectedErrors: ["foo"], + expectedStepsRun: [], + }, + }), }, - }) + } ); testFn( - "throws a NonRetriableError when one is thrown inside a step", + "throws a retriable error when an empty object is thrown inside the main function body", () => { - const A = jest.fn(() => { - throw new NonRetriableError("A"); - }); - const fn = inngest.createFunction( - { name: "Foo" }, - "foo", - async ({ step: { run } }) => { - await run("A", A); + { id: "Foo" }, + { event: "foo" }, + async () => { + throw {}; } ); - return { fn, steps: { A } }; + return { fn, steps: {} }; }, { - A: "c0a4028e0b48a2eeff383fa7186fd2d3763f5412", - }, - ({ A }) => ({ - "first run executes A, which throws a NonRetriable error": { - expectedReturn: [ - "run", - expect.objectContaining({ - id: A, - name: "A", - op: StepOpCode.RunStep, - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - error: expect.any(NonRetriableError), - }), - ], - expectedStepsRun: ["A"], + [ExecutionVersion.V0]: { + hashes: {}, + tests: () => ({ + "throws a retriable error": { + expectedReturn: { + type: "function-rejected", + retriable: true, + error: matchError({}), + }, + expectedErrors: ["{}"], + }, + }), }, - }) + [ExecutionVersion.V1]: { + hashes: {}, + tests: () => ({ + "throws a retriable error": { + expectedReturn: { + type: "function-rejected", + retriable: true, + error: matchError({}), + }, + expectedErrors: ["{}"], + expectedStepsRun: [], + }, + }), + }, + } ); testFn( @@ -990,13 +2096,13 @@ describe("runFn", () => { const fn = inngest.createFunction( { - name: "name", + id: "name", onFailure: async ({ step: { run } }) => { await run("A", A); await run("B", B); }, }, - "foo", + { event: "foo" }, () => undefined ); @@ -1020,67 +2126,129 @@ describe("runFn", () => { return { fn, steps: { A, B }, event, onFailure: true }; }, { - A: "c0a4028e0b48a2eeff383fa7186fd2d3763f5412", - B: "b494def3936f5c59986e81bc29443609bfc2384a", - }, - ({ A, B }) => ({ - "first run runs A step": { - expectedReturn: [ - "run", - expect.objectContaining({ - id: A, - name: "A", - op: StepOpCode.RunStep, - data: "A", - }), - ], - expectedStepsRun: ["A"], - }, - "requesting to run A runs A": { - runStep: A, - expectedReturn: [ - "run", - expect.objectContaining({ - id: A, - name: "A", - op: StepOpCode.RunStep, - data: "A", - }), - ], - expectedStepsRun: ["A"], - }, - "request with A in stack runs B step": { - stack: [ - { - id: A, - data: "A", - }, - ], - expectedReturn: [ - "run", - expect.objectContaining({ - id: B, - name: "B", - op: StepOpCode.RunStep, - data: "B", - }), - ], - expectedStepsRun: ["B"], + [ExecutionVersion.V0]: { + hashes: { + A: "c0a4028e0b48a2eeff383fa7186fd2d3763f5412", + B: "b494def3936f5c59986e81bc29443609bfc2384a", + }, + tests: ({ A, B }) => ({ + "first run runs A step": { + expectedReturn: { + type: "step-ran", + step: expect.objectContaining({ + id: A, + name: "A", + op: StepOpCode.RunStep, + data: "A", + }), + }, + expectedStepsRun: ["A"], + }, + "requesting to run A runs A": { + runStep: A, + expectedReturn: { + type: "step-ran", + step: expect.objectContaining({ + id: A, + name: "A", + op: StepOpCode.RunStep, + data: "A", + }), + }, + expectedStepsRun: ["A"], + }, + "request with A in stack runs B step": { + stack: { + [A]: { + id: A, + data: "A", + }, + }, + expectedReturn: { + type: "step-ran", + step: expect.objectContaining({ + id: B, + name: "B", + op: StepOpCode.RunStep, + data: "B", + }), + }, + expectedStepsRun: ["B"], + }, + "final request returns empty response": { + stack: { + [A]: { + id: A, + data: "A", + }, + [B]: { + id: B, + data: "B", + }, + }, + stackOrder: [A, B], + expectedReturn: { + type: "function-resolved", + data: null, + }, + }, + }), }, - "final request returns empty response": { - stack: [ - { - id: A, - data: "A", + [ExecutionVersion.V1]: { + hashes: { + A: "A", + B: "B", + } as const, + tests: ({ A, B }) => ({ + "first run runs A step": { + expectedReturn: { + type: "step-ran", + step: expect.objectContaining({ + id: A, + name: "A", + op: StepOpCode.RunStep, + data: { data: "A" }, + }), + }, + expectedStepsRun: ["A"], }, - { - id: B, - data: "B", + "request with A in stack runs B step": { + stack: { + [A]: { + id: A, + data: "A", + }, + }, + expectedReturn: { + type: "step-ran", + step: expect.objectContaining({ + id: B, + name: "B", + op: StepOpCode.RunStep, + data: { data: "B" }, + }), + }, + expectedStepsRun: ["B"], }, - ], - expectedReturn: ["complete", undefined], + "final request returns empty response": { + stack: { + [A]: { + id: A, + data: "A", + }, + [B]: { + id: B, + data: "B", + }, + }, + expectedReturn: { + type: "function-resolved", + data: null, + }, + }, + }), }, - }) + } ); testFn( @@ -1097,8 +2265,8 @@ describe("runFn", () => { }); const fn = inngest.createFunction( - "name", - "foo", + { id: "name" }, + { event: "foo" }, async ({ step: { run }, logger }) => { assertType>(true); logger.info("info1"); @@ -1112,99 +2280,193 @@ describe("runFn", () => { return { fn, steps: { A, B } }; }, { - A: "c0a4028e0b48a2eeff383fa7186fd2d3763f5412", - B: "b494def3936f5c59986e81bc29443609bfc2384a", - }, - ({ A, B }) => ({ - "first run runs A step": { - expectedReturn: [ - "run", - expect.objectContaining({ - id: A, - name: "A", - op: StepOpCode.RunStep, - data: "A", - }), - ], - expectedStepsRun: ["A"], - customTests() { - let loggerInfoSpy: jest.SpiedFunction<() => void>; - - beforeAll(() => { - loggerInfoSpy = jest.spyOn(DefaultLogger.prototype, "info"); - }); - - test("log called", () => { - expect(loggerInfoSpy.mock.calls).toEqual([["info1"], ["A"]]); - }); + [ExecutionVersion.V0]: { + hashes: { + A: "c0a4028e0b48a2eeff383fa7186fd2d3763f5412", + B: "b494def3936f5c59986e81bc29443609bfc2384a", }, - }, - "request with A in stack runs B step": { - stack: [ - { - id: A, - data: "A", - }, - ], - expectedReturn: [ - "run", - expect.objectContaining({ - id: B, - name: "B", - op: StepOpCode.RunStep, - data: "B", - }), - ], - expectedStepsRun: ["B"], - customTests() { - let loggerInfoSpy: jest.SpiedFunction<() => void>; - - beforeAll(() => { - loggerInfoSpy = jest.spyOn(DefaultLogger.prototype, "info"); - }); + tests: ({ A, B }) => ({ + "first run runs A step": { + expectedReturn: { + type: "step-ran", + step: expect.objectContaining({ + id: A, + name: "A", + op: StepOpCode.RunStep, + data: "A", + }), + }, + expectedStepsRun: ["A"], + customTests() { + let loggerInfoSpy: jest.SpiedFunction<() => void>; - test("log called", () => { - expect(loggerInfoSpy.mock.calls).toEqual([["2"], ["B"]]); - }); - }, - }, - "final request returns empty response": { - stack: [ - { - id: A, - data: "A", + beforeAll(() => { + loggerInfoSpy = jest.spyOn(DefaultLogger.prototype, "info"); + }); + + test("log called", () => { + expect(loggerInfoSpy.mock.calls).toEqual([["info1"], ["A"]]); + }); + }, }, - { - id: B, - data: "B", + "request with A in stack runs B step": { + stack: { + [A]: { + id: A, + data: "A", + }, + }, + expectedReturn: { + type: "step-ran", + step: expect.objectContaining({ + id: B, + name: "B", + op: StepOpCode.RunStep, + data: "B", + }), + }, + expectedStepsRun: ["B"], + customTests() { + let loggerInfoSpy: jest.SpiedFunction<() => void>; + + beforeAll(() => { + loggerInfoSpy = jest.spyOn(DefaultLogger.prototype, "info"); + }); + + test("log called", () => { + expect(loggerInfoSpy.mock.calls).toEqual([["2"], ["B"]]); + }); + }, }, - ], - expectedReturn: ["complete", undefined], - customTests() { - let loggerInfoSpy: jest.SpiedFunction<() => void>; + "final request returns empty response": { + stack: { + [A]: { + id: A, + data: "A", + }, + [B]: { + id: B, + data: "B", + }, + }, + expectedReturn: { + type: "function-resolved", + data: null, + }, + customTests() { + let loggerInfoSpy: jest.SpiedFunction<() => void>; - beforeAll(() => { - loggerInfoSpy = jest.spyOn(DefaultLogger.prototype, "info"); - }); + beforeAll(() => { + loggerInfoSpy = jest.spyOn(DefaultLogger.prototype, "info"); + }); - test("log called", () => { - expect(loggerInfoSpy.mock.calls).toEqual([["3"]]); - }); + test("log called", () => { + expect(loggerInfoSpy.mock.calls).toEqual([["3"]]); + }); + }, + }, + }), + }, + [ExecutionVersion.V1]: { + hashes: { + A: "A", + B: "B", }, + tests: ({ A, B }) => ({ + "first run runs A step": { + expectedReturn: { + type: "step-ran", + step: expect.objectContaining({ + id: A, + name: "A", + op: StepOpCode.RunStep, + data: { data: "A" }, + }), + }, + expectedStepsRun: ["A"], + customTests() { + let loggerInfoSpy: jest.SpiedFunction<() => void>; + + beforeAll(() => { + loggerInfoSpy = jest.spyOn(DefaultLogger.prototype, "info"); + }); + + test("log called", () => { + expect(loggerInfoSpy.mock.calls).toEqual([["info1"], ["A"]]); + }); + }, + }, + "request with A in stack runs B step": { + stack: { + [A]: { + id: A, + data: "A", + }, + }, + expectedReturn: { + type: "step-ran", + step: expect.objectContaining({ + id: B, + name: "B", + op: StepOpCode.RunStep, + data: { data: "B" }, + }), + }, + expectedStepsRun: ["B"], + customTests() { + let loggerInfoSpy: jest.SpiedFunction<() => void>; + + beforeAll(() => { + loggerInfoSpy = jest.spyOn(DefaultLogger.prototype, "info"); + }); + + test("log called", () => { + expect(loggerInfoSpy.mock.calls).toEqual([["2"], ["B"]]); + }); + }, + }, + "final request returns empty response": { + stack: { + [A]: { + id: A, + data: "A", + }, + [B]: { + id: B, + data: "B", + }, + }, + expectedReturn: { + type: "function-resolved", + data: null, + }, + customTests() { + let loggerInfoSpy: jest.SpiedFunction<() => void>; + + beforeAll(() => { + loggerInfoSpy = jest.spyOn(DefaultLogger.prototype, "info"); + }); + + test("log called", () => { + expect(loggerInfoSpy.mock.calls).toEqual([["3"]]); + }); + }, + }, + }), }, - }) + } ); }); describe("onFailure functions", () => { describe("types", () => { describe("no custom types", () => { - const inngest = createClient({ name: "test" }); + const inngest = createClient({ id: "test" }); test("onFailure function has unknown internal event", () => { inngest.createFunction( { - name: "test", + id: "test", onFailure: ({ error, event }) => { assertType<`${internalEvents.FunctionFailed}`>(event.name); assertType(event); @@ -1221,7 +2483,7 @@ describe("runFn", () => { describe("multiple custom types", () => { const inngest = createClient({ - name: "test", + id: "test", schemas: new EventSchemas().fromRecord<{ foo: { name: "foo"; @@ -1237,7 +2499,7 @@ describe("runFn", () => { test("onFailure function has known internal event", () => { inngest.createFunction( { - name: "test", + id: "test", onFailure: ({ error, event }) => { assertType<`${internalEvents.FunctionFailed}`>(event.name); assertType(event); @@ -1255,54 +2517,13 @@ describe("runFn", () => { ); }); }); - - describe("passed fns have correct types", () => { - const inngest = createClient({ name: "test" }); - - const lib = { - foo: true, - bar: 5, - baz: (name: T) => `Hello, ${name}!` as const, - qux: (name: string) => `Hello, ${name}!`, - }; - - test("has shimmed fn types", () => { - inngest.createFunction( - { - name: "test", - fns: { ...lib }, - onFailure: ({ fns: { qux } }) => { - assertType>(qux("world")); - }, - }, - { event: "foo" }, - () => { - // no-op - } - ); - }); - - test.skip("has shimmed fn types that preserve generics", () => { - inngest.createFunction( - { - name: "test", - fns: { ...lib }, - onFailure: ({ fns: { baz: _baz } }) => { - // assertType>(baz("world")); - }, - }, - { event: "foo" }, - () => { - // no-op - } - ); - }); - }); }); test("specifying an onFailure function registers correctly", () => { + const clientId = "testclient"; + const inngest = createClient({ - name: "test", + id: clientId, schemas: new EventSchemas().fromRecord<{ foo: { name: "foo"; @@ -1317,7 +2538,7 @@ describe("runFn", () => { const fn = inngest.createFunction( { - name: "test", + id: "testfn", onFailure: () => { // no-op }, @@ -1331,19 +2552,19 @@ describe("runFn", () => { expect(fn).toBeInstanceOf(InngestFunction); const [fnConfig, failureFnConfig] = fn["getConfig"]( - new URL("https://example.com") + new URL("https://example.com"), + clientId ); expect(fnConfig).toMatchObject({ - id: "test", - name: "test", + id: "testclient-testfn", steps: { [InngestFunction.stepId]: { id: InngestFunction.stepId, name: InngestFunction.stepId, runtime: { type: "http", - url: `https://example.com/?fnId=test&stepId=${InngestFunction.stepId}`, + url: `https://example.com/?fnId=testclient-testfn&stepId=${InngestFunction.stepId}`, }, }, }, @@ -1351,22 +2572,21 @@ describe("runFn", () => { }); expect(failureFnConfig).toMatchObject({ - id: "test-failure", - name: "test (failure)", + id: "testclient-testfn-failure", steps: { [InngestFunction.stepId]: { id: InngestFunction.stepId, name: InngestFunction.stepId, runtime: { type: "http", - url: `https://example.com/?fnId=test-failure&stepId=${InngestFunction.stepId}`, + url: `https://example.com/?fnId=testclient-testfn-failure&stepId=${InngestFunction.stepId}`, }, }, }, triggers: [ { event: internalEvents.FunctionFailed, - expression: "event.data.function_id == 'test'", + expression: "event.data.function_id == 'testclient-testfn'", }, ], }); @@ -1376,11 +2596,11 @@ describe("runFn", () => { describe("cancellation", () => { describe("types", () => { describe("no custom types", () => { - const inngest = createClient({ name: "test" }); + const inngest = createClient({ id: "test" }); test("allows any event name", () => { inngest.createFunction( - { name: "test", cancelOn: [{ event: "anything" }] }, + { id: "test", cancelOn: [{ event: "anything" }] }, { event: "test" }, () => { // no-op @@ -1391,7 +2611,7 @@ describe("runFn", () => { test("allows any match", () => { inngest.createFunction( { - name: "test", + id: "test", cancelOn: [{ event: "anything", match: "data.anything" }], }, { event: "test" }, @@ -1404,7 +2624,7 @@ describe("runFn", () => { describe("multiple custom types", () => { const inngest = createClient({ - name: "test", + id: "test", schemas: new EventSchemas().fromRecord<{ foo: { name: "foo"; @@ -1438,7 +2658,7 @@ describe("runFn", () => { test("allows known event name", () => { inngest.createFunction( - { name: "test", cancelOn: [{ event: "bar" }] }, + { id: "test", cancelOn: [{ event: "bar" }] }, { event: "foo" }, () => { // no-op @@ -1463,7 +2683,7 @@ describe("runFn", () => { test("allows known event name with good field match", () => { inngest.createFunction( { - name: "test", + id: "test", cancelOn: [{ event: "baz", match: "data.title" }], }, { event: "foo" }, @@ -1476,8 +2696,10 @@ describe("runFn", () => { }); test("specifying a cancellation event registers correctly", () => { + const clientId = "testclient"; + const inngest = createClient({ - name: "test", + id: clientId, schemas: new EventSchemas().fromRecord<{ foo: { name: "foo"; @@ -1495,25 +2717,27 @@ describe("runFn", () => { }); const fn = inngest.createFunction( - { name: "test", cancelOn: [{ event: "baz", match: "data.title" }] }, + { id: "testfn", cancelOn: [{ event: "baz", match: "data.title" }] }, { event: "foo" }, () => { // no-op } ); - const [fnConfig] = fn["getConfig"](new URL("https://example.com")); + const [fnConfig] = fn["getConfig"]( + new URL("https://example.com"), + clientId + ); expect(fnConfig).toMatchObject({ - id: "test", - name: "test", + id: "testclient-testfn", steps: { [InngestFunction.stepId]: { id: InngestFunction.stepId, name: InngestFunction.stepId, runtime: { type: "http", - url: `https://example.com/?fnId=test&stepId=${InngestFunction.stepId}`, + url: `https://example.com/?fnId=testclient-testfn&stepId=${InngestFunction.stepId}`, }, }, }, diff --git a/packages/inngest/src/components/InngestFunction.ts b/packages/inngest/src/components/InngestFunction.ts index f22950eb2..07ddf76df 100644 --- a/packages/inngest/src/components/InngestFunction.ts +++ b/packages/inngest/src/components/InngestFunction.ts @@ -1,41 +1,25 @@ -import { z } from "zod"; -import { type ServerTiming } from "../helpers/ServerTiming"; import { internalEvents, queryKeys } from "../helpers/consts"; +import { timeStr } from "../helpers/strings"; import { - ErrCode, - OutgoingResultError, - deserializeError, - functionStoppedRunningErr, - prettyError, - serializeError, -} from "../helpers/errors"; -import { resolveAfterPending, resolveNextTick } from "../helpers/promises"; -import { slugify, timeStr } from "../helpers/strings"; -import { - StepOpCode, - failureEventErrorSchema, - type BaseContext, type ClientOptions, - type Context, type EventNameFromTrigger, - type EventPayload, - type FailureEventArgs, type FunctionConfig, type FunctionOptions, type FunctionTrigger, type Handler, - type IncomingOp, - type OpStack, - type OutgoingOp, } from "../types"; import { type EventsFromOpts, type Inngest } from "./Inngest"; +import { type MiddlewareRegisterReturn } from "./InngestMiddleware"; import { - getHookStack, - type MiddlewareRegisterReturn, - type RunHookStack, -} from "./InngestMiddleware"; -import { createStepTools, type TickOp } from "./InngestStepTools"; -import { NonRetriableError } from "./NonRetriableError"; + ExecutionVersion, + type IInngestExecution, + type InngestExecutionOptions, +} from "./execution/InngestExecution"; +import { createV0InngestExecution } from "./execution/v0"; +import { createV1InngestExecution } from "./execution/v1"; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type AnyInngestFunction = InngestFunction; /** * A stateless Inngest function, wrapping up function configuration and any @@ -62,8 +46,8 @@ export class InngestFunction< public readonly opts: Opts; public readonly trigger: Trigger; - readonly #fn: Handler; - readonly #onFailureFn?: Handler; + private readonly fn: Handler; + private readonly onFailureFn?: Handler; readonly #client: Inngest; private readonly middleware: Promise; @@ -87,8 +71,8 @@ export class InngestFunction< this.#client = client; this.opts = opts; this.trigger = trigger; - this.#fn = fn; - this.#onFailureFn = this.opts.onFailure; + this.fn = fn; + this.onFailureFn = this.opts.onFailure; this.middleware = this.#client["initializeMiddleware"]( this.opts.middleware, @@ -99,15 +83,15 @@ export class InngestFunction< /** * The generated or given ID for this function. */ - public id(prefix?: string) { - return this.opts.id || this.#generateId(prefix); + public id(prefix?: string): string { + return [prefix, this.opts.id].filter(Boolean).join("-"); } /** * The name of this function as it will appear in the Inngest Cloud UI. */ - public get name() { - return this.opts.name; + public get name(): string { + return this.opts.name || this.id(); } /** @@ -127,7 +111,7 @@ export class InngestFunction< stepUrl.searchParams.set(queryKeys.FnId, fnId); stepUrl.searchParams.set(queryKeys.StepId, InngestFunction.stepId); - const { retries: attempts, cancelOn, fns: _, ...opts } = this.opts; + const { retries: attempts, cancelOn, ...opts } = this.opts; /** * Convert retries into the format required when defining function @@ -175,10 +159,10 @@ export class InngestFunction< const config: FunctionConfig[] = [fn]; - if (this.#onFailureFn) { + if (this.onFailureFn) { const failureOpts = { ...opts }; const id = `${fn.id}${InngestFunction.failureSuffix}`; - const name = `${fn.name} (failure)`; + const name = `${fn.name ?? fn.id} (failure)`; const failureStepUrl = new URL(stepUrl.href); failureStepUrl.searchParams.set(queryKeys.FnId, id); @@ -210,602 +194,23 @@ export class InngestFunction< return config; } - /** - * Run this function, optionally providing an op stack to pass as state. - * - * It is a `private` method to prevent users from being exposed to it - * directly, but ensuring it is available to the generated handler. - * - * For a single-step function that doesn't use any step tooling, this will - * await the result of the function given to this instance of - * `InngestFunction` and return the data and a boolean indicating that the - * function is complete and should not be called again. - * - * For a multi-step function, also try to await the result of the function - * given to this instance of `InngestFunction`, though will check whether an - * op has been submitted for use (or a Promise is pending, such as a step - * running) after the function has completed. - */ - private async runFn( - /** - * The data to pass to the function, probably straight from Inngest. - */ - data: unknown, - - /** - * The op stack to pass to the function as state, likely stored in - * `ctx._state` in the Inngest payload. - * - * This must be provided in order to always be cognizant of step function - * state and to allow for multi-step functions. - */ - opStack: OpStack, - - /** - * The step ID that Inngest wants to run and receive data from. If this is - * defined, the step's user code will be run after filling the op stack. If - * this is `null`, the function will be run and next operations will be - * returned instead. - */ - requestedRunStep: string | null, - - timer: ServerTiming, - - /** - * TODO Ugly boolean option; wrap this. - */ - isFailureHandler: boolean - ): Promise< - | [type: "complete", data: unknown] - | [type: "discovery", ops: OutgoingOp[]] - | [type: "run", op: OutgoingOp] - > { - const ctx = data as Pick< - Readonly< - BaseContext< - ClientOptions, - string, - Record unknown> - > - >, - "event" | "events" | "runId" | "attempt" - >; - - const hookStack = await getHookStack( - this.middleware, - "onFunctionRun", - { ctx, fn: this, steps: opStack }, - { - transformInput: (prev, output) => { - return { - ctx: { ...prev.ctx, ...output?.ctx }, - fn: this, - steps: prev.steps.map((step, i) => ({ - ...step, - ...output?.steps?.[i], - })), - }; - }, - transformOutput: (prev, output) => { - return { - result: { ...prev.result, ...output?.result }, - step: prev.step, - }; - }, - } - ); - - const createFinalError = async ( - err: unknown, - step?: OutgoingOp - ): Promise => { - await hookStack.afterExecution?.(); - - const result: Pick = { - error: err, - }; - - try { - result.data = serializeError(err); - } catch (serializationErr) { - console.warn( - "Could not serialize error to return to Inngest; stringifying instead", - serializationErr - ); - - result.data = err; - } - - const hookOutput = await applyHookToOutput(hookStack.transformOutput, { - result, - step, - }); - - return new OutgoingResultError(hookOutput); + private createExecution(opts: CreateExecutionOptions): IInngestExecution { + const options: InngestExecutionOptions = { + client: this.#client, + fn: this, + ...opts.partialOptions, }; - const state = createExecutionState(); - - const memoizingStop = timer.start("memoizing"); - - /** - * Create some values to be mutated and passed to the step tools. Once the - * user's function has run, we can check the mutated state of these to see - * if an op has been submitted or not. - */ - const step = createStepTools(this.#client, state); - - try { - /** - * Create args to pass in to our function. We blindly pass in the data and - * add tools. - */ - let fnArg = { - ...(data as { event: EventPayload }), - step, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } as Context; + const versionHandlers = { + [ExecutionVersion.V1]: () => createV1InngestExecution(options), + [ExecutionVersion.V0]: () => createV0InngestExecution(options), + } satisfies Record IInngestExecution>; - let userFnToRun = this.#fn; - - /** - * If the incoming event is an Inngest function failure event, we also want - * to pass some extra data to the function to act as shortcuts to the event - * payload. - */ - if (isFailureHandler) { - /** - * The user could have created a function that intentionally listens for - * these events. In this case, we may want to use the original handler. - * - * We only use the onFailure handler if - */ - if (!this.#onFailureFn) { - // TODO PrettyError - throw new Error( - `Function "${this.name}" received a failure event to handle, but no failure handler was defined.` - ); - } - - userFnToRun = this.#onFailureFn; - - const eventData = z - .object({ error: failureEventErrorSchema }) - .parse(fnArg.event?.data); - - (fnArg as Partial>) = { - ...fnArg, - error: deserializeError(eventData.error), - }; - } - - /** - * If the user has passed functions they wish to use in their step, add them - * here. - * - * We simply place a thin `tools.run()` wrapper around the function and - * nothing else. - */ - if (this.opts.fns) { - fnArg.fns = Object.entries(this.opts.fns).reduce((acc, [key, fn]) => { - if (typeof fn !== "function") { - return acc; - } - - return { - ...acc, - // eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-call - [key]: (...args: unknown[]) => step.run(key, () => fn(...args)), - }; - }, {}); - } - - const inputMutations = await hookStack.transformInput?.({ - ctx: { ...fnArg } as unknown as Parameters< - NonNullable<(typeof hookStack)["transformInput"]> - >[0]["ctx"], - steps: opStack, - fn: this, - }); - - if (inputMutations?.ctx) { - fnArg = inputMutations?.ctx as unknown as Context< - TOpts, - Events, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - any, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - any - >; - } - - if (inputMutations?.steps) { - opStack = inputMutations?.steps as OpStack; - } - - await hookStack.beforeMemoization?.(); - - if (opStack.length === 0 && !requestedRunStep) { - await hookStack.afterMemoization?.(); - await hookStack.beforeExecution?.(); - } - - // eslint-disable-next-line @typescript-eslint/no-misused-promises, no-async-promise-executor - const userFnPromise = new Promise(async (resolve, reject) => { - try { - resolve(await userFnToRun(fnArg)); - } catch (err) { - // logger.error(err); - reject(err); - } - }); - - let pos = -1; - - do { - if (pos >= 0) { - if (!requestedRunStep && pos == opStack.length - 1) { - await hookStack.afterMemoization?.(); - await hookStack.beforeExecution?.(); - } - - state.tickOps = {}; - const incomingOp = opStack[pos] as IncomingOp; - state.currentOp = state.allFoundOps[incomingOp.id]; - - if (!state.currentOp) { - /** - * We're trying to resume the function, but we can't find where to go. - * - * This means that either the function has changed or there are async - * actions in-between steps that we haven't noticed in previous - * executions. - * - * Whichever the case, this is bad and we can't continue in this - * undefined state. - */ - throw new NonRetriableError( - prettyError({ - whatHappened: " Your function was stopped from running", - why: "We couldn't resume your function's state because it may have changed since the run started or there are async actions in-between steps that we haven't noticed in previous executions.", - consequences: - "Continuing to run the function may result in unexpected behaviour, so we've stopped your function to ensure nothing unexpected happened!", - toFixNow: - "Ensure that your function is either entirely step-based or entirely non-step-based, by either wrapping all asynchronous logic in `step.run()` calls or by removing all `step.*()` calls.", - otherwise: - "For more information on why step functions work in this manner, see https://www.inngest.com/docs/functions/multi-step#gotchas", - stack: true, - code: ErrCode.NON_DETERMINISTIC_FUNCTION, - }) - ); - } - - state.currentOp.fulfilled = true; - - if (typeof incomingOp.data !== "undefined") { - state.currentOp.resolve(incomingOp.data); - } else { - state.currentOp.reject(incomingOp.error); - } - } - - await timer.wrap("memoizing-ticks", resolveAfterPending); - - state.reset(); - pos++; - } while (pos < opStack.length); - - memoizingStop(); - await hookStack.afterMemoization?.(); - - const discoveredOps = Object.values(state.tickOps).map( - tickOpToOutgoing - ); - - /** - * We make an optimization here by immediately invoking an op if it's the - * only one we've discovered. The alternative is to plan the step and then - * complete it, so we skip at least one entire execution with this. - */ - const runStep = requestedRunStep || getEarlyExecRunStep(discoveredOps); - - if (runStep) { - const userFnOp = state.allFoundOps[runStep]; - const userFnToRun = userFnOp?.fn; - - if (!userFnToRun) { - // TODO PrettyError - throw new Error( - `Bad stack; executor requesting to run unknown step "${runStep}"` - ); - } - - const outgoingUserFnOp = { - ...tickOpToOutgoing(userFnOp), - op: StepOpCode.RunStep, - }; - - await hookStack.beforeExecution?.(); - const runningStepStop = timer.start("running-step"); - state.executingStep = true; - - const result = await new Promise((resolve) => { - return resolve(userFnToRun()); - }) - .finally(() => { - state.executingStep = false; - runningStepStop(); - }) - .catch(async (err: Error) => { - return await createFinalError(err, outgoingUserFnOp); - }) - .then(async (data) => { - await hookStack.afterExecution?.(); - - return await applyHookToOutput(hookStack.transformOutput, { - result: { data: typeof data === "undefined" ? null : data }, - step: outgoingUserFnOp, - }); - }); - - return ["run", { ...outgoingUserFnOp, ...result }]; - } - - /** - * Now we're here, we've memoised any function state and we know that this - * request was a discovery call to find out next steps. - * - * We've already given the user's function a lot of chance to register any - * more ops, so we can assume that this list of discovered ops is final. - * - * With that in mind, if this list is empty AND we haven't previously used - * any step tools, we can assume that the user's function is not one that'll - * be using step tooling, so we'll just wait for it to complete and return - * the result. - * - * An empty list while also using step tooling is a valid state when the end - * of a chain of promises is reached, so we MUST also check if step tooling - * has previously been used. - */ - if (!discoveredOps.length) { - const fnRet = await Promise.race([ - userFnPromise.then((data) => ({ type: "complete", data } as const)), - resolveNextTick().then(() => ({ type: "incomplete" } as const)), - ]); - - if (fnRet.type === "complete") { - await hookStack.afterExecution?.(); - - /** - * The function has returned a value, so we should return this to - * Inngest. Doing this will cause the function to be marked as - * complete, so we should only do this if we're sure that all - * registered ops have been resolved. - */ - const allOpsFulfilled = Object.values(state.allFoundOps).every( - (op) => { - return op.fulfilled; - } - ); - - if (allOpsFulfilled) { - const result = await applyHookToOutput(hookStack.transformOutput, { - result: { data: fnRet.data }, - }); - - return ["complete", result.data]; - } - - /** - * If we're here, it means that the user's function has returned a - * value but not all ops have been resolved. This might be intentional - * if they are purposefully pushing work to the background, but also - * might be unintentional and a bug in the user's code where they - * expected an order to be maintained. - * - * To be safe, we'll show a warning here to tell users that this might - * be unintentional, but otherwise carry on as normal. - */ - // TODO PrettyError - console.warn( - `Warning: Your "${this.name}" function has returned a value, but not all ops have been resolved, i.e. you have used step tooling without \`await\`. This may be intentional, but if you expect your ops to be resolved in order, you should \`await\` them. If you are knowingly leaving ops unresolved using \`.catch()\` or \`void\`, you can ignore this warning.` - ); - } else if (!state.hasUsedTools) { - /** - * If we're here, it means that the user's function has not returned - * a value, but also has not used step tooling. This is a valid - * state, indicating that the function is a single-action async - * function. - * - * We should wait for the result and return it. - * - * A caveat here is that the user could use step tooling later on, - * resulting in a mix of step and non-step logic. This is not something - * we want to support without an opt-in from the user, so we should - * throw if this is the case. - */ - state.nonStepFnDetected = true; - - const data = await userFnPromise; - await hookStack.afterExecution?.(); - - const { data: result } = await applyHookToOutput( - hookStack.transformOutput, - { - result: { data }, - } - ); - - return ["complete", result]; - } else { - /** - * If we're here, the user's function has not returned a value, has not - * reported any new ops, but has also previously used step tools and - * successfully memoized state. - * - * This indicates that the user has mixed step and non-step logic, which - * is not something we want to support without an opt-in from the user. - * - * We should throw here to let the user know that this is not supported. - * - * We need to be careful, though; it's a valid state for a chain of - * promises to return no further actions, so we should only throw if - * this state is reached and there are no other pending steps. - */ - const hasOpsPending = Object.values(state.allFoundOps).some((op) => { - return op.fulfilled === false; - }); - - if (!hasOpsPending) { - throw new NonRetriableError( - functionStoppedRunningErr( - ErrCode.ASYNC_DETECTED_AFTER_MEMOIZATION - ) - ); - } - } - } - - await hookStack.afterExecution?.(); - - return ["discovery", discoveredOps]; - } catch (err) { - throw await createFinalError(err); - } finally { - await hookStack.beforeResponse?.(); - } - } - - /** - * Generate an ID based on the function's name. - */ - #generateId(prefix?: string) { - return slugify([prefix || "", this.opts.name].join("-")); + return versionHandlers[opts.version](); } } -const tickOpToOutgoing = (op: TickOp): OutgoingOp => { - return { - op: op.op, - id: op.id, - name: op.name, - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - opts: op.opts, - }; -}; - -/** - * Given the list of outgoing ops, decide if we can execute an op early and - * return the ID of the step to run if we can. - */ -const getEarlyExecRunStep = (ops: OutgoingOp[]): string | undefined => { - if (ops.length !== 1) return; - - const op = ops[0]; - - if ( - op && - op.op === StepOpCode.StepPlanned && - typeof op.opts === "undefined" - ) { - return op.id; - } -}; - -export interface ExecutionState { - /** - * The tree of all found ops in the entire invocation. - */ - allFoundOps: Record; - - /** - * All synchronous operations found in this particular tick. The array is - * reset every tick. - */ - tickOps: Record; - - /** - * A hash of operations found within this tick, with keys being the hashed - * ops themselves (without a position) and the values being the number of - * times that op has been found. - * - * This is used to provide some mutation resilience to the op stack, - * allowing us to survive same-tick mutations of code by ensuring per-tick - * hashes are based on uniqueness rather than order. - */ - tickOpHashes: Record; - - /** - * Tracks the current operation being processed. This can be used to - * understand the contextual parent of any recorded operations. - */ - currentOp: TickOp | undefined; - - /** - * If we've found a user function to run, we'll store it here so a component - * higher up can invoke and await it. - */ - userFnToRun?: (...args: unknown[]) => unknown; - - /** - * A boolean to represent whether the user's function is using any step - * tools. - * - * If the function survives an entire tick of the event loop and hasn't - * touched any tools, we assume that it is a single-step async function and - * should be awaited as usual. - */ - hasUsedTools: boolean; - - /** - * A function that should be used to reset the state of the tools after a - * tick has completed. - */ - reset: () => void; - - /** - * If `true`, any use of step tools will, by default, throw an error. We do - * this when we detect that a function may be mixing step and non-step code. - * - * Created step tooling can decide how to manually handle this on a - * case-by-case basis. - * - * In the future, we can provide a way for a user to override this if they - * wish to and understand the danger of side-effects. - * - * Defaults to `false`. - */ - nonStepFnDetected: boolean; - - /** - * When true, we are currently executing a user's code for a single step - * within a step function. - */ - executingStep: boolean; -} - -export const createExecutionState = (): ExecutionState => { - const state: ExecutionState = { - allFoundOps: {}, - tickOps: {}, - tickOpHashes: {}, - currentOp: undefined, - hasUsedTools: false, - reset: () => { - state.tickOpHashes = {}; - state.allFoundOps = { ...state.allFoundOps, ...state.tickOps }; - }, - nonStepFnDetected: false, - executingStep: false, - }; - - return state; -}; - -const applyHookToOutput = async ( - outputHook: RunHookStack["transformOutput"], - arg: Parameters>[0] -): Promise> => { - const hookOutput = await outputHook?.(arg); - return { ...arg.result, ...hookOutput?.result }; +export type CreateExecutionOptions = { + version: ExecutionVersion; + partialOptions: Omit; }; diff --git a/packages/inngest/src/components/InngestMiddleware.test.ts b/packages/inngest/src/components/InngestMiddleware.test.ts index 00f2590dd..e0b080c66 100644 --- a/packages/inngest/src/components/InngestMiddleware.test.ts +++ b/packages/inngest/src/components/InngestMiddleware.test.ts @@ -24,12 +24,12 @@ describe("stacking and inference", () => { }, }); - const inngest = new Inngest({ name: "test", middleware: [mw] }); + const inngest = new Inngest({ id: "test", middleware: [mw] }); test("input context has value", () => { inngest.createFunction( { - name: "", + id: "", onFailure: (ctx) => { assertType>(true); }, @@ -60,12 +60,12 @@ describe("stacking and inference", () => { }, }); - const inngest = new Inngest({ name: "test", middleware: [mw] }); + const inngest = new Inngest({ id: "test", middleware: [mw] }); test("input context has value", () => { inngest.createFunction( { - name: "", + id: "", onFailure: (ctx) => { assertType>(true); }, @@ -96,12 +96,12 @@ describe("stacking and inference", () => { }, }); - const inngest = new Inngest({ name: "test", middleware: [mw] }); + const inngest = new Inngest({ id: "test", middleware: [mw] }); test("input context has value", () => { inngest.createFunction( { - name: "", + id: "", onFailure: (ctx) => { assertType>(true); }, @@ -149,12 +149,12 @@ describe("stacking and inference", () => { }, }); - const inngest = new Inngest({ name: "test", middleware: [mw1, mw2] }); + const inngest = new Inngest({ id: "test", middleware: [mw1, mw2] }); test("input context has foo value", () => { inngest.createFunction( { - name: "", + id: "", onFailure: (ctx) => { assertType>(true); }, @@ -169,7 +169,7 @@ describe("stacking and inference", () => { test("input context has bar value", () => { inngest.createFunction( { - name: "", + id: "", onFailure: (ctx) => { assertType>(true); }, @@ -217,12 +217,12 @@ describe("stacking and inference", () => { }, }); - const inngest = new Inngest({ name: "test", middleware: [mw1, mw2] }); + const inngest = new Inngest({ id: "test", middleware: [mw1, mw2] }); test("input context has new value", () => { inngest.createFunction( { - name: "", + id: "", onFailure: (ctx) => { assertType>(true); }, @@ -236,4 +236,291 @@ describe("stacking and inference", () => { }); }); }); + + describe("onSendEvent", () => { + describe("transformOutput", () => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const mockFetch = jest.fn(() => + Promise.resolve({ + status: 200, + json: () => Promise.resolve({ ids: [], status: 200 }), + text: () => Promise.resolve(""), + }) + ) as any; + + beforeEach(() => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call + mockFetch.mockClear(); + }); + + describe("can add a value to output context", () => { + const mw = new InngestMiddleware({ + name: "mw", + init() { + return { + onSendEvent() { + return { + transformOutput() { + return { + result: { foo: "bar" }, + }; + }, + }; + }, + }; + }, + }); + + const inngest = new Inngest({ + id: "test", + middleware: [mw], + eventKey: "123", + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + fetch: mockFetch, + }); + + const payload = { name: "foo", data: { foo: "bar" } }; + + test("output context has value", () => { + inngest.createFunction({ id: "" }, { event: "" }, ({ step }) => { + const directRes = inngest.send(payload); + assertType["foo"], string>>(true); + + const res = step.sendEvent("id", payload); + assertType["foo"], string>>(true); + }); + }); + + test("output context retains default 'ids' value", () => { + inngest.createFunction({ id: "" }, { event: "" }, ({ step }) => { + const directRes = inngest.send(payload); + assertType["ids"], string[]>>( + true + ); + + const res = step.sendEvent("id", payload); + assertType["ids"], string[]>>(true); + }); + }); + }); + + describe("can add a literal value to output context", () => { + const mw = new InngestMiddleware({ + name: "mw", + init() { + return { + onSendEvent() { + return { + transformOutput() { + return { + result: { foo: "bar" }, + } as const; + }, + }; + }, + }; + }, + }); + + const inngest = new Inngest({ + id: "test", + middleware: [mw], + eventKey: "123", + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + fetch: mockFetch, + }); + + const payload = { name: "foo", data: { foo: "bar" } }; + + test("output context has value", () => { + inngest.createFunction({ id: "" }, { event: "" }, ({ step }) => { + const directRes = inngest.send(payload); + assertType["foo"], "bar">>(true); + + const res = step.sendEvent("id", payload); + assertType["foo"], "bar">>(true); + }); + }); + + test("output context retains default 'ids' value", () => { + inngest.createFunction({ id: "" }, { event: "" }, ({ step }) => { + const directRes = inngest.send(payload); + assertType["ids"], string[]>>( + true + ); + + const res = step.sendEvent("id", payload); + assertType["ids"], string[]>>(true); + }); + }); + }); + + describe("can mutate an existing output context value", () => { + const mw = new InngestMiddleware({ + name: "mw", + init() { + return { + onSendEvent() { + return { + transformOutput() { + return { + result: { ids: true }, + }; + }, + }; + }, + }; + }, + }); + + const inngest = new Inngest({ + id: "test", + middleware: [mw], + eventKey: "123", + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + fetch: mockFetch, + }); + + const payload = { name: "foo", data: { foo: "bar" } }; + + test("output context has value", () => { + inngest.createFunction({ id: "" }, { event: "" }, ({ step }) => { + const directRes = inngest.send(payload); + assertType["ids"], boolean>>( + true + ); + + const res = step.sendEvent("id", payload); + assertType["ids"], boolean>>(true); + }); + }); + }); + + describe("can add multiple output context values via stacking", () => { + const mw1 = new InngestMiddleware({ + name: "mw1", + init() { + return { + onSendEvent() { + return { + transformOutput() { + return { + result: { foo: "foo" }, + }; + }, + }; + }, + }; + }, + }); + + const mw2 = new InngestMiddleware({ + name: "mw2", + init() { + return { + onSendEvent() { + return { + transformOutput() { + return { + result: { bar: true }, + }; + }, + }; + }, + }; + }, + }); + + const inngest = new Inngest({ + id: "test", + middleware: [mw1, mw2], + eventKey: "123", + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + fetch: mockFetch, + }); + + const payload = { name: "foo", data: { foo: "bar" } }; + + test("output context has foo value", () => { + inngest.createFunction({ id: "" }, { event: "" }, ({ step }) => { + const directRes = inngest.send(payload); + assertType["foo"], string>>(true); + + const res = step.sendEvent("id", payload); + assertType["foo"], string>>(true); + }); + }); + + test("output context has bar value", () => { + inngest.createFunction({ id: "" }, { event: "" }, ({ step }) => { + const directRes = inngest.send(payload); + assertType["bar"], boolean>>( + true + ); + + const res = step.sendEvent("id", payload); + assertType["bar"], boolean>>(true); + }); + }); + }); + + describe("can overwrite a new value in output context", () => { + const mw1 = new InngestMiddleware({ + name: "mw1", + init() { + return { + onSendEvent() { + return { + transformOutput() { + return { + result: { foo: "bar" }, + }; + }, + }; + }, + }; + }, + }); + + const mw2 = new InngestMiddleware({ + name: "mw2", + init() { + return { + onSendEvent() { + return { + transformOutput() { + return { + result: { foo: true }, + }; + }, + }; + }, + }; + }, + }); + + const inngest = new Inngest({ + id: "test", + middleware: [mw1, mw2], + eventKey: "123", + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + fetch: mockFetch, + }); + + const payload = { name: "foo", data: { foo: "bar" } }; + + test("output context has new value", () => { + inngest.createFunction({ id: "" }, { event: "" }, ({ step }) => { + const directRes = inngest.send(payload); + assertType["foo"], boolean>>( + true + ); + + const res = step.sendEvent("id", payload); + assertType["foo"], boolean>>(true); + }); + }); + }); + }); + }); }); diff --git a/packages/inngest/src/components/InngestMiddleware.ts b/packages/inngest/src/components/InngestMiddleware.ts index b4da8ad86..c9b5c4cb7 100644 --- a/packages/inngest/src/components/InngestMiddleware.ts +++ b/packages/inngest/src/components/InngestMiddleware.ts @@ -13,16 +13,15 @@ import { type IncomingOp, type MiddlewareStack, type OutgoingOp, + type SendEventBaseOutput, } from "../types"; -import { type Inngest } from "./Inngest"; -import { type InngestFunction } from "./InngestFunction"; +import { type AnyInngest } from "./Inngest"; +import { type AnyInngestFunction } from "./InngestFunction"; /** * A middleware that can be registered with Inngest to hook into various * lifecycles of the SDK and affect input and output of Inngest functionality. * - * TODO Add docs and shortlink. - * * See {@link https://innge.st/middleware} * * @example @@ -32,7 +31,7 @@ import { type InngestFunction } from "./InngestFunction"; * middleware: [ * new InngestMiddleware({ * name: "My Middleware", - * register: () => { + * init: () => { * // ... * } * }) @@ -109,6 +108,10 @@ export type RunHookStack = PromisifiedFunctionRecord< Await >; +export type SendEventHookStack = PromisifiedFunctionRecord< + Await +>; + /** * Given some middleware and an entrypoint, runs the initializer for the given * `key` and returns functions that will pass arguments through a stack of each @@ -335,9 +338,6 @@ export type MiddlewareRegisterReturn = { * The `output` hook is called after the event has been sent to Inngest. * This is where you can perform any final actions after the event has * been sent to Inngest and can modify the output the SDK sees. - * - * TODO This needs to be a result object that we spread into, not just some - * unknown value. */ transformOutput?: MiddlewareSendEventOutput; }>; @@ -349,20 +349,14 @@ export type MiddlewareRegisterReturn = { export type MiddlewareRegisterFn = (ctx: { /** * The client this middleware is being registered on. - * - * TODO This should not use `any`, but the generic type expected. */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - client: Inngest; + client: AnyInngest; /** * If defined, this middleware has been applied directly to an Inngest * function rather than on the client. - * - * TODO This should not use `any`, but the generic type expected. */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - fn?: InngestFunction; + fn?: AnyInngestFunction; }) => MaybePromise; /** @@ -382,27 +376,17 @@ type MiddlewareRunArgs = Readonly<{ * The context object that will be passed to the function. This contains * event data, some contextual data such as the run's ID, and step tooling. */ - ctx: Record & - Readonly< - BaseContext< - ClientOptions, - string, - Record unknown> - > - >; + ctx: Record & Readonly>; /** * The step data that will be passed to the function. */ - steps: Readonly>[]; + steps: Readonly[]; /** * The function that is being executed. - * - * TODO This should not use `any`, but the generic type expected. */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - fn: InngestFunction; + fn: AnyInngestFunction; }>; /** @@ -442,11 +426,11 @@ type MiddlewareRunInput = (ctx: MiddlewareRunArgs) => MaybePromise<{ } | void>; /** - * Arguments sent to some `sendEvent` lifecycle hooks of a middleware. + * Arguments for the SendEventInput hook * * @internal */ -type MiddlewareSendEventArgs = Readonly<{ +type MiddlewareSendEventInputArgs = Readonly<{ payloads: ReadonlyArray; }>; @@ -456,17 +440,26 @@ type MiddlewareSendEventArgs = Readonly<{ * * @internal */ -type MiddlewareSendEventInput = (ctx: MiddlewareSendEventArgs) => MaybePromise<{ +type MiddlewareSendEventInput = ( + ctx: MiddlewareSendEventInputArgs +) => MaybePromise<{ payloads?: EventPayload[]; } | void>; +/** + * Arguments for the SendEventOutput hook + * + * @internal + */ +type MiddlewareSendEventOutputArgs = { result: Readonly }; + /** * The shape of an `output` hook within a `sendEvent`, optionally returning a * change to the result value. */ type MiddlewareSendEventOutput = ( - ctx: MiddlewareSendEventArgs -) => MaybePromise; + ctx: MiddlewareSendEventOutputArgs +) => MaybePromise<{ result?: Record } | void>; /** * @internal @@ -474,7 +467,7 @@ type MiddlewareSendEventOutput = ( type MiddlewareRunOutput = (ctx: { result: Readonly>; step?: Readonly>; -}) => { result?: Partial> } | void; +}) => MaybePromise<{ result?: Partial> } | void>; /** * @internal @@ -501,6 +494,40 @@ type GetMiddlewareRunInputMutation< /** * @internal */ +type GetMiddlewareSendEventOutputMutation< + TMiddleware extends InngestMiddleware +> = TMiddleware extends InngestMiddleware + ? TOpts["init"] extends MiddlewareRegisterFn + ? Await< + Await["onSendEvent"]>["transformOutput"] + > extends { + result: infer TResult; + } + ? { + [K in keyof TResult]: TResult[K]; + } + : // eslint-disable-next-line @typescript-eslint/ban-types + {} + : // eslint-disable-next-line @typescript-eslint/ban-types + {} + : // eslint-disable-next-line @typescript-eslint/ban-types + {}; + +/** + * @internal + */ +export type MiddlewareStackSendEventOutputMutation< + TContext, + TMiddleware extends MiddlewareStack +> = ObjectAssign< + { + [K in keyof TMiddleware]: GetMiddlewareSendEventOutputMutation< + TMiddleware[K] + >; + }, + TContext +>; + export type ExtendWithMiddleware< TMiddlewareStacks extends MiddlewareStack[], // eslint-disable-next-line @typescript-eslint/ban-types diff --git a/packages/inngest/src/components/InngestStepTools.test.ts b/packages/inngest/src/components/InngestStepTools.test.ts index 1f7d392ee..f7ab499af 100644 --- a/packages/inngest/src/components/InngestStepTools.test.ts +++ b/packages/inngest/src/components/InngestStepTools.test.ts @@ -1,136 +1,179 @@ -/* eslint-disable @typescript-eslint/no-unsafe-call */ +/* eslint-disable @typescript-eslint/ban-types */ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ -import { EventSchemas, type EventsFromOpts } from "@local"; -import { - createExecutionState, - type ExecutionState, -} from "@local/components/InngestFunction"; +import { EventSchemas } from "@local/components/EventSchemas"; +import { type EventsFromOpts } from "@local/components/Inngest"; import { createStepTools, - type TickOp, + getStepOptions, } from "@local/components/InngestStepTools"; -import { - StepOpCode, - type ClientOptions, - type SendEventResponse, -} from "@local/types"; +import { StepOpCode, type ClientOptions } from "@local/types"; import ms from "ms"; import { assertType } from "type-plus"; import { createClient } from "../test/helpers"; +const getStepTools = () => { + const step = createStepTools( + createClient({ id: "test" }), + ({ args, matchOp }) => { + const stepOptions = getStepOptions(args[0]); + return Promise.resolve(matchOp(stepOptions, ...args.slice(1))); + } + ); + + return step; +}; + +type StepTools = ReturnType; + describe("waitForEvent", () => { - const client = createClient({ name: "test" }); - let waitForEvent: ReturnType["waitForEvent"]; - let state: ExecutionState; - let getOp: () => TickOp | undefined; + let step: StepTools; beforeEach(() => { - state = createExecutionState(); - ({ waitForEvent } = createStepTools(client, state)); - getOp = () => Object.values(state.tickOps)[0]; + step = getStepTools(); }); - test("return WaitForEvent step op code", () => { - void waitForEvent("event", { timeout: "2h" }); - expect(getOp()).toMatchObject({ + test("return WaitForEvent step op code", async () => { + await expect( + step.waitForEvent("id", { event: "event", timeout: "2h" }) + ).resolves.toMatchObject({ op: StepOpCode.WaitForEvent, }); }); - test("returns `event` as ID", () => { - void waitForEvent("event", { timeout: "2h" }); - expect(getOp()).toMatchObject({ - name: "event", + test("returns `id` as ID", async () => { + await expect( + step.waitForEvent("id", { event: "event", timeout: "2h" }) + ).resolves.toMatchObject({ + id: "id", }); }); - test("return blank opts if none given", () => { - void waitForEvent("event", { timeout: "2h" }); - expect(getOp()).toMatchObject({ - opts: {}, + test("returns ID by default", async () => { + await expect( + step.waitForEvent("id", { event: "event", timeout: "2h" }) + ).resolves.toMatchObject({ + displayName: "id", }); }); - test("return a hash of the op", () => { - void waitForEvent("event", { timeout: "2h" }); - expect(getOp()).toMatchObject({ + test("returns specific name if given", async () => { + await expect( + step.waitForEvent( + { id: "id", name: "name" }, + { event: "event", timeout: "2h" } + ) + ).resolves.toMatchObject({ + displayName: "name", + }); + }); + + test("return event name as name", async () => { + await expect( + step.waitForEvent("id", { event: "event", timeout: "2h" }) + ).resolves.toMatchObject({ name: "event", - op: "WaitForEvent", + }); + }); + + test("return blank opts if none given", async () => { + await expect( + step.waitForEvent("id", { event: "event", timeout: "2h" }) + ).resolves.toMatchObject({ opts: {}, }); }); - test("return TTL if string `timeout` given", () => { - void waitForEvent("event", { timeout: "1m" }); - expect(getOp()).toMatchObject({ + test("return TTL if string `timeout` given", async () => { + await expect( + step.waitForEvent("id", { event: "event", timeout: "1m" }) + ).resolves.toMatchObject({ opts: { timeout: "1m", }, }); }); - test("return TTL if date `timeout` given", () => { + test("return TTL if date `timeout` given", async () => { const upcoming = new Date(); upcoming.setDate(upcoming.getDate() + 6); upcoming.setHours(upcoming.getHours() + 1); - void waitForEvent("event", { timeout: upcoming }); - expect(getOp()).toMatchObject({ + await expect( + step.waitForEvent("id", { event: "event", timeout: upcoming }) + ).resolves.toMatchObject({ opts: { timeout: expect.stringMatching(upcoming.toISOString()), }, }); }); - test("return simple field match if `match` string given", () => { - void waitForEvent("event", { match: "name", timeout: "2h" }); - expect(getOp()).toMatchObject({ + test("return simple field match if `match` string given", async () => { + await expect( + step.waitForEvent("id", { event: "event", match: "name", timeout: "2h" }) + ).resolves.toMatchObject({ opts: { if: "event.name == async.name", }, }); }); - test("return custom match statement if `if` given", () => { - void waitForEvent("event", { if: "name == 123", timeout: "2h" }); - expect(getOp()).toMatchObject({ + test("return custom match statement if `if` given", async () => { + await expect( + step.waitForEvent("id", { + event: "event", + if: "name == 123", + timeout: "2h", + }) + ).resolves.toMatchObject({ opts: { if: "name == 123", }, }); }); - test("uses custom `id` if given", () => { - void waitForEvent("event", { id: "custom", timeout: "2h" }); - expect(getOp()).toMatchObject({ - id: "custom", + describe("type errors", () => { + test("does not allow both `match` and `if`", () => { + // @ts-expect-error `match` and `if` cannot be defined together + void step.waitForEvent("id", { + event: "event", + match: "name", + if: "name", + timeout: "2h", + }); }); }); }); describe("run", () => { - const client = createClient({ name: "test" }); - let run: ReturnType["run"]; - let state: ExecutionState; - let getOp: () => TickOp | undefined; + let step: StepTools; beforeEach(() => { - state = createExecutionState(); - ({ run } = createStepTools(client, state)); - getOp = () => Object.values(state.tickOps)[0]; + step = getStepTools(); }); - test("return Step step op code", () => { - void run("step", () => undefined); - expect(getOp()).toMatchObject({ + test("return Step step op code", async () => { + await expect(step.run("step", () => undefined)).resolves.toMatchObject({ op: StepOpCode.StepPlanned, }); }); - test("return step name as name", () => { - void run("step", () => undefined); - expect(getOp()).toMatchObject({ - name: "step", + test("returns `id` as ID", async () => { + await expect(step.run("id", () => undefined)).resolves.toMatchObject({ + id: "id", + }); + }); + + test("return ID by default", async () => { + await expect(step.run("id", () => undefined)).resolves.toMatchObject({ + displayName: "id", + }); + }); + + test("return specific name if given", async () => { + await expect( + step.run({ id: "id", name: "name" }, () => undefined) + ).resolves.toMatchObject({ + displayName: "name", }); }); @@ -155,7 +198,7 @@ describe("run", () => { set: new Set(), }; - const output = run("step", () => input); + const output = step.run("step", () => input); assertType< Promise<{ @@ -176,174 +219,165 @@ describe("run", () => { }> >(output); }); - - test("uses custom `id` if given", () => { - void run("step", () => undefined, { id: "custom" }); - expect(getOp()).toMatchObject({ - id: "custom", - }); - }); }); describe("sleep", () => { - const client = createClient({ name: "test" }); - let sleep: ReturnType["sleep"]; - let state: ExecutionState; - let getOp: () => TickOp | undefined; + let step: StepTools; beforeEach(() => { - state = createExecutionState(); - ({ sleep } = createStepTools(client, state)); - getOp = () => Object.values(state.tickOps)[0]; + step = getStepTools(); + }); + + test("return id", async () => { + await expect(step.sleep("id", "1m")).resolves.toMatchObject({ + id: "id", + }); }); - test("return Sleep step op code", () => { - void sleep("1m"); - expect(getOp()).toMatchObject({ + test("return Sleep step op code", async () => { + await expect(step.sleep("id", "1m")).resolves.toMatchObject({ op: StepOpCode.Sleep, }); }); - test("return time string as name", () => { - void sleep("1m"); - expect(getOp()).toMatchObject({ - name: "1m", + test("return ID by default", async () => { + await expect(step.sleep("id", "1m")).resolves.toMatchObject({ + displayName: "id", }); }); - test("uses custom `id` if given", () => { - void sleep("1m", { id: "custom" }); - expect(getOp()).toMatchObject({ - id: "custom", + test("return specific name if given", async () => { + await expect( + step.sleep({ id: "id", name: "name" }, "1m") + ).resolves.toMatchObject({ + displayName: "name", }); }); }); describe("sleepUntil", () => { - const client = createClient({ name: "test" }); - let sleepUntil: ReturnType["sleepUntil"]; - let state: ExecutionState; - let getOp: () => TickOp | undefined; + let step: StepTools; beforeEach(() => { - state = createExecutionState(); - ({ sleepUntil } = createStepTools(client, state)); - getOp = () => Object.values(state.tickOps)[0]; + step = getStepTools(); + }); + + test("return id", async () => { + const future = new Date(); + future.setDate(future.getDate() + 1); + + await expect(step.sleepUntil("id", future)).resolves.toMatchObject({ + id: "id", + }); + }); + + test("return ID by default", async () => { + const future = new Date(); + future.setDate(future.getDate() + 1); + + await expect(step.sleepUntil("id", future)).resolves.toMatchObject({ + displayName: "id", + }); + }); + + test("return specific name if given", async () => { + const future = new Date(); + future.setDate(future.getDate() + 1); + + await expect( + step.sleepUntil({ id: "id", name: "name" }, future) + ).resolves.toMatchObject({ + displayName: "name", + }); }); - test("return Sleep step op code", () => { + test("return Sleep step op code", async () => { const future = new Date(); future.setDate(future.getDate() + 1); - void sleepUntil(future); - expect(getOp()).toMatchObject({ + await expect(step.sleepUntil("id", future)).resolves.toMatchObject({ op: StepOpCode.Sleep, }); }); - test("parses dates", () => { + test("parses dates", async () => { const next = new Date(); - void sleepUntil(next); - expect(getOp()).toMatchObject({ + await expect(step.sleepUntil("id", next)).resolves.toMatchObject({ name: next.toISOString(), }); }); - test("parses ISO strings", () => { + test("parses ISO strings", async () => { const next = new Date(new Date().valueOf() + ms("6d")).toISOString(); - void sleepUntil(next); - expect(getOp()).toMatchObject({ + await expect(step.sleepUntil("id", next)).resolves.toMatchObject({ name: next, }); }); - test("throws if invalid date given", () => { + test("throws if invalid date given", async () => { const next = new Date("bad"); - expect(() => sleepUntil(next)).toThrow( + await expect(() => step.sleepUntil("id", next)).rejects.toThrow( "Invalid date or date string passed" ); }); - test("throws if invalid time string given", () => { + test("throws if invalid time string given", async () => { const next = "bad"; - expect(() => sleepUntil(next)).toThrow( + await expect(() => step.sleepUntil("id", next)).rejects.toThrow( "Invalid date or date string passed" ); }); - - test("uses custom `id` if given", () => { - void sleepUntil(new Date(), { id: "custom" }); - expect(getOp()).toMatchObject({ - id: "custom", - }); - }); }); describe("sendEvent", () => { describe("runtime", () => { - const fetchMock = jest.fn(() => { - const json: SendEventResponse = { - status: 200, - ids: [], - }; - - return Promise.resolve({ - status: 200, - json: () => { - return Promise.resolve(json); - }, - }); - }) as unknown as typeof fetch; - - const client = createClient({ - name: "test", - fetch: fetchMock, - eventKey: "123", - }); - const sendSpy = jest.spyOn(client, "send"); - - let sendEvent: ReturnType["sendEvent"]; - let state: ExecutionState; - let getOp: () => TickOp | undefined; - + let step: StepTools; beforeEach(() => { - state = createExecutionState(); - ({ sendEvent } = createStepTools(client, state)); - getOp = () => Object.values(state.tickOps)[0]; + step = getStepTools(); }); - test("return Step step op code", () => { - void sendEvent({ name: "step", data: "foo" }); - - expect(getOp()).toMatchObject({ op: StepOpCode.StepPlanned }); - expect(sendSpy).not.toHaveBeenCalled(); + test("return id", async () => { + await expect( + step.sendEvent("id", { name: "step", data: "foo" }) + ).resolves.toMatchObject({ + id: "id", + }); }); - test('return "sendEvent" as name', () => { - void sendEvent({ name: "step", data: "foo" }); - - expect(getOp()).toMatchObject({ name: "sendEvent" }); - expect(sendSpy).not.toHaveBeenCalled(); + test("return Step step op code", async () => { + await expect( + step.sendEvent("id", { name: "step", data: "foo" }) + ).resolves.toMatchObject({ + op: StepOpCode.StepPlanned, + }); }); - test("execute inline if non-step fn", () => { - state.nonStepFnDetected = true; - void sendEvent({ name: "step", data: "foo" }); - - expect(getOp()).toBeUndefined(); - expect(sendSpy).toHaveBeenCalledWith({ name: "step", data: "foo" }); + test("return ID by default", async () => { + await expect( + step.sendEvent("id", { name: "step", data: "foo" }) + ).resolves.toMatchObject({ displayName: "id" }); }); - test("uses custom `id` if given", () => { - void sendEvent({ name: "step", data: "foo" }, { id: "custom" }); + test("return specific name if given", async () => { + await expect( + step.sendEvent( + { id: "id", name: "name" }, + { name: "step", data: "foo" } + ) + ).resolves.toMatchObject({ displayName: "name" }); + }); - expect(getOp()).toMatchObject({ - id: "custom", - }); + test("retain legacy `name` field for backwards compatibility with <=v2", async () => { + await expect( + step.sendEvent( + { id: "id", name: "name" }, + { name: "step", data: "foo" } + ) + ).resolves.toMatchObject({ name: "sendEvent" }); }); }); @@ -354,15 +388,15 @@ describe("sendEvent", () => { (() => undefined) as any; test("allows sending a single event with a string", () => { - void sendEvent({ name: "anything", data: "foo" }); + void sendEvent("id", { name: "anything", data: "foo" }); }); test("allows sending a single event with an object", () => { - void sendEvent({ name: "anything", data: "foo" }); + void sendEvent("id", { name: "anything", data: "foo" }); }); test("allows sending multiple events", () => { - void sendEvent([ + void sendEvent("id", [ { name: "anything", data: "foo" }, { name: "anything", data: "foo" }, ]); @@ -382,7 +416,7 @@ describe("sendEvent", () => { }>(); const opts = ((x: T): T => x)({ - name: "", + id: "", schemas, }); @@ -402,7 +436,7 @@ describe("sendEvent", () => { }); test("disallows sending multiple unknown events", () => { - void sendEvent([ + void sendEvent("id", [ // @ts-expect-error Unknown event { name: "unknown", data: { foo: "" } }, // @ts-expect-error Unknown event @@ -411,7 +445,7 @@ describe("sendEvent", () => { }); test("disallows sending one unknown event with multiple known events", () => { - void sendEvent([ + void sendEvent("id", [ { name: "foo", data: { foo: "" } }, // @ts-expect-error Unknown event { name: "unknown", data: { foo: "" } }, @@ -429,7 +463,7 @@ describe("sendEvent", () => { }); test("disallows sending multiple known events with invalid data", () => { - void sendEvent([ + void sendEvent("id", [ // @ts-expect-error Invalid data { name: "foo", data: { bar: "" } }, // @ts-expect-error Invalid data @@ -438,15 +472,15 @@ describe("sendEvent", () => { }); test("allows sending a single known event with a string", () => { - void sendEvent({ name: "foo", data: { foo: "" } }); + void sendEvent("id", { name: "foo", data: { foo: "" } }); }); test("allows sending a single known event with an object", () => { - void sendEvent({ name: "foo", data: { foo: "" } }); + void sendEvent("id", { name: "foo", data: { foo: "" } }); }); test("allows sending multiple known events", () => { - void sendEvent([ + void sendEvent("id", [ { name: "foo", data: { foo: "" } }, { name: "bar", data: { bar: "" } }, ]); diff --git a/packages/inngest/src/components/InngestStepTools.ts b/packages/inngest/src/components/InngestStepTools.ts index 65fdb63c0..745ec0273 100644 --- a/packages/inngest/src/components/InngestStepTools.ts +++ b/packages/inngest/src/components/InngestStepTools.ts @@ -1,15 +1,9 @@ -import canonicalize from "canonicalize"; -import { sha1 } from "hash.js"; import { type Jsonify } from "type-fest"; -import { - ErrCode, - functionStoppedRunningErr, - prettyError, -} from "../helpers/errors"; import { timeStr } from "../helpers/strings"; import { + type ExclusiveKeys, type ObjectPaths, - type PartialK, + type ParametersExceptFirst, type SendEventPayload, } from "../helpers/types"; import { @@ -17,19 +11,91 @@ import { type ClientOptions, type EventPayload, type HashedOp, - type StepOpts, + type SendEventOutput, + type StepOptions, + type StepOptionsOrId, } from "../types"; import { type EventsFromOpts, type Inngest } from "./Inngest"; -import { type ExecutionState } from "./InngestFunction"; -import { NonRetriableError } from "./NonRetriableError"; -export interface TickOp extends HashedOp { +export interface FoundStep extends HashedOp { + hashedId: string; fn?: (...args: unknown[]) => unknown; fulfilled: boolean; - resolve: (value: unknown | PromiseLike) => void; - reject: (reason?: unknown) => void; + handled: boolean; + + /** + * Returns a boolean representing whether or not the step was handled on this + * invocation. + */ + handle: () => boolean; } +export type MatchOpFn< + T extends (...args: unknown[]) => Promise = ( + ...args: unknown[] + ) => Promise +> = ( + stepOptions: StepOptions, + /** + * Arguments passed by the user. + */ + ...args: ParametersExceptFirst +) => Omit; + +export type StepHandler = (info: { + matchOp: MatchOpFn; + opts?: StepToolOptions; + args: [StepOptionsOrId, ...unknown[]]; +}) => Promise; + +export interface StepToolOptions< + T extends (...args: unknown[]) => Promise = ( + ...args: unknown[] + ) => Promise +> { + /** + * Optionally, we can also provide a function that will be called when + * Inngest tells us to run this operation. + * + * If this function is defined, the first time the tool is used it will + * report the desired operation (including options) to the Inngest. Inngest + * will then call back to the function to tell it to run the step and then + * retrieve data. + * + * We do this in order to allow functionality such as per-step retries; this + * gives the SDK the opportunity to tell Inngest what it wants to do before + * it does it. + * + * This function is passed the arguments passed by the user. It will be run + * when we receive an operation matching this one that does not contain a + * `data` property. + */ + fn?: (...args: Parameters) => unknown; + + /** + * If `true` and we have detected that this is a non-step function, the + * provided `fn` will be called and the result returned immediately + * instead of being executed later. + * + * If no `fn` is provided to the tool, this will throw the same error as + * if this setting was `false`. + */ + nonStepExecuteInline?: boolean; +} + +export const getStepOptions = (options: StepOptionsOrId): StepOptions => { + if (typeof options === "string") { + return { id: options }; + } + + return options; +}; + +/** + * Suffix used to namespace steps that are automatically indexed. + */ +export const STEP_INDEXING_SUFFIX = ":"; + /** * Create a new set of step function tools ready to be used in a step function. * This function should be run and a fresh set of tools provided every time a @@ -44,52 +110,8 @@ export const createStepTools = < TriggeringEvent extends keyof Events & string >( client: Inngest, - state: ExecutionState + stepHandler: StepHandler ) => { - // Start referencing everything - state.tickOps = state.allFoundOps; - - /** - * Create a unique hash of an operation using only a subset of the operation's - * properties; will never use `data` and will guarantee the order of the - * object so we don't rely on individual tools for that. - * - * If the operation already contains an ID, the current ID will be used - * instead, so that users can provide their own IDs. - */ - const hashOp = ( - /** - * The op to generate a hash from. We only use a subset of the op's - * properties when creating the hash. - */ - op: PartialK - ): HashedOp => { - /** - * If the op already has an ID, we don't need to generate one. This allows - * users to specify their own IDs. - */ - if (op.id) { - return op as HashedOp; - } - - const obj = { - parent: state.currentOp?.id ?? null, - op: op.op, - name: op.name, - opts: op.opts ?? null, - }; - - const collisionHash = _internals.hashData(obj); - - const pos = (state.tickOpHashes[collisionHash] = - (state.tickOpHashes[collisionHash] ?? -1) + 1); - - return { - ...op, - id: _internals.hashData({ pos, ...obj }), - }; - }; - /** * A local helper used to create tools that can be used to submit an op. * @@ -107,84 +129,12 @@ export const createStepTools = < * * Most simple tools will likely only need to define this. */ - matchOp: ( - /** - * Arguments passed by the user. - */ - ...args: Parameters - ) => PartialK, "id">, - - opts?: { - /** - * Optionally, we can also provide a function that will be called when - * Inngest tells us to run this operation. - * - * If this function is defined, the first time the tool is used it will - * report the desired operation (including options) to the Inngest. Inngest - * will then call back to the function to tell it to run the step and then - * retrieve data. - * - * We do this in order to allow functionality such as per-step retries; this - * gives the SDK the opportunity to tell Inngest what it wants to do before - * it does it. - * - * This function is passed the arguments passed by the user. It will be run - * when we receive an operation matching this one that does not contain a - * `data` property. - */ - fn?: (...args: Parameters) => unknown; - - /** - * If `true` and we have detected that this is a non-step function, the - * provided `fn` will be called and the result returned immediately - * instead of being executed later. - * - * If no `fn` is provided to the tool, this will throw the same error as - * if this setting was `false`. - */ - nonStepExecuteInline?: boolean; - } + matchOp: MatchOpFn, + opts?: StepToolOptions ): T => { - return ((...args: Parameters): Promise => { - if (state.nonStepFnDetected) { - if (opts?.nonStepExecuteInline && opts.fn) { - return Promise.resolve(opts.fn(...args)); - } - - throw new NonRetriableError( - functionStoppedRunningErr(ErrCode.STEP_USED_AFTER_ASYNC) - ); - } - - if (state.executingStep) { - throw new NonRetriableError( - prettyError({ - whatHappened: "Your function was stopped from running", - why: "We detected that you have nested `step.*` tooling.", - consequences: "Nesting `step.*` tooling is not supported.", - stack: true, - toFixNow: - "Make sure you're not using `step.*` tooling inside of other `step.*` tooling. If you need to compose steps together, you can create a new async function and call it from within your step function, or use promise chaining.", - otherwise: - "For more information on step functions with Inngest, see https://www.inngest.com/docs/functions/multi-step", - code: ErrCode.NESTING_STEPS, - }) - ); - } - - state.hasUsedTools = true; - - const opId = hashOp(matchOp(...args)); - - return new Promise((resolve, reject) => { - state.tickOps[opId.id] = { - ...opId, - ...(opts?.fn ? { fn: () => opts.fn?.(...args) } : {}), - resolve, - reject, - fulfilled: false, - }; - }); + return (async (...args: Parameters): Promise => { + const parsedArgs = args as unknown as [StepOptionsOrId, ...unknown[]]; + return stepHandler({ args: parsedArgs, matchOp, opts }); }) as T; }; @@ -222,20 +172,21 @@ export const createStepTools = < */ sendEvent: createTool<{ >>( - payload: Payload, - opts?: StepOpts - ): Promise; + idOrOptions: StepOptionsOrId, + payload: Payload + ): Promise>; }>( - (_payload, opts) => { + ({ id, name }) => { return { - id: opts?.id, + id, op: StepOpCode.StepPlanned, name: "sendEvent", + displayName: name ?? id, }; }, { nonStepExecuteInline: true, - fn: (payload) => { + fn: (idOrOptions, payload) => { return client.send(payload); }, } @@ -251,28 +202,9 @@ export const createStepTools = < * returning `null` instead of any event data. */ waitForEvent: createTool< - ( - event: IncomingEvent extends keyof Events - ? IncomingEvent - : IncomingEvent extends EventPayload - ? IncomingEvent["name"] - : never, - opts: - | string - | ((IncomingEvent extends keyof Events - ? WaitForEventOpts - : IncomingEvent extends EventPayload - ? WaitForEventOpts - : never) & { - if?: never; - }) - | ((IncomingEvent extends keyof Events - ? WaitForEventOpts - : IncomingEvent extends EventPayload - ? WaitForEventOpts - : never) & { - match?: never; - }) + ( + idOrOptions: StepOptionsOrId, + opts: WaitForEventOpts ) => Promise< IncomingEvent extends keyof Events ? Events[IncomingEvent] | null @@ -280,26 +212,18 @@ export const createStepTools = < > >( ( - /** - * The event name to wait for. - */ - event, + { id, name }, /** * Options to control the event we're waiting for. */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - opts: WaitForEventOpts | string + opts ) => { const matchOpts: { timeout: string; if?: string } = { timeout: timeStr(typeof opts === "string" ? opts : opts.timeout), }; - let id: string | undefined; - if (typeof opts !== "string") { - id = opts?.id; - if (opts?.match) { matchOpts.if = `event.${opts.match} == async.${opts.match}`; } else if (opts?.if) { @@ -310,8 +234,9 @@ export const createStepTools = < return { id, op: StepOpCode.WaitForEvent, - name: event as string, + name: opts.event, opts: matchOpts, + displayName: name ?? id, }; } ), @@ -330,12 +255,7 @@ export const createStepTools = < */ run: createTool< unknown>( - /** - * The name of this step as it will appear in the Inngest Cloud UI. This - * is also used as a unique identifier for the step and should not match - * any other steps within this step function. - */ - name: string, + idOrOptions: StepOptionsOrId, /** * The function to run when this step is executed. Can be synchronous or @@ -345,8 +265,7 @@ export const createStepTools = < * call to `run`, meaning you can return and reason about return data * for next steps. */ - fn: T, - opts?: StepOpts + fn: T ) => Promise< /** * TODO Middleware can affect this. If run input middleware has returned @@ -361,14 +280,15 @@ export const createStepTools = < > > >( - (name, _fn, opts) => { + ({ id, name }) => { return { - id: opts?.id, + id, op: StepOpCode.StepPlanned, - name, + name: id, + displayName: name ?? id, }; }, - { fn: (_, fn) => fn() } + { fn: (stepOptions, fn) => fn() } ), /** @@ -383,21 +303,23 @@ export const createStepTools = < */ sleep: createTool< ( + idOrOptions: StepOptionsOrId, + /** * The amount of time to wait before continuing. */ - time: number | string, - opts?: StepOpts + time: number | string ) => Promise - >((time, opts) => { + >(({ id, name }, time) => { /** * The presence of this operation in the returned stack indicates that the * sleep is over and we should continue execution. */ return { - id: opts?.id, + id, op: StepOpCode.Sleep, name: timeStr(time), + displayName: name ?? id, }; }), @@ -409,13 +331,14 @@ export const createStepTools = < */ sleepUntil: createTool< ( + idOrOptions: StepOptionsOrId, + /** * The date to wait until before continuing. */ - time: Date | string, - opts?: StepOpts + time: Date | string ) => Promise - >((time, opts) => { + >(({ id, name }, time) => { const date = typeof time === "string" ? new Date(time) : time; /** @@ -424,9 +347,10 @@ export const createStepTools = < */ try { return { - id: opts?.id, + id, op: StepOpCode.Sleep, name: date.toISOString(), + displayName: name ?? id, }; } catch (err) { /** @@ -451,10 +375,13 @@ export const createStepTools = < * A set of optional parameters given to a `waitForEvent` call to control how * the event is handled. */ -interface WaitForEventOpts< - TriggeringEvent extends EventPayload, - IncomingEvent extends EventPayload -> extends StepOpts { +type WaitForEventOpts< + Events extends Record, + TriggeringEvent extends keyof Events, + IncomingEvent extends keyof Events +> = { + event: IncomingEvent; + /** * The step function will wait for the event for a maximum of this time, at * which point the event will be returned as `null` instead of any event data. @@ -466,63 +393,46 @@ interface WaitForEventOpts< * {@link https://npm.im/ms} */ timeout: number | string | Date; +} & ExclusiveKeys< + { + /** + * If provided, the step function will wait for the incoming event to match + * particular criteria. If the event does not match, it will be ignored and + * the step function will wait for another event. + * + * It must be a string of a dot-notation field name within both events to + * compare, e.g. `"data.id"` or `"user.email"`. + * + * ``` + * // Wait for an event where the `user.email` field matches + * match: "user.email" + * ``` + * + * All of these are helpers for the `if` option, which allows you to specify + * a custom condition to check. This can be useful if you need to compare + * multiple fields or use a more complex condition. + * + * See the Inngest expressions docs for more information. + * + * {@link https://www.inngest.com/docs/functions/expressions} + */ + match?: ObjectPaths & + ObjectPaths; - /** - * If provided, the step function will wait for the incoming event to match - * particular criteria. If the event does not match, it will be ignored and - * the step function will wait for another event. - * - * It must be a string of a dot-notation field name within both events to - * compare, e.g. `"data.id"` or `"user.email"`. - * - * ``` - * // Wait for an event where the `user.email` field matches - * match: "user.email" - * ``` - * - * All of these are helpers for the `if` option, which allows you to specify - * a custom condition to check. This can be useful if you need to compare - * multiple fields or use a more complex condition. - * - * See the Inngest expressions docs for more information. - * - * {@link https://www.inngest.com/docs/functions/expressions} - */ - match?: ObjectPaths & ObjectPaths; - - /** - * If provided, the step function will wait for the incoming event to match - * the given condition. If the event does not match, it will be ignored and - * the step function will wait for another event. - * - * The condition is a string of Google's Common Expression Language. For most - * simple cases, you might prefer to use `match` instead. - * - * See the Inngest expressions docs for more information. - * - * {@link https://www.inngest.com/docs/functions/expressions} - */ - if?: string; -} - -/** - * An operation ready to hash to be used to memoise step function progress. - * - * @internal - */ -export type UnhashedOp = { - name: string; - op: StepOpCode; - opts: Record | null; - parent: string | null; - pos?: number; -}; - -const hashData = (op: UnhashedOp): string => { - return sha1().update(canonicalize(op)).digest("hex"); -}; - -/** - * Exported for testing. - */ -export const _internals = { hashData }; + /** + * If provided, the step function will wait for the incoming event to match + * the given condition. If the event does not match, it will be ignored and + * the step function will wait for another event. + * + * The condition is a string of Google's Common Expression Language. For most + * simple cases, you might prefer to use `match` instead. + * + * See the Inngest expressions docs for more information. + * + * {@link https://www.inngest.com/docs/functions/expressions} + */ + if?: string; + }, + "match", + "if" +>; diff --git a/packages/inngest/src/components/RetryAfterError.ts b/packages/inngest/src/components/RetryAfterError.ts new file mode 100644 index 000000000..e5eabcb81 --- /dev/null +++ b/packages/inngest/src/components/RetryAfterError.ts @@ -0,0 +1,66 @@ +import ms from "ms"; + +/** + * An error that, when thrown, indicates to Inngest that the function should be + * retried after a given amount of time. + * + * A `message` must be provided, as well as a `retryAfter` parameter, which can + * be a `number` of milliseconds, an `ms`-compatible time string, or a `Date`. + * + * An optional `cause` can be provided to provide more context to the error. + * + * @public + */ +export class RetryAfterError extends Error { + /** + * The underlying cause of the error, if any. + * + * This will be serialized and sent to Inngest. + */ + public readonly cause?: unknown; + + /** + * The time after which the function should be retried. Represents either a + * number of seconds or a RFC3339 date. + */ + public readonly retryAfter: string; + + constructor( + message: string, + + /** + * The time after which the function should be retried. Represents either a + * number of seconds or a RFC3339 date. + */ + retryAfter: number | string | Date, + + options?: { + /** + * The underlying cause of the error, if any. + * + * This will be serialized and sent to Inngest. + */ + cause?: unknown; + } + ) { + super(message); + + if (retryAfter instanceof Date) { + this.retryAfter = retryAfter.toISOString(); + } else { + const seconds = `${Math.ceil( + (typeof retryAfter === "string" ? ms(retryAfter) : retryAfter) / 1000 + )}`; + + if (!isFinite(Number(seconds))) { + throw new Error( + "retryAfter must be a number of seconds, a ms-compatible string, or a Date" + ); + } + + this.retryAfter = seconds; + } + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + this.cause = options?.cause; + } +} diff --git a/packages/inngest/src/components/execution/InngestExecution.ts b/packages/inngest/src/components/execution/InngestExecution.ts new file mode 100644 index 000000000..af497015b --- /dev/null +++ b/packages/inngest/src/components/execution/InngestExecution.ts @@ -0,0 +1,78 @@ +import Debug, { type Debugger } from "debug"; +import { type Simplify } from "type-fest"; +import { type MaybePromise } from "type-plus"; +import { type ServerTiming } from "../../helpers/ServerTiming"; +import { debugPrefix } from "../../helpers/consts"; +import { type AnyContext, type IncomingOp, type OutgoingOp } from "../../types"; +import { type AnyInngest } from "../Inngest"; +import { type ActionResponse } from "../InngestCommHandler"; +import { type AnyInngestFunction } from "../InngestFunction"; + +/** + * The possible results of an execution. + */ +export interface ExecutionResults { + "function-resolved": { data: unknown }; + "step-ran": { step: OutgoingOp }; + "function-rejected": { error: unknown; retriable: boolean | string }; + "steps-found": { steps: [OutgoingOp, ...OutgoingOp[]] }; + "step-not-found": { step: OutgoingOp }; +} + +export type ExecutionResult = { + [K in keyof ExecutionResults]: Simplify<{ type: K } & ExecutionResults[K]>; +}[keyof ExecutionResults]; + +export type ExecutionResultHandler = ( + result: ExecutionResult +) => MaybePromise; + +export type ExecutionResultHandlers = { + [E in ExecutionResult as E["type"]]: (result: E) => MaybePromise; +}; + +export interface MemoizedOp extends IncomingOp { + fulfilled?: boolean; + seen?: boolean; +} + +export enum ExecutionVersion { + V0 = 0, + V1 = 1, +} + +export const PREFERRED_EXECUTION_VERSION = + ExecutionVersion.V1 satisfies ExecutionVersion; + +/** + * Options for creating a new {@link InngestExecution} instance. + */ +export interface InngestExecutionOptions { + client: AnyInngest; + fn: AnyInngestFunction; + runId: string; + data: Omit; + stepState: Record; + stepCompletionOrder: string[]; + requestedRunStep?: string; + timer?: ServerTiming; + isFailureHandler?: boolean; + disableImmediateExecution?: boolean; +} + +export type InngestExecutionFactory = ( + options: InngestExecutionOptions +) => IInngestExecution; + +export class InngestExecution { + protected debug: Debugger; + + constructor(protected options: InngestExecutionOptions) { + this.options = options; + this.debug = Debug(debugPrefix).extend(this.options.runId); + } +} + +export interface IInngestExecution { + start(): Promise; +} diff --git a/packages/inngest/src/components/execution/v0.ts b/packages/inngest/src/components/execution/v0.ts new file mode 100644 index 000000000..05576303d --- /dev/null +++ b/packages/inngest/src/components/execution/v0.ts @@ -0,0 +1,638 @@ +import canonicalize from "canonicalize"; +import { sha1 } from "hash.js"; +import { z } from "zod"; +import { + ErrCode, + deserializeError, + functionStoppedRunningErr, + prettyError, + serializeError, +} from "../../helpers/errors"; +import { undefinedToNull } from "../../helpers/functions"; +import { + resolveAfterPending, + resolveNextTick, + runAsPromise, +} from "../../helpers/promises"; +import { type PartialK } from "../../helpers/types"; +import { + StepOpCode, + failureEventErrorSchema, + type AnyContext, + type AnyHandler, + type BaseContext, + type ClientOptions, + type EventPayload, + type FailureEventArgs, + type HashedOp, + type IncomingOp, + type OpStack, + type OutgoingOp, +} from "../../types"; +import { getHookStack, type RunHookStack } from "../InngestMiddleware"; +import { + createStepTools, + getStepOptions, + type StepHandler, +} from "../InngestStepTools"; +import { NonRetriableError } from "../NonRetriableError"; +import { RetryAfterError } from "../RetryAfterError"; +import { + InngestExecution, + type ExecutionResult, + type IInngestExecution, + type InngestExecutionFactory, + type InngestExecutionOptions, +} from "./InngestExecution"; + +export const createV0InngestExecution: InngestExecutionFactory = (options) => { + return new V0InngestExecution(options); +}; + +export class V0InngestExecution + extends InngestExecution + implements IInngestExecution +{ + #state: V0ExecutionState; + #execution: Promise | undefined; + #userFnToRun: AnyHandler; + #fnArg: AnyContext; + + constructor(options: InngestExecutionOptions) { + super(options); + + this.#userFnToRun = this.#getUserFnToRun(); + this.#state = this.#createExecutionState(); + this.#fnArg = this.#createFnArg(); + } + + public start() { + this.debug("starting V0 execution"); + + return (this.#execution ??= this.#start().then((result) => { + this.debug("result:", result); + return result; + })); + } + + async #start(): Promise { + this.#state.hooks = await this.#initializeMiddleware(); + + try { + await this.#transformInput(); + await this.#state.hooks.beforeMemoization?.(); + + if (this.#state.opStack.length === 0 && !this.options.requestedRunStep) { + await this.#state.hooks.afterMemoization?.(); + await this.#state.hooks.beforeExecution?.(); + } + + const userFnPromise = runAsPromise(() => this.#userFnToRun(this.#fnArg)); + + let pos = -1; + + do { + if (pos >= 0) { + if ( + !this.options.requestedRunStep && + pos === this.#state.opStack.length - 1 + ) { + await this.#state.hooks.afterMemoization?.(); + await this.#state.hooks.beforeExecution?.(); + } + + this.#state.tickOps = {}; + const incomingOp = this.#state.opStack[pos] as IncomingOp; + this.#state.currentOp = this.#state.allFoundOps[incomingOp.id]; + + if (!this.#state.currentOp) { + /** + * We're trying to resume the function, but we can't find where to go. + * + * This means that either the function has changed or there are async + * actions in-between steps that we haven't noticed in previous + * executions. + * + * Whichever the case, this is bad and we can't continue in this + * undefined state. + */ + throw new NonRetriableError( + prettyError({ + whatHappened: " Your function was stopped from running", + why: "We couldn't resume your function's state because it may have changed since the run started or there are async actions in-between steps that we haven't noticed in previous executions.", + consequences: + "Continuing to run the function may result in unexpected behaviour, so we've stopped your function to ensure nothing unexpected happened!", + toFixNow: + "Ensure that your function is either entirely step-based or entirely non-step-based, by either wrapping all asynchronous logic in `step.run()` calls or by removing all `step.*()` calls.", + otherwise: + "For more information on why step functions work in this manner, see https://www.inngest.com/docs/functions/multi-step#gotchas", + stack: true, + code: ErrCode.NON_DETERMINISTIC_FUNCTION, + }) + ); + } + + this.#state.currentOp.fulfilled = true; + + if (typeof incomingOp.data !== "undefined") { + this.#state.currentOp.resolve(incomingOp.data); + } else { + this.#state.currentOp.reject(incomingOp.error); + } + } + + await resolveAfterPending(); + this.#state.reset(); + pos++; + } while (pos < this.#state.opStack.length); + + await this.#state.hooks.afterMemoization?.(); + + const discoveredOps = Object.values(this.#state.tickOps).map( + tickOpToOutgoing + ); + + const runStep = + this.options.requestedRunStep || + this.#getEarlyExecRunStep(discoveredOps); + + if (runStep) { + const userFnOp = this.#state.allFoundOps[runStep]; + const stepToRun = userFnOp?.fn; + + if (!stepToRun) { + throw new Error( + `Bad stack; executor requesting to run unknown step "${runStep}"` + ); + } + + const outgoingUserFnOp = { + ...tickOpToOutgoing(userFnOp), + op: StepOpCode.RunStep, + }; + + await this.#state.hooks.beforeExecution?.(); + this.#state.executingStep = true; + + const result = await runAsPromise(stepToRun) + .finally(() => { + this.#state.executingStep = false; + }) + .catch(async (error: Error) => { + return await this.#transformOutput({ error }, outgoingUserFnOp); + }) + .then(async (data) => { + await this.#state.hooks?.afterExecution?.(); + return await this.#transformOutput({ data }, outgoingUserFnOp); + }); + + const { type: _type, ...rest } = result; + + return { type: "step-ran", step: { ...outgoingUserFnOp, ...rest } }; + } + + if (!discoveredOps.length) { + const fnRet = await Promise.race([ + userFnPromise.then((data) => ({ type: "complete", data } as const)), + resolveNextTick().then(() => ({ type: "incomplete" } as const)), + ]); + + if (fnRet.type === "complete") { + await this.#state.hooks.afterExecution?.(); + + const allOpsFulfilled = Object.values(this.#state.allFoundOps).every( + (op) => { + return op.fulfilled; + } + ); + + if (allOpsFulfilled) { + return await this.#transformOutput({ data: fnRet.data }); + } + } else if (!this.#state.hasUsedTools) { + this.#state.nonStepFnDetected = true; + const data = await userFnPromise; + await this.#state.hooks.afterExecution?.(); + return await this.#transformOutput({ data }); + } else { + const hasOpsPending = Object.values(this.#state.allFoundOps).some( + (op) => { + return op.fulfilled === false; + } + ); + + if (!hasOpsPending) { + throw new NonRetriableError( + functionStoppedRunningErr( + ErrCode.ASYNC_DETECTED_AFTER_MEMOIZATION + ) + ); + } + } + } + + await this.#state.hooks.afterExecution?.(); + + return { + type: "steps-found", + steps: discoveredOps as [OutgoingOp, ...OutgoingOp[]], + }; + } catch (error) { + return await this.#transformOutput({ error }); + } finally { + await this.#state.hooks.beforeResponse?.(); + } + } + + async #initializeMiddleware(): Promise { + const ctx = this.options.data as Pick< + Readonly>, + "event" | "events" | "runId" + >; + + const hooks = await getHookStack( + this.options.fn["middleware"], + "onFunctionRun", + { + ctx, + fn: this.options.fn, + steps: Object.values(this.options.stepState), + }, + { + transformInput: (prev, output) => { + return { + ctx: { ...prev.ctx, ...output?.ctx }, + fn: this.options.fn, + steps: prev.steps.map((step, i) => ({ + ...step, + ...output?.steps?.[i], + })), + }; + }, + transformOutput: (prev, output) => { + return { + result: { ...prev.result, ...output?.result }, + step: prev.step, + }; + }, + } + ); + + return hooks; + } + + #createExecutionState(): V0ExecutionState { + const state: V0ExecutionState = { + allFoundOps: {}, + tickOps: {}, + tickOpHashes: {}, + currentOp: undefined, + hasUsedTools: false, + reset: () => { + state.tickOpHashes = {}; + state.allFoundOps = { ...state.allFoundOps, ...state.tickOps }; + }, + nonStepFnDetected: false, + executingStep: false, + opStack: this.options.stepCompletionOrder.reduce( + (acc, stepId) => { + const stepState = this.options.stepState[stepId]; + if (!stepState) { + return acc; + } + + return [...acc, stepState]; + }, + [] + ), + }; + + return state; + } + + #getUserFnToRun(): AnyHandler { + if (!this.options.isFailureHandler) { + return this.options.fn["fn"]; + } + + if (!this.options.fn["onFailureFn"]) { + /** + * Somehow, we've ended up detecting that this is a failure handler but + * doesn't have an `onFailure` function. This should never happen. + */ + throw new Error("Cannot find function `onFailure` handler"); + } + + return this.options.fn["onFailureFn"]; + } + + #createFnArg(): AnyContext { + // Start referencing everything + this.#state.tickOps = this.#state.allFoundOps; + + /** + * Create a unique hash of an operation using only a subset of the operation's + * properties; will never use `data` and will guarantee the order of the + * object so we don't rely on individual tools for that. + * + * If the operation already contains an ID, the current ID will be used + * instead, so that users can provide their own IDs. + */ + const hashOp = ( + /** + * The op to generate a hash from. We only use a subset of the op's + * properties when creating the hash. + */ + op: PartialK + ): HashedOp => { + /** + * It's difficult for v0 to understand whether or not an op has + * historically contained a custom ID, as all step usage now require them. + * + * For this reason, we make the assumption that steps in v0 do not have a + * custom ID and generate one for them as we would in all recommendations + * and examples. + */ + const obj = { + parent: this.#state.currentOp?.id ?? null, + op: op.op, + name: op.name as string, + opts: op.opts ?? null, + }; + + const collisionHash = _internals.hashData(obj); + + const pos = (this.#state.tickOpHashes[collisionHash] = + (this.#state.tickOpHashes[collisionHash] ?? -1) + 1); + + return { + ...op, + id: _internals.hashData({ pos, ...obj }), + }; + }; + + const stepHandler: StepHandler = ({ args, matchOp, opts }) => { + if (this.#state.nonStepFnDetected) { + if (opts?.nonStepExecuteInline && opts.fn) { + return Promise.resolve(opts.fn(...args)); + } + + throw new NonRetriableError( + functionStoppedRunningErr(ErrCode.STEP_USED_AFTER_ASYNC) + ); + } + + if (this.#state.executingStep) { + throw new NonRetriableError( + prettyError({ + whatHappened: "Your function was stopped from running", + why: "We detected that you have nested `step.*` tooling.", + consequences: "Nesting `step.*` tooling is not supported.", + stack: true, + toFixNow: + "Make sure you're not using `step.*` tooling inside of other `step.*` tooling. If you need to compose steps together, you can create a new async function and call it from within your step function, or use promise chaining.", + otherwise: + "For more information on step functions with Inngest, see https://www.inngest.com/docs/functions/multi-step", + code: ErrCode.NESTING_STEPS, + }) + ); + } + + this.#state.hasUsedTools = true; + + const stepOptions = getStepOptions(args[0]); + const opId = hashOp(matchOp(stepOptions, ...args.slice(1))); + + return new Promise((resolve, reject) => { + this.#state.tickOps[opId.id] = { + ...opId, + ...(opts?.fn ? { fn: () => opts.fn?.(...args) } : {}), + resolve, + reject, + fulfilled: false, + }; + }); + }; + + const step = createStepTools(this.options.client, stepHandler); + + let fnArg = { + ...(this.options.data as { event: EventPayload }), + step, + } as AnyContext; + + if (this.options.isFailureHandler) { + const eventData = z + .object({ error: failureEventErrorSchema }) + .parse(fnArg.event?.data); + + (fnArg as Partial>) = { + ...fnArg, + error: deserializeError(eventData.error), + }; + } + + return fnArg; + } + + /** + * Using middleware, transform input before running. + */ + async #transformInput() { + const inputMutations = await this.#state.hooks?.transformInput?.({ + ctx: { ...this.#fnArg }, + steps: Object.values(this.options.stepState), + fn: this.options.fn, + }); + + if (inputMutations?.ctx) { + this.#fnArg = inputMutations.ctx; + } + + if (inputMutations?.steps) { + this.#state.opStack = [...inputMutations.steps]; + } + } + + #getEarlyExecRunStep(ops: OutgoingOp[]): string | undefined { + if (ops.length !== 1) return; + + const op = ops[0]; + + if ( + op && + op.op === StepOpCode.StepPlanned && + typeof op.opts === "undefined" + ) { + return op.id; + } + } + + /** + * Using middleware, transform output before returning. + */ + async #transformOutput( + dataOrError: Parameters< + NonNullable + >[0]["result"], + step?: Readonly> + ): Promise { + const output = { ...dataOrError }; + + if (typeof output.error !== "undefined") { + output.data = serializeError(output.error); + } + + const transformedOutput = await this.#state.hooks?.transformOutput?.({ + result: { ...output }, + step, + }); + + const { data, error } = { ...output, ...transformedOutput?.result }; + + if (typeof error !== "undefined") { + /** + * Ensure we give middleware the chance to decide on retriable behaviour + * by looking at the error returned from output transformation. + */ + let retriable: boolean | string = !(error instanceof NonRetriableError); + if (retriable && error instanceof RetryAfterError) { + retriable = error.retryAfter; + } + + const serializedError = serializeError(error); + + return { type: "function-rejected", error: serializedError, retriable }; + } + + return { type: "function-resolved", data: undefinedToNull(data) }; + } +} + +interface TickOp extends HashedOp { + fn?: (...args: unknown[]) => unknown; + fulfilled: boolean; + resolve: (value: unknown | PromiseLike) => void; + reject: (reason?: unknown) => void; +} + +export interface V0ExecutionState { + /** + * The tree of all found ops in the entire invocation. + */ + allFoundOps: Record; + + /** + * All synchronous operations found in this particular tick. The array is + * reset every tick. + */ + tickOps: Record; + + /** + * A hash of operations found within this tick, with keys being the hashed + * ops themselves (without a position) and the values being the number of + * times that op has been found. + * + * This is used to provide some mutation resilience to the op stack, + * allowing us to survive same-tick mutations of code by ensuring per-tick + * hashes are based on uniqueness rather than order. + */ + tickOpHashes: Record; + + /** + * Tracks the current operation being processed. This can be used to + * understand the contextual parent of any recorded operations. + */ + currentOp: TickOp | undefined; + + /** + * If we've found a user function to run, we'll store it here so a component + * higher up can invoke and await it. + */ + userFnToRun?: (...args: unknown[]) => unknown; + + /** + * A boolean to represent whether the user's function is using any step + * tools. + * + * If the function survives an entire tick of the event loop and hasn't + * touched any tools, we assume that it is a single-step async function and + * should be awaited as usual. + */ + hasUsedTools: boolean; + + /** + * A function that should be used to reset the state of the tools after a + * tick has completed. + */ + reset: () => void; + + /** + * If `true`, any use of step tools will, by default, throw an error. We do + * this when we detect that a function may be mixing step and non-step code. + * + * Created step tooling can decide how to manually handle this on a + * case-by-case basis. + * + * In the future, we can provide a way for a user to override this if they + * wish to and understand the danger of side-effects. + * + * Defaults to `false`. + */ + nonStepFnDetected: boolean; + + /** + * When true, we are currently executing a user's code for a single step + * within a step function. + */ + executingStep: boolean; + + /** + * Initialized middleware hooks for this execution. + * + * Middleware hooks are cached to ensure they can only be run once, which + * means that these hooks can be called in many different places to ensure we + * handle all possible execution paths. + */ + hooks?: RunHookStack; + + /** + * The op stack to pass to the function as state, likely stored in + * `ctx._state` in the Inngest payload. + * + * This must be provided in order to always be cognizant of step function + * state and to allow for multi-step functions. + */ + opStack: OpStack; +} + +const tickOpToOutgoing = (op: TickOp): OutgoingOp => { + return { + op: op.op, + id: op.id, + name: op.name, + opts: op.opts, + }; +}; + +/** + * An operation ready to hash to be used to memoise step function progress. + * + * @internal + */ +export type UnhashedOp = { + name: string; + op: StepOpCode; + opts: Record | null; + parent: string | null; + pos?: number; +}; + +const hashData = (op: UnhashedOp): string => { + return sha1().update(canonicalize(op)).digest("hex"); +}; + +/** + * Exported for testing. + */ +export const _internals = { hashData }; diff --git a/packages/inngest/src/components/execution/v1.ts b/packages/inngest/src/components/execution/v1.ts new file mode 100644 index 000000000..38b90a698 --- /dev/null +++ b/packages/inngest/src/components/execution/v1.ts @@ -0,0 +1,932 @@ +import { sha1 } from "hash.js"; +import { type Simplify } from "type-fest"; +import { z } from "zod"; +import { logPrefix } from "../../helpers/consts"; +import { + ErrCode, + deserializeError, + prettyError, + serializeError, +} from "../../helpers/errors"; +import { undefinedToNull } from "../../helpers/functions"; +import { + createDeferredPromise, + createTimeoutPromise, + resolveAfterPending, + runAsPromise, +} from "../../helpers/promises"; +import { type MaybePromise } from "../../helpers/types"; +import { + StepOpCode, + failureEventErrorSchema, + type AnyContext, + type AnyHandler, + type BaseContext, + type ClientOptions, + type EventPayload, + type FailureEventArgs, + type OutgoingOp, +} from "../../types"; +import { getHookStack, type RunHookStack } from "../InngestMiddleware"; +import { + STEP_INDEXING_SUFFIX, + createStepTools, + getStepOptions, + type FoundStep, + type StepHandler, +} from "../InngestStepTools"; +import { NonRetriableError } from "../NonRetriableError"; +import { RetryAfterError } from "../RetryAfterError"; +import { + InngestExecution, + type ExecutionResult, + type IInngestExecution, + type InngestExecutionFactory, + type InngestExecutionOptions, + type MemoizedOp, +} from "./InngestExecution"; + +export const createV1InngestExecution: InngestExecutionFactory = (options) => { + return new V1InngestExecution(options); +}; + +class V1InngestExecution extends InngestExecution implements IInngestExecution { + #state: V1ExecutionState; + #fnArg: AnyContext; + #checkpointHandlers: CheckpointHandlers; + #timeoutDuration = 1000 * 10; + #execution: Promise | undefined; + #userFnToRun: AnyHandler; + + /** + * If we're supposed to run a particular step via `requestedRunStep`, this + * will be a `Promise` that resolves after no steps have been found for + * `timeoutDuration` milliseconds. + * + * If we're not supposed to run a particular step, this will be `undefined`. + */ + #timeout?: ReturnType; + + constructor(options: InngestExecutionOptions) { + super(options); + + this.#userFnToRun = this.#getUserFnToRun(); + this.#state = this.#createExecutionState(); + this.#fnArg = this.#createFnArg(); + this.#checkpointHandlers = this.#createCheckpointHandlers(); + this.#initializeTimer(this.#state); + + this.debug( + "created new V1 execution for run;", + this.options.requestedRunStep + ? `wanting to run step "${this.options.requestedRunStep}"` + : "discovering steps" + ); + + this.debug("existing state keys:", Object.keys(this.#state.stepState)); + } + + /** + * Idempotently start the execution of the user's function. + */ + public start() { + this.debug("starting V1 execution"); + + return (this.#execution ??= this.#start().then((result) => { + this.debug("result:", result); + return result; + })); + } + + /** + * Starts execution of the user's function and the core loop. + */ + async #start(): Promise { + try { + const allCheckpointHandler = this.#getCheckpointHandler(""); + this.#state.hooks = await this.#initializeMiddleware(); + await this.#startExecution(); + + for await (const checkpoint of this.#state.loop) { + await allCheckpointHandler(checkpoint); + + const handler = this.#getCheckpointHandler(checkpoint.type); + const result = await handler(checkpoint); + + if (result) { + return result; + } + } + } catch (error) { + return await this.#transformOutput({ error }); + } finally { + void this.#state.loop.return(); + await this.#state.hooks?.beforeResponse?.(); + } + + /** + * If we're here, the generator somehow finished without returning a value. + * This should never happen. + */ + throw new Error("Core loop finished without returning a value"); + } + + /** + * Creates a handler for every checkpoint type, defining what to do when we + * reach that checkpoint in the core loop. + */ + #createCheckpointHandlers(): CheckpointHandlers { + return { + /** + * Run for all checkpoints. Best used for logging or common actions. + * Use other handlers to return values and interrupt the core loop. + */ + "": (checkpoint) => { + this.debug("checkpoint:", checkpoint); + }, + + /** + * The user's function has completed and returned a value. + */ + "function-resolved": async (checkpoint) => { + return await this.#transformOutput({ data: checkpoint.data }); + }, + + /** + * The user's function has thrown an error. + */ + "function-rejected": async (checkpoint) => { + return await this.#transformOutput({ error: checkpoint.error }); + }, + + /** + * We've found one or more steps. Here we may want to run a step or report + * them back to Inngest. + */ + "steps-found": async ({ steps }) => { + const stepResult = await this.#tryExecuteStep(steps); + if (stepResult) { + const transformResult = await this.#transformOutput(stepResult); + + /** + * Transforming output will always return either function rejection or + * resolution. In most cases, this can be immediately returned, but in + * this particular case we want to handle it differently. + */ + if (transformResult.type === "function-resolved") { + return { + type: "step-ran", + step: _internals.hashOp({ + ...stepResult, + data: { data: transformResult.data }, + }), + }; + } + + return transformResult; + } + + const newSteps = await this.#filterNewSteps( + Object.values(this.#state.steps) + ); + if (newSteps) { + return { + type: "steps-found", + steps: newSteps, + }; + } + }, + + /** + * While trying to find a step that Inngest has told us to run, we've + * timed out or have otherwise decided that it doesn't exist. + */ + "step-not-found": ({ step }) => { + return { type: "step-not-found", step }; + }, + }; + } + + #getCheckpointHandler(type: keyof CheckpointHandlers) { + return this.#checkpointHandlers[type] as ( + checkpoint: Checkpoint + ) => MaybePromise; + } + + async #tryExecuteStep(steps: FoundStep[]): Promise { + const hashedStepIdToRun = + this.options.requestedRunStep || this.#getEarlyExecRunStep(steps); + if (!hashedStepIdToRun) { + return; + } + + const step = steps.find( + (step) => step.hashedId === hashedStepIdToRun && step.fn + ); + + if (step) { + return await this.#executeStep(step); + } + + /** + * Ensure we reset the timeout if we have a requested run step but couldn't + * find it, but also that we don't reset if we found and executed it. + */ + void this.#timeout?.reset(); + } + + /** + * Given a list of outgoing ops, decide if we can execute an op early and + * return the ID of the step to execute if we can. + */ + #getEarlyExecRunStep(steps: FoundStep[]): string | void { + /** + * We may have been disabled due to parallelism, in which case we can't + * immediately execute unless explicitly requested. + */ + if (this.options.disableImmediateExecution) return; + + const unfulfilledSteps = steps.filter((step) => !step.fulfilled); + if (unfulfilledSteps.length !== 1) return; + + const op = unfulfilledSteps[0]; + + if ( + op && + op.op === StepOpCode.StepPlanned && + typeof op.opts === "undefined" + ) { + return op.hashedId; + } + } + + async #filterNewSteps( + steps: FoundStep[] + ): Promise<[OutgoingOp, ...OutgoingOp[]] | void> { + if (this.options.requestedRunStep) { + return; + } + + /** + * Gather any steps that aren't memoized and report them. + */ + const newSteps = steps.filter((step) => !step.fulfilled); + + if (!newSteps.length) { + return; + } + + /** + * Warn if we've found new steps but haven't yet seen all previous + * steps. This may indicate that step presence isn't determinate. + */ + const stepsToFulfil = Object.keys(this.#state.stepState).length; + const fulfilledSteps = steps.filter((step) => step.fulfilled).length; + const foundAllCompletedSteps = stepsToFulfil === fulfilledSteps; + + if (!foundAllCompletedSteps) { + // TODO Tag + console.warn( + prettyError({ + type: "warn", + whatHappened: "Function may be indeterminate", + why: "We found new steps before seeing all previous steps, which may indicate that the function is non-deterministic.", + consequences: + "This may cause unexpected behaviour as Inngest executes your function.", + reassurance: + "This is expected if a function is updated in the middle of a run, but may indicate a bug if not.", + }) + ); + } + + /** + * We're finishing up; let's trigger the last of the hooks. + */ + await this.#state.hooks?.afterMemoization?.(); + await this.#state.hooks?.beforeExecution?.(); + await this.#state.hooks?.afterExecution?.(); + + return newSteps.map((step) => ({ + op: step.op, + id: step.hashedId, + name: step.name, + opts: step.opts, + })) as [OutgoingOp, ...OutgoingOp[]]; + } + + async #executeStep({ id, name, opts, fn }: FoundStep): Promise { + this.#timeout?.clear(); + await this.#state.hooks?.afterMemoization?.(); + await this.#state.hooks?.beforeExecution?.(); + + const outgoingOp: OutgoingOp = { id, op: StepOpCode.RunStep, name, opts }; + this.#state.executingStep = outgoingOp; + this.debug(`executing step "${id}"`); + + return ( + runAsPromise(fn) + // eslint-disable-next-line @typescript-eslint/no-misused-promises + .finally(async () => { + await this.#state.hooks?.afterExecution?.(); + }) + .then((data) => { + return { + ...outgoingOp, + data, + }; + }) + .catch((error) => { + return { + ...outgoingOp, + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + error, + }; + }) + ); + } + + /** + * Starts execution of the user's function, including triggering checkpoints + * and middleware hooks where appropriate. + */ + async #startExecution(): Promise { + /** + * Mutate input as neccessary based on middleware. + */ + await this.#transformInput(); + + /** + * Start the timer to time out the run if needed. + */ + void this.#timeout?.start(); + + await this.#state.hooks?.beforeMemoization?.(); + + /** + * If we had no state to begin with, immediately end the memoization phase. + */ + if (this.#state.allStateUsed()) { + await this.#state.hooks?.afterMemoization?.(); + await this.#state.hooks?.beforeExecution?.(); + } + + /** + * Trigger the user's function. + */ + runAsPromise(() => this.#userFnToRun(this.#fnArg)) + // eslint-disable-next-line @typescript-eslint/no-misused-promises + .finally(async () => { + await this.#state.hooks?.afterMemoization?.(); + await this.#state.hooks?.beforeExecution?.(); + await this.#state.hooks?.afterExecution?.(); + }) + .then((data) => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + this.#state.setCheckpoint({ type: "function-resolved", data }); + }) + .catch((error) => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + this.#state.setCheckpoint({ type: "function-rejected", error }); + }); + } + + /** + * Using middleware, transform input before running. + */ + async #transformInput() { + const inputMutations = await this.#state.hooks?.transformInput?.({ + ctx: { ...this.#fnArg }, + steps: Object.values(this.#state.stepState), + fn: this.options.fn, + }); + + if (inputMutations?.ctx) { + this.#fnArg = inputMutations.ctx; + } + + if (inputMutations?.steps) { + this.#state.stepState = inputMutations.steps.reduce( + (steps, step) => ({ + ...steps, + [step.id]: step, + }), + {} + ); + } + } + + /** + * Using middleware, transform output before returning. + */ + async #transformOutput( + dataOrError: Parameters< + NonNullable + >[0]["result"] + ): Promise { + const output = { ...dataOrError }; + + if (typeof output.error !== "undefined") { + output.data = serializeError(output.error); + } + + const transformedOutput = await this.#state.hooks?.transformOutput?.({ + result: { ...output }, + step: this.#state.executingStep, + }); + + const { data, error } = { ...output, ...transformedOutput?.result }; + + if (typeof error !== "undefined") { + /** + * Ensure we give middleware the chance to decide on retriable behaviour + * by looking at the error returned from output transformation. + */ + let retriable: boolean | string = !(error instanceof NonRetriableError); + if (retriable && error instanceof RetryAfterError) { + retriable = error.retryAfter; + } + + const serializedError = serializeError(error); + + return { type: "function-rejected", error: serializedError, retriable }; + } + + return { type: "function-resolved", data: undefinedToNull(data) }; + } + + #createExecutionState(): V1ExecutionState { + let { promise: checkpointPromise, resolve: checkpointResolve } = + createDeferredPromise(); + + const loop: V1ExecutionState["loop"] = (async function* ( + cleanUp?: () => void + ) { + try { + while (true) { + yield await checkpointPromise; + } + } finally { + cleanUp?.(); + } + })(() => { + this.#timeout?.clear(); + }); + + const state: V1ExecutionState = { + stepState: this.options.stepState, + steps: {}, + loop, + hasSteps: Boolean(Object.keys(this.options.stepState).length), + stepCompletionOrder: this.options.stepCompletionOrder, + setCheckpoint: (checkpoint: Checkpoint) => { + ({ promise: checkpointPromise, resolve: checkpointResolve } = + checkpointResolve(checkpoint)); + }, + allStateUsed: () => { + return Object.values(state.stepState).every((step) => { + return step.seen; + }); + }, + }; + + return state; + } + + #createFnArg(): AnyContext { + const step = this.#createStepTools(); + + let fnArg = { + ...(this.options.data as { event: EventPayload }), + step, + } as AnyContext; + + /** + * Handle use of the `onFailure` option by deserializing the error. + */ + if (this.options.isFailureHandler) { + const eventData = z + .object({ error: failureEventErrorSchema }) + .parse(fnArg.event?.data); + + (fnArg as Partial>) = { + ...fnArg, + error: deserializeError(eventData.error), + }; + } + + return fnArg; + } + + #createStepTools(): ReturnType< + typeof createStepTools, string> + > { + /** + * A list of steps that have been found and are being rolled up before being + * reported to the core loop. + */ + let foundStepsToReport: FoundStep[] = []; + + /** + * A promise that's used to ensure that step reporting cannot be run more than + * once in a given asynchronous time span. + */ + let foundStepsReportPromise: Promise | undefined; + + /** + * A promise that's used to represent middleware hooks running before + * execution. + */ + let beforeExecHooksPromise: Promise | undefined; + + /** + * A flag used to ensure that we only warn about parallel indexing once per + * execution to avoid spamming the console. + */ + let warnOfParallelIndexing = false; + + /** + * Given a colliding step ID, maybe warn the user about parallel indexing. + */ + const maybeWarnOfParallelIndexing = (collisionId: string) => { + if (warnOfParallelIndexing) { + return; + } + + const stepExists = Boolean(this.#state.steps[collisionId]); + + const stepFoundThisTick = foundStepsToReport.some((step) => { + return step.id === collisionId; + }); + + if (stepExists && !stepFoundThisTick) { + warnOfParallelIndexing = true; + + console.warn( + prettyError({ + type: "warn", + whatHappened: + "We detected that you have multiple steps with the same ID.", + code: ErrCode.AUTOMATIC_PARALLEL_INDEXING, + why: `This can happen if you're using the same ID for multiple steps across different chains of parallel work. We found the issue with step "${collisionId}".`, + reassurance: + "Your function is still running, though it may exhibit unexpected behaviour.", + consequences: + "Using the same IDs across parallel chains of work can cause unexpected behaviour.", + toFixNow: + "We recommend using a unique ID for each step, especially those happening in parallel.", + }) + ); + } + }; + + /** + * A helper used to report steps to the core loop. Used after adding an item + * to `foundStepsToReport`. + */ + const reportNextTick = () => { + // Being explicit instead of using `??=` to appease TypeScript. + if (foundStepsReportPromise) { + return; + } + + foundStepsReportPromise = resolveAfterPending() + /** + * Ensure that we wait for this promise to resolve before continuing. + * + * The groups in which steps are reported can affect how we detect some + * more complex determinism issues like parallel indexing. This promise + * can represent middleware hooks being run early, in the middle of + * ingesting steps to report. + * + * Because of this, it's important we wait for this middleware to resolve + * before continuing to report steps to ensure that all steps have a + * chance to be reported throughout this asynchronous action. + */ + .then(() => beforeExecHooksPromise) + .then(() => { + foundStepsReportPromise = undefined; + + for (let i = 0; i < this.#state.stepCompletionOrder.length; i++) { + const handled = foundStepsToReport + .find((step) => { + return step.hashedId === this.#state.stepCompletionOrder[i]; + }) + ?.handle(); + + if (handled) { + return void reportNextTick(); + } + } + + // If we've handled no steps in this "tick," roll up everything we've + // found and report it. + const steps = [...foundStepsToReport] as [FoundStep, ...FoundStep[]]; + foundStepsToReport = []; + + return void this.#state.setCheckpoint({ + type: "steps-found", + steps: steps, + }); + }); + }; + + /** + * A helper used to push a step to the list of steps to report. + */ + const pushStepToReport = (step: FoundStep) => { + foundStepsToReport.push(step); + reportNextTick(); + }; + + const stepHandler: StepHandler = async ({ + args, + matchOp, + opts, + }): Promise => { + await beforeExecHooksPromise; + + if (!this.#state.hasSteps && opts?.nonStepExecuteInline && opts.fn) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + return runAsPromise(() => opts.fn?.(...args)); + } + + if (this.#state.executingStep) { + /** + * If a step is found after asynchronous actions during another step's + * execution, everything is fine. The problem here is if we've found + * that a step nested inside another a step, which is something we don't + * support at the time of writing. + * + * In this case, we could use something like Async Hooks to understand + * how the step is being triggered, though this isn't available in all + * environments. + * + * Therefore, we'll only show a warning here to indicate that this is + * potentially an issue. + */ + console.warn( + prettyError({ + whatHappened: "We detected that you have nested `step.*` tooling.", + consequences: "Nesting `step.*` tooling is not supported.", + type: "warn", + reassurance: + "It's possible to see this warning if steps are separated by regular asynchronous calls, which is fine.", + stack: true, + toFixNow: + "Make sure you're not using `step.*` tooling inside of other `step.*` tooling. If you need to compose steps together, you can create a new async function and call it from within your step function, or use promise chaining.", + code: ErrCode.NESTING_STEPS, + }) + ); + } + + const stepOptions = getStepOptions(args[0]); + const opId = matchOp(stepOptions, ...args.slice(1)); + + if (this.#state.steps[opId.id]) { + const originalId = opId.id; + maybeWarnOfParallelIndexing(originalId); + + for (let i = 1; ; i++) { + const newId = [originalId, STEP_INDEXING_SUFFIX, i].join(""); + + if (!this.#state.steps[newId]) { + opId.id = newId; + break; + } + } + + console.debug( + `${logPrefix} debug - Step "${originalId}" already exists; automatically indexing to "${opId.id}"` + ); + } + + const { promise, resolve, reject } = createDeferredPromise(); + const hashedId = _internals.hashId(opId.id); + const stepState = this.#state.stepState[hashedId]; + if (stepState) { + stepState.seen = true; + } + + const step: FoundStep = { + ...opId, + hashedId, + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + fn: opts?.fn ? () => opts.fn?.(...args) : undefined, + fulfilled: Boolean(stepState), + displayName: opId.displayName ?? opId.id, + handled: false, + handle: () => { + if (step.handled) { + return false; + } + + step.handled = true; + + if (stepState) { + stepState.fulfilled = true; + + if (typeof stepState.data !== "undefined") { + resolve(stepState.data); + } else { + reject(stepState.error); + } + } + + return true; + }, + }; + + this.#state.steps[opId.id] = step; + this.#state.hasSteps = true; + pushStepToReport(step); + + /** + * If this is the last piece of state we had, we've now finished + * memoizing. + */ + if (!beforeExecHooksPromise && this.#state.allStateUsed()) { + await (beforeExecHooksPromise = (async () => { + await this.#state.hooks?.beforeExecution?.(); + await this.#state.hooks?.afterMemoization?.(); + })()); + } + + return promise; + }; + + return createStepTools(this.options.client, stepHandler); + } + + #getUserFnToRun(): AnyHandler { + if (!this.options.isFailureHandler) { + return this.options.fn["fn"]; + } + + if (!this.options.fn["onFailureFn"]) { + /** + * Somehow, we've ended up detecting that this is a failure handler but + * doesn't have an `onFailure` function. This should never happen. + */ + throw new Error("Cannot find function `onFailure` handler"); + } + + return this.options.fn["onFailureFn"]; + } + + #initializeTimer(state: V1ExecutionState): void { + if (!this.options.requestedRunStep) { + return; + } + + this.#timeout = createTimeoutPromise(this.#timeoutDuration); + + void this.#timeout.then(async () => { + await this.#state.hooks?.afterMemoization?.(); + await this.#state.hooks?.beforeExecution?.(); + await this.#state.hooks?.afterExecution?.(); + + state.setCheckpoint({ + type: "step-not-found", + step: { + id: this.options.requestedRunStep as string, + op: StepOpCode.StepNotFound, + }, + }); + }); + } + + async #initializeMiddleware(): Promise { + const ctx = this.options.data as Pick< + Readonly>, + "event" | "events" | "runId" + >; + + const hooks = await getHookStack( + this.options.fn["middleware"], + "onFunctionRun", + { + ctx, + fn: this.options.fn, + steps: Object.values(this.options.stepState), + }, + { + transformInput: (prev, output) => { + return { + ctx: { ...prev.ctx, ...output?.ctx }, + fn: this.options.fn, + steps: prev.steps.map((step, i) => ({ + ...step, + ...output?.steps?.[i], + })), + }; + }, + transformOutput: (prev, output) => { + return { + result: { ...prev.result, ...output?.result }, + step: prev.step, + }; + }, + } + ); + + return hooks; + } +} + +/** + * Types of checkpoints that can be reached during execution. + */ +export interface Checkpoints { + "steps-found": { steps: [FoundStep, ...FoundStep[]] }; + "function-rejected": { error: unknown }; + "function-resolved": { data: unknown }; + "step-not-found": { step: OutgoingOp }; +} + +type Checkpoint = { + [K in keyof Checkpoints]: Simplify<{ type: K } & Checkpoints[K]>; +}[keyof Checkpoints]; + +type CheckpointHandlers = { + [C in Checkpoint as C["type"]]: ( + checkpoint: C + ) => MaybePromise; +} & { + "": (checkpoint: Checkpoint) => MaybePromise; +}; + +export interface V1ExecutionState { + /** + * A value that indicates that we're executing this step. Can be used to + * ensure steps are not accidentally nested until we support this across all + * platforms. + */ + executingStep?: Readonly>; + + /** + * A map of step IDs to their data, used to fill previously-completed steps + * with state from the executor. + */ + stepState: Record; + + /** + * A map of step IDs to their functions to run. The executor can request a + * specific step to run, so we need to store the function to run here. + */ + steps: Record; + + /** + * A flag which represents whether or not steps are understood to be used in + * this function. This is used to determine whether or not we should run + * some steps (such as `step.sendEvent`) inline as they are found. + */ + hasSteps: boolean; + + /** + * The core loop - a generator used to take an action upon finding the next + * checkpoint. Manages the flow of execution and cleaning up after itself. + */ + loop: AsyncGenerator; + + /** + * A function that resolves the `Promise` returned by `waitForNextDecision`. + */ + setCheckpoint: (data: Checkpoint) => void; + + /** + * Initialized middleware hooks for this execution. + * + * Middleware hooks are cached to ensure they can only be run once, which + * means that these hooks can be called in many different places to ensure we + * handle all possible execution paths. + */ + hooks?: RunHookStack; + + /** + * Returns whether or not all state passed from the executor has been used to + * fulfill found steps. + */ + allStateUsed: () => boolean; + + /** + * An ordered list of step IDs that represents the order in which their + * execution was completed. + */ + stepCompletionOrder: string[]; +} + +const hashId = (id: string): string => { + return sha1().update(id).digest("hex"); +}; + +const hashOp = (op: OutgoingOp): OutgoingOp => { + return { + ...op, + id: hashId(op.id), + }; +}; + +/** + * Exported for testing. + */ +export const _internals = { hashOp, hashId }; diff --git a/packages/inngest/src/deno/fresh.ts b/packages/inngest/src/deno/fresh.ts index e6ab8e2ee..0611817f5 100644 --- a/packages/inngest/src/deno/fresh.ts +++ b/packages/inngest/src/deno/fresh.ts @@ -1,11 +1,10 @@ import { InngestCommHandler, - type ServeHandler, + type ServeHandlerOptions, } from "../components/InngestCommHandler"; -import { headerKeys, queryKeys } from "../helpers/consts"; import { type SupportedFrameworkName } from "../types"; -export const name: SupportedFrameworkName = "deno/fresh"; +export const frameworkName: SupportedFrameworkName = "deno/fresh"; /** * With Deno's Fresh framework, serve and register any declared functions with @@ -13,50 +12,27 @@ export const name: SupportedFrameworkName = "deno/fresh"; * * @public */ -export const serve: ServeHandler = (nameOrInngest, fns, opts) => { - const handler = new InngestCommHandler( - name as string, - nameOrInngest, - fns, - opts, - (req: Request, env: { [index: string]: string }) => { - const url = new URL(req.url, `https://${req.headers.get("host") || ""}`); - +export const serve = (options: ServeHandlerOptions) => { + const handler = new InngestCommHandler({ + frameworkName, + ...options, + handler: (req: Request, env: Record) => { return { - url, - env, - register: () => { - if (req.method === "PUT") { - return { - deployId: url.searchParams.get(queryKeys.DeployId), - }; - } - }, - run: async () => { - if (req.method === "POST") { - return { - data: (await req.json()) as Record, - fnId: url.searchParams.get(queryKeys.FnId) as string, - stepId: url.searchParams.get(queryKeys.StepId) as string, - signature: req.headers.get(headerKeys.Signature) || undefined, - }; - } - }, - view: () => { - if (req.method === "GET") { - return { - isIntrospection: url.searchParams.has(queryKeys.Introspect), - }; - } + body: () => req.json(), + headers: (key) => req.headers.get(key), + method: () => req.method, + env: () => env, + url: () => new URL(req.url, `https://${req.headers.get("host") || ""}`), + transformResponse: ({ body, status, headers }) => { + return new Response(body, { status, headers }); }, }; }, - ({ body, status, headers }): Response => { - return new Response(body, { status, headers }); - } - ).createHandler(); + }); + + const fn = handler.createHandler(); - return (req: Request) => handler(req, Deno.env.toObject()); + return (req: Request) => fn(req, Deno.env.toObject()); }; declare const Deno: { env: { toObject: () => { [index: string]: string } } }; diff --git a/packages/inngest/src/digitalocean.ts b/packages/inngest/src/digitalocean.ts index 03b1ddedd..acd1db842 100644 --- a/packages/inngest/src/digitalocean.ts +++ b/packages/inngest/src/digitalocean.ts @@ -1,8 +1,7 @@ import { InngestCommHandler, - type ServeHandler, + type ServeHandlerOptions, } from "./components/InngestCommHandler"; -import { headerKeys, queryKeys } from "./helpers/consts"; import { type SupportedFrameworkName } from "./types"; type HTTP = { @@ -11,74 +10,36 @@ type HTTP = { path: string; }; -type Main = { - http: HTTP; - // data can include any JSON-decoded post-data, and query args/saerch params. - [data: string]: unknown; -}; +type Main = + | { + http?: HTTP; + // data can include any JSON-decoded post-data, and query args/saerch params. + [data: string]: unknown; + } + | undefined; -export const name: SupportedFrameworkName = "digitalocean"; +export const frameworkName: SupportedFrameworkName = "digitalocean"; export const serve = ( - nameOrInngest: Parameters[0], - fns: Parameters[1], - opts: Parameters[2] & - Required[2]>, "serveHost">> + options: ServeHandlerOptions & + Required, "serveHost">> ) => { - const handler = new InngestCommHandler( - name, - nameOrInngest, - fns, - opts, - (main: Main) => { - // Copy all params as data. - let { http, ...data } = main || {}; - - if (http === undefined) { - // This is an invocation from the DigitalOcean UI; main is an empty object. - // In this case provide some defaults so that this doesn't run functions. - http = { method: "GET", headers: {}, path: "" }; - data = {}; - } - - // serveHost and servePath must be defined when running in DigitalOcean in order - // for the SDK to properly register and run functions. - // - // DigitalOcean provides no hostname or path in its arguments during execution. - const url = new URL(`${opts.serveHost}${opts?.servePath || "/"}`); + const handler = new InngestCommHandler({ + frameworkName, + ...options, + handler: (main: Main = {}) => { + const { http = { method: "GET", headers: {}, path: "" }, ...data } = main; return { - url, - register: () => { - if (http.method === "PUT") { - return { - deployId: main[queryKeys.DeployId] as string, - }; - } - }, - run: () => { - if (http.method === "POST") { - return { - data: data as Record, - fnId: (main[queryKeys.FnId] as string) || "", - stepId: (main[queryKeys.StepId] as string) || "", - signature: http.headers[headerKeys.Signature] as string, - }; - } - }, - view: () => { - if (http.method === "GET") { - return { - isIntrospection: Object.hasOwnProperty.call( - main, - queryKeys.Introspect - ), - }; - } - }, + body: () => data || {}, + headers: (key) => http?.headers?.[key], + method: () => http.method, + url: () => new URL(`${options.serveHost}${options.servePath || "/"}`), + queryString: (key) => main[key] as string, + transformResponse: (res) => res, }; }, - (res) => res - ); + }); + return handler.createHandler(); }; diff --git a/packages/inngest/src/edge.ts b/packages/inngest/src/edge.ts index 8329ba478..eeb686dac 100644 --- a/packages/inngest/src/edge.ts +++ b/packages/inngest/src/edge.ts @@ -1,11 +1,10 @@ import { InngestCommHandler, - type ServeHandler, + type ServeHandlerOptions, } from "./components/InngestCommHandler"; -import { headerKeys, queryKeys } from "./helpers/consts"; import { type SupportedFrameworkName } from "./types"; -export const name: SupportedFrameworkName = "edge"; +export const frameworkName: SupportedFrameworkName = "edge"; /** * In an edge runtime, serve and register any declared functions with Inngest, @@ -18,55 +17,28 @@ export const name: SupportedFrameworkName = "edge"; * @example * ```ts * import { serve } from "inngest/edge"; - * import fns from "~/inngest"; + * import functions from "~/inngest"; * - * export const handler = serve("My Edge App", fns); + * export const handler = serve({ id: "my-edge-app", functions }); * ``` */ -export const serve: ServeHandler = (nameOrInngest, fns, opts) => { - const handler = new InngestCommHandler( - name, - nameOrInngest, - fns, - { - fetch: fetch.bind(globalThis), - ...opts, - }, - (req: Request) => { - const url = new URL(req.url, `https://${req.headers.get("host") || ""}`); - +export const serve = (options: ServeHandlerOptions) => { + const handler = new InngestCommHandler({ + frameworkName, + fetch: fetch.bind(globalThis), + ...options, + handler: (req: Request) => { return { - url, - register: () => { - if (req.method === "PUT") { - return { - deployId: url.searchParams.get(queryKeys.DeployId) as string, - }; - } - }, - run: async () => { - if (req.method === "POST") { - return { - data: (await req.json()) as Record, - fnId: url.searchParams.get(queryKeys.FnId) as string, - stepId: url.searchParams.get(queryKeys.StepId) as string, - signature: req.headers.get(headerKeys.Signature) as string, - }; - } - }, - view: () => { - if (req.method === "GET") { - return { - isIntrospection: url.searchParams.has(queryKeys.Introspect), - }; - } + 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 }); }, }; }, - ({ body, status, headers }): Response => { - return new Response(body, { status, headers }); - } - ); + }); return handler.createHandler(); }; diff --git a/packages/inngest/src/express.test.ts b/packages/inngest/src/express.test.ts index 37538311a..994032be5 100644 --- a/packages/inngest/src/express.test.ts +++ b/packages/inngest/src/express.test.ts @@ -1,7 +1,6 @@ -import { InngestCommHandler } from "@local/components/InngestCommHandler"; import * as ExpressHandler from "@local/express"; import { type VercelRequest, type VercelRequestQuery } from "@vercel/node"; -import { createClient, testFramework } from "./test/helpers"; +import { testFramework } from "./test/helpers"; testFramework("Express", ExpressHandler); @@ -19,32 +18,3 @@ testFramework("Express (Vercel)", ExpressHandler, { return [req, res]; }, }); - -describe("InngestCommHandler", () => { - describe("registerBody", () => { - it("Includes correct base URL for functions", () => { - const client = createClient({ name: "test" }); - - const fn = client.createFunction( - { name: "Test Express Function" }, - { event: "test/event.name" }, - () => undefined - ); - const ch = new InngestCommHandler( - "test-framework", - client, - [fn], - {}, - // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-return - () => undefined as unknown as any, - () => undefined - ); - - const url = new URL("http://localhost:8000/api/inngest"); - - const body = ch["registerBody"](url); - expect(body.appName).toBe("test"); - expect(body.url).toBe("http://localhost:8000/api/inngest"); - }); - }); -}); diff --git a/packages/inngest/src/express.ts b/packages/inngest/src/express.ts index 2521d00f1..fbf39adf2 100644 --- a/packages/inngest/src/express.ts +++ b/packages/inngest/src/express.ts @@ -2,81 +2,69 @@ import { type VercelRequest, type VercelResponse } from "@vercel/node"; import { type Request, type Response } from "express"; import { InngestCommHandler, - type ServeHandler, + type ServeHandlerOptions, } from "./components/InngestCommHandler"; -import { headerKeys, queryKeys } from "./helpers/consts"; import { type Either } from "./helpers/types"; import { type SupportedFrameworkName } from "./types"; -export const name: SupportedFrameworkName = "express"; +export const frameworkName: SupportedFrameworkName = "express"; /** * Serve and register any declared functions with Inngest, making them available * to be triggered by events. * + * The return type is currently `any` to ensure there's no required type matches + * between the `express` and `vercel` packages. This may change in the future to + * appropriately infer. + * * @public */ -export const serve: ServeHandler = (nameOrInngest, fns, opts) => { - const handler = new InngestCommHandler( - name, - nameOrInngest, - fns, - opts, - ( - req: Either, - _res: Either +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const serve = (options: ServeHandlerOptions): any => { + const handler = new InngestCommHandler({ + frameworkName, + ...options, + handler: ( + req: Either, + res: Either ) => { - // `req.hostname` can filter out port numbers; beware! - const hostname = req.headers["host"] || opts?.serveHost; + return { + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + body: () => req.body, + headers: (key) => { + const header = req.headers[key]; + return Array.isArray(header) ? header[0] : header; + }, + method: () => req.method || "GET", + url: () => { + // `req.hostname` can filter out port numbers; beware! + const hostname = req.headers["host"] || options?.serveHost; - const protocol = hostname?.includes("://") - ? "" - : `${req.protocol || "https"}://`; + const protocol = hostname?.includes("://") + ? "" + : `${req.protocol || "https"}://`; - const url = new URL( - req.originalUrl || req.url || "", - `${protocol}${hostname || ""}` - ); + const url = new URL( + req.originalUrl || req.url || "", + `${protocol}${hostname || ""}` + ); - return { - url, - run: () => { - if (req.method === "POST") { - return { - fnId: req.query[queryKeys.FnId] as string, - stepId: req.query[queryKeys.StepId] as string, - data: req.body as Record, - signature: req.headers[headerKeys.Signature] as string, - }; - } + return url; }, - register: () => { - if (req.method === "PUT") { - return { - deployId: req.query[queryKeys.DeployId]?.toString(), - }; - } + queryString: (key) => { + const qs = req.query[key]; + return Array.isArray(qs) ? qs[0] : qs; }, - view: () => { - if (req.method === "GET") { - return { - isIntrospection: Object.hasOwnProperty.call( - req.query, - queryKeys.Introspect - ), - }; + transformResponse: ({ body, headers, status }) => { + for (const [name, value] of Object.entries(headers)) { + res.setHeader(name, value); } + + return res.status(status).send(body); }, }; }, - (actionRes, _req, res) => { - for (const [name, value] of Object.entries(actionRes.headers)) { - res.setHeader(name, value); - } - - return res.status(actionRes.status).send(actionRes.body); - } - ); + }); return handler.createHandler(); }; diff --git a/packages/inngest/src/fastify.ts b/packages/inngest/src/fastify.ts index f43077653..8a3a4b458 100644 --- a/packages/inngest/src/fastify.ts +++ b/packages/inngest/src/fastify.ts @@ -6,21 +6,12 @@ import { import { type Inngest } from "./components/Inngest"; import { InngestCommHandler, - type ServeHandler, + type ServeHandlerOptions, } from "./components/InngestCommHandler"; import { type InngestFunction } from "./components/InngestFunction"; -import { headerKeys, queryKeys } from "./helpers/consts"; import { type RegisterOptions, type SupportedFrameworkName } from "./types"; -export const name: SupportedFrameworkName = "fastify"; - -type QueryString = { - [key in queryKeys]: string; -}; - -type Headers = { - [key in headerKeys]: string; -}; +export const frameworkName: SupportedFrameworkName = "fastify"; type InngestPluginOptions = { // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -36,59 +27,42 @@ type InngestPluginOptions = { * * @public */ -export const serve: ServeHandler = (nameOrInngest, fns, opts) => { - const handler = new InngestCommHandler( - name, - nameOrInngest, - fns, - opts, - ( - req: FastifyRequest<{ Querystring: QueryString; Headers: Headers }>, - _reply: FastifyReply +export const serve = (options: ServeHandlerOptions) => { + const handler = new InngestCommHandler({ + frameworkName, + ...options, + handler: ( + req: FastifyRequest<{ Querystring: Record }>, + reply: FastifyReply ) => { - const hostname = req.headers["host"]; - const protocol = hostname?.includes("://") ? "" : `${req.protocol}://`; - const url = new URL(req.url, `${protocol}${hostname || ""}`); - return { - url, - run: () => { - if (req.method === "POST") { - return { - fnId: req.query[queryKeys.FnId], - stepId: req.query[queryKeys.StepId], - data: req.body as Record, - signature: req.headers[headerKeys.Signature], - }; - } + body: () => req.body, + headers: (key) => { + const header = req.headers[key]; + return Array.isArray(header) ? header[0] : header; }, - register: () => { - if (req.method === "PUT") { - return { - deployId: req.query[queryKeys.DeployId]?.toString(), - }; - } + method: () => req.method, + url: () => { + const hostname = req.headers["host"]; + const protocol = hostname?.includes("://") + ? "" + : `${req.protocol}://`; + + const url = new URL(req.url, `${protocol}${hostname || ""}`); + + return url; }, - view: () => { - if (req.method === "GET") { - return { - isIntrospection: Object.hasOwnProperty.call( - req.query, - queryKeys.Introspect - ), - }; + queryString: (key) => req.query[key], + transformResponse: ({ body, status, headers }) => { + for (const [name, value] of Object.entries(headers)) { + void reply.header(name, value); } + void reply.code(status); + return reply.send(body); }, }; }, - (actionRes, _req, reply) => { - for (const [name, value] of Object.entries(actionRes.headers)) { - void reply.header(name, value); - } - void reply.code(actionRes.status); - return reply.send(actionRes.body); - } - ); + }); return handler.createHandler(); }; @@ -100,15 +74,29 @@ export const serve: ServeHandler = (nameOrInngest, fns, opts) => { * @public */ const fastifyPlugin = ((fastify, options, done) => { + if (!options?.client) { + throw new Error( + "Inngest `client` is required when serving with Fastify plugin" + ); + } + + if (!options?.functions) { + throw new Error( + "Inngest `functions` are required when serving with Fastify plugin" + ); + } + try { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const handler = serve(options.client, options.functions, options.options); + const handler = serve({ + client: options?.client, + functions: options?.functions, + ...options?.options, + }); fastify.route({ method: ["GET", "POST", "PUT"], - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment handler, - url: options.options?.servePath || "/api/inngest", + url: options?.options?.servePath || "/api/inngest", }); done(); diff --git a/packages/inngest/src/h3.ts b/packages/inngest/src/h3.ts index 1ee3ab020..67a3e8c4c 100644 --- a/packages/inngest/src/h3.ts +++ b/packages/inngest/src/h3.ts @@ -8,13 +8,12 @@ import { } from "h3"; import { InngestCommHandler, - type ServeHandler, + type ServeHandlerOptions, } from "./components/InngestCommHandler"; -import { headerKeys, queryKeys } from "./helpers/consts"; import { processEnv } from "./helpers/env"; import { type SupportedFrameworkName } from "./types"; -export const name: SupportedFrameworkName = "h3"; +export const frameworkName: SupportedFrameworkName = "h3"; /** * In h3, serve and register any declared functions with Inngest, making @@ -22,55 +21,32 @@ export const name: SupportedFrameworkName = "h3"; * * @public */ -export const serve: ServeHandler = (nameOrInngest, fns, opts) => { - const handler = new InngestCommHandler( - name, - nameOrInngest, - fns, - opts, - (event: H3Event) => { - const host = String(getHeader(event, "host")); - const protocol = - processEnv("NODE_ENV") === "development" ? "http" : "https"; - const url = new URL(String(event.path), `${protocol}://${host}`); - const method = event.method; - const query = getQuery(event); - +export const serve = (options: ServeHandlerOptions) => { + const handler = new InngestCommHandler({ + frameworkName, + ...options, + handler: (event: H3Event) => { return { - url, - run: async () => { - if (method === "POST") { - return { - fnId: query[queryKeys.FnId]?.toString() ?? "", - stepId: query[queryKeys.StepId]?.toString() ?? "", - signature: getHeader(event, headerKeys.Signature), - data: await readBody(event), - }; - } - }, - register: () => { - if (method === "PUT") { - return { - deployId: query[queryKeys.DeployId]?.toString(), - }; - } - }, - view: () => { - if (method === "GET") { - return { - isIntrospection: query && queryKeys.Introspect in query, - }; - } + body: () => readBody(event), + headers: (key) => getHeader(event, key), + method: () => event.method, + url: () => + new URL( + String(event.path), + `${ + processEnv("NODE_ENV") === "development" ? "http" : "https" + }://${String(getHeader(event, "host"))}` + ), + queryString: (key) => String(getQuery(event)[key]), + transformResponse: (actionRes) => { + const { res } = event.node; + res.statusCode = actionRes.status; + setHeaders(event, actionRes.headers); + return send(event, actionRes.body); }, }; }, - (actionRes, event: H3Event) => { - const { res } = event.node; - res.statusCode = actionRes.status; - setHeaders(event, actionRes.headers); - return send(event, actionRes.body); - } - ); + }); return handler.createHandler(); }; diff --git a/packages/inngest/src/helpers/ServerTiming.ts b/packages/inngest/src/helpers/ServerTiming.ts index 277e94446..84a5a1aea 100644 --- a/packages/inngest/src/helpers/ServerTiming.ts +++ b/packages/inngest/src/helpers/ServerTiming.ts @@ -1,3 +1,5 @@ +import { runAsPromise } from "./promises"; + interface Timing { description: string; timers: { @@ -78,7 +80,7 @@ export class ServerTiming { const stop = this.start(name, description); try { - return (await Promise.resolve(fn())) as Awaited>; + return (await runAsPromise(fn)) as Awaited>; } finally { stop(); } diff --git a/packages/inngest/src/helpers/consts.ts b/packages/inngest/src/helpers/consts.ts index 6723fb042..284a0da8c 100644 --- a/packages/inngest/src/helpers/consts.ts +++ b/packages/inngest/src/helpers/consts.ts @@ -1,3 +1,5 @@ +import chalk from "chalk"; + /** * Keys for accessing query parameters included in requests from Inngest to run * functions. @@ -10,16 +12,29 @@ export enum queryKeys { FnId = "fnId", StepId = "stepId", - Introspect = "introspect", DeployId = "deployId", } export enum envKeys { - SigningKey = "INNGEST_SIGNING_KEY", - EventKey = "INNGEST_EVENT_KEY", - DevServerUrl = "INNGEST_DEVSERVER_URL", - Environment = "INNGEST_ENV", + InngestSigningKey = "INNGEST_SIGNING_KEY", + InngestEventKey = "INNGEST_EVENT_KEY", + + /** + * @deprecated Removed in v3. Use {@link InngestBaseUrl} instead. + */ + InngestDevServerUrl = "INNGEST_DEVSERVER_URL", + InngestEnvironment = "INNGEST_ENV", + InngestBaseUrl = "INNGEST_BASE_URL", + InngestServeHost = "INNGEST_SERVE_HOST", + InngestServePath = "INNGEST_SERVE_PATH", + InngestLogLevel = "INNGEST_LOG_LEVEL", + InngestStreaming = "INNGEST_STREAMING", + BranchName = "BRANCH_NAME", + + /** + * @deprecated Removed in v3. Use {@link InngestBaseUrl} instead. + */ InngestApiBaseUrl = "INNGEST_API_BASE_URL", /** @@ -88,9 +103,7 @@ export enum envKeys { } export enum prodEnvKeys { - NodeEnvKey = "NODE_ENV", VercelEnvKey = "VERCEL_ENV", - NetlifyEnvKey = "CONTEXT", } /** @@ -109,8 +122,12 @@ export enum headerKeys { Platform = "x-inngest-platform", Framework = "x-inngest-framework", NoRetry = "x-inngest-no-retry", + RequestVersion = "x-inngest-req-version", + RetryAfter = "retry-after", } +export const defaultInngestBaseUrl = "https://api.inngest.com/"; +export const defaultInngestEventBaseUrl = "https://inn.gs/"; export const defaultDevServerHost = "http://127.0.0.1:8288/"; /** @@ -126,3 +143,7 @@ export enum internalEvents { */ FunctionFailed = "inngest/function.failed", } + +export const logPrefix = chalk.magenta.bold("[Inngest]"); + +export const debugPrefix = "inngest"; diff --git a/packages/inngest/src/helpers/devserver.ts b/packages/inngest/src/helpers/devserver.ts index 322abc950..c6277313f 100644 --- a/packages/inngest/src/helpers/devserver.ts +++ b/packages/inngest/src/helpers/devserver.ts @@ -1,4 +1,3 @@ -import { type FunctionConfig } from "../types"; import { defaultDevServerHost } from "./consts"; /** @@ -51,42 +50,3 @@ export const devServerUrl = ( ): URL => { return new URL(pathname, host.includes("://") ? host : `http://${host}`); }; - -// InfoResponse is the API response for the dev server's /dev endpoint. -export type InfoResponse = { - version: string; // Version of the dev server - startOpts: { - sdkURLs: string[]; // URLs the dev server was started with - }; - // Account helpers - authed: boolean; // Are we logged in? - workspaces: { - prod: WorkspaceResponse; // To validate keys in test & prod. - test: WorkspaceResponse; - }; - // SDK registration helpers - functions: FunctionConfig[]; - handlers: SDKHandler[]; -}; - -type WorkspaceResponse = { - signingKey: string; - eventKeys: Array<{ - name: string; - key: string; - }>; -}; - -type SDKHandler = { - functionIDs: Array; - createdAt: string; - updatedAt: string; - errors: Array; // A list of errors from eg. function validation, or key validation. - sdk: { - url: string; - language: string; - version: string; - framework?: string; - app: string; // app name - }; -}; diff --git a/packages/inngest/src/helpers/env.ts b/packages/inngest/src/helpers/env.ts index 8f3663d22..a55803307 100644 --- a/packages/inngest/src/helpers/env.ts +++ b/packages/inngest/src/helpers/env.ts @@ -9,6 +9,16 @@ import { version } from "../version"; import { envKeys, headerKeys, prodEnvKeys } from "./consts"; import { stringifyUnknown } from "./strings"; +/** + * @public + */ +export type Env = Record; + +/** + * @public + */ +export type EnvValue = string | undefined; + /** * devServerHost returns the dev server host by searching for the INNGEST_DEVSERVER_URL * environment variable (plus project prefixces for eg. react, such as REACT_APP_INNGEST_DEVSERVER_URL). @@ -17,9 +27,7 @@ import { stringifyUnknown } from "./strings"; * * @example devServerHost() */ -export const devServerHost = ( - env: Record = allProcessEnv() -): string | undefined => { +export const devServerHost = (env: Env = allProcessEnv()): EnvValue => { // devServerKeys are the env keys we search for to discover the dev server // URL. This includes the standard key first, then includes prefixed keys // for use within common frameworks (eg. CRA, next). @@ -29,19 +37,16 @@ export const devServerHost = ( // text replacement instead of actually understanding the AST, despite webpack // being fully capable of understanding the AST. const values = [ - env[envKeys.DevServerUrl], - env["REACT_APP_INNGEST_DEVSERVER_URL"], - env["NEXT_PUBLIC_INNGEST_DEVSERVER_URL"], + env[envKeys.InngestBaseUrl], + env[`REACT_APP_${envKeys.InngestBaseUrl}`], + env[`NEXT_PUBLIC_${envKeys.InngestBaseUrl}`], ]; - return values.find((a) => !!a) as string; + return values.find((a) => !!a); }; const checkFns = (< - T extends Record< - string, - (actual: string | undefined, expected: string | undefined) => boolean - > + T extends Record boolean> >( checks: T ): T => checks)({ @@ -122,15 +127,13 @@ export const isProd = ( * This could be used to determine if we're on a branch deploy or not, though it * should be noted that we don't know if this is the default branch or not. */ -export const getEnvironmentName = ( - env: Record = allProcessEnv() -): string | undefined => { +export const getEnvironmentName = (env: Env = allProcessEnv()): EnvValue => { /** * Order is important; more than one of these env vars may be set, so ensure * that we check the most specific, most reliable env vars first. */ return ( - env[envKeys.Environment] || + env[envKeys.InngestEnvironment] || env[envKeys.BranchName] || env[envKeys.VercelBranch] || env[envKeys.NetlifyBranch] || @@ -140,12 +143,12 @@ export const getEnvironmentName = ( ); }; -export const processEnv = (key: string): string | undefined => { +export const processEnv = (key: string): EnvValue => { return allProcessEnv()[key]; }; declare const Deno: { - env: { toObject: () => Record }; + env: { toObject: () => Env }; }; /** @@ -156,7 +159,7 @@ declare const Deno: { * Using this ensures we don't dangerously access `process.env` in environments * where it may not be defined, such as Deno or the browser. */ -export const allProcessEnv = (): Record => { +export const allProcessEnv = (): Env => { try { // eslint-disable-next-line @inngest/internal/process-warn if (process.env) { @@ -192,7 +195,7 @@ export const inngestHeaders = (opts?: { * default source. Useful for platforms where environment variables are passed * in alongside requests. */ - env?: Record; + env?: Env; /** * The framework name to use in the `X-Inngest-Framework` header. This is not @@ -267,10 +270,7 @@ const platformChecks = { "cloudflare-pages": (env) => env[envKeys.IsCloudflarePages] === "1", render: (env) => env[envKeys.IsRender] === "true", railway: (env) => Boolean(env[envKeys.RailwayEnvironment]), -} satisfies Record< - string, - (env: Record) => boolean ->; +} satisfies Record boolean>; declare const EdgeRuntime: string | undefined; @@ -287,10 +287,7 @@ declare const EdgeRuntime: string | undefined; const streamingChecks: Partial< Record< keyof typeof platformChecks, - ( - framework: SupportedFrameworkName, - env: Record - ) => boolean + (framework: SupportedFrameworkName, env: Env) => boolean > > = { /** @@ -306,7 +303,7 @@ const streamingChecks: Partial< vercel: (_framework, _env) => typeof EdgeRuntime === "string", }; -const getPlatformName = (env: Record) => { +const getPlatformName = (env: Env) => { return (Object.keys(platformChecks) as (keyof typeof platformChecks)[]).find( (key) => { return platformChecks[key](env); @@ -323,7 +320,7 @@ const getPlatformName = (env: Record) => { */ export const platformSupportsStreaming = ( framework: SupportedFrameworkName, - env: Record = allProcessEnv() + env: Env = allProcessEnv() ): boolean => { return ( streamingChecks[getPlatformName(env) as keyof typeof streamingChecks]?.( diff --git a/packages/inngest/src/helpers/errors.test.ts b/packages/inngest/src/helpers/errors.test.ts new file mode 100644 index 000000000..9f7296384 --- /dev/null +++ b/packages/inngest/src/helpers/errors.test.ts @@ -0,0 +1,105 @@ +import { isSerializedError, serializeError } from "@local/helpers/errors"; + +interface ErrorTests { + name: string; + error: unknown; + tests: { + name?: string; + message?: string; + }; +} + +const testError = ({ name, error: errToTest, tests }: ErrorTests) => { + describe(name, () => { + const err = serializeError(errToTest); + + if (tests.name) { + it("should have a name", () => { + expect(err.name).toBe(tests.name ?? "Error"); + }); + } + + if (tests.message) { + it("should have a message", () => { + expect(err.message).toBe(tests.message); + }); + } + + it("should have a stack", () => { + expect(err.stack).toBeDefined(); + }); + + it("should be detected as a serialized error", () => { + expect(isSerializedError(err)).toBeDefined(); + }); + }); +}; + +class CustomError extends Error { + constructor(message?: string) { + super(message); + this.name = "CustomError"; + } +} + +describe("serializeError", () => { + testError({ + name: "string", + error: "test", + tests: { message: "test" }, + }); + + testError({ + name: "number", + error: 1, + tests: { message: "1" }, + }); + + testError({ + name: "boolean", + error: true, + tests: { message: "true" }, + }); + + testError({ + name: "null", + error: null, + tests: { message: "null" }, + }); + + testError({ + name: "undefined", + error: undefined, + tests: { message: "{}" }, + }); + + testError({ + name: "object", + error: { foo: "bar" }, + tests: { message: '{"foo":"bar"}' }, + }); + + testError({ + name: "array", + error: [], + tests: { message: "[]" }, + }); + + testError({ + name: "Blank error", + error: new Error(), + tests: { message: "{}" }, + }); + + testError({ + name: "Custom error", + error: new CustomError("test"), + tests: { name: "CustomError", message: "test" }, + }); + + testError({ + name: "Existing serialized error", + error: serializeError(new Error("test")), + tests: { message: "test" }, + }); +}); diff --git a/packages/inngest/src/helpers/errors.ts b/packages/inngest/src/helpers/errors.ts index 2db405837..f638ad7d1 100644 --- a/packages/inngest/src/helpers/errors.ts +++ b/packages/inngest/src/helpers/errors.ts @@ -181,10 +181,33 @@ export const deserializeError = (subject: Partial): Error => { }; export enum ErrCode { + NESTING_STEPS = "NESTING_STEPS", + + /** + * Legacy v0 execution error code for when a function has changed and no + * longer matches its in-progress state. + * + * @deprecated Not for use in latest execution method. + */ NON_DETERMINISTIC_FUNCTION = "NON_DETERMINISTIC_FUNCTION", + + /** + * Legacy v0 execution error code for when a function is found to be using + * async actions after memoziation has occurred, which v0 doesn't support. + * + * @deprecated Not for use in latest execution method. + */ ASYNC_DETECTED_AFTER_MEMOIZATION = "ASYNC_DETECTED_AFTER_MEMOIZATION", + + /** + * Legacy v0 execution error code for when a function is found to be using + * steps after a non-step async action has occurred. + * + * @deprecated Not for use in latest execution method. + */ STEP_USED_AFTER_ASYNC = "STEP_USED_AFTER_ASYNC", - NESTING_STEPS = "NESTING_STEPS", + + AUTOMATIC_PARALLEL_INDEXING = "AUTOMATIC_PARALLEL_INDEXING", } export interface PrettyError { @@ -323,21 +346,6 @@ export const prettyError = ({ return colorFn(message); }; -export const functionStoppedRunningErr = (code: ErrCode) => { - return prettyError({ - whatHappened: "Your function was stopped from running", - why: "We detected a mix of asynchronous logic, some using step tooling and some not.", - consequences: - "This can cause unexpected behaviour when a function is paused and resumed and is therefore strongly discouraged; we stopped your function to ensure nothing unexpected happened!", - stack: true, - toFixNow: - "Ensure that your function is either entirely step-based or entirely non-step-based, by either wrapping all asynchronous logic in `step.run()` calls or by removing all `step.*()` calls.", - otherwise: - "For more information on why step functions work in this manner, see https://www.inngest.com/docs/functions/multi-step#gotchas", - code, - }); -}; - export const fixEventKeyMissingSteps = [ "Set the `INNGEST_EVENT_KEY` environment variable", `Pass a key to the \`new Inngest()\` constructor using the \`${ @@ -363,3 +371,48 @@ export class OutgoingResultError extends Error { this.result = result; } } + +/** + * Create a function that will rethrow an error with a prefix added to the + * message. + * + * Useful for adding context to errors that are rethrown. + * + * @example + * ```ts + * await doSomeAction().catch(rethrowError("Failed to do some action")); + * ``` + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const rethrowError = (prefix: string): ((err: any) => never) => { + return (err) => { + try { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/restrict-template-expressions + err.message &&= `${prefix}; ${err.message}`; + } catch (noopErr) { + // no-op + } finally { + // eslint-disable-next-line no-unsafe-finally + throw err; + } + }; +}; + +/** + * Legacy v0 execution error for functions that don't support mixing steps and + * regular async actions. + */ +export const functionStoppedRunningErr = (code: ErrCode) => { + return prettyError({ + whatHappened: "Your function was stopped from running", + why: "We detected a mix of asynchronous logic, some using step tooling and some not.", + consequences: + "This can cause unexpected behaviour when a function is paused and resumed and is therefore strongly discouraged; we stopped your function to ensure nothing unexpected happened!", + stack: true, + toFixNow: + "Ensure that your function is either entirely step-based or entirely non-step-based, by either wrapping all asynchronous logic in `step.run()` calls or by removing all `step.*()` calls.", + otherwise: + "For more information on why step functions work in this manner, see https://www.inngest.com/docs/functions/multi-step#gotchas", + code, + }); +}; diff --git a/packages/inngest/src/helpers/functions.test.ts b/packages/inngest/src/helpers/functions.test.ts index c34576d7f..482434f67 100644 --- a/packages/inngest/src/helpers/functions.test.ts +++ b/packages/inngest/src/helpers/functions.test.ts @@ -1,5 +1,5 @@ -import { InngestApi } from "@local/api/api"; -import { parseFnData } from "@local/helpers/functions"; +import { ExecutionVersion } from "@local/components/execution/InngestExecution"; +import { parseFnData, type FnData } from "@local/helpers/functions"; import { type EventPayload } from "@local/types"; const randomstr = (): string => { @@ -16,17 +16,23 @@ const generateEvent = (): EventPayload => { }; describe("#parseFnData", () => { - const API = new InngestApi({ signingKey: "something" }); - - [ + const specs: { + name: string; + data: Extract; + isOk: boolean; + }[] = [ { name: "should parse successfully for valid data", data: { + version: 1, event: generateEvent(), events: [...Array(5).keys()].map(() => generateEvent()), steps: {}, ctx: { run_id: randomstr(), + attempt: 0, + disable_immediate_execution: false, + use_api: false, stack: { stack: [randomstr()], current: 0, @@ -37,13 +43,18 @@ describe("#parseFnData", () => { }, { name: "should return an error for missing event", + // @ts-expect-error No `event` data: { + version: ExecutionVersion.V1, events: [...Array(5).keys()].map(() => generateEvent()), steps: {}, ctx: { run_id: randomstr(), + attempt: 0, + disable_immediate_execution: false, + use_api: false, stack: { - stack: [], + stack: [randomstr()], current: 0, }, }, @@ -52,13 +63,19 @@ describe("#parseFnData", () => { }, { name: "should return an error with empty object", + // @ts-expect-error No data at all data: {}, isOk: false, }, - ].forEach((test) => { - it(test.name, async () => { - const result = await parseFnData(test.data, API); - expect(result.ok).toEqual(test.isOk); + ]; + + specs.forEach((test) => { + it(test.name, () => { + if (test.isOk) { + return expect(() => parseFnData(test.data)).not.toThrow(); + } else { + return expect(() => parseFnData(test.data)).toThrow(); + } }); }); }); diff --git a/packages/inngest/src/helpers/functions.ts b/packages/inngest/src/helpers/functions.ts index 67f2ad507..51aee6fb1 100644 --- a/packages/inngest/src/helpers/functions.ts +++ b/packages/inngest/src/helpers/functions.ts @@ -1,6 +1,11 @@ -import { ZodError } from "zod"; +import { ZodError, z } from "zod"; import { type InngestApi } from "../api/api"; -import { err, fnDataSchema, ok, type FnData, type Result } from "../types"; +import { stepsSchema } from "../api/schema"; +import { + ExecutionVersion, + PREFERRED_EXECUTION_VERSION, +} from "../components/execution/InngestExecution"; +import { err, ok, type Result } from "../types"; import { prettyError } from "./errors"; import { type Await } from "./types"; @@ -8,14 +13,14 @@ import { type Await } from "./types"; * Wraps a function with a cache. When the returned function is run, it will * cache the result and return it on subsequent calls. */ -export const cacheFn = unknown>( - fn: T -): T => { +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const cacheFn = any>(fn: T): T => { const key = "value"; const cache = new Map(); return ((...args) => { if (!cache.has(key)) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument cache.set(key, fn(...args)); } @@ -33,8 +38,6 @@ export const cacheFn = unknown>( * * Because this needs to support both sync and async functions, it only allows * functions that accept a single argument. - * - * TODO Add a second function that decides how to merge results from prev and current results. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any export const waterfall = any)[]>( @@ -63,15 +66,130 @@ export const waterfall = any)[]>( }; }; +/** + * Given a value `v`, return `v` if it's not undefined, otherwise return `null`. + */ +export const undefinedToNull = (v: unknown) => { + const isUndefined = typeof v === "undefined"; + return isUndefined ? null : v; +}; + +const fnDataVersionSchema = z.object({ + version: z + .literal(-1) + .or(z.literal(0)) + .or(z.literal(1)) + .optional() + .transform((v) => { + if (typeof v === "undefined") { + console.warn( + `No request version specified by executor; defaulting to v${PREFERRED_EXECUTION_VERSION}` + ); + + return PREFERRED_EXECUTION_VERSION; + } + + return v === -1 ? PREFERRED_EXECUTION_VERSION : v; + }), +}); + +export const parseFnData = (data: unknown) => { + let version: ExecutionVersion; + + try { + ({ version } = fnDataVersionSchema.parse(data)); + + const versionHandlers = { + [ExecutionVersion.V0]: () => + ({ + version: ExecutionVersion.V0, + ...z + .object({ + event: z.record(z.any()), + events: z.array(z.record(z.any())).default([]), + steps: z + .record( + z.any().refine((v) => typeof v !== "undefined", { + message: "Values in steps must be defined", + }) + ) + .optional() + .nullable(), + ctx: z + .object({ + run_id: z.string(), + attempt: z.number().default(0), + stack: z + .object({ + stack: z + .array(z.string()) + .nullable() + .transform((v) => (Array.isArray(v) ? v : [])), + current: z.number(), + }) + .passthrough() + .optional() + .nullable(), + }) + .optional() + .nullable(), + use_api: z.boolean().default(false), + }) + .parse(data), + } as const), + + [ExecutionVersion.V1]: () => + ({ + version: ExecutionVersion.V1, + ...z + .object({ + event: z.record(z.any()), + events: z.array(z.record(z.any())).default([]), + steps: stepsSchema, + ctx: z + .object({ + run_id: z.string(), + attempt: z.number().default(0), + disable_immediate_execution: z.boolean().default(false), + use_api: z.boolean().default(false), + stack: z + .object({ + stack: z + .array(z.string()) + .nullable() + .transform((v) => (Array.isArray(v) ? v : [])), + current: z.number(), + }) + .passthrough() + .optional() + .nullable(), + }) + .optional() + .nullable(), + }) + .parse(data), + } as const), + } satisfies Record unknown>; + + return versionHandlers[version](); + } catch (err) { + throw new Error(parseFailureErr(err)); + } +}; +export type FnData = ReturnType; + type ParseErr = string; -export const parseFnData = async ( - data: unknown, +export const fetchAllFnData = async ( + data: FnData, api: InngestApi ): Promise> => { - try { - const result = fnDataSchema.parse(data); + const result = { ...data }; - if (result.use_api) { + try { + if ( + (result.version === 0 && result.use_api) || + (result.version === 1 && result.ctx?.use_api) + ) { if (!result.ctx?.run_id) { return err( prettyError({ @@ -121,20 +239,22 @@ export const parseFnData = async ( // move to something like protobuf so we don't have to deal with this console.error(error); - let why: string | undefined; - if (error instanceof ZodError) { - why = error.toString(); - } + return err(parseFailureErr(error)); + } +}; - return err( - prettyError({ - whatHappened: "Failed to parse data from executor.", - consequences: "Function execution can't continue.", - toFixNow: - "Make sure that your API is set up to parse incoming request bodies as JSON, like body-parser for Express (https://expressjs.com/en/resources/middleware/body-parser.html).", - stack: true, - why, - }) - ); +const parseFailureErr = (err: unknown) => { + let why: string | undefined; + if (err instanceof ZodError) { + why = err.toString(); } + + return prettyError({ + whatHappened: "Failed to parse data from executor.", + consequences: "Function execution can't continue.", + toFixNow: + "Make sure that your API is set up to parse incoming request bodies as JSON, like body-parser for Express (https://expressjs.com/en/resources/middleware/body-parser.html).", + stack: true, + why, + }); }; diff --git a/packages/inngest/src/helpers/promises.test.ts b/packages/inngest/src/helpers/promises.test.ts new file mode 100644 index 000000000..4b4bfd65d --- /dev/null +++ b/packages/inngest/src/helpers/promises.test.ts @@ -0,0 +1,87 @@ +import { runAsPromise } from "@local/helpers/promises"; +import { assertType } from "type-plus"; + +describe("runAsPromise", () => { + describe("synchronous functions", () => { + describe("throwing synchronously is caught", () => { + const fn = () => { + throw new Error("test"); + }; + + test("rejects with error", async () => { + await expect(runAsPromise(fn)).rejects.toThrow("test"); + }); + }); + + describe("resolves with value on success", () => { + test("resolves with value", async () => { + await expect( + runAsPromise(() => { + return "test"; + }) + ).resolves.toBe("test"); + }); + }); + }); + + describe("asynchronous functions", () => { + const wait = (ms: number) => + new Promise((resolve) => setTimeout(resolve, ms)); + + describe("throwing asynchronously is caught", () => { + test("rejects with error", async () => { + await expect( + runAsPromise(async () => { + await wait(100); + throw new Error("test"); + }) + ).rejects.toThrow("test"); + }); + }); + + describe("resolves with value on success", () => { + test("resolves with value", async () => { + await expect( + runAsPromise(async () => { + await wait(100); + return "test"; + }) + ).resolves.toBe("test"); + }); + }); + }); + + describe("resolves with undefined if `fn` undefined", () => { + test("resolves with undefined", async () => { + await expect(runAsPromise(undefined)).resolves.toBeUndefined(); + }); + }); + + describe("types", () => { + describe("fn can be undefined", () => { + test("allows undefined fn", () => { + void runAsPromise(undefined); + }); + + test("returns undefined", () => { + const ret = runAsPromise(undefined); + assertType>(ret); + }); + }); + + test("no arguments allowed", () => { + // @ts-expect-error No arguments allowed + void runAsPromise((_foo: string) => { + // no-op + }); + }); + + test("returns value", () => { + const ret = runAsPromise(() => { + return "test"; + }); + + assertType>(ret); + }); + }); +}); diff --git a/packages/inngest/src/helpers/promises.ts b/packages/inngest/src/helpers/promises.ts index abf8d9136..12046fdff 100644 --- a/packages/inngest/src/helpers/promises.ts +++ b/packages/inngest/src/helpers/promises.ts @@ -62,6 +62,113 @@ export const resolveAfterPending = (): Promise => { }); }; +type DeferredPromiseReturn = { + promise: Promise; + resolve: (value: T) => DeferredPromiseReturn; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + reject: (reason: any) => DeferredPromiseReturn; +}; + +/** + * Creates and returns Promise that can be resolved or rejected with the + * returned `resolve` and `reject` functions. + * + * Resolving or rejecting the function will return a new set of Promise control + * functions. These can be ignored if the original Promise is all that's needed. + */ +export const createDeferredPromise = (): DeferredPromiseReturn => { + let resolve: DeferredPromiseReturn["resolve"]; + let reject: DeferredPromiseReturn["reject"]; + + const promise = new Promise((_resolve, _reject) => { + resolve = (value: T) => { + _resolve(value); + return createDeferredPromise(); + }; + + reject = (reason) => { + _reject(reason); + return createDeferredPromise(); + }; + }); + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return { promise, resolve: resolve!, reject: reject! }; +}; + +interface TimeoutPromise extends Promise { + /** + * Starts the timeout. If the timer is already started, this does nothing. + * + * @returns The promise that will resolve when the timeout expires. + */ + start: () => TimeoutPromise; + + /** + * Clears the timeout. + */ + clear: () => void; + + /** + * Clears the timeout and starts it again. + * + * @returns The promise that will resolve when the timeout expires. + */ + reset: () => TimeoutPromise; +} + +/** + * Creates a Promise that will resolve after the given duration, along with + * methods to start, clear, and reset the timeout. + */ +export const createTimeoutPromise = (duration: number): TimeoutPromise => { + const { promise, resolve } = createDeferredPromise(); + + let timeout: ReturnType | undefined; + // eslint-disable-next-line prefer-const + let ret: TimeoutPromise; + + const start = () => { + if (timeout) return ret; + + timeout = setTimeout(() => { + resolve(); + }, duration); + + return ret; + }; + + const clear = () => { + clearTimeout(timeout); + timeout = undefined; + }; + + const reset = () => { + clear(); + return start(); + }; + + ret = Object.assign(promise, { start, clear, reset }); + + return ret; +}; + +/** + * Take any function and safely promisify such that both synchronous and + * asynchronous errors are caught and returned as a rejected Promise. + * + * The passed `fn` can be undefined to support functions that may conditionally + * be defined. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const runAsPromise = any) | undefined>( + fn: T + // eslint-disable-next-line @typescript-eslint/no-explicit-any +): Promise any ? Awaited> : T> => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return Promise.resolve().then(fn); +}; + /** * Returns a Promise that resolve after the current event loop tick. */ diff --git a/packages/inngest/src/helpers/scalar.ts b/packages/inngest/src/helpers/scalar.ts deleted file mode 100644 index c6f93bc48..000000000 --- a/packages/inngest/src/helpers/scalar.ts +++ /dev/null @@ -1,14 +0,0 @@ -/** - * Returns a boolean representing whether a string was `"true"` or `"false"` - * when lowercased and trimmed. - * - * If the string was neither, will return `null`. - */ -export const strBoolean = (str: string | undefined): boolean | null => { - const trimmed = str?.toLowerCase().trim(); - - if (trimmed === "true") return true; - if (trimmed === "false") return false; - - return null; -}; diff --git a/packages/inngest/src/helpers/strings.ts b/packages/inngest/src/helpers/strings.ts index de6536ec6..a757c64f8 100644 --- a/packages/inngest/src/helpers/strings.ts +++ b/packages/inngest/src/helpers/strings.ts @@ -1,10 +1,15 @@ -import ms from "ms"; import { sha256 } from "hash.js"; +import ms from "ms"; import { type TimeStr } from "../types"; export { default as stringify } from "json-stringify-safe"; /** - * Returns a slugified string used ot generate consistent IDs. + * Returns a slugified string used to generate consistent IDs. + * + * This can be used to generate a consistent ID for a function when migrating + * from v2 to v3 of the SDK. + * + * @public */ export const slugify = (str: string): string => { const join = "-"; diff --git a/packages/inngest/src/helpers/types.ts b/packages/inngest/src/helpers/types.ts index d69c22702..c44041bae 100644 --- a/packages/inngest/src/helpers/types.ts +++ b/packages/inngest/src/helpers/types.ts @@ -1,15 +1,6 @@ import { type Simplify } from "type-fest"; import { type EventPayload } from "../types"; -/** - * Returns a union of all of the values in a given object, regardless of key. - */ -export type ValueOf = T extends Record - ? { - [K in keyof T]: T[K]; - }[keyof T] - : never; - /** * Returns the given generic as either itself or an array of itself. */ @@ -40,13 +31,6 @@ export type SendEventPayload> = }[keyof Events] >; -/** - * Retrieve an event's name based on the given payload. Defaults to `string`. - */ -export type EventName = Event extends EventPayload - ? Event["name"] - : string; - /** * A list of simple, JSON-compatible, primitive types that contain no other * values. @@ -81,14 +65,6 @@ type Path = T extends Array // eslint-disable-next-line @typescript-eslint/no-explicit-any export type ObjectPaths> = Path; -/** - * Filter out all keys from `T` where the associated value does not match type - * `U`. - */ -export type KeysNotOfType = { - [P in keyof T]: T[P] extends U ? never : P; -}[keyof T]; - /** * Returns all keys from objects in the union `T`. * @@ -198,3 +174,16 @@ export type ExclusiveKeys = * types and unique properties are marked as optional. */ export type Either = Partial & Partial & (A | B); + +/** + * Given a function `T`, return the parameters of that function, except for the + * first one. + */ +export type ParametersExceptFirst = T extends ( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + arg0: any, + ...rest: infer U +) => // eslint-disable-next-line @typescript-eslint/no-explicit-any +any + ? U + : never; diff --git a/packages/inngest/src/index.ts b/packages/inngest/src/index.ts index 35f0510c3..a4cde5c3e 100644 --- a/packages/inngest/src/index.ts +++ b/packages/inngest/src/index.ts @@ -9,7 +9,7 @@ export { export { Inngest } from "./components/Inngest"; export type { EventsFromOpts } from "./components/Inngest"; export { InngestCommHandler } from "./components/InngestCommHandler"; -export type { ServeHandler } from "./components/InngestCommHandler"; +export type { ServeHandlerOptions } from "./components/InngestCommHandler"; export { InngestMiddleware } from "./components/InngestMiddleware"; export type { MiddlewareOptions, @@ -17,7 +17,9 @@ export type { MiddlewareRegisterReturn, } from "./components/InngestMiddleware"; export { NonRetriableError } from "./components/NonRetriableError"; +export { RetryAfterError } from "./components/RetryAfterError"; export { headerKeys, internalEvents, queryKeys } from "./helpers/consts"; +export { slugify } from "./helpers/strings"; export type { IsStringLiteral, StrictUnion, @@ -36,6 +38,8 @@ export type { GetEvents, LogLevel, RegisterOptions, + StepOptions, + StepOptionsOrId, TimeStr, TriggerOptions, } from "./types"; diff --git a/packages/inngest/src/lambda.ts b/packages/inngest/src/lambda.ts index 9b8146975..43cb6f6d4 100644 --- a/packages/inngest/src/lambda.ts +++ b/packages/inngest/src/lambda.ts @@ -6,12 +6,12 @@ import { } from "aws-lambda"; import { InngestCommHandler, - type ServeHandler, + type ServeHandlerOptions, } from "./components/InngestCommHandler"; -import { headerKeys, queryKeys } from "./helpers/consts"; +import { type Either } from "./helpers/types"; import { type SupportedFrameworkName } from "./types"; -export const name: SupportedFrameworkName = "aws-lambda"; +export const frameworkName: SupportedFrameworkName = "aws-lambda"; /** * With AWS Lambda, serve and register any declared functions with Inngest, @@ -23,28 +23,29 @@ export const name: SupportedFrameworkName = "aws-lambda"; * import { Inngest } from "inngest"; * import { serve } from "inngest/lambda"; * - * const inngest = new Inngest({ name: "My Lambda App" }); + * const inngest = new Inngest({ id: "my-lambda-app" }); * * const fn = inngest.createFunction( - * { name: "Hello World" }, + * { id: "hello-world" }, * { event: "test/hello.world" }, * async ({ event }) => { - * return "Hello World"; - * } + * return "Hello World"; + * } * ); * - * export const handler = serve(inngest, [fn]); + * export const handler = serve({ client: inngest, functions: [fn] }); * ``` * * @public */ -export const serve: ServeHandler = (nameOrInngest, fns, opts) => { - const handler = new InngestCommHandler( - name, - nameOrInngest, - fns, - { ...opts }, - (event: APIGatewayEvent | APIGatewayProxyEventV2, _context: Context) => { +export const serve = (options: ServeHandlerOptions) => { + const handler = new InngestCommHandler({ + frameworkName, + ...options, + handler: ( + event: Either, + _context: Context + ) => { /** * Try to handle multiple incoming event types, as Lambda can have many * triggers. @@ -57,71 +58,43 @@ export const serve: ServeHandler = (nameOrInngest, fns, opts) => { return (ev as APIGatewayProxyEventV2).version === "2.0"; })(event); - const method = eventIsV2 - ? event.requestContext.http.method - : event.httpMethod; - const path = eventIsV2 ? event.requestContext.http.path : event.path; - - let url: URL; - - try { - const proto = event.headers["x-forwarded-proto"] || "https"; - url = new URL(path, `${proto}://${event.headers.host || ""}`); - } catch (err) { - // TODO PrettyError - throw new Error("Could not parse URL from `event.headers.host`"); - } - return { - url, - register: () => { - if (method === "PUT") { - return { - deployId: event.queryStringParameters?.[ - queryKeys.DeployId - ] as string, - }; - } + body: () => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return JSON.parse( + event.body + ? event.isBase64Encoded + ? Buffer.from(event.body, "base64").toString() + : event.body + : "{}" + ); }, - - run: () => { - if (method === "POST") { - return { - data: JSON.parse( - event.body - ? event.isBase64Encoded - ? Buffer.from(event.body, "base64").toString() - : event.body - : "{}" - ) as Record, - fnId: event.queryStringParameters?.[queryKeys.FnId] as string, - stepId: event.queryStringParameters?.[queryKeys.StepId] as string, - signature: event.headers[headerKeys.Signature] as string, - }; - } + headers: (key) => event.headers[key], + method: () => { + return eventIsV2 + ? event.requestContext.http.method + : event.httpMethod; }, + url: () => { + const path = eventIsV2 ? event.requestContext.http.path : event.path; + const proto = event.headers["x-forwarded-proto"] || "https"; + const url = new URL(path, `${proto}://${event.headers.host || ""}`); - view: () => { - if (method === "GET") { - return { - isIntrospection: Object.hasOwnProperty.call( - event.queryStringParameters || {}, - queryKeys.Introspect - ), - }; - } + return url; + }, + queryString: (key) => { + return event.queryStringParameters?.[key]; + }, + transformResponse: ({ + body, + status: statusCode, + headers, + }): Promise => { + return Promise.resolve({ body, statusCode, headers }); }, }; }, - - ({ body, status, headers }): Promise => { - return Promise.resolve({ - body, - statusCode: status, - headers, - }); - } - ); + }); return handler.createHandler(); }; diff --git a/packages/inngest/src/next.ts b/packages/inngest/src/next.ts index 7462d311d..dd9306c6f 100644 --- a/packages/inngest/src/next.ts +++ b/packages/inngest/src/next.ts @@ -2,12 +2,12 @@ import { type NextApiRequest, type NextApiResponse } from "next"; import { type NextRequest } from "next/server"; import { InngestCommHandler, - type ServeHandler, + type ServeHandlerOptions, } from "./components/InngestCommHandler"; -import { headerKeys, queryKeys } from "./helpers/consts"; +import { type Either } from "./helpers/types"; import { type SupportedFrameworkName } from "./types"; -export const name: SupportedFrameworkName = "nextjs"; +export const frameworkName: SupportedFrameworkName = "nextjs"; const isNextEdgeRequest = ( req: NextApiRequest | NextRequest @@ -21,142 +21,114 @@ const isNextEdgeRequest = ( * * @example Next.js <=12 can export the handler directly * ```ts - * export default serve(inngest, [fn1, fn2]); + * export default serve({ client: inngest, functions: [fn1, fn2] }); * ``` * * @example Next.js >=13 with the `app` dir must export individual methods * ```ts - * export const { GET, POST, PUT } = serve(inngest, [fn1, fn2]); + * export const { GET, POST, PUT } = serve({ + * client: inngest, + * functions: [fn1, fn2], + * }); * ``` * * @public */ -export const serve: ServeHandler = (nameOrInngest, fns, opts) => { - const handler = new InngestCommHandler( - name, - nameOrInngest, - fns, - opts, - ( +export const serve = (options: ServeHandlerOptions) => { + const handler = new InngestCommHandler({ + frameworkName, + ...options, + handler: ( reqMethod: "GET" | "POST" | "PUT" | undefined, - req: NextApiRequest | NextRequest, - _res: NextApiResponse + req: Either, + res: NextApiResponse ) => { - /** - * `req.method`, though types say otherwise, is not available in Next.js - * 13 {@link https://beta.nextjs.org/docs/routing/route-handlers Route Handlers}. - * - * Therefore, we must try to set the method ourselves where we know it. - */ - const method = reqMethod || req.method; - if (!method) { - // TODO PrettyError - throw new Error( - "No method found on request; check that your exports are correct." - ); - } - const isEdge = isNextEdgeRequest(req); - let scheme: "http" | "https" = "https"; - - try { - // eslint-disable-next-line @inngest/internal/process-warn - if (process.env.NODE_ENV === "development") { - scheme = "http"; - } - } catch (err) { - // no-op - } - - const url = isEdge - ? new URL(req.url) - : new URL(req.url as string, `${scheme}://${req.headers.host || ""}`); - - const getQueryParam = (key: string): string | undefined => { - return ( - (isEdge ? url.searchParams.get(key) : req.query[key]?.toString()) ?? - undefined - ); - }; + return { + body: () => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return isEdge ? req.json() : req.body; + }, + headers: (key) => { + if (isEdge) { + return req.headers.get(key); + } - const hasQueryParam = (key: string): boolean => { - return ( - (isEdge - ? url.searchParams.has(key) - : Object.hasOwnProperty.call(req.query, key)) ?? false - ); - }; + const header = req.headers[key]; + return Array.isArray(header) ? header[0] : header; + }, + method: () => { + /** + * `req.method`, though types say otherwise, is not available in Next.js + * 13 {@link https://beta.nextjs.org/docs/routing/route-handlers Route Handlers}. + * + * Therefore, we must try to set the method ourselves where we know it. + */ + return reqMethod || req.method || ""; + }, + isProduction: () => { + /** + * Vercel Edge Functions do not allow dynamic access to environment + * variables, so we'll manage production checks directly here. + * + * We try/catch to avoid situations where Next.js is being used in + * environments where `process.env` is not accessible or polyfilled. + */ + try { + // eslint-disable-next-line @inngest/internal/process-warn + return process.env.NODE_ENV === "production"; + } catch (err) { + // no-op + } + }, + queryString: (key, url) => { + if (isEdge) { + return url.searchParams.get(key); + } - const getHeader = (key: string): string | undefined => { - return ( - (isEdge ? req.headers.get(key) : req.headers[key]?.toString()) ?? - undefined - ); - }; + const qs = req.query[key]; + return Array.isArray(qs) ? qs[0] : qs; + }, - /** - * Vercel Edge Functions do not allow dynamic access to environment - * variables, so we'll manage `isProd` directly here. - * - * We try/catch to avoid situations where Next.js is being used in - * environments where `process.env` is not accessible or polyfilled. - */ - let isProduction: boolean | undefined; + url: () => { + if (isEdge) { + return new URL(req.url); + } - try { - // eslint-disable-next-line @inngest/internal/process-warn - isProduction = process.env.NODE_ENV === "production"; - } catch (err) { - // no-op - } + let scheme: "http" | "https" = "https"; - return { - isProduction, - url, - register: () => { - if (method === "PUT") { - return { - deployId: getQueryParam(queryKeys.DeployId)?.toString(), - }; + try { + // eslint-disable-next-line @inngest/internal/process-warn + if (process.env.NODE_ENV === "development") { + scheme = "http"; + } + } catch (err) { + // no-op } + + return new URL( + req.url as string, + `${scheme}://${req.headers.host || ""}` + ); }, - run: async () => { - if (method === "POST") { - return { - data: isEdge - ? ((await req.json()) as Record) - : (req.body as Record), - fnId: getQueryParam(queryKeys.FnId) as string, - stepId: getQueryParam(queryKeys.StepId) as string, - signature: getHeader(headerKeys.Signature) as string, - }; + transformResponse: ({ body, headers, status }) => { + if (isNextEdgeRequest(req)) { + return new Response(body, { status, headers }); } - }, - view: () => { - if (method === "GET") { - return { - isIntrospection: hasQueryParam(queryKeys.Introspect), - }; + + for (const [key, value] of Object.entries(headers)) { + res.setHeader(key, value); } + + res.status(status).send(body); + }, + transformStreamingResponse: ({ body, headers, status }) => { + return new Response(body, { status, headers }); }, }; }, - ({ body, headers, status }, _method, req, res) => { - if (isNextEdgeRequest(req)) { - return new Response(body, { status, headers }); - } - - for (const [key, value] of Object.entries(headers)) { - res.setHeader(key, value); - } - - res.status(status).send(body); - }, - ({ body, headers, status }) => { - return new Response(body, { status, headers }); - } - ); + }); /** * Next.js 13 uses diff --git a/packages/inngest/src/nuxt.ts b/packages/inngest/src/nuxt.ts index 33b6fa908..618031281 100644 --- a/packages/inngest/src/nuxt.ts +++ b/packages/inngest/src/nuxt.ts @@ -1,11 +1,11 @@ -import { type ServeHandler } from "./components/InngestCommHandler"; -import { serve as serveH3 } from "./h3"; import { - type InternalRegisterOptions, - type SupportedFrameworkName, -} from "./types"; + type InternalServeHandlerOptions, + type ServeHandlerOptions, +} from "./components/InngestCommHandler"; +import { serve as serveH3 } from "./h3"; +import { type SupportedFrameworkName } from "./types"; -export const name: SupportedFrameworkName = "nuxt"; +export const frameworkName: SupportedFrameworkName = "nuxt"; /** * In Nuxt 3, serve and register any declared functions with Inngest, making @@ -13,12 +13,11 @@ export const name: SupportedFrameworkName = "nuxt"; * * @public */ -export const serve: ServeHandler = (client, functions, opts) => { - const optsOverrides: InternalRegisterOptions = { - ...opts, - frameworkName: name, +export const serve = (options: ServeHandlerOptions) => { + const optsOverrides: InternalServeHandlerOptions = { + ...options, + frameworkName, }; - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - return serveH3(client, functions, optsOverrides); + return serveH3(optsOverrides); }; diff --git a/packages/inngest/src/redwood.ts b/packages/inngest/src/redwood.ts index 96bc7ca16..cf9b17489 100644 --- a/packages/inngest/src/redwood.ts +++ b/packages/inngest/src/redwood.ts @@ -4,9 +4,8 @@ import { } from "aws-lambda"; import { InngestCommHandler, - type ServeHandler, + type ServeHandlerOptions, } from "./components/InngestCommHandler"; -import { headerKeys, queryKeys } from "./helpers/consts"; import { processEnv } from "./helpers/env"; import { type SupportedFrameworkName } from "./types"; @@ -16,7 +15,7 @@ export interface RedwoodResponse { headers?: Record; } -export const name: SupportedFrameworkName = "redwoodjs"; +export const frameworkName: SupportedFrameworkName = "redwoodjs"; /** * In Redwood.js, serve and register any declared functions with Inngest, making @@ -24,71 +23,45 @@ export const name: SupportedFrameworkName = "redwoodjs"; * * @public */ -export const serve: ServeHandler = (nameOrInngest, fns, opts): unknown => { - const handler = new InngestCommHandler( - name, - nameOrInngest, - fns, - opts, - (event: APIGatewayProxyEvent, _context: LambdaContext) => { - const scheme = - processEnv("NODE_ENV") === "development" ? "http" : "https"; - const url = new URL( - event.path, - `${scheme}://${event.headers.host || ""}` - ); - +export const serve = (options: ServeHandlerOptions) => { + const handler = new InngestCommHandler({ + frameworkName, + ...options, + handler: (event: APIGatewayProxyEvent, _context: LambdaContext) => { return { - url, - register: () => { - if (event.httpMethod === "PUT") { - return { - deployId: event.queryStringParameters?.[queryKeys.DeployId], - }; - } + body: () => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return JSON.parse( + event.body + ? event.isBase64Encoded + ? Buffer.from(event.body, "base64").toString() + : event.body + : "{}" + ); }, - run: () => { - if (event.httpMethod === "POST") { - /** - * Some requests can be base64 encoded, requiring us to decode it - * first before parsing as JSON. - */ - const data = JSON.parse( - event.body - ? event.isBase64Encoded - ? Buffer.from(event.body, "base64").toString() - : event.body - : "{}" - ) as Record; + headers: (key) => event.headers[key], + method: () => event.httpMethod, + url: () => { + const scheme = + processEnv("NODE_ENV") === "development" ? "http" : "https"; + const url = new URL( + event.path, + `${scheme}://${event.headers.host || ""}` + ); - return { - data, - fnId: event.queryStringParameters?.[queryKeys.FnId] as string, - signature: event.headers[headerKeys.Signature] as string, - stepId: event.queryStringParameters?.[queryKeys.StepId] as string, - }; - } + return url; }, - view: () => { - if (event.httpMethod === "GET") { - return { - isIntrospection: Object.hasOwnProperty.call( - event.queryStringParameters, - queryKeys.Introspect - ), - }; - } + queryString: (key) => event.queryStringParameters?.[key], + transformResponse: ({ + body, + status: statusCode, + headers, + }): RedwoodResponse => { + return { body, statusCode, headers }; }, }; }, - (actionRes): RedwoodResponse => { - return { - statusCode: actionRes.status, - body: actionRes.body, - headers: actionRes.headers, - }; - } - ); + }); return handler.createHandler(); }; diff --git a/packages/inngest/src/remix.ts b/packages/inngest/src/remix.ts index 2c7454618..3f55bb29e 100644 --- a/packages/inngest/src/remix.ts +++ b/packages/inngest/src/remix.ts @@ -1,12 +1,11 @@ import { InngestCommHandler, type ActionResponse, - type ServeHandler, + type ServeHandlerOptions, } from "./components/InngestCommHandler"; -import { headerKeys, queryKeys } from "./helpers/consts"; import { type SupportedFrameworkName } from "./types"; -export const name: SupportedFrameworkName = "remix"; +export const frameworkName: SupportedFrameworkName = "remix"; const createNewResponse = ({ body, @@ -47,55 +46,30 @@ const createNewResponse = ({ * @example * ```ts * import { serve } from "inngest/remix"; - * import fns from "~/inngest"; + * import functions from "~/inngest"; * - * const handler = serve("My Remix App", fns); + * const handler = serve({ id: "my-remix-app", functions }); * * export { handler as loader, handler as action }; * ``` * * @public */ -export const serve: ServeHandler = (nameOrInngest, fns, opts): unknown => { - const handler = new InngestCommHandler( - name, - nameOrInngest, - fns, - opts, - ({ request: req }: { request: Request }) => { - const url = new URL(req.url, `https://${req.headers.get("host") || ""}`); - +export const serve = (options: ServeHandlerOptions) => { + const handler = new InngestCommHandler({ + frameworkName, + ...options, + handler: ({ request: req }: { request: Request }) => { return { - url, - register: () => { - if (req.method === "PUT") { - return { - deployId: url.searchParams.get(queryKeys.DeployId), - }; - } - }, - run: async () => { - if (req.method === "POST") { - return { - data: (await req.json()) as Record, - fnId: url.searchParams.get(queryKeys.FnId) as string, - stepId: url.searchParams.get(queryKeys.StepId) as string, - signature: req.headers.get(headerKeys.Signature) || undefined, - }; - } - }, - view: () => { - if (req.method === "GET") { - return { - isIntrospection: url.searchParams.has(queryKeys.Introspect), - }; - } - }, + body: () => req.json(), + headers: (key) => req.headers.get(key), + method: () => req.method, + url: () => new URL(req.url, `https://${req.headers.get("host") || ""}`), + transformResponse: createNewResponse, + transformStreamingResponse: createNewResponse, }; }, - createNewResponse, - createNewResponse - ); + }); return handler.createHandler(); }; diff --git a/packages/inngest/src/test/functions/client.ts b/packages/inngest/src/test/functions/client.ts index db0581956..bcd79fe5e 100644 --- a/packages/inngest/src/test/functions/client.ts +++ b/packages/inngest/src/test/functions/client.ts @@ -1,6 +1,6 @@ import { Inngest } from "inngest"; export const inngest = new Inngest({ - name: "Example App", + id: "example-app", eventKey: "test-key-123", }); diff --git a/packages/inngest/src/test/functions/handler.ts b/packages/inngest/src/test/functions/handler.ts index f06df89b3..2a6616828 100644 --- a/packages/inngest/src/test/functions/handler.ts +++ b/packages/inngest/src/test/functions/handler.ts @@ -1,9 +1,4 @@ -import { - headerKeys, - InngestCommHandler, - queryKeys, - type ServeHandler, -} from "inngest"; +import { InngestCommHandler, type ServeHandlerOptions } from "inngest"; /** * An example serve handler to demonstrate how to create a custom serve handler @@ -24,45 +19,27 @@ import { * signature of the `serve` function in `inngest`. This function takes a name or * Inngest instance, an object of functions, and an options object. */ -export const serve: ServeHandler = (nameOrInngest, fns, opts) => { +export const serve = (options: ServeHandlerOptions) => { /** * First we create a new `InngestCommHandler` instance. This instance is * responsible for handling the communication between Inngest and your * functions, and is typed strictly to ensure you can't miss any * functionality. */ - const handler = new InngestCommHandler( + const handler = new InngestCommHandler({ /** - * The first argument is the name of the framework or runtime you're - * creating a handler for. This is used to identify your handler in the - * Inngest dashboard. It's recommended that it's a short, lowercase string - * that doesn't contain any spaces. + * A `frameworkName` is needed, which is the name of the framework or + * runtime you're creating a handler for. This is used to identify your + * handler in the Inngest dashboard. It's recommended that it's a short, + * lowercase string that doesn't contain any spaces. */ - "edge", + frameworkName: "edge", /** - * The second argument is the name of our handler or an instance of Inngest. - * We use the input `nameOrInngest` argument here that's passed by the user. + * Next, we'll spread the user's input options into here. This allows the + * user to override any options that we set by default. */ - nameOrInngest, - - /** - * The third argument is an object of functions that we want to make - * available to Inngest. We use the input `fns` argument here that's passed - * by the user. - */ - fns, - - /** - * The fourth argument is an options object. We use the input `opts` - * argument here that's passed by the user and spread it into the options - * object. This allows the user to override any of the default options. - * - * This is a great place to set any sensible defaults for your handler. - */ - { - ...opts, - }, + ...options, /** * This function will take a request and return a typed object that Inngest @@ -74,120 +51,52 @@ export const serve: ServeHandler = (nameOrInngest, fns, opts) => { * and `NextJSResponse` object. In this edge example, it'll be a regular * global `Request` object. */ - (req: Request) => { - /** - * Next we grab the URL of the endpoint. Function registration isn't - * always triggered by Inngest, so the SDK needs to be able to self-report - * its endpoint. - */ - const url = new URL(req.url, `https://${req.headers.get("host") || ""}`); - + handler: (req: Request) => { /** - * This function enforces that we return an object with this shape. We - * always need a URL, then a function for each action that can be provided - * by the SDK. - * - * These returned functions are used by Inngest to decide what kind of - * request is incoming, ensuring you can control how the framework's input - * should be interpreted. - * - * We can also specify some overrides: - * - * `env` provides environment variables if env vars in this - * framework/runtime are not available at `process.env`. Inngest needs - * access to these to be able to find event keys, signing keys, and other - * important details. - * - * `isProduction` is a boolean that tells Inngest whether or not this is a - * production environment. This is used to determine whether or not to - * utilise local development functionality such as attempting to contact - * the development server. By default, we'll try to use environment - * variables such as `NODE_ENV` to infer this. + * The function must return an object that tells the Inngest SDK how + * to access different parts of the request, as well as how to + * transform an Inngest response into a response that your framework + * can use. * + * All returned functions can be synchronous or asynchronous. */ return { - url, - - /** - * When wanting to register a function, Inngest will send a `PUT` - * request to the endpoint. This function should either return - * `undefined` if it is not a register request, or an object with - * details required to register the function. - */ - register: () => { - if (req.method === "PUT") { - return { - /** - * See what we use the `queryKeys` enum here to access search - * param variables - make sure to always use these enums to ensure - * your handler is compatible with future versions of Inngest. - */ - deployId: url.searchParams.get(queryKeys.DeployId) as string, - }; - } - }, + body: () => req.json(), + headers: (key) => req.headers.get(key), + method: () => req.method, + url: () => new URL(req.url, `https://${req.headers.get("host") || ""}`), /** - * When wanting to run a function, Inngest will send a `POST` request - * to the endpoint. This function should either return `undefined` if - * it is not a run request, or an object with details required to run - * the function. + * This function tells the handler how a response from the Inngest + * SDK should be transformed into a response that your framework can + * use. * - * There's lots of enum use for accessing the query params and headers - * here. + * If you'd like the handler to be able to support streaming, you + * can also add a `transformStreamingResponse` function with the + * same format. */ - run: async () => { - if (req.method === "POST") { - return { - /** - * Data is expected to be a parsed JSON object whose values will - * be validated internally. In this case, `req.json()` returns a - * `Promise`; any of these methods can be async if needed. - */ - data: (await req.json()) as Record, - fnId: url.searchParams.get(queryKeys.FnId) as string, - stepId: url.searchParams.get(queryKeys.StepId) as string, - signature: req.headers.get(headerKeys.Signature) as string, - }; - } + transformResponse: ({ body, status, headers }) => { + return new Response(body, { status, headers }); }, /** - * A `GET` request can be sent to the endpoint to see some basic - * information about the functions being served and can be used as a - * simple health check. This function should either return `undefined` - * if it is not a view request, or an object with the details required. + * Not all options are provided; some will maintain sensible + * defaults if not provided. We'll show the approximate defaults + * below. */ - view: () => { - if (req.method === "GET") { - return { - isIntrospection: url.searchParams.has(queryKeys.Introspect), - }; - } - }, + // env: () => process.env, + // isProduction: () => internalChecks(), + // queryString: (key, url) => url.searchParams.get(key), }; }, - - /** - * Finally, this function will take the internal response from Inngest and - * transform it into a response that your framework can use. - * - * In this case - for an edge handler - we just return a global `Response` - * object. - * - * This function also receives any of the arguments that your framework - * passes to an HTTP invocation that we specified above. This ensures that - * you can use calls such as `res.send()` in Express-like frameworks where - * a particular return isn't required. - */ - ({ body, status, headers }, _req): Response => { - return new Response(body, { status, headers }); - } - ); + }); /** * Finally, we call the `createHandler` method on our `InngestCommHandler` * instance to create the serve handler that we'll export. + * + * This takes the inferred types from your `handler` above to ensure that the + * handler given to the user is typed correctly for their framework. */ return handler.createHandler(); }; diff --git a/packages/inngest/src/test/functions/hello-world/index.test.ts b/packages/inngest/src/test/functions/hello-world/index.test.ts index 6e8899f7c..4aed7d410 100644 --- a/packages/inngest/src/test/functions/hello-world/index.test.ts +++ b/packages/inngest/src/test/functions/hello-world/index.test.ts @@ -7,7 +7,7 @@ import { } from "@local/test/helpers"; checkIntrospection({ - name: "Hello World", + name: "hello-world", triggers: [{ event: "demo/hello.world" }], }); @@ -20,7 +20,7 @@ describe("run", () => { }); test("runs in response to 'demo/hello.world'", async () => { - runId = await eventRunWithName(eventId, "Hello World"); + runId = await eventRunWithName(eventId, "hello-world"); expect(runId).toEqual(expect.any(String)); }, 60000); @@ -29,7 +29,10 @@ describe("run", () => { runHasTimeline(runId, { __typename: "StepEvent", stepType: "COMPLETED", - output: JSON.stringify({ body: "Hello, Inngest!", status: 200 }), + output: JSON.stringify({ + body: "Hello, Inngest!", + status: 200, + }), }) ).resolves.toBeDefined(); }, 60000); diff --git a/packages/inngest/src/test/functions/hello-world/index.ts b/packages/inngest/src/test/functions/hello-world/index.ts index 74f063973..d892927ea 100644 --- a/packages/inngest/src/test/functions/hello-world/index.ts +++ b/packages/inngest/src/test/functions/hello-world/index.ts @@ -1,7 +1,7 @@ import { inngest } from "../client"; export default inngest.createFunction( - { name: "Hello World" }, + { id: "hello-world" }, { event: "demo/hello.world" }, () => "Hello, Inngest!" ); diff --git a/packages/inngest/src/test/functions/index.ts b/packages/inngest/src/test/functions/index.ts index 3eef8c2ac..66190b734 100644 --- a/packages/inngest/src/test/functions/index.ts +++ b/packages/inngest/src/test/functions/index.ts @@ -6,6 +6,7 @@ import promiseAll from "./promise-all"; import promiseRace from "./promise-race"; import sendEvent from "./send-event"; import sequentialReduce from "./sequential-reduce"; +import undefinedData from "./undefined-data"; export const functions = [ helloWorld, @@ -16,6 +17,7 @@ export const functions = [ sequentialReduce, polling, sendEvent, + undefinedData, ]; export { inngest } from "./client"; diff --git a/packages/inngest/src/test/functions/parallel-reduce/index.test.ts b/packages/inngest/src/test/functions/parallel-reduce/index.test.ts index fc1be2045..4483a444c 100644 --- a/packages/inngest/src/test/functions/parallel-reduce/index.test.ts +++ b/packages/inngest/src/test/functions/parallel-reduce/index.test.ts @@ -7,7 +7,7 @@ import { } from "@local/test/helpers"; checkIntrospection({ - name: "Parallel Reduce", + name: "parallel-reduce", triggers: [{ event: "demo/parallel.reduce" }], }); @@ -20,7 +20,7 @@ describe("run", () => { }); test("runs in response to 'demo/parallel.reduce'", async () => { - runId = await eventRunWithName(eventId, "Parallel Reduce"); + runId = await eventRunWithName(eventId, "parallel-reduce"); expect(runId).toEqual(expect.any(String)); }, 60000); diff --git a/packages/inngest/src/test/functions/parallel-reduce/index.ts b/packages/inngest/src/test/functions/parallel-reduce/index.ts index dd474fc9e..1a6659f50 100644 --- a/packages/inngest/src/test/functions/parallel-reduce/index.ts +++ b/packages/inngest/src/test/functions/parallel-reduce/index.ts @@ -7,7 +7,7 @@ const scoresDb: Record = { }; export default inngest.createFunction( - { name: "Parallel Reduce" }, + { id: "parallel-reduce" }, { event: "demo/parallel.reduce" }, async ({ step }) => { const teams = Object.keys(scoresDb); diff --git a/packages/inngest/src/test/functions/parallel-work/index.test.ts b/packages/inngest/src/test/functions/parallel-work/index.test.ts index 16632017a..64a6ee8b9 100644 --- a/packages/inngest/src/test/functions/parallel-work/index.test.ts +++ b/packages/inngest/src/test/functions/parallel-work/index.test.ts @@ -7,7 +7,7 @@ import { } from "@local/test/helpers"; checkIntrospection({ - name: "Parallel Work", + name: "parallel-work", triggers: [{ event: "demo/parallel.work" }], }); @@ -20,7 +20,7 @@ describe("run", () => { }); test("runs in response to 'demo/parallel.work'", async () => { - runId = await eventRunWithName(eventId, "Parallel Work"); + runId = await eventRunWithName(eventId, "parallel-work"); expect(runId).toEqual(expect.any(String)); }, 60000); @@ -51,7 +51,7 @@ describe("run", () => { __typename: "StepEvent", stepType: "COMPLETED", name, - output: `"${fruit}"`, + output: JSON.stringify({ data: fruit }), }) ).resolves.toBeDefined(); }, 60000); diff --git a/packages/inngest/src/test/functions/parallel-work/index.ts b/packages/inngest/src/test/functions/parallel-work/index.ts index ca8641eda..dd7b4fb15 100644 --- a/packages/inngest/src/test/functions/parallel-work/index.ts +++ b/packages/inngest/src/test/functions/parallel-work/index.ts @@ -1,7 +1,7 @@ import { inngest } from "../client"; export default inngest.createFunction( - { name: "Parallel Work" }, + { id: "parallel-work" }, { event: "demo/parallel.work" }, async ({ step }) => { // Run some steps in sequence to add up scores diff --git a/packages/inngest/src/test/functions/polling/index.test.ts b/packages/inngest/src/test/functions/polling/index.test.ts index c80e3f597..e1379ac64 100644 --- a/packages/inngest/src/test/functions/polling/index.test.ts +++ b/packages/inngest/src/test/functions/polling/index.test.ts @@ -2,6 +2,6 @@ import { checkIntrospection } from "@local/test/helpers"; checkIntrospection({ - name: "Polling", + name: "polling", triggers: [{ event: "demo/polling" }], }); diff --git a/packages/inngest/src/test/functions/polling/index.ts b/packages/inngest/src/test/functions/polling/index.ts index 82533041b..e45a59075 100644 --- a/packages/inngest/src/test/functions/polling/index.ts +++ b/packages/inngest/src/test/functions/polling/index.ts @@ -1,12 +1,13 @@ import { inngest } from "../client"; export default inngest.createFunction( - { name: "Polling" }, + { id: "polling" }, { event: "demo/polling" }, async ({ step }) => { const poll = async () => { let timedOut = false; - void step.sleep("30s").then(() => (timedOut = true)); + void step.sleep("polling-time-out", "30s").then(() => (timedOut = true)); + let interval = 0; do { const jobData = await step.run("Check if external job complete", () => { @@ -23,7 +24,7 @@ export default inngest.createFunction( return jobData; } - await step.sleep("10s"); + await step.sleep(`interval-${interval++}`, "10s"); } while (!timedOut); return null; diff --git a/packages/inngest/src/test/functions/promise-all/index.test.ts b/packages/inngest/src/test/functions/promise-all/index.test.ts index 0c1d68f33..165c3ed6a 100644 --- a/packages/inngest/src/test/functions/promise-all/index.test.ts +++ b/packages/inngest/src/test/functions/promise-all/index.test.ts @@ -7,7 +7,7 @@ import { } from "@local/test/helpers"; checkIntrospection({ - name: "Promise.all", + name: "promise-all", triggers: [{ event: "demo/promise.all" }], }); @@ -20,7 +20,7 @@ describe("run", () => { }); test("runs in response to 'demo/promise.all'", async () => { - runId = await eventRunWithName(eventId, "Promise.all"); + runId = await eventRunWithName(eventId, "promise-all"); expect(runId).toEqual(expect.any(String)); }, 60000); @@ -30,7 +30,7 @@ describe("run", () => { __typename: "StepEvent", stepType: "COMPLETED", name: "Step 1", - output: "1", + output: JSON.stringify({ data: 1 }), }) ).resolves.toBeDefined(); }, 60000); @@ -41,7 +41,7 @@ describe("run", () => { __typename: "StepEvent", stepType: "COMPLETED", name: "Step 2", - output: "2", + output: JSON.stringify({ data: 2 }), }) ).resolves.toBeDefined(); }, 60000); @@ -52,7 +52,7 @@ describe("run", () => { __typename: "StepEvent", stepType: "COMPLETED", name: "Step 3", - output: "3", + output: JSON.stringify({ data: 3 }), }) ).resolves.toBeDefined(); }, 60000); diff --git a/packages/inngest/src/test/functions/promise-all/index.ts b/packages/inngest/src/test/functions/promise-all/index.ts index 6c41a50b5..93def283f 100644 --- a/packages/inngest/src/test/functions/promise-all/index.ts +++ b/packages/inngest/src/test/functions/promise-all/index.ts @@ -1,7 +1,7 @@ import { inngest } from "../client"; export default inngest.createFunction( - { name: "Promise.all" }, + { id: "promise-all" }, { event: "demo/promise.all" }, async ({ step }) => { const [one, two] = await Promise.all([ diff --git a/packages/inngest/src/test/functions/promise-race/index.test.ts b/packages/inngest/src/test/functions/promise-race/index.test.ts index 48b92236b..b213e4605 100644 --- a/packages/inngest/src/test/functions/promise-race/index.test.ts +++ b/packages/inngest/src/test/functions/promise-race/index.test.ts @@ -9,7 +9,7 @@ import { } from "@local/test/helpers"; checkIntrospection({ - name: "Promise.race", + name: "promise-race", triggers: [{ event: "demo/promise.race" }], }); @@ -22,7 +22,7 @@ describe("run", () => { }); test("runs in response to 'demo/promise.race'", async () => { - runId = await eventRunWithName(eventId, "Promise.race"); + runId = await eventRunWithName(eventId, "promise-race"); expect(runId).toEqual(expect.any(String)); }, 60000); @@ -32,7 +32,7 @@ describe("run", () => { __typename: "StepEvent", stepType: "COMPLETED", name: "Step A", - output: '"A"', + output: JSON.stringify({ data: "A" }), }) ).resolves.toBeDefined(); }, 60000); @@ -43,7 +43,7 @@ describe("run", () => { __typename: "StepEvent", stepType: "COMPLETED", name: "Step B", - output: '"B"', + output: JSON.stringify({ data: "B" }), }) ).resolves.toBeDefined(); }, 60000); @@ -60,9 +60,9 @@ describe("run", () => { expect(timelineItem).toBeDefined(); const output = JSON.parse(timelineItem.output); winner = - output === "A is the winner!" + output.data === "A is the winner!" ? "A" - : output === "B is the winner!" + : output.data === "B is the winner!" ? "B" : undefined; expect(["A", "B"]).toContain(winner); diff --git a/packages/inngest/src/test/functions/promise-race/index.ts b/packages/inngest/src/test/functions/promise-race/index.ts index 4c2098e01..6a9c5108b 100644 --- a/packages/inngest/src/test/functions/promise-race/index.ts +++ b/packages/inngest/src/test/functions/promise-race/index.ts @@ -1,7 +1,7 @@ import { inngest } from "../client"; export default inngest.createFunction( - { name: "Promise.race" }, + { id: "promise-race" }, { event: "demo/promise.race" }, async ({ step }) => { const winner = await Promise.race([ diff --git a/packages/inngest/src/test/functions/send-event/index.test.ts b/packages/inngest/src/test/functions/send-event/index.test.ts index fa357130a..c58156e12 100644 --- a/packages/inngest/src/test/functions/send-event/index.test.ts +++ b/packages/inngest/src/test/functions/send-event/index.test.ts @@ -5,12 +5,11 @@ import { checkIntrospection, eventRunWithName, receivedEventWithName, - runHasTimeline, sendEvent, } from "@local/test/helpers"; checkIntrospection({ - name: "Send event", + name: "send-event", triggers: [{ event: "demo/send.event" }], }); @@ -23,24 +22,14 @@ describe("run", () => { }); test("runs in response to 'demo/send.event'", async () => { - runId = await eventRunWithName(eventId, "Send event"); + runId = await eventRunWithName(eventId, "send-event"); expect(runId).toEqual(expect.any(String)); }, 60000); - test("ran Step 'sendEvent'", async () => { - await expect( - runHasTimeline(runId, { - __typename: "StepEvent", - stepType: "COMPLETED", - name: "sendEvent", - }) - ).resolves.toBeDefined(); - }, 60000); - test("sent event 'app/my.event.happened'", async () => { const event = await receivedEventWithName("app/my.event.happened"); expect(event).toBeDefined(); - expect(JSON.parse(event?.payload ?? {})).toMatchObject({ foo: "bar" }); + expect(JSON.parse(event?.payload ?? "{}")).toMatchObject({ foo: "bar" }); }, 60000); test("sent event 'app/my.event.happened.multiple.1'", async () => { @@ -48,7 +37,7 @@ describe("run", () => { "app/my.event.happened.multiple.1" ); expect(event).toBeDefined(); - expect(JSON.parse(event?.payload ?? {})).toMatchObject({ foo: "bar" }); + expect(JSON.parse(event?.payload ?? "{}")).toMatchObject({ foo: "bar" }); }, 60000); test("sent event 'app/my.event.happened.multiple.2'", async () => { @@ -56,6 +45,6 @@ describe("run", () => { "app/my.event.happened.multiple.2" ); expect(event).toBeDefined(); - expect(JSON.parse(event?.payload ?? {})).toMatchObject({ foo: "bar" }); + expect(JSON.parse(event?.payload ?? "{}")).toMatchObject({ foo: "bar" }); }, 60000); }); diff --git a/packages/inngest/src/test/functions/send-event/index.ts b/packages/inngest/src/test/functions/send-event/index.ts index 2e5878ded..2b240c4bd 100644 --- a/packages/inngest/src/test/functions/send-event/index.ts +++ b/packages/inngest/src/test/functions/send-event/index.ts @@ -1,15 +1,18 @@ import { inngest } from "../client"; export default inngest.createFunction( - { name: "Send event" }, - "demo/send.event", + { id: "send-event" }, + { event: "demo/send.event" }, async ({ step }) => { await Promise.all([ // Send a single event - step.sendEvent({ name: "app/my.event.happened", data: { foo: "bar" } }), + step.sendEvent("single-event", { + name: "app/my.event.happened", + data: { foo: "bar" }, + }), // Send multiple events - step.sendEvent([ + step.sendEvent("multiple-events", [ { name: "app/my.event.happened.multiple.1", data: { foo: "bar" }, diff --git a/packages/inngest/src/test/functions/sequential-reduce/index.test.ts b/packages/inngest/src/test/functions/sequential-reduce/index.test.ts index e6cc44417..efd929db5 100644 --- a/packages/inngest/src/test/functions/sequential-reduce/index.test.ts +++ b/packages/inngest/src/test/functions/sequential-reduce/index.test.ts @@ -7,7 +7,7 @@ import { } from "@local/test/helpers"; checkIntrospection({ - name: "Sequential Reduce", + name: "sequential-reduce", triggers: [{ event: "demo/sequential.reduce" }], }); @@ -20,7 +20,7 @@ describe("run", () => { }); test("runs in response to 'demo/sequential.reduce'", async () => { - runId = await eventRunWithName(eventId, "Sequential Reduce"); + runId = await eventRunWithName(eventId, "sequential-reduce"); expect(runId).toEqual(expect.any(String)); }, 60000); diff --git a/packages/inngest/src/test/functions/sequential-reduce/index.ts b/packages/inngest/src/test/functions/sequential-reduce/index.ts index a7343e579..574d9d829 100644 --- a/packages/inngest/src/test/functions/sequential-reduce/index.ts +++ b/packages/inngest/src/test/functions/sequential-reduce/index.ts @@ -7,7 +7,7 @@ const scoresDb: Record = { }; export default inngest.createFunction( - { name: "Sequential Reduce" }, + { id: "sequential-reduce" }, { event: "demo/sequential.reduce" }, async ({ step }) => { const teams = Object.keys(scoresDb); diff --git a/packages/inngest/src/test/functions/undefined-data/index.test.ts b/packages/inngest/src/test/functions/undefined-data/index.test.ts new file mode 100644 index 000000000..3a6556244 --- /dev/null +++ b/packages/inngest/src/test/functions/undefined-data/index.test.ts @@ -0,0 +1,92 @@ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +import { + checkIntrospection, + eventRunWithName, + runHasTimeline, + sendEvent, +} from "@local/test/helpers"; + +checkIntrospection({ + name: "undefined-data", + triggers: [{ event: "demo/undefined.data" }], +}); + +describe("run", () => { + let eventId: string; + let runId: string; + + beforeAll(async () => { + eventId = await sendEvent("demo/undefined.data"); + }); + + test("runs in response to 'demo/undefined.data'", async () => { + runId = await eventRunWithName(eventId, "undefined-data"); + expect(runId).toEqual(expect.any(String)); + }, 60000); + + test("ran step1", async () => { + await expect( + runHasTimeline(runId, { + __typename: "StepEvent", + stepType: "COMPLETED", + name: "step1", + output: JSON.stringify({ data: null }), + }) + ).resolves.toBeDefined(); + }, 60000); + + test("ran step2res", async () => { + await expect( + runHasTimeline(runId, { + __typename: "StepEvent", + stepType: "COMPLETED", + name: "step2res", + output: JSON.stringify({ data: "step2res" }), + }) + ).resolves.toBeDefined(); + }, 60000); + + test("ran step2nores", async () => { + await expect( + runHasTimeline(runId, { + __typename: "StepEvent", + stepType: "COMPLETED", + name: "step2nores", + output: JSON.stringify({ data: null }), + }) + ).resolves.toBeDefined(); + }, 60000); + + test("ran step2res2", async () => { + await expect( + runHasTimeline(runId, { + __typename: "StepEvent", + stepType: "COMPLETED", + name: "step2res2", + output: JSON.stringify({ data: "step2res2" }), + }) + ).resolves.toBeDefined(); + }, 60000); + + test("ran step2", async () => { + await expect( + runHasTimeline(runId, { + __typename: "StepEvent", + stepType: "COMPLETED", + name: "step2", + output: JSON.stringify({ data: null }), + }) + ).resolves.toBeDefined(); + }, 60000); + + test("ran step3", async () => { + await expect( + runHasTimeline(runId, { + __typename: "StepEvent", + stepType: "COMPLETED", + name: "step3", + output: JSON.stringify({ data: null }), + }) + ).resolves.toBeDefined(); + }, 60000); +}); diff --git a/packages/inngest/src/test/functions/undefined-data/index.ts b/packages/inngest/src/test/functions/undefined-data/index.ts new file mode 100644 index 000000000..c554073ef --- /dev/null +++ b/packages/inngest/src/test/functions/undefined-data/index.ts @@ -0,0 +1,29 @@ +import { inngest } from "../client"; + +export default inngest.createFunction( + { id: "undefined-data" }, + { event: "demo/undefined.data" }, + async ({ step }) => { + await step.run("step1res", () => "step1res"); + + await step.run("step1", () => { + // no-op + }); + + await Promise.all([ + step.run("step2res", () => "step2res"), + step.run("step2nores", () => { + // no-op + }), + step.run("step2res2", () => "step2res2"), + ]); + + await step.run("step2", async () => { + // no-op + }); + + await step.run("step3", async () => { + // no-op + }); + } +); diff --git a/packages/inngest/src/test/helpers.ts b/packages/inngest/src/test/helpers.ts index c97318570..3c05d991d 100644 --- a/packages/inngest/src/test/helpers.ts +++ b/packages/inngest/src/test/helpers.ts @@ -3,8 +3,9 @@ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ /* eslint-disable @typescript-eslint/no-unsafe-call */ import { Inngest } from "@local"; -import { type ServeHandler } from "@local/components/InngestCommHandler"; +import { type ServeHandlerOptions } from "@local/components/InngestCommHandler"; import { envKeys, headerKeys, queryKeys } from "@local/helpers/consts"; +import { type Env } from "@local/helpers/env"; import { slugify } from "@local/helpers/strings"; import { type FunctionTrigger } from "@local/types"; import fetch from "cross-fetch"; @@ -43,7 +44,7 @@ export const createClient = >( ) as unknown as Inngest; }; -const inngest = createClient({ name: "test", eventKey: "event-key-123" }); +const inngest = createClient({ id: "test", eventKey: "event-key-123" }); export const testFramework = ( /** @@ -55,7 +56,11 @@ export const testFramework = ( /** * The serve handler exported by this handler. */ - handler: { name: string; serve: ServeHandler }, + handler: { + frameworkName: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + serve: (options: ServeHandlerOptions) => any; + }, /** * Optional tests and changes to make to this test suite. @@ -79,11 +84,7 @@ export const testFramework = ( * Returning void is useful if you need to make environment changes but * are still fine with the default behaviour past that point. */ - transformReq?: ( - req: Request, - res: Response, - env: Record - ) => unknown[] | void; + transformReq?: (req: Request, res: Response, env: Env) => unknown[] | void; /** * Specify a transformer for a given response, which will be used to @@ -125,11 +126,10 @@ export const testFramework = ( const run = async ( handlerOpts: Parameters<(typeof handler)["serve"]>, reqOpts: Parameters, - env: Record = {} + env: Env = {} ): Promise => { - const [nameOrInngest, functions, givenOpts] = handlerOpts; - const serveHandler = handler.serve(nameOrInngest, functions, { - ...givenOpts, + const serveHandler = handler.serve({ + ...handlerOpts[0], /** * For testing, the fetch implementation has to be stable for us to @@ -236,9 +236,12 @@ export const testFramework = ( describe("Serve return", opts.handlerTests); } - describe("GET (landing page)", () => { + describe("GET", () => { test("shows introspection data", async () => { - const ret = await run([inngest, []], [{ method: "GET" }]); + const ret = await run( + [{ client: inngest, functions: [] }], + [{ method: "GET" }] + ); const body = JSON.parse(ret.body); @@ -246,7 +249,9 @@ export const testFramework = ( status: 200, headers: expect.objectContaining({ [headerKeys.SdkVersion]: expect.stringContaining("inngest-js:v"), - [headerKeys.Framework]: expect.stringMatching(handler.name), + [headerKeys.Framework]: expect.stringMatching( + handler.frameworkName + ), }), }); @@ -275,7 +280,10 @@ export const testFramework = ( status: 200, }); - const ret = await run([inngest, []], [{ method: "PUT" }]); + const ret = await run( + [{ client: inngest, functions: [] }], + [{ method: "PUT", url: "/api/inngest" }] + ); const retBody = JSON.parse(ret.body); @@ -283,7 +291,9 @@ export const testFramework = ( status: 200, headers: expect.objectContaining({ [headerKeys.SdkVersion]: expect.stringContaining("inngest-js:v"), - [headerKeys.Framework]: expect.stringMatching(handler.name), + [headerKeys.Framework]: expect.stringMatching( + handler.frameworkName + ), }), }); @@ -301,9 +311,13 @@ export const testFramework = ( status: 200, }); - const ret = await run([inngest, []], [{ method: "PUT" }], { - [envKeys.IsNetlify]: "true", - }); + const ret = await run( + [{ client: inngest, functions: [] }], + [{ method: "PUT" }], + { + [envKeys.IsNetlify]: "true", + } + ); expect(ret).toMatchObject({ headers: expect.objectContaining({ @@ -328,7 +342,7 @@ export const testFramework = ( }); const ret = await run( - [inngest, []], + [{ client: inngest, functions: [] }], [{ method: "PUT", url: customUrl }] ); @@ -338,7 +352,9 @@ export const testFramework = ( status: 200, headers: expect.objectContaining({ [headerKeys.SdkVersion]: expect.stringContaining("inngest-js:v"), - [headerKeys.Framework]: expect.stringMatching(handler.name), + [headerKeys.Framework]: expect.stringMatching( + handler.frameworkName + ), }), }); @@ -366,14 +382,17 @@ export const testFramework = ( }); const fn1 = inngest.createFunction( - "fn1", - "demo/event.sent", + { id: "fn1" }, + { event: "demo/event.sent" }, () => "fn1" ); const serveHost = "https://example.com"; const stepId = "step"; - await run([inngest, [fn1], { serveHost }], [{ method: "PUT" }]); + await run( + [{ client: inngest, functions: [fn1], serveHost }], + [{ method: "PUT" }] + ); expect(reqToMock).toMatchObject({ url: `${serveHost}/api/inngest`, @@ -406,14 +425,17 @@ export const testFramework = ( }); const fn1 = inngest.createFunction( - "fn1", - "demo/event.sent", + { id: "fn1" }, + { event: "demo/event.sent" }, () => "fn1" ); const servePath = "/foo/bar/inngest/endpoint"; const stepId = "step"; - await run([inngest, [fn1], { servePath }], [{ method: "PUT" }]); + await run( + [{ client: inngest, functions: [fn1], servePath }], + [{ method: "PUT" }] + ); expect(reqToMock).toMatchObject({ url: `https://localhost:3000${servePath}`, @@ -430,26 +452,6 @@ export const testFramework = ( ], }); }); - - test("still access dev server if URL passed as environment variable", async () => { - const testDevServerHost = "https://exampledevserver.com"; - let devServerCalled = false; - - nock(testDevServerHost) - .get("/dev", (body) => { - devServerCalled = true; - return body; - }) - .reply(500, { status: 500 }); - - await run([inngest, []], [{ method: "PUT" }], { - [envKeys.DevServerUrl]: testDevServerHost, - NODE_ENV: "production", - ENVIRONMENT: "production", - }); - - expect(devServerCalled).toBe(true); - }); }); describe("env detection and headers", () => { @@ -457,7 +459,12 @@ export const testFramework = ( nock("https://api.inngest.com").post("/fn/register").reply(200); const ret = await run( - [new Inngest({ name: "Test", env: "FOO" }), []], + [ + { + client: new Inngest({ id: "Test", env: "FOO" }), + functions: [], + }, + ], [{ method: "PUT" }] ); @@ -485,8 +492,8 @@ export const testFramework = ( }); const fn1 = inngest.createFunction( - "fn1", - "demo/event.sent", + { id: "fn1" }, + { event: "demo/event.sent" }, () => "fn1" ); const serveHost = "https://example.com"; @@ -494,7 +501,7 @@ export const testFramework = ( const stepId = "step"; await run( - [inngest, [fn1], { serveHost, servePath }], + [{ client: inngest, functions: [fn1], serveHost, servePath }], [{ method: "PUT" }] ); @@ -520,7 +527,7 @@ export const testFramework = ( describe("POST (run function)", () => { describe("signature validation", () => { - const client = createClient({ name: "test" }); + const client = createClient({ id: "test" }); const fn = client.createFunction( { name: "Test", id: "test" }, @@ -534,7 +541,8 @@ export const testFramework = ( }; test("should throw an error in prod with no signature", async () => { const ret = await run( - [inngest, [fn], { signingKey: "test" }], + [{ client: inngest, functions: [fn], signingKey: "test" }], + [{ method: "POST", headers: {} }], env ); @@ -548,7 +556,7 @@ export const testFramework = ( }); test("should throw an error with an invalid signature", async () => { const ret = await run( - [inngest, [fn], { signingKey: "test" }], + [{ client: inngest, functions: [fn], signingKey: "test" }], [{ method: "POST", headers: { [headerKeys.Signature]: "t=&s=" } }], env ); @@ -564,7 +572,7 @@ export const testFramework = ( const yesterday = new Date(); yesterday.setDate(yesterday.getDate() - 1); const ret = await run( - [inngest, [fn], { signingKey: "test" }], + [{ client: inngest, functions: [fn], signingKey: "test" }], [ { method: "POST", @@ -573,7 +581,7 @@ export const testFramework = ( yesterday.getTime() / 1000 )}&s=expired`, }, - url: "/api/inngest?fnId=test", + url: "/api/inngest?fnId=test-test", body: { event: {}, events: [{}] }, }, ], @@ -612,9 +620,9 @@ export const testFramework = ( }; const ret = await run( [ - inngest, - [fn], { + client: inngest, + functions: [fn], signingKey: "signkey-test-f00f3005a3666b359a79c2bc3380ce2715e62727ac461ae1a2618f8766029c9f", __testingAllowExpiredSignatures: true, @@ -628,7 +636,7 @@ export const testFramework = ( [headerKeys.Signature]: "t=1687306735&s=70312c7815f611a4aa0b6f985910a85a6c232c845838d7f49f1d05fd8b2b0779", }, - url: "/api/inngest?fnId=test&stepId=step", + url: "/api/inngest?fnId=test-test&stepId=step", body, }, ], @@ -636,15 +644,13 @@ export const testFramework = ( ); expect(ret).toMatchObject({ status: 200, - body: '"fn"', + body: JSON.stringify("fn"), }); }); }); describe("malformed payloads", () => { - const client = createClient({ name: "test" }); - - const fn = client.createFunction( + const fn = inngest.createFunction( { name: "Test", id: "test" }, { event: "demo/event.sent" }, () => "fn" @@ -657,11 +663,11 @@ export const testFramework = ( test("should throw an error with an invalid JSON body", async () => { const ret = await run( - [inngest, [fn], { signingKey: "test" }], + [{ client: inngest, functions: [fn], signingKey: "test" }], [ { method: "POST", - url: "/api/inngest?fnId=test", + url: "/api/inngest?fnId=test-test", body: undefined, }, ], diff --git a/packages/inngest/src/types.ts b/packages/inngest/src/types.ts index d67dbbc3b..4e5297cb0 100644 --- a/packages/inngest/src/types.ts +++ b/packages/inngest/src/types.ts @@ -1,27 +1,52 @@ import { z } from "zod"; import { type EventSchemas } from "./components/EventSchemas"; -import { type EventsFromOpts, type Inngest } from "./components/Inngest"; +import { + type AnyInngest, + type EventsFromOpts, + type Inngest, + type builtInMiddleware, +} from "./components/Inngest"; import { type InngestMiddleware, type MiddlewareOptions, + type MiddlewareStackSendEventOutputMutation, } from "./components/InngestMiddleware"; import { type createStepTools } from "./components/InngestStepTools"; import { type internalEvents } from "./helpers/consts"; import { type IsStringLiteral, - type KeysNotOfType, + type ObjectAssign, type ObjectPaths, type StrictUnion, } from "./helpers/types"; import { type Logger } from "./middleware/logger"; /** - * TODO + * When passed an Inngest client, will return all event types for that client. + * + * It's recommended to use this instead of directly reusing your event types, as + * Inngest will add extra properties and internal events such as `ts` and + * `inngest/function.failed`. + * + * @example + * ```ts + * import { EventSchemas, Inngest, type GetEvents } from "inngest"; + * + * export const inngest = new Inngest({ + * name: "Example App", + * schemas: new EventSchemas().fromRecord<{ + * "app/user.created": { data: { userId: string } }; + * }>(), + * }); + * + * type Events = GetEvents; + * type AppUserCreated = Events["app/user.created"]; + * + * ``` * * @public */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export type GetEvents> = T extends Inngest +export type GetEvents = T extends Inngest ? EventsFromOpts : never; @@ -79,6 +104,15 @@ export enum StepOpCode { RunStep = "Step", StepPlanned = "StepPlanned", Sleep = "Sleep", + + /** + * Used to signify that the executor has requested that a step run, but we + * could not find that step. + * + * This is likely indicative that a step was renamed or removed from the + * function. + */ + StepNotFound = "StepNotFound", } /** @@ -92,9 +126,19 @@ export type Op = { op: StepOpCode; /** - * The unhashed step name for this operation. + * The unhashed step name for this operation. This is a legacy field that is + * sometimes used for critical data, like the sleep duration for + * `step.sleep()`. + * + * @deprecated For display name, use `displayName` instead. */ - name: string; + name?: string; + + /** + * An optional name for this step that can be used to display in the Inngest + * UI. + */ + displayName?: string; /** * Any additional data required for this operation to send to Inngest. This @@ -138,8 +182,8 @@ export type OutgoingOp = Pick< */ export type HashedOp = Op & { /** - * The hashed identifier for this operation, used to confirm that the operation - * was completed when it is received from Inngest. + * The hashed identifier for this operation, used to confirm that the + * operation was completed when it is received from Inngest. */ id: string; }; @@ -173,8 +217,7 @@ export type TimeStrBatch = `${`${number}s`}`; export type BaseContext< TOpts extends ClientOptions, - TTrigger extends keyof EventsFromOpts & string, - TShimmedFns extends Record unknown> + TTrigger extends keyof EventsFromOpts & string > = { /** * The event data present in the payload. @@ -195,42 +238,6 @@ export type BaseContext< typeof createStepTools, TTrigger> >; - /** - * Any `fns` passed when creating your Inngest function will be - * available here and can be used as normal. - * - * Every call to one of these functions will become a new retryable - * step. - * - * @example - * - * Both examples behave the same; it's preference as to which you - * prefer. - * - * ```ts - * import { userDb } from "./db"; - * - * // Specify `fns` and be able to use them in your Inngest function - * inngest.createFunction( - * { name: "Create user from PR", fns: { ...userDb } }, - * { event: "github/pull_request" }, - * async ({ tools: { run }, fns: { createUser } }) => { - * await createUser("Alice"); - * } - * ); - * - * // Or always use `run()` to run inline steps and use them directly - * inngest.createFunction( - * { name: "Create user from PR" }, - * { event: "github/pull_request" }, - * async ({ tools: { run } }) => { - * await run("createUser", () => userDb.createUser("Alice")); - * } - * ); - * ``` - */ - fns: TShimmedFns; - /** * The current zero-indexed attempt number for this function execution. The * first attempt will be `0`, the second `1`, and so on. The attempt number @@ -239,27 +246,6 @@ export type BaseContext< attempt: number; }; -/** - * Given a set of generic objects, extract any top-level functions and - * appropriately shim their types. - */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export type ShimmedFns> = { - /** - * The key omission here allows the user to pass anything to the `fns` - * object and have it be correctly understand and transformed. - * - * Crucially, we use a complex `Omit` here to ensure that function - * comments and metadata is preserved, meaning the user can still use - * the function exactly like they would in the rest of their codebase, - * even though we're shimming with `tools.run()`. - */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - [K in keyof Omit any>>]: ( - ...args: Parameters - ) => Promise>>; -}; - /** * Builds a context object for an Inngest handler, optionally overriding some * keys. @@ -268,10 +254,11 @@ export type Context< TOpts extends ClientOptions, TEvents extends Record, TTrigger extends keyof TEvents & string, - TShimmedFns extends Record unknown>, TOverrides extends Record = Record -> = Omit, keyof TOverrides> & - TOverrides; +> = Omit, keyof TOverrides> & TOverrides; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type AnyContext = Context; /** * The shape of a Inngest function, taking in event, step, ctx, and step @@ -283,19 +270,18 @@ export type Handler< TOpts extends ClientOptions, TEvents extends EventsFromOpts, TTrigger extends keyof TEvents & string, - TShimmedFns extends Record unknown> = Record< - never, - never - >, TOverrides extends Record = Record > = ( /** * The context argument provides access to all data and tooling available to * the function. */ - ctx: Context + ctx: Context ) => unknown; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type AnyHandler = Handler; + /** * The shape of a single event's payload. It should be extended to enforce * adherence to given events and not used as a method of creating them (i.e. as @@ -360,6 +346,26 @@ export const sendEventResponseSchema = z.object({ */ export type SendEventResponse = z.output; +/** + * The response in code from sending an event to Inngest. + */ +export type SendEventBaseOutput = { + ids: SendEventResponse["ids"]; +}; + +export type SendEventOutput = ObjectAssign< + [ + // eslint-disable-next-line @typescript-eslint/ban-types + MiddlewareStackSendEventOutputMutation<{}, typeof builtInMiddleware>, + MiddlewareStackSendEventOutputMutation< + // eslint-disable-next-line @typescript-eslint/ban-types + {}, + NonNullable + > + ], + SendEventBaseOutput +>; + /** * An HTTP-like, standardised response format that allows Inngest to help * orchestrate steps and retries. @@ -414,10 +420,14 @@ export type Step = ( */ export interface ClientOptions { /** - * The name of this instance, most commonly the name of the application it + * 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. */ - name: string; + id: string; /** * Inngest event key, used to send events to Inngest Cloud. If not provided, @@ -428,10 +438,12 @@ export interface ClientOptions { eventKey?: string; /** - * The base Inngest Source API URL to append the Source API Key to. - * Defaults to https://inn.gs/ + * The base URL to use when contacting Inngest. + * + * Defaults to https://inn.gs/ for sending events and https://api.inngest.com + * for all other communication with Inngest. */ - inngestBaseUrl?: string; + baseUrl?: string; /** * If provided, will override the used `fetch` implementation. Useful for @@ -501,7 +513,22 @@ export interface ClientOptions { * * @public */ -export type LogLevel = "fatal" | "error" | "warn" | "info" | "debug" | "silent"; +export const logLevels = [ + "fatal", + "error", + "warn", + "info", + "debug", + "silent", +] as const; + +/** + * A set of log levels that can be used to control the amount of logging output + * from various parts of the Inngest library. + * + * @public + */ +export type LogLevel = (typeof logLevels)[number]; /** * A set of options for configuring the registration of Inngest functions. @@ -531,7 +558,7 @@ export interface RegisterOptions { * The URL used to register functions with Inngest. * Defaults to https://api.inngest.com/fn/register */ - inngestRegisterUrl?: string; + baseUrl?: string; /** * If provided, will override the used `fetch` implementation. Useful for @@ -543,21 +570,6 @@ export interface RegisterOptions { */ fetch?: typeof fetch; - /** - * Controls whether a landing page with introspection capabilities is shown - * when a `GET` request is performed to this handler. - * - * Defaults to using the boolean value of `process.env.INNGEST_LANDING_PAGE` - * (e.g. `"true"`), and `true` if that env var is not defined. - * - * This page is highly recommended when getting started in development, - * testing, or staging environments. - * - * @deprecated This page is no longer available. Please use the Inngest Dev - * Server instead via `npx inngest-cli@latest dev`. - */ - landingPage?: boolean; - /** * The path to the Inngest serve endpoint. e.g.: * @@ -621,18 +633,10 @@ export interface RegisterOptions { streaming?: "allow" | "force" | false; /** - * The name of this app as it will be seen in the Inngest dashboard. Will use - * the name of the client passed if not provided. - */ - name?: string; -} - -export interface InternalRegisterOptions extends RegisterOptions { - /** - * Can be used to override the framework name given to a particular serve - * handler. + * The ID of this app. This is used to group functions together in the Inngest + * UI. The ID of the passed client is used by default. */ - frameworkName?: string; + id?: string; } /** @@ -640,17 +644,15 @@ export interface InternalRegisterOptions extends RegisterOptions { * * @public */ -export type TriggerOptions = - | T - | StrictUnion< - | { - event: T; - if?: string; - } - | { - cron: string; - } - >; +export type TriggerOptions = StrictUnion< + | { + event: T; + if?: string; + } + | { + cron: string; + } +>; /** * A set of options for configuring an Inngest function. @@ -662,30 +664,19 @@ export interface FunctionOptions< Event extends keyof Events & string > { /** - * An optional unique ID used to identify the function. This is used - * internally for versioning and referring to your function, so should not - * change between deployments. - * - * By default, this is a slugified version of the given `name`, e.g. - * `"My FN :)"` would be slugified to `"my-fn"`. + * An unique ID used to identify the function. This is used internally for + * versioning and referring to your function, so should not change between + * deployments. * - * If you are not specifying an ID and get a warning about duplicate - * functions, make sure to explicitly set an ID for the duplicate or change - * the name. + * If you'd like to set a prettier name for your function, use the `name` + * option. */ - id?: string; + id: string; /** * A name for the function as it will appear in the Inngest Cloud UI. - * - * This is used to create a slugified ID for the function too, e.g. - * `"My FN :)"` would create a slugified ID of `"my-fn"`. - * - * If you are not specifying an ID and get a warning about duplicate - * functions, make sure to explicitly set an ID for the duplicate or change - * the name. */ - name: string; + name?: string; /** * Concurrency specifies a limit on the total number of concurrent steps that @@ -717,8 +708,6 @@ export interface FunctionOptions< timeout: TimeStrBatch; }; - fns?: Record; - /** * Allow the specification of an idempotency key using event data. If * specified, this overrides the `rateLimit` object. @@ -780,7 +769,10 @@ export interface FunctionOptions< onFailure?: (...args: unknown[]) => unknown; /** - * TODO + * Define a set of middleware that can be registered to hook into various + * lifecycles of the SDK and affect input and output of Inngest functionality. + * + * See {@link https://innge.st/middleware} */ middleware?: MiddlewareStack; } @@ -857,30 +849,6 @@ export type Cancellation< }; }[keyof Events & string]; -/** - * Expected responses to be used within an `InngestCommHandler` in order to - * appropriately respond to Inngest. - * - * @internal - */ -export type StepRunResponse = - | { - status: 500; - error?: unknown; - } - | { - status: 200; - body?: unknown; - } - | { - status: 206; - body: OutgoingOp[]; - } - | { - status: 400; - error: string; - }; - /** * The response to send to Inngest when pushing function config either directly * or when pinged by Inngest Cloud. @@ -979,7 +947,7 @@ export type FunctionTrigger = * @internal */ export interface FunctionConfig { - name: string; + name?: string; id: string; triggers: FunctionTrigger[]; steps: Record< @@ -1059,20 +1027,31 @@ export type SupportedFrameworkName = /** * A set of options that can be passed to any step to configure it. + * + * @public */ -export interface StepOpts { +export interface StepOptions { /** - * Passing an `id` for a step will overwrite the generated hash that is used - * by Inngest to pause and resume a function. - * - * This is useful if you want to ensure that a step is always the same ID even - * if the code changes. - * - * We recommend not using this unless you have a specific reason to do so. + * The ID to use to memoize the result of this step, ensuring it is run only + * once. Changing this ID in an existing function will cause the step to be + * run again for in-progress runs; it is recommended to use a stable ID. */ - id?: string; + id: string; + + /** + * The display name to use for this step in the Inngest UI. This can be + * changed at any time without affecting the step's behaviour. + */ + name?: string; } +/** + * Either a step ID or a set of step options. + * + * @public + */ +export type StepOptionsOrId = StepOptions["id"] | StepOptions; + /** * Simplified version of Rust style `Result` * @@ -1090,50 +1069,3 @@ export const ok = (data: T): Result => { export const err = (error?: E): Result => { return { ok: false, error }; }; - -/** - * Format of data send from the executor to the SDK - */ -export const fnDataSchema = z.object({ - event: z.object({}).passthrough(), - events: z.array(z.object({}).passthrough()).default([]), - /** - * When handling per-step errors, steps will need to be an object with - * either a `data` or an `error` key. - * - * For now, we support the current method of steps just being a map of - * step ID to step data. - * - * TODO When the executor does support per-step errors, we can uncomment - * the expected schema below. - */ - steps: z - .record( - z.any().refine((v) => typeof v !== "undefined", { - message: "Values in steps must be defined", - }) - ) - .optional() - .nullable(), - // steps: z.record(incomingOpSchema.passthrough()).optional().nullable(), - ctx: z - .object({ - run_id: z.string(), - attempt: z.number().default(0), - stack: z - .object({ - stack: z - .array(z.string()) - .nullable() - .transform((v) => (Array.isArray(v) ? v : [])), - current: z.number(), - }) - .passthrough() - .optional() - .nullable(), - }) - .optional() - .nullable(), - use_api: z.boolean().default(false), -}); -export type FnData = z.infer; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d68a231af..58ebe8844 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,6 +35,9 @@ importers: cross-fetch: specifier: ^4.0.0 version: 4.0.0 + debug: + specifier: ^4.3.4 + version: 4.3.4 h3: specifier: ^1.8.1 version: 1.8.1 @@ -47,9 +50,6 @@ importers: ms: specifier: ^2.1.3 version: 2.1.3 - queue-microtask: - specifier: ^1.2.3 - version: 1.2.3 serialize-error-cjs: specifier: ^0.1.3 version: 0.1.3 @@ -57,8 +57,8 @@ importers: specifier: ^3.5.1 version: 3.5.1 zod: - specifier: ^3.19.1 - version: 3.19.1 + specifier: ~3.21.4 + version: 3.21.4 devDependencies: '@actions/core': specifier: ^1.10.0 @@ -81,6 +81,9 @@ importers: '@types/aws-lambda': specifier: ^8.10.108 version: 8.10.108 + '@types/debug': + specifier: ^4.1.8 + version: 4.1.8 '@types/express': specifier: ^4.17.13 version: 4.17.13 @@ -107,10 +110,10 @@ importers: version: 2.4.0 '@typescript-eslint/eslint-plugin': specifier: ^5.47.0 - version: 5.56.0(@typescript-eslint/parser@5.56.0)(eslint@8.36.0)(typescript@5.0.2) + version: 5.56.0(@typescript-eslint/parser@5.56.0)(eslint@8.36.0)(typescript@5.2.2) '@typescript-eslint/parser': specifier: ^5.47.0 - version: 5.56.0(eslint@8.36.0)(typescript@5.0.2) + version: 5.56.0(eslint@8.36.0)(typescript@5.2.2) '@vercel/node': specifier: ^2.15.9 version: 2.15.9 @@ -167,7 +170,7 @@ importers: version: 0.3.4 ts-jest: specifier: ^29.1.0 - version: 29.1.0(@babel/core@7.21.3)(jest@29.5.0)(typescript@5.0.2) + version: 29.1.0(@babel/core@7.21.3)(jest@29.5.0)(typescript@5.2.2) tsx: specifier: ^3.12.7 version: 3.12.7 @@ -175,8 +178,8 @@ importers: specifier: ^5.1.0 version: 5.6.0 typescript: - specifier: ^5.0.0 - version: 5.0.2 + specifier: ^5.2.2 + version: 5.2.2 ulid: specifier: ^2.3.0 version: 2.3.0 @@ -670,7 +673,7 @@ packages: resolution: {integrity: sha512-JppheLu7S114aEs157fOZDjFqUDpm7eHdq5E8SSR0gUBTEK0cNSHsrSR5a66xs0z3RWuo46QvA3vawp8BxDHvg==} dependencies: dataloader: 1.4.0 - node-fetch: 2.7.0 + node-fetch: 2.6.9 transitivePeerDependencies: - encoding dev: true @@ -1710,6 +1713,12 @@ packages: '@types/node': 18.16.16 dev: true + /@types/debug@4.1.8: + resolution: {integrity: sha512-/vPO1EPOs306Cvhwv7KfVfYvOJqA/S/AXjaHQiJboCZzcNDb+TIJFN9/2C9DZ//ijSKWioNyUxD792QmDJ+HKQ==} + dependencies: + '@types/ms': 0.7.31 + dev: true + /@types/express-serve-static-core@4.17.30: resolution: {integrity: sha512-gstzbTWro2/nFed1WXtf+TtrpwxH7Ggs4RLYTLbeVgIkUQOI3WG/JKjgeOU1zXDvezllupjrf8OPIdvTbIaVOQ==} dependencies: @@ -1869,7 +1878,7 @@ packages: '@types/yargs-parser': 21.0.0 dev: true - /@typescript-eslint/eslint-plugin@5.56.0(@typescript-eslint/parser@5.56.0)(eslint@8.36.0)(typescript@5.0.2): + /@typescript-eslint/eslint-plugin@5.56.0(@typescript-eslint/parser@5.56.0)(eslint@8.36.0)(typescript@5.2.2): resolution: {integrity: sha512-ZNW37Ccl3oMZkzxrYDUX4o7cnuPgU+YrcaYXzsRtLB16I1FR5SHMqga3zGsaSliZADCWo2v8qHWqAYIj8nWCCg==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} peerDependencies: @@ -1881,23 +1890,23 @@ packages: optional: true dependencies: '@eslint-community/regexpp': 4.4.0 - '@typescript-eslint/parser': 5.56.0(eslint@8.36.0)(typescript@5.0.2) + '@typescript-eslint/parser': 5.56.0(eslint@8.36.0)(typescript@5.2.2) '@typescript-eslint/scope-manager': 5.56.0 - '@typescript-eslint/type-utils': 5.56.0(eslint@8.36.0)(typescript@5.0.2) - '@typescript-eslint/utils': 5.56.0(eslint@8.36.0)(typescript@5.0.2) + '@typescript-eslint/type-utils': 5.56.0(eslint@8.36.0)(typescript@5.2.2) + '@typescript-eslint/utils': 5.56.0(eslint@8.36.0)(typescript@5.2.2) debug: 4.3.4 eslint: 8.36.0 grapheme-splitter: 1.0.4 ignore: 5.2.4 natural-compare-lite: 1.4.0 semver: 7.3.8 - tsutils: 3.21.0(typescript@5.0.2) - typescript: 5.0.2 + tsutils: 3.21.0(typescript@5.2.2) + typescript: 5.2.2 transitivePeerDependencies: - supports-color dev: true - /@typescript-eslint/parser@5.56.0(eslint@8.36.0)(typescript@5.0.2): + /@typescript-eslint/parser@5.56.0(eslint@8.36.0)(typescript@5.2.2): resolution: {integrity: sha512-sn1OZmBxUsgxMmR8a8U5QM/Wl+tyqlH//jTqCg8daTAmhAk26L2PFhcqPLlYBhYUJMZJK276qLXlHN3a83o2cg==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} peerDependencies: @@ -1909,10 +1918,10 @@ packages: dependencies: '@typescript-eslint/scope-manager': 5.56.0 '@typescript-eslint/types': 5.56.0 - '@typescript-eslint/typescript-estree': 5.56.0(typescript@5.0.2) + '@typescript-eslint/typescript-estree': 5.56.0(typescript@5.2.2) debug: 4.3.4 eslint: 8.36.0 - typescript: 5.0.2 + typescript: 5.2.2 transitivePeerDependencies: - supports-color dev: true @@ -1925,7 +1934,7 @@ packages: '@typescript-eslint/visitor-keys': 5.56.0 dev: true - /@typescript-eslint/type-utils@5.56.0(eslint@8.36.0)(typescript@5.0.2): + /@typescript-eslint/type-utils@5.56.0(eslint@8.36.0)(typescript@5.2.2): resolution: {integrity: sha512-8WxgOgJjWRy6m4xg9KoSHPzBNZeQbGlQOH7l2QEhQID/+YseaFxg5J/DLwWSsi9Axj4e/cCiKx7PVzOq38tY4A==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} peerDependencies: @@ -1935,12 +1944,12 @@ packages: typescript: optional: true dependencies: - '@typescript-eslint/typescript-estree': 5.56.0(typescript@5.0.2) - '@typescript-eslint/utils': 5.56.0(eslint@8.36.0)(typescript@5.0.2) + '@typescript-eslint/typescript-estree': 5.56.0(typescript@5.2.2) + '@typescript-eslint/utils': 5.56.0(eslint@8.36.0)(typescript@5.2.2) debug: 4.3.4 eslint: 8.36.0 - tsutils: 3.21.0(typescript@5.0.2) - typescript: 5.0.2 + tsutils: 3.21.0(typescript@5.2.2) + typescript: 5.2.2 transitivePeerDependencies: - supports-color dev: true @@ -1950,7 +1959,7 @@ packages: engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} dev: true - /@typescript-eslint/typescript-estree@5.56.0(typescript@5.0.2): + /@typescript-eslint/typescript-estree@5.56.0(typescript@5.2.2): resolution: {integrity: sha512-41CH/GncsLXOJi0jb74SnC7jVPWeVJ0pxQj8bOjH1h2O26jXN3YHKDT1ejkVz5YeTEQPeLCCRY0U2r68tfNOcg==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} peerDependencies: @@ -1965,13 +1974,13 @@ packages: globby: 11.1.0 is-glob: 4.0.3 semver: 7.3.8 - tsutils: 3.21.0(typescript@5.0.2) - typescript: 5.0.2 + tsutils: 3.21.0(typescript@5.2.2) + typescript: 5.2.2 transitivePeerDependencies: - supports-color dev: true - /@typescript-eslint/utils@5.56.0(eslint@8.36.0)(typescript@5.0.2): + /@typescript-eslint/utils@5.56.0(eslint@8.36.0)(typescript@5.2.2): resolution: {integrity: sha512-XhZDVdLnUJNtbzaJeDSCIYaM+Tgr59gZGbFuELgF7m0IY03PlciidS7UQNKLE0+WpUTn1GlycEr6Ivb/afjbhA==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} peerDependencies: @@ -1982,7 +1991,7 @@ packages: '@types/semver': 7.3.13 '@typescript-eslint/scope-manager': 5.56.0 '@typescript-eslint/types': 5.56.0 - '@typescript-eslint/typescript-estree': 5.56.0(typescript@5.0.2) + '@typescript-eslint/typescript-estree': 5.56.0(typescript@5.2.2) eslint: 8.36.0 eslint-scope: 5.1.1 semver: 7.3.8 @@ -2515,7 +2524,7 @@ packages: normalize-path: 3.0.0 readdirp: 3.6.0 optionalDependencies: - fsevents: 2.3.2 + fsevents: 2.3.3 dev: true /ci-info@3.8.0: @@ -3319,7 +3328,7 @@ packages: eslint-import-resolver-webpack: optional: true dependencies: - '@typescript-eslint/parser': 5.56.0(eslint@8.36.0)(typescript@5.0.2) + '@typescript-eslint/parser': 5.56.0(eslint@8.36.0)(typescript@5.2.2) debug: 3.2.7(supports-color@5.5.0) eslint: 8.36.0 eslint-import-resolver-node: 0.3.7 @@ -3337,7 +3346,7 @@ packages: '@typescript-eslint/parser': optional: true dependencies: - '@typescript-eslint/parser': 5.56.0(eslint@8.36.0)(typescript@5.0.2) + '@typescript-eslint/parser': 5.56.0(eslint@8.36.0)(typescript@5.2.2) array-includes: 3.1.6 array.prototype.flat: 1.3.1 array.prototype.flatmap: 1.3.1 @@ -3807,8 +3816,8 @@ packages: /fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} - /fsevents@2.3.2: - resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + /fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] requiresBuild: true @@ -4561,7 +4570,7 @@ packages: micromatch: 4.0.5 walker: 1.0.8 optionalDependencies: - fsevents: 2.3.2 + fsevents: 2.3.3 dev: true /jest-leak-detector@29.5.0: @@ -5273,6 +5282,7 @@ packages: optional: true dependencies: whatwg-url: 5.0.0 + dev: false /node-int64@0.4.0: resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} @@ -6475,7 +6485,7 @@ packages: engines: {node: '>=8'} dev: true - /ts-jest@29.1.0(@babel/core@7.21.3)(jest@29.5.0)(typescript@5.0.2): + /ts-jest@29.1.0(@babel/core@7.21.3)(jest@29.5.0)(typescript@5.2.2): resolution: {integrity: sha512-ZhNr7Z4PcYa+JjMl62ir+zPiNJfXJN6E8hSLnaUKhOgqcn8vb3e537cpkd0FuAfRK3sR1LSqM1MOhliXNgOFPA==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} hasBin: true @@ -6505,7 +6515,7 @@ packages: lodash.memoize: 4.1.2 make-error: 1.3.6 semver: 7.3.8 - typescript: 5.0.2 + typescript: 5.2.2 yargs-parser: 21.1.1 dev: true @@ -6568,14 +6578,14 @@ packages: resolution: {integrity: sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==} dev: true - /tsutils@3.21.0(typescript@5.0.2): + /tsutils@3.21.0(typescript@5.2.2): resolution: {integrity: sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==} engines: {node: '>= 6'} peerDependencies: typescript: '>=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta' dependencies: tslib: 1.14.1 - typescript: 5.0.2 + typescript: 5.2.2 dev: true /tsx@3.12.7: @@ -6586,7 +6596,7 @@ packages: '@esbuild-kit/core-utils': 3.1.0 '@esbuild-kit/esm-loader': 2.5.5 optionalDependencies: - fsevents: 2.3.2 + fsevents: 2.3.3 dev: true /tty-table@4.2.1: @@ -6683,9 +6693,9 @@ packages: hasBin: true dev: true - /typescript@5.0.2: - resolution: {integrity: sha512-wVORMBGO/FAs/++blGNeAVdbNKtIh1rbBL2EyQ1+J9lClJ93KiiKe8PmFIVdXhHcyv44SL9oglmfeSsndo0jRw==} - engines: {node: '>=12.20'} + /typescript@5.2.2: + resolution: {integrity: sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w==} + engines: {node: '>=14.17'} hasBin: true dev: true @@ -6995,6 +7005,6 @@ packages: commander: 9.5.0 dev: true - /zod@3.19.1: - resolution: {integrity: sha512-LYjZsEDhCdYET9ikFu6dVPGp2YH9DegXjdJToSzD9rO6fy4qiRYFoyEYwps88OseJlPyl2NOe2iJuhEhL7IpEA==} + /zod@3.21.4: + resolution: {integrity: sha512-m46AKbrzKVzOzs/DZgVnG5H55N1sv1M8qZU3A8RIKbs3mrACDNeIOeilDymVb2HdmP8uwshOCF4uJ8uM9rCqJw==} dev: false