diff --git a/.changeset/many-elephants-smoke.md b/.changeset/many-elephants-smoke.md new file mode 100644 index 00000000..092fa39f --- /dev/null +++ b/.changeset/many-elephants-smoke.md @@ -0,0 +1,5 @@ +--- +"inngest": minor +--- + +INN-2754 Add support for `INNGEST_DEV` and the `isDev` option, allowing a devleoper to explicitly set either Cloud or Dev mode diff --git a/packages/inngest/etc/inngest.api.md b/packages/inngest/etc/inngest.api.md index 8b9885d5..b731390f 100644 --- a/packages/inngest/etc/inngest.api.md +++ b/packages/inngest/etc/inngest.api.md @@ -26,6 +26,7 @@ export interface ClientOptions { eventKey?: string; fetch?: typeof fetch; id: string; + isDev?: boolean; // 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 @@ -231,7 +232,7 @@ export enum headerKeys { // @public export class Inngest { - constructor({ id, eventKey, baseUrl, fetch, env, logger, middleware, }: TOpts); + constructor({ id, eventKey, baseUrl, fetch, env, logger, middleware, isDev, }: TOpts); // Warning: (ae-forgotten-export) The symbol "ExclusiveKeys" needs to be exported by the entry point index.d.ts // // (undocumented) @@ -273,9 +274,10 @@ export class InngestCommHandler Record): Promise<{ status: number; @@ -306,7 +308,6 @@ export class InngestCommHandler { + describe("mode", () => { + const createTestClient = ({ + env, + opts, + }: { + env?: Record; + opts?: Omit[0], "id">; + } = {}): Inngest.Any => { + let ogKeys: Record = {}; + + if (env) { + ogKeys = Object.keys(env).reduce>( + (acc, key) => { + acc[key] = process.env[key]; + process.env[key] = env[key]; + return acc; + }, + {} + ); + } + + const inngest = new Inngest({ id: "test", ...opts }); + + if (env) { + Object.keys(ogKeys).forEach((key) => { + process.env[key] = ogKeys[key]; + }); + } + + return inngest; + }; + + test("should default to inferred dev mode", () => { + const inngest = createTestClient(); + expect(inngest["mode"].isDev).toBe(true); + expect(inngest["mode"].isExplicit).toBe(false); + }); + + test("`isDev: true` sets explicit dev mode", () => { + const inngest = createTestClient({ opts: { isDev: true } }); + expect(inngest["mode"].isDev).toBe(true); + expect(inngest["mode"].isExplicit).toBe(true); + }); + + test("`isDev: false` sets explict cloud mode", () => { + const inngest = createTestClient({ opts: { isDev: false } }); + expect(inngest["mode"].isCloud).toBe(true); + expect(inngest["mode"].isExplicit).toBe(true); + }); + + test("`INNGEST_DEV=1 sets explicit dev mode", () => { + const inngest = createTestClient({ + env: { [envKeys.InngestDevMode]: "1" }, + }); + expect(inngest["mode"].isDev).toBe(true); + expect(inngest["mode"].isExplicit).toBe(true); + }); + + test("`INNGEST_DEV=true` sets explicit dev mode", () => { + const inngest = createTestClient({ + env: { [envKeys.InngestDevMode]: "true" }, + }); + expect(inngest["mode"].isDev).toBe(true); + expect(inngest["mode"].isExplicit).toBe(true); + }); + + test("`INNGEST_DEV=false` sets explicit cloud mode", () => { + const inngest = createTestClient({ + env: { [envKeys.InngestDevMode]: "false" }, + }); + expect(inngest["mode"].isCloud).toBe(true); + expect(inngest["mode"].isExplicit).toBe(true); + }); + + test("`INNGEST_DEV=0 sets explicit cloud mode", () => { + const inngest = createTestClient({ + env: { [envKeys.InngestDevMode]: "0" }, + }); + expect(inngest["mode"].isCloud).toBe(true); + expect(inngest["mode"].isExplicit).toBe(true); + }); + + test("`isDev` overwrites `INNGEST_DEV`", () => { + const inngest = createTestClient({ + env: { [envKeys.InngestDevMode]: "1" }, + opts: { isDev: false }, + }); + expect(inngest["mode"].isDev).toBe(false); + expect(inngest["mode"].isExplicit).toBe(true); + }); + + test("`INNGEST_DEV=URL sets explicit dev mode", () => { + const inngest = createTestClient({ + env: { [envKeys.InngestDevMode]: "http://localhost:3000" }, + }); + expect(inngest["mode"].isDev).toBe(true); + expect(inngest["mode"].isExplicit).toBe(true); + expect(inngest["mode"].explicitDevUrl?.href).toBe( + "http://localhost:3000/" + ); + }); + }); +}); + describe("send", () => { describe("runtime", () => { const originalProcessEnv = process.env; diff --git a/packages/inngest/src/components/Inngest.ts b/packages/inngest/src/components/Inngest.ts index 5c1c42af..ed55abe3 100644 --- a/packages/inngest/src/components/Inngest.ts +++ b/packages/inngest/src/components/Inngest.ts @@ -12,9 +12,10 @@ import { import { devServerAvailable, devServerUrl } from "../helpers/devserver"; import { getFetch, + getMode, inngestHeaders, processEnv, - skipDevServer, + type Mode, } from "../helpers/env"; import { fixEventKeyMissingSteps, prettyError } from "../helpers/errors"; import { stringify } from "../helpers/strings"; @@ -131,6 +132,18 @@ export class Inngest { */ private readonly middleware: Promise; + /** + * Whether the client is running in a production environment. This can + * sometimes be `undefined` if the client has expressed no preference or + * perhaps environment variables are only available at a later stage in the + * runtime, for example when receiving a request. + * + * An {@link InngestCommHandler} should prioritize this value over all other + * settings, but should still check for the presence of an environment + * variable if it is not set. + */ + private readonly mode: Mode; + /** * A client used to interact with the Inngest API by sending or reacting to * events. @@ -159,6 +172,7 @@ export class Inngest { env, logger = new DefaultLogger(), middleware, + isDev, }: TOpts) { if (!id) { // TODO PrettyError @@ -167,15 +181,22 @@ export class Inngest { this.id = id; + this.mode = getMode({ + explicitMode: + typeof isDev === "boolean" ? (isDev ? "dev" : "cloud") : undefined, + }); + this.apiBaseUrl = baseUrl || processEnv(envKeys.InngestApiBaseUrl) || - processEnv(envKeys.InngestBaseUrl); + processEnv(envKeys.InngestBaseUrl) || + this.mode.getExplicitUrl(defaultInngestApiBaseUrl); this.eventBaseUrl = baseUrl || processEnv(envKeys.InngestEventApiBaseUrl) || - processEnv(envKeys.InngestBaseUrl); + processEnv(envKeys.InngestBaseUrl) || + this.mode.getExplicitUrl(defaultInngestEventBaseUrl); this.setEventKey(eventKey || processEnv(envKeys.InngestEventKey) || ""); @@ -407,21 +428,9 @@ export class Inngest { let url = this.sendEventUrl.href; /** - * INNGEST_BASE_URL is used to set both dev server and prod URLs, so if a - * user has set this it means they have already chosen a URL to hit. + * If in prod mode and key is not present, fail now. */ - if (!skipDevServer()) { - if (!this.eventBaseUrl) { - const devAvailable = await devServerAvailable( - defaultDevServerHost, - this.fetch - ); - - if (devAvailable) { - url = devServerUrl(defaultDevServerHost, `e/${this.eventKey}`).href; - } - } - } else if (!this.eventKeySet()) { + if (this.mode.isCloud && !this.eventKeySet()) { throw new Error( prettyError({ whatHappened: "Failed to send event", @@ -432,6 +441,25 @@ export class Inngest { ); } + /** + * If dev mode has been inferred, try to hit the dev server first to see if + * it exists. If it does, use it, otherwise fall back to whatever server we + * have configured. + * + * `INNGEST_BASE_URL` is used to set both dev server and prod URLs, so if a + * user has set this it means they have already chosen a URL to hit. + */ + if (this.mode.isDev && this.mode.isInferred && !this.eventBaseUrl) { + const devAvailable = await devServerAvailable( + defaultDevServerHost, + this.fetch + ); + + if (devAvailable) { + url = devServerUrl(defaultDevServerHost, `e/${this.eventKey}`).href; + } + } + const response = await this.fetch(url, { method: "POST", body: stringify(payloads), diff --git a/packages/inngest/src/components/InngestCommHandler.ts b/packages/inngest/src/components/InngestCommHandler.ts index 25119214..eed8dcfc 100644 --- a/packages/inngest/src/components/InngestCommHandler.ts +++ b/packages/inngest/src/components/InngestCommHandler.ts @@ -13,13 +13,13 @@ import { } from "../helpers/consts"; import { devServerAvailable, devServerUrl } from "../helpers/devserver"; import { + Mode, allProcessEnv, devServerHost, getFetch, + getMode, inngestHeaders, - isProd, platformSupportsStreaming, - skipDevServer, type Env, } from "../helpers/env"; import { rethrowError, serializeError } from "../helpers/errors"; @@ -252,14 +252,7 @@ export class InngestCommHandler< * * Should be set every time a request is received. */ - protected _isProd = false; - - /** - * Whether we should attempt to use the dev server. - * - * Should be set every time a request is received. - */ - protected _skipDevServer = false; + protected _mode: Mode | undefined; /** * The localized `fetch` implementation used by this handler. @@ -670,17 +663,23 @@ export class InngestCommHandler< getInngestHeaders: () => Record; reqArgs: unknown[]; }): Promise { - this._isProd = - (await actions.isProduction?.("starting to handle request")) ?? - isProd(this.env); + const assumedMode = getMode({ env: this.env, client: this.client }); - /** - * If we've been explicitly passed an Inngest dev sever URL, assume that - * we shouldn't skip the dev server. - */ - this._skipDevServer = devServerHost(this.env) - ? false - : this._isProd ?? skipDevServer(this.env); + if (assumedMode.isExplicit) { + this._mode = assumedMode; + } else { + const serveIsProd = await actions.isProduction?.( + "starting to handle request" + ); + if (typeof serveIsProd === "boolean") { + this._mode = new Mode({ + type: serveIsProd ? "cloud" : "dev", + isExplicit: false, + }); + } else { + this._mode = assumedMode; + } + } this.upsertKeysFromEnv(); @@ -834,6 +833,7 @@ export class InngestCommHandler< hasEventKey: this.client["eventKeySet"](), hasSigningKey: Boolean(this.signingKey), functionsFound: registerBody.functions.length, + mode: this._mode, }; return { @@ -888,8 +888,7 @@ export class InngestCommHandler< status: 405, body: JSON.stringify({ message: "No action found; request was likely not POST, PUT, or GET", - isProd: this._isProd, - skipDevServer: this._skipDevServer, + mode: this._mode, }), headers: {}, version: undefined, @@ -1093,12 +1092,17 @@ export class InngestCommHandler< // mutating the property between requests. let registerURL = new URL(this.inngestRegisterUrl.href); - if (!this._skipDevServer) { + const inferredDevMode = + this._mode && this._mode.isInferred && this._mode.isDev; + + if (inferredDevMode) { const host = devServerHost(this.env); const hasDevServer = await devServerAvailable(host, this.fetch); if (hasDevServer) { registerURL = devServerUrl(host, "/fn/register"); } + } else if (this._mode?.explicitDevUrl) { + registerURL = new URL(this._mode.explicitDevUrl); } if (deployId) { @@ -1156,10 +1160,6 @@ export class InngestCommHandler< return { status, message: error, modified }; } - private get isProd() { - return this._isProd; - } - /** * Given an environment, upsert any missing keys. This is useful in * situations where environment variables are passed directly to handlers or @@ -1188,8 +1188,10 @@ export class InngestCommHandler< } protected validateSignature(sig: string | undefined, body: unknown) { - // Never validate signatures in development. - if (!this.isProd) { + // Never validate signatures outside of prod. Make sure to check the mode + // exists here instead of using nullish coalescing to confirm that the check + // has been completed. + if (this._mode && !this._mode.isCloud) { return; } @@ -1427,7 +1429,7 @@ export interface ActionResponse< * This enables us to provide accurate errors for each access without having to * wrap every access in a try/catch. */ -type HandlerResponseWithErrors = { +export type HandlerResponseWithErrors = { [K in keyof HandlerResponse]: NonNullable extends ( ...args: infer Args ) => infer R diff --git a/packages/inngest/src/helpers/consts.ts b/packages/inngest/src/helpers/consts.ts index c78c5709..19994d73 100644 --- a/packages/inngest/src/helpers/consts.ts +++ b/packages/inngest/src/helpers/consts.ts @@ -31,6 +31,7 @@ export enum envKeys { InngestServePath = "INNGEST_SERVE_PATH", InngestLogLevel = "INNGEST_LOG_LEVEL", InngestStreaming = "INNGEST_STREAMING", + InngestDevMode = "INNGEST_DEV", BranchName = "BRANCH_NAME", @@ -97,9 +98,7 @@ export enum envKeys { * {@link https://docs.railway.app/develop/variables#railway-provided-variables} */ RailwayEnvironment = "RAILWAY_ENVIRONMENT", -} -export enum prodEnvKeys { VercelEnvKey = "VERCEL_ENV", } diff --git a/packages/inngest/src/helpers/env.ts b/packages/inngest/src/helpers/env.ts index a34d6577..1f748830 100644 --- a/packages/inngest/src/helpers/env.ts +++ b/packages/inngest/src/helpers/env.ts @@ -6,7 +6,7 @@ import { type Inngest } from "../components/Inngest"; import { type SupportedFrameworkName } from "../types"; import { version } from "../version"; -import { envKeys, headerKeys, prodEnvKeys } from "./consts"; +import { defaultDevServerHost, envKeys, headerKeys } from "./consts"; import { stringifyUnknown } from "./strings"; /** @@ -36,13 +36,26 @@ export const devServerHost = (env: Env = allProcessEnv()): EnvValue => { // processed using webpack's DefinePlugin, which is dumb and does a straight // text replacement instead of actually understanding the AST, despite webpack // being fully capable of understanding the AST. - const values = [ - env[envKeys.InngestBaseUrl], - env[`REACT_APP_${envKeys.InngestBaseUrl}`], - env[`NEXT_PUBLIC_${envKeys.InngestBaseUrl}`], - ]; + const prefixes = ["REACT_APP_", "NEXT_PUBLIC_"]; + const keys = [envKeys.InngestBaseUrl, envKeys.InngestDevMode]; - return values.find((a) => !!a); + const values = keys.flatMap((key) => { + return prefixes.map((prefix) => { + return env[prefix + key]; + }); + }); + + return values.find((v) => { + if (!v) { + return; + } + + try { + return Boolean(new URL(v)); + } catch { + // no-op + } + }); }; const checkFns = (< @@ -69,55 +82,139 @@ const prodChecks: [ ["NODE_ENV", "starts with", "prod"], ["VERCEL_ENV", "starts with", "prod"], ["DENO_DEPLOYMENT_ID", "is truthy"], -]; - -// platformDeployChecks are a series of predicates that attempt to check whether -// we're deployed outside of localhost testing. This extends prodChecks with -// platform specific checks, ensuring that if you deploy to eg. vercel without -// NODE_ENV=production we still use prod mode. -const platformDeployChecks: [ - key: string, - customCheck: keyof typeof checkFns, - value?: string, -][] = [ - // Extend prod checks, then check if we're deployed to a platform. - [prodEnvKeys.VercelEnvKey, "is truthy but not", "development"], + [envKeys.VercelEnvKey, "is truthy but not", "development"], [envKeys.IsNetlify, "is truthy"], [envKeys.IsRender, "is truthy"], [envKeys.RailwayBranch, "is truthy"], [envKeys.IsCloudflarePages, "is truthy"], ]; -const skipDevServerChecks = prodChecks.concat(platformDeployChecks); - -/** - * Returns `true` if we're running in production or on a platform, based off of - * either passed environment variables or `process.env`. - */ -export const skipDevServer = ( +interface IsProdOptions { /** * The optional environment variables to use instead of `process.env`. */ - env: Record = allProcessEnv() -): boolean => { - return skipDevServerChecks.some(([key, checkKey, expected]) => { - return checkFns[checkKey](stringifyUnknown(env[key]), expected); - }); -}; + env?: Record; -/** - * Returns `true` if we believe the current environment is production based on - * either passed environment variables or `process.env`. - */ -export const isProd = ( /** - * The optional environment variables to use instead of `process.env`. + * The Inngest client that's being used when performing this check. This is + * used to check if the client has an explicit mode set, and if so, to use + * that mode instead of inferring it from the environment. */ - env: Record = allProcessEnv() -): boolean => { - return prodChecks.some(([key, checkKey, expected]) => { + client?: Inngest.Any; + + /** + * If specified as a `boolean`, this will be returned as the result of the + * function. Useful for options that may or may not be set by users. + */ + explicitMode?: Mode["type"]; +} + +export interface ModeOptions { + type: "cloud" | "dev"; + + /** + * Whether the mode was explicitly set, or inferred from other sources. + */ + isExplicit: boolean; + + /** + * If the mode was explicitly set as a dev URL, this is the URL that was set. + */ + explicitDevUrl?: URL; +} + +export class Mode { + private readonly type: "cloud" | "dev"; + + /** + * Whether the mode was explicitly set, or inferred from other sources. + */ + public readonly isExplicit: boolean; + + public readonly explicitDevUrl?: URL; + + constructor({ type, isExplicit, explicitDevUrl }: ModeOptions) { + this.type = type; + this.isExplicit = isExplicit || Boolean(explicitDevUrl); + this.explicitDevUrl = explicitDevUrl; + } + + public get isDev(): boolean { + return this.type === "dev"; + } + + public get isCloud(): boolean { + return this.type === "cloud"; + } + + public get isInferred(): boolean { + return !this.isExplicit; + } + + /** + * If we are explicitly in a particular mode, retrieve the URL that we are + * sure we should be using, not considering any environment variables or other + * influences. + */ + public getExplicitUrl(defaultCloudUrl: string): string | undefined { + if (!this.isExplicit) { + return undefined; + } + + if (this.explicitDevUrl) { + return this.explicitDevUrl.href; + } + + if (this.isCloud) { + return defaultCloudUrl; + } + + if (this.isDev) { + return defaultDevServerHost; + } + + return undefined; + } +} + +/** + * Returns the mode of the current environment, based off of either passed + * environment variables or `process.env`, or explicit settings. + */ +export const getMode = ({ + env = allProcessEnv(), + client, + explicitMode, +}: IsProdOptions = {}): Mode => { + if (explicitMode) { + return new Mode({ type: explicitMode, isExplicit: true }); + } + + if (client?.["mode"].isExplicit) { + return client["mode"]; + } + + if (envKeys.InngestDevMode in env) { + if (typeof env[envKeys.InngestDevMode] === "string") { + try { + const explicitDevUrl = new URL(env[envKeys.InngestDevMode]); + return new Mode({ type: "dev", isExplicit: true, explicitDevUrl }); + } catch { + // no-op + } + } + + const envIsDev = parseAsBoolean(env[envKeys.InngestDevMode]); + if (typeof envIsDev === "boolean") { + return new Mode({ type: envIsDev ? "dev" : "cloud", isExplicit: true }); + } + } + + const isProd = prodChecks.some(([key, checkKey, expected]) => { return checkFns[checkKey](stringifyUnknown(env[key]), expected); }); + + return new Mode({ type: isProd ? "cloud" : "dev", isExplicit: false }); }; /** @@ -388,3 +485,37 @@ export const getResponse = (): typeof Response => { // eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-var-requires return require("cross-fetch").Response; }; + +/** + * Given an unknown value, try to parse it as a `boolean`. Useful for parsing + * environment variables that could be a selection of different values such as + * `"true"`, `"1"`. + * + * If the value could not be confidently parsed as a `boolean` or was seen to be + * `undefined`, this function returns `undefined`. + */ +export const parseAsBoolean = (value: unknown): boolean | undefined => { + if (typeof value === "boolean") { + return value; + } + + if (typeof value === "number") { + return Boolean(value); + } + + if (typeof value === "string") { + const trimmed = value.trim().toLowerCase(); + + if (trimmed === "undefined") { + return undefined; + } + + if (["true", "1"].includes(trimmed)) { + return true; + } + + return false; + } + + return undefined; +}; diff --git a/packages/inngest/src/test/helpers.ts b/packages/inngest/src/test/helpers.ts index 405c8219..0abd48f6 100644 --- a/packages/inngest/src/test/helpers.ts +++ b/packages/inngest/src/test/helpers.ts @@ -550,7 +550,7 @@ export const testFramework = ( const ret = await run( [ { - client: new Inngest({ id: "Test", env: "FOO" }), + client: new Inngest({ id: "Test", env: "FOO", isDev: false }), functions: [], }, ], @@ -698,6 +698,7 @@ export const testFramework = ( DENO_DEPLOYMENT_ID: "1", NODE_ENV: "production", ENVIRONMENT: "production", + INNGEST_DEV: "0", }; test("should throw an error in prod with no signature", async () => { const ret = await run( @@ -816,9 +817,7 @@ export const testFramework = ( () => "fn" ); const env = { - DENO_DEPLOYMENT_ID: undefined, - NODE_ENV: "development", - ENVIRONMENT: "development", + INNGEST_DEV: "1", }; test("should throw an error with an invalid JSON body", async () => { diff --git a/packages/inngest/src/types.ts b/packages/inngest/src/types.ts index 8702000a..706e621f 100644 --- a/packages/inngest/src/types.ts +++ b/packages/inngest/src/types.ts @@ -14,6 +14,7 @@ import { } from "./components/InngestMiddleware"; import { type createStepTools } from "./components/InngestStepTools"; import { type internalEvents } from "./helpers/consts"; +import { type Mode } from "./helpers/env"; import { type IsStringLiteral, type ObjectPaths, @@ -585,6 +586,16 @@ export interface ClientOptions { */ logger?: Logger; middleware?: MiddlewareStack; + + /** + * Can be used to explicitly set the client to Development Mode, which will + * turn off signature verification and default to using a local URL to access + * a local Dev Server. + * + * This is useful for forcing the client to use a local Dev Server while also + * running in a production-like environment. + */ + isDev?: boolean; } /** @@ -1100,6 +1111,12 @@ export interface IntrospectRequest { * The number of Inngest functions found at this handler. */ functionsFound: number; + + /** + * The mode that this handler is running in and whether it has been inferred + * or explicitly set. + */ + mode: Mode; } /**