diff --git a/.changeset/pink-kangaroos-itch.md b/.changeset/pink-kangaroos-itch.md new file mode 100644 index 000000000..d203a59c8 --- /dev/null +++ b/.changeset/pink-kangaroos-itch.md @@ -0,0 +1,5 @@ +--- +"inngest": patch +--- + +Updated contribution guidelines diff --git a/.github/actions/setup-and-build/action.yml b/.github/actions/setup-and-build/action.yml index 48490fb39..8dd1027b2 100644 --- a/.github/actions/setup-and-build/action.yml +++ b/.github/actions/setup-and-build/action.yml @@ -12,30 +12,16 @@ inputs: runs: using: composite steps: - - uses: volta-cli/action@v4 - - uses: pnpm/action-setup@v2 with: run_install: false - - name: Get pnpm store directory - id: pnpm-cache - shell: bash - run: | - echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT - - - uses: actions/cache@v3 - name: Setup pnpm cache + - uses: actions/setup-node@v3 with: - path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} - key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-pnpm-store- - - - run: volta install node - shell: bash + node-version: lts/* + cache: pnpm - - run: volta install @antfu/ni + - run: npm i -g @antfu/ni shell: bash - name: Install dependencies diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 000000000..a36f61270 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,22 @@ +## Summary + + + + +## Checklist + + + + +- [ ] Added a [docs PR](https://github.com/inngest/website) that references this PR +- [ ] Added unit/integration tests +- [ ] Added changesets if applicable + +## Related + + + + + + +- INN- diff --git a/.github/workflows/pr-task-list-checker.yml b/.github/workflows/pr-task-list-checker.yml new file mode 100644 index 000000000..eb9bb9b42 --- /dev/null +++ b/.github/workflows/pr-task-list-checker.yml @@ -0,0 +1,13 @@ +name: GitHub PR Task List Checker +on: + pull_request: + types: [opened, edited, synchronize, reopened] +jobs: + task-list-checker: + runs-on: ubuntu-latest + if: ${{ github.event.pull_request.user.login != 'inngest-release-bot' && github.event.pull_request.user.login != 'renovate[bot]' && github.event.pull_request.user.login != 'dependabot[bot]' }} + steps: + - name: Check for incomplete task list items + uses: Shopify/task-list-checker@main + with: + github-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index e9347dc5c..ca94097a2 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -29,12 +29,15 @@ jobs: - 14 - 16 - 18 - - lts + - 20 steps: - uses: actions/checkout@v3 - uses: ./.github/actions/setup-and-build + - uses: actions/setup-node@v3 + with: + node-version: ${{ matrix.nodeVersion }} # Uses npm as pnpm doesn't support Node < 16 - - run: volta run --node ${{ matrix.nodeVersion }} npm run test + - run: node --version && npm --version && npm run test types: name: Types @@ -48,6 +51,7 @@ jobs: tsVersion: - 'latest' - 'next' + - 'beta' - '~5.1.0' - '~5.0.0' - '~4.9.0' diff --git a/examples/functions/hello-world/index.test.ts b/examples/functions/hello-world/index.test.ts index e3b8bc89c..6e8899f7c 100644 --- a/examples/functions/hello-world/index.test.ts +++ b/examples/functions/hello-world/index.test.ts @@ -22,7 +22,7 @@ describe("run", () => { test("runs in response to 'demo/hello.world'", async () => { runId = await eventRunWithName(eventId, "Hello World"); expect(runId).toEqual(expect.any(String)); - }); + }, 60000); test("returns 'Hello, Inngest!'", async () => { await expect( @@ -32,5 +32,5 @@ describe("run", () => { output: JSON.stringify({ body: "Hello, Inngest!", status: 200 }), }) ).resolves.toBeDefined(); - }); + }, 60000); }); diff --git a/examples/functions/package.json b/examples/functions/package.json index 66b14ee0d..2a7dcc7d0 100644 --- a/examples/functions/package.json +++ b/examples/functions/package.json @@ -1,5 +1,5 @@ { - "name": "@inngest/functions", + "name": "@inngest/example-functions", "private": true, "version": "1.0.0", "description": "", diff --git a/examples/functions/parallel-reduce/index.test.ts b/examples/functions/parallel-reduce/index.test.ts index 91ef1bdd8..fc1be2045 100644 --- a/examples/functions/parallel-reduce/index.test.ts +++ b/examples/functions/parallel-reduce/index.test.ts @@ -22,7 +22,7 @@ describe("run", () => { test("runs in response to 'demo/parallel.reduce'", async () => { runId = await eventRunWithName(eventId, "Parallel Reduce"); expect(runId).toEqual(expect.any(String)); - }); + }, 60000); ["blue", "red", "green"].forEach((team) => { test(`ran "Get ${team} team score" step`, async () => { @@ -35,7 +35,7 @@ describe("run", () => { expect(step).toBeDefined(); // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access expect(step.output).toEqual(expect.any(String)); - }); + }, 60000); }); test("Returned total score", async () => { @@ -46,5 +46,5 @@ describe("run", () => { output: JSON.stringify({ body: "150", status: 200 }), }) ).resolves.toBeDefined(); - }); + }, 60000); }); diff --git a/examples/functions/parallel-work/index.test.ts b/examples/functions/parallel-work/index.test.ts index fa594a38a..16632017a 100644 --- a/examples/functions/parallel-work/index.test.ts +++ b/examples/functions/parallel-work/index.test.ts @@ -22,7 +22,7 @@ describe("run", () => { test("runs in response to 'demo/parallel.work'", async () => { runId = await eventRunWithName(eventId, "Parallel Work"); expect(runId).toEqual(expect.any(String)); - }); + }, 60000); ["First", "Second", "Third"].forEach((scoreStep) => { const name = `${scoreStep} score`; @@ -37,7 +37,7 @@ describe("run", () => { expect(step).toBeDefined(); // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access expect(step.output).toEqual(expect.any(String)); - }); + }, 60000); }); const fruits = ["Apple", "Banana", "Orange"]; @@ -54,7 +54,7 @@ describe("run", () => { output: `"${fruit}"`, }) ).resolves.toBeDefined(); - }); + }, 60000); }); test("Returned correct data", async () => { @@ -68,5 +68,5 @@ describe("run", () => { }), }) ).resolves.toBeDefined(); - }); + }, 60000); }); diff --git a/examples/functions/promise-all/index.test.ts b/examples/functions/promise-all/index.test.ts index 27a549dce..0c1d68f33 100644 --- a/examples/functions/promise-all/index.test.ts +++ b/examples/functions/promise-all/index.test.ts @@ -22,7 +22,7 @@ describe("run", () => { test("runs in response to 'demo/promise.all'", async () => { runId = await eventRunWithName(eventId, "Promise.all"); expect(runId).toEqual(expect.any(String)); - }); + }, 60000); test("ran Step 1", async () => { await expect( @@ -33,7 +33,7 @@ describe("run", () => { output: "1", }) ).resolves.toBeDefined(); - }); + }, 60000); test("ran Step 2", async () => { await expect( @@ -44,7 +44,7 @@ describe("run", () => { output: "2", }) ).resolves.toBeDefined(); - }); + }, 60000); test("ran Step 3", async () => { await expect( @@ -55,5 +55,5 @@ describe("run", () => { output: "3", }) ).resolves.toBeDefined(); - }); + }, 60000); }); diff --git a/examples/functions/promise-race/index.test.ts b/examples/functions/promise-race/index.test.ts index cc5a88754..48b92236b 100644 --- a/examples/functions/promise-race/index.test.ts +++ b/examples/functions/promise-race/index.test.ts @@ -24,7 +24,7 @@ describe("run", () => { test("runs in response to 'demo/promise.race'", async () => { runId = await eventRunWithName(eventId, "Promise.race"); expect(runId).toEqual(expect.any(String)); - }); + }, 60000); test("ran Step A", async () => { await expect( @@ -35,7 +35,7 @@ describe("run", () => { output: '"A"', }) ).resolves.toBeDefined(); - }); + }, 60000); test("ran Step B", async () => { await expect( @@ -46,7 +46,7 @@ describe("run", () => { output: '"B"', }) ).resolves.toBeDefined(); - }); + }, 60000); let winner: "A" | "B" | undefined; @@ -66,5 +66,5 @@ describe("run", () => { ? "B" : undefined; expect(["A", "B"]).toContain(winner); - }); + }, 60000); }); diff --git a/examples/functions/send-event/index.test.ts b/examples/functions/send-event/index.test.ts index 80471b5af..fa357130a 100644 --- a/examples/functions/send-event/index.test.ts +++ b/examples/functions/send-event/index.test.ts @@ -25,7 +25,7 @@ describe("run", () => { test("runs in response to 'demo/send.event'", async () => { runId = await eventRunWithName(eventId, "Send event"); expect(runId).toEqual(expect.any(String)); - }); + }, 60000); test("ran Step 'sendEvent'", async () => { await expect( @@ -35,13 +35,13 @@ describe("run", () => { 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" }); - }); + }, 60000); test("sent event 'app/my.event.happened.multiple.1'", async () => { const event = await receivedEventWithName( @@ -49,7 +49,7 @@ describe("run", () => { ); expect(event).toBeDefined(); expect(JSON.parse(event?.payload ?? {})).toMatchObject({ foo: "bar" }); - }); + }, 60000); test("sent event 'app/my.event.happened.multiple.2'", async () => { const event = await receivedEventWithName( @@ -57,5 +57,5 @@ describe("run", () => { ); expect(event).toBeDefined(); expect(JSON.parse(event?.payload ?? {})).toMatchObject({ foo: "bar" }); - }); + }, 60000); }); diff --git a/examples/functions/sequential-reduce/index.test.ts b/examples/functions/sequential-reduce/index.test.ts index 9403cdd2e..e6cc44417 100644 --- a/examples/functions/sequential-reduce/index.test.ts +++ b/examples/functions/sequential-reduce/index.test.ts @@ -22,7 +22,7 @@ describe("run", () => { test("runs in response to 'demo/sequential.reduce'", async () => { runId = await eventRunWithName(eventId, "Sequential Reduce"); expect(runId).toEqual(expect.any(String)); - }); + }, 60000); ["blue", "red", "green"].forEach((team) => { test(`ran "Get ${team} team score" step`, async () => { @@ -35,7 +35,7 @@ describe("run", () => { expect(step).toBeDefined(); // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access expect(step.output).toEqual(expect.any(String)); - }); + }, 60000); }); test("Returned total score", async () => { @@ -46,5 +46,5 @@ describe("run", () => { output: JSON.stringify({ body: "150", status: 200 }), }) ).resolves.toBeDefined(); - }); + }, 60000); }); diff --git a/packages/inngest/CHANGELOG.md b/packages/inngest/CHANGELOG.md index 70e67f255..db7b65eb2 100644 --- a/packages/inngest/CHANGELOG.md +++ b/packages/inngest/CHANGELOG.md @@ -1,5 +1,15 @@ # inngest +## 2.4.1 + +### Patch Changes + +- f2ffc8b: Fix `cross-fetch` import issue in testing environemtnst. API package also uses custom `fetch` passed via arguments. +- acfa07c: Throw error when using `inngest/express` and not using a body parser +- b535e1e: Ensure users are not allowed to configure batching with cancellation or rate limiting, as these features do not yet function together +- c271eb1: Add `x-inngest-no-retry: true` header when non-retriable for internal executor changes +- 2a93f0b: Fix `onFailure` functions missing types applied by middleware + ## 2.4.0 ### Minor Changes diff --git a/packages/inngest/README.md b/packages/inngest/README.md index 1041310f9..920f124da 100644 --- a/packages/inngest/README.md +++ b/packages/inngest/README.md @@ -14,7 +14,7 @@
- +

@@ -131,49 +131,43 @@ inngest.send("app/user.signup", { ## Contributing -Clone the repository, then: +Prerequisites: -```sh -yarn dev # install dependencies, build/lint/test -``` - -We use [Volta](https://volta.sh/) to manage Node/Yarn versions. - -> When making a pull request, make sure to commit the changed `etc/inngest.api.md` file; this is a generated types/docs file that will highlight changes to the exposed API. +1. Clone this repository +2. Intall [`pnpm`](https://pnpm.io/installation) +3. Install [Volta](https://volta.sh/) to manage consistent Node versions (optional) -### Locally linking (`npm|yarn link`) +### Development -To test changes with other local repos, you can link the project like so (replace `npm` for `yarn` if desired): +Run the following command in the `packages/inngest/` directory: ```sh -# in this repo -yarn build -yarn link - -# in another repo -yarn link inngest +pnpm dev ``` -Alternatively, you can also package the library and ship it with an application. This is a nice way to generate and ship snapshot/test versions of the library to test in production environments without requiring releasing to npm. +This will install dependencies, build, and lint the package. It will watch for changes and re-run appropriate commands. + +### Testing the package + +To test changes with other local repositories, we recommend packaging the library entirely and directly installing the resulting `.tgz` file. This is often more reliable than linking, which can cause issues when using multiple package managers. ```sh -# in this repo -yarn local:pack -cp inngest.tgz ../some-other-repo-root +# in packages/inngest/ +pnpm local:pack # creates inngest.tgz # in another repo -yarn add ./inngest.tgz +yarn add ~/path/to/packages/inngest/inngest.tgz ``` -Some platforms require manually installing the package again at build time to properly link dependencies, so you may have to change your `yarn build` script to be prefixed with this install, e.g.: - -```sh -yarn add ./inngest.tgz && framework dev -``` +You can also use this method to ship a snapshot of the library with an application. This is a nice way to generate and ship snapshot versions without requiring a release to npm. ### Releasing -To release to production, we use [Changesets](https://github.com/changesets/changesets). This means that releasing and changelog generation is all managed through PRs, where a bot will guide you through the process of announcing changes in PRs and releasing them once merged to `main`. +To release to production, we use [Changesets](https://github.com/changesets/changesets). This means that releasing and changelog generation is all managed through PRs, where a bot will guide you through the process of adding release notes to PRs. + +As PRs are merged into `main`, a new PR (usually called **Release @latest**) is created that rolls up all release notes since the last release, allowing you bundle changes together. Once you're happy with the release, merge this new PR and the bot will release the package to npm for you. + +Merging PRs to `main` (therefore both introducing a potential change and releasing to npm) requires that tests pass and a contributor has approved the PR. #### Legacy versions @@ -195,7 +189,7 @@ You can see the currently available tags on the [`inngest` npm page](https://www If the current active version is `v1.1.1`, this is a minor release, and our tag is `foo`, we'd do: ```sh -yarn version v1.2.0-foo.1 +yarn version 1.2.0-foo.1 yarn build npm publish --access public --tag foo ``` diff --git a/packages/inngest/etc/inngest.api.md b/packages/inngest/etc/inngest.api.md index 73c78e4be..d8b4d0b70 100644 --- a/packages/inngest/etc/inngest.api.md +++ b/packages/inngest/etc/inngest.api.md @@ -117,6 +117,8 @@ export enum headerKeys { // (undocumented) Framework = "x-inngest-framework", // (undocumented) + NoRetry = "x-inngest-no-retry", + // (undocumented) Platform = "x-inngest-platform", // (undocumented) SdkVersion = "x-inngest-sdk", @@ -128,18 +130,27 @@ export enum headerKeys { 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 + // 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 "MiddlewareStackRunInputMutation" 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 // Warning: (ae-forgotten-export) The symbol "builtInMiddleware" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "InngestFunction" needs to be exported by the entry point index.d.ts // 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 | (Omit, TTriggerName>, "fns" | "onFailure" | "middleware"> & { + 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, FailureEventArgs[TTriggerName]>>; + onFailure?: Handler, TTriggerName, TShimmedFns, ExtendWithMiddleware<[ + typeof builtInMiddleware, + NonNullable, + TMiddleware + ], FailureEventArgs[TTriggerName]>>>; middleware?: TMiddleware; - }), trigger: TTrigger, handler: Handler, TTriggerName, TShimmedFns, MiddlewareStackRunInputMutation<{}, typeof builtInMiddleware> & MiddlewareStackRunInputMutation<{}, NonNullable> & MiddlewareStackRunInputMutation<{}, TMiddleware>>): InngestFunction, FunctionTrigger & string>, FunctionOptions, keyof EventsFromOpts & string>>; + }, "batchEvents", "cancelOn" | "rateLimit">, trigger: TTrigger, handler: Handler, TTriggerName, TShimmedFns, ExtendWithMiddleware<[ + typeof builtInMiddleware, + NonNullable, + TMiddleware + ]>>): InngestFunction, FunctionTrigger & string>, FunctionOptions, keyof EventsFromOpts & string>>; readonly inngestBaseUrl: URL; readonly name: string; // Warning: (ae-forgotten-export) The symbol "SendEventPayload" needs to be exported by the entry point index.d.ts @@ -337,7 +348,7 @@ export type ZodEventSchemas = Record> { const url = new URL(`/v0/runs/${runId}/actions`, this.baseUrl); - return fetch(url, { + return this.fetch(url, { headers: { Authorization: `Bearer ${this.hashedKey}` }, }) .then(async (resp) => { @@ -65,7 +72,7 @@ export class InngestApi { ): Promise> { const url = new URL(`/v0/runs/${runId}/batch`, this.baseUrl); - return fetch(url, { + return this.fetch(url, { headers: { Authorization: `Bearer ${this.hashedKey}` }, }) .then(async (resp) => { diff --git a/packages/inngest/src/components/Inngest.test.ts b/packages/inngest/src/components/Inngest.test.ts index 5f9af4cd5..7c239e6d6 100644 --- a/packages/inngest/src/components/Inngest.test.ts +++ b/packages/inngest/src/components/Inngest.test.ts @@ -433,6 +433,36 @@ describe("createFunction", () => { ); }); + test("disallows specifying cancellation with batching", () => { + inngest.createFunction( + { + name: "test", + batchEvents: { maxSize: 5, timeout: "5s" }, + // @ts-expect-error Cannot specify cancellation with batching + cancelOn: [{ event: "test2" }], + }, + { event: "test" }, + () => { + // no-op + } + ); + }); + + test("disallows specifying rate limit with batching", () => { + inngest.createFunction( + { + name: "test", + batchEvents: { maxSize: 5, timeout: "5s" }, + // @ts-expect-error Cannot specify rate limit with batching + rateLimit: { limit: 5, period: "5s" }, + }, + { event: "test" }, + () => { + // no-op + } + ); + }); + test("allows trigger to be a string", () => { inngest.createFunction("test", "test", ({ event }) => { assertType(event.name); diff --git a/packages/inngest/src/components/Inngest.ts b/packages/inngest/src/components/Inngest.ts index 0b1c2eee1..c2ba4347d 100644 --- a/packages/inngest/src/components/Inngest.ts +++ b/packages/inngest/src/components/Inngest.ts @@ -10,7 +10,7 @@ import { } from "../helpers/env"; import { fixEventKeyMissingSteps, prettyError } from "../helpers/errors"; import { stringify } from "../helpers/strings"; -import { type SendEventPayload } from "../helpers/types"; +import { type ExclusiveKeys, type SendEventPayload } from "../helpers/types"; import { DefaultLogger, ProxyLogger, type Logger } from "../middleware/logger"; import { type ClientOptions, @@ -29,10 +29,10 @@ import { InngestFunction } from "./InngestFunction"; import { InngestMiddleware, getHookStack, + type ExtendWithMiddleware, type MiddlewareOptions, type MiddlewareRegisterFn, type MiddlewareRegisterReturn, - type MiddlewareStackRunInputMutation, } from "./InngestMiddleware"; /** @@ -165,15 +165,16 @@ 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, + fetch: this.fetch, }); - this.fetch = getFetch(fetch); this.logger = logger; this.middleware = this.initializeMiddleware([ @@ -402,77 +403,89 @@ export class Inngest { >( nameOrOpts: | string - | (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, - FailureEventArgs[TTriggerName]> - >; - - /** - * TODO - */ - middleware?: TMiddleware; - }), + | 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" + >, trigger: TTrigger, handler: Handler< TOpts, EventsFromOpts, TTriggerName, TShimmedFns, - // eslint-disable-next-line @typescript-eslint/ban-types - MiddlewareStackRunInputMutation<{}, typeof builtInMiddleware> & - // eslint-disable-next-line @typescript-eslint/ban-types - MiddlewareStackRunInputMutation<{}, NonNullable> & - // eslint-disable-next-line @typescript-eslint/ban-types - MiddlewareStackRunInputMutation<{}, TMiddleware> + ExtendWithMiddleware< + [ + typeof builtInMiddleware, + NonNullable, + TMiddleware + ] + > > ): InngestFunction< TOpts, diff --git a/packages/inngest/src/components/InngestCommHandler.ts b/packages/inngest/src/components/InngestCommHandler.ts index 170ed8a05..7129bce39 100644 --- a/packages/inngest/src/components/InngestCommHandler.ts +++ b/packages/inngest/src/components/InngestCommHandler.ts @@ -640,6 +640,14 @@ export class InngestCommHandler< ); if (stepRes.status === 500 || stepRes.status === 400) { + const headers: Record = { + "Content-Type": "application/json", + }; + + if (stepRes.status === 400) { + headers[headerKeys.NoRetry] = "true"; + } + return { status: stepRes.status, body: stringify( @@ -650,9 +658,7 @@ export class InngestCommHandler< ) ) ), - headers: { - "Content-Type": "application/json", - }, + headers, }; } diff --git a/packages/inngest/src/components/InngestMiddleware.test.ts b/packages/inngest/src/components/InngestMiddleware.test.ts index 8d59da1d7..00f2590dd 100644 --- a/packages/inngest/src/components/InngestMiddleware.test.ts +++ b/packages/inngest/src/components/InngestMiddleware.test.ts @@ -27,9 +27,18 @@ describe("stacking and inference", () => { const inngest = new Inngest({ name: "test", middleware: [mw] }); test("input context has value", () => { - inngest.createFunction({ name: "" }, { event: "" }, (ctx) => { - assertType>(true); - }); + inngest.createFunction( + { + name: "", + onFailure: (ctx) => { + assertType>(true); + }, + }, + { event: "" }, + (ctx) => { + assertType>(true); + } + ); }); }); @@ -54,9 +63,18 @@ describe("stacking and inference", () => { const inngest = new Inngest({ name: "test", middleware: [mw] }); test("input context has value", () => { - inngest.createFunction({ name: "" }, { event: "" }, (ctx) => { - assertType>(true); - }); + inngest.createFunction( + { + name: "", + onFailure: (ctx) => { + assertType>(true); + }, + }, + { event: "" }, + (ctx) => { + assertType>(true); + } + ); }); }); @@ -81,9 +99,18 @@ describe("stacking and inference", () => { const inngest = new Inngest({ name: "test", middleware: [mw] }); test("input context has value", () => { - inngest.createFunction({ name: "" }, { event: "" }, (ctx) => { - assertType>(true); - }); + inngest.createFunction( + { + name: "", + onFailure: (ctx) => { + assertType>(true); + }, + }, + { event: "" }, + (ctx) => { + assertType>(true); + } + ); }); }); @@ -125,15 +152,33 @@ describe("stacking and inference", () => { const inngest = new Inngest({ name: "test", middleware: [mw1, mw2] }); test("input context has foo value", () => { - inngest.createFunction({ name: "" }, { event: "" }, (ctx) => { - assertType>(true); - }); + inngest.createFunction( + { + name: "", + onFailure: (ctx) => { + assertType>(true); + }, + }, + { event: "" }, + (ctx) => { + assertType>(true); + } + ); }); test("input context has bar value", () => { - inngest.createFunction({ name: "" }, { event: "" }, (ctx) => { - assertType>(true); - }); + inngest.createFunction( + { + name: "", + onFailure: (ctx) => { + assertType>(true); + }, + }, + { event: "" }, + (ctx) => { + assertType>(true); + } + ); }); }); @@ -175,9 +220,18 @@ describe("stacking and inference", () => { const inngest = new Inngest({ name: "test", middleware: [mw1, mw2] }); test("input context has new value", () => { - inngest.createFunction({ name: "" }, { event: "" }, (ctx) => { - assertType>(true); - }); + inngest.createFunction( + { + name: "", + onFailure: (ctx) => { + assertType>(true); + }, + }, + { event: "" }, + (ctx) => { + assertType>(true); + } + ); }); }); }); diff --git a/packages/inngest/src/components/InngestMiddleware.ts b/packages/inngest/src/components/InngestMiddleware.ts index c45204c93..b4da8ad86 100644 --- a/packages/inngest/src/components/InngestMiddleware.ts +++ b/packages/inngest/src/components/InngestMiddleware.ts @@ -498,6 +498,24 @@ type GetMiddlewareRunInputMutation< : // eslint-disable-next-line @typescript-eslint/ban-types {}; +/** + * @internal + */ +export type ExtendWithMiddleware< + TMiddlewareStacks extends MiddlewareStack[], + // eslint-disable-next-line @typescript-eslint/ban-types + TContext = {} +> = ObjectAssign< + { + [K in keyof TMiddlewareStacks]: MiddlewareStackRunInputMutation< + // eslint-disable-next-line @typescript-eslint/ban-types + {}, + TMiddlewareStacks[K] + >; + }, + TContext +>; + /** * @internal */ diff --git a/packages/inngest/src/helpers/consts.ts b/packages/inngest/src/helpers/consts.ts index 2dc87f7ed..6723fb042 100644 --- a/packages/inngest/src/helpers/consts.ts +++ b/packages/inngest/src/helpers/consts.ts @@ -108,6 +108,7 @@ export enum headerKeys { Environment = "x-inngest-env", Platform = "x-inngest-platform", Framework = "x-inngest-framework", + NoRetry = "x-inngest-no-retry", } export const defaultDevServerHost = "http://127.0.0.1:8288/"; diff --git a/packages/inngest/src/helpers/functions.ts b/packages/inngest/src/helpers/functions.ts index b66f83f44..67f2ad507 100644 --- a/packages/inngest/src/helpers/functions.ts +++ b/packages/inngest/src/helpers/functions.ts @@ -1,7 +1,8 @@ -import { type Await } from "./types"; -import { prettyError } from "./errors"; -import { fnDataSchema, type FnData, type Result, ok, err } from "../types"; +import { ZodError } from "zod"; import { type InngestApi } from "../api/api"; +import { err, fnDataSchema, ok, type FnData, type Result } from "../types"; +import { prettyError } from "./errors"; +import { type Await } from "./types"; /** * Wraps a function with a cache. When the returned function is run, it will @@ -120,11 +121,19 @@ 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( prettyError({ - whatHappened: "failed to parse data from executor", - consequences: "function execution can't continue", + 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/types.ts b/packages/inngest/src/helpers/types.ts index c1e94779b..209826c73 100644 --- a/packages/inngest/src/helpers/types.ts +++ b/packages/inngest/src/helpers/types.ts @@ -155,3 +155,34 @@ export type ObjectAssign = TArr extends [ ] ? Simplify & TFirst>> : TAcc; + +/** + * Make a type's keys mutually exclusive. + * + * @example + * Make 1 key mutually exclusive with 1 other key. + * + * ```ts + * type MyType = ExclusiveKeys<{a: number, b: number}, "a", "b"> + * + * const valid1: MyType = { a: 1 } + * const valid2: MyType = { b: 1 } + * const invalid1: MyType = { a: 1, b: 1 } + * ``` + * + * @example + * Make 1 key mutually exclusive with 2 other keys. + * + * ```ts + * type MyType = ExclusiveKeys<{a: number, b: number, c: number}, "a", "b" | "c"> + * + * const valid1: MyType = { a: 1 }; + * const valid2: MyType = { b: 1, c: 1 }; + * const invalid1: MyType = { a: 1, b: 1 }; + * const invalid2: MyType = { a: 1, c: 1 }; + * const invalid3: MyType = { a: 1, b: 1, c: 1 }; + * ``` + */ +export type ExclusiveKeys = + | (Omit & { [K in Keys1]?: never }) + | (Omit & { [K in Keys2]?: never }); diff --git a/packages/inngest/src/test/helpers.ts b/packages/inngest/src/test/helpers.ts index 62f2a7853..4f12bd309 100644 --- a/packages/inngest/src/test/helpers.ts +++ b/packages/inngest/src/test/helpers.ts @@ -4,7 +4,7 @@ /* eslint-disable @typescript-eslint/no-unsafe-call */ import { Inngest } from "@local"; import { type ServeHandler } from "@local/components/InngestCommHandler"; -import { envKeys, headerKeys } from "@local/helpers/consts"; +import { envKeys, headerKeys, queryKeys } from "@local/helpers/consts"; import { slugify } from "@local/helpers/strings"; import { type FunctionTrigger } from "@local/types"; import fetch from "cross-fetch"; @@ -138,16 +138,36 @@ export const testFramework = ( fetch, }); - const [req, res] = createReqRes({ + const host = "localhost:3000"; + + const mockReqOpts: httpMocks.RequestOptions = { hostname: "localhost", url: "/api/inngest", protocol: "https", ...reqOpts[0], headers: { ...reqOpts[0]?.headers, - host: "localhost:3000", + host, }, - }); + }; + + if (mockReqOpts.method === "POST") { + const mockUrl = new URL( + `${mockReqOpts.protocol as string}://${host}${ + mockReqOpts.url as string + }` + ); + + if (!mockUrl.searchParams.has(queryKeys.FnId)) { + mockUrl.searchParams.set(queryKeys.FnId, ulid()); + } + + if (!mockUrl.searchParams.has(queryKeys.StepId)) { + mockUrl.searchParams.set(queryKeys.StepId, "step"); + } + } + + const [req, res] = createReqRes(mockReqOpts); let envToPass = { ...env }; @@ -600,6 +620,39 @@ export const testFramework = ( }); }); }); + + describe("malformed payloads", () => { + const client = createClient({ name: "test" }); + + const fn = client.createFunction( + { name: "Test", id: "test" }, + { event: "demo/event.sent" }, + () => "fn" + ); + const env = { + DENO_DEPLOYMENT_ID: undefined, + NODE_ENV: "development", + ENVIRONMENT: "development", + }; + + test("should throw an error with an invalid JSON body", async () => { + const ret = await run( + [inngest, [fn], { signingKey: "test" }], + [ + { + method: "POST", + url: "/api/inngest?fnId=test", + body: undefined, + }, + ], + env + ); + expect(ret).toMatchObject({ + status: 500, + body: expect.stringContaining("Failed to parse data from executor"), + }); + }); + }); }); }); }; @@ -662,7 +715,7 @@ export const receivedEventWithName = async ( name: string; payload: string; }> => { - for (let i = 0; i < 5; i++) { + for (let i = 0; i < 140; i++) { const start = new Date(); const res = await fetch("http://localhost:8288/v0/gql", { @@ -697,7 +750,7 @@ export const receivedEventWithName = async ( return event; } - await waitUpTo(1000, start); + await waitUpTo(400, start); } throw new Error("Event not received"); @@ -713,7 +766,7 @@ export const eventRunWithName = async ( eventId: string, name: string ): Promise => { - for (let i = 0; i < 5; i++) { + for (let i = 0; i < 140; i++) { const start = new Date(); const res = await fetch("http://localhost:8288/v0/gql", { @@ -753,7 +806,7 @@ export const eventRunWithName = async ( return run.id; } - await waitUpTo(1000, start); + await waitUpTo(400, start); } throw new Error("Event run not found"); @@ -776,7 +829,7 @@ export const runHasTimeline = async ( } // eslint-disable-next-line @typescript-eslint/no-explicit-any ): Promise => { - for (let i = 0; i < 5; i++) { + for (let i = 0; i < 140; i++) { const start = new Date(); const res = await fetch("http://localhost:8288/v0/gql", { @@ -828,7 +881,7 @@ export const runHasTimeline = async ( return timelineItem; } - await waitUpTo(1000, start); + await waitUpTo(400, start); } return; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 62251b5b2..d7a82e70b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1,4 +1,4 @@ -lockfileVersion: '6.0' +lockfileVersion: '6.1' settings: autoInstallPeers: true