diff --git a/packages/framework/src/errors/base.errors.ts b/packages/framework/src/errors/base.errors.ts
index 27a79915482..9f0cb581833 100644
--- a/packages/framework/src/errors/base.errors.ts
+++ b/packages/framework/src/errors/base.errors.ts
@@ -1,8 +1,25 @@
-import { isNativeError } from 'node:util/types';
-
import { HttpStatusEnum } from '../constants';
import { ErrorCodeEnum } from '../constants/error.constants';
+/**
+ * Check if the object is a native error.
+ *
+ * This method relies on `Object.prototype.toString()` behavior. It is possible to obtain
+ * an incorrect result when the object argument has a non `Error`-suffixed `name` property.
+ *
+ * @param object - The object to check.
+ * @returns `true` if the object is a native error, `false` otherwise.
+ */
+export const isNativeError = (object: unknown): object is Error => {
+ if (typeof object !== 'object' || object === null) {
+ return false;
+ }
+
+ const proto = Object.getPrototypeOf(object);
+
+ return proto?.constructor?.name.endsWith('Error') ?? false;
+};
+
/**
* Base error class.
*/
diff --git a/packages/framework/src/errors/guard.errors.test.ts b/packages/framework/src/errors/guard.errors.test.ts
index 9e5b3069320..464fc7ec5e0 100644
--- a/packages/framework/src/errors/guard.errors.test.ts
+++ b/packages/framework/src/errors/guard.errors.test.ts
@@ -1,8 +1,8 @@
import { expect, it, describe } from 'vitest';
import { isFrameworkError, isPlatformError } from './guard.errors';
+import { isNativeError, FrameworkError } from './base.errors';
import { PlatformError } from './platform.errors';
import { ErrorCodeEnum, HttpStatusEnum } from '../constants';
-import { FrameworkError } from './base.errors';
import { BridgeError } from './bridge.errors';
class TestFrameworkError extends FrameworkError {
@@ -11,6 +11,24 @@ class TestFrameworkError extends FrameworkError {
}
describe('error utils', () => {
+ describe('isNativeError', () => {
+ it('should return true for native errors', () => {
+ expect(isNativeError(new Error('Test error'))).toBe(true);
+ });
+
+ it('should return true for framework errors', () => {
+ expect(isNativeError(new TestFrameworkError('Unable to find the workflow'))).toBe(true);
+ });
+
+ const falseCases = [{}, null, undefined, 'Test error', 123, true, [], () => {}, Symbol('test')];
+
+ falseCases.forEach((value) => {
+ it(`should return false for ${typeof value}`, () => {
+ expect(isNativeError(value)).toBe(false);
+ });
+ });
+ });
+
describe('isFrameworkError', () => {
it('should return true for framework errors', () => {
expect(isFrameworkError(new TestFrameworkError('Unable to find the workflow'))).toBe(true);
diff --git a/packages/framework/src/handler.ts b/packages/framework/src/handler.ts
index 2e71b85d822..90bec00c5c9 100644
--- a/packages/framework/src/handler.ts
+++ b/packages/framework/src/handler.ts
@@ -1,5 +1,3 @@
-import { createHmac } from 'node:crypto';
-
import { Client } from './client';
import {
GetActionEnum,
@@ -23,7 +21,7 @@ import {
SigningKeyNotFoundError,
} from './errors';
import type { Awaitable, EventTriggerParams, Workflow } from './types';
-import { initApiClient } from './utils';
+import { initApiClient, createHmacSubtle } from './utils';
import { isPlatformError } from './errors/guard.errors';
export type ServeHandlerOptions = {
@@ -148,7 +146,7 @@ export class NovuRequestHandler {
try {
if (action !== GetActionEnum.HEALTH_CHECK) {
- this.validateHmac(body, signatureHeader);
+ await this.validateHmac(body, signatureHeader);
}
const postActionMap = this.getPostActionMap(body, workflowId, stepId, action);
@@ -293,7 +291,7 @@ export class NovuRequestHandler {
}
}
- private validateHmac(payload: unknown, hmacHeader: string | null): void {
+ private async validateHmac(payload: unknown, hmacHeader: string | null): Promise {
if (!this.hmacEnabled) return;
if (!hmacHeader) {
throw new SignatureNotFoundError();
@@ -316,7 +314,7 @@ export class NovuRequestHandler {
throw new SignatureExpiredError();
}
- const localHash = this.hashHmac(this.client.secretKey as string, `${timestampPayload}.${JSON.stringify(payload)}`);
+ const localHash = await createHmacSubtle(this.client.secretKey, `${timestampPayload}.${JSON.stringify(payload)}`);
const isMatching = localHash === signaturePayload;
@@ -324,8 +322,4 @@ export class NovuRequestHandler {
throw new SignatureMismatchError();
}
}
-
- private hashHmac(secretKey: string, data: string): string {
- return createHmac('sha256', secretKey).update(data).digest('hex');
- }
}
diff --git a/packages/framework/src/servers/next.ts b/packages/framework/src/servers/next.ts
index 249e6c193c2..80c967e68d3 100644
--- a/packages/framework/src/servers/next.ts
+++ b/packages/framework/src/servers/next.ts
@@ -21,6 +21,27 @@ import { getResponse } from '../utils';
export * from '../index';
export const frameworkName: SupportedFrameworkName = 'next';
+/**
+ * Defines a request handler for Next.js 12+.
+ *
+ * The argument types are kept abstract due to varying type checks across
+ * Next.js versions. Next.js 15 uses `RouteContext` for the second argument,
+ * while versions 13 and 14 omit it, and version 12 uses `NextApiResponse`,
+ * which varies by environment (edge vs serverless).
+ */
+export type RequestHandler = (expectedReq: NextRequest, res: unknown) => Promise;
+
+// Helper function to check if the response is a Next.js 12 API response
+const isNext12ApiResponse = (val: unknown): val is NextApiResponse => {
+ return (
+ typeof val === 'object' &&
+ val !== null &&
+ typeof (val as NextApiResponse).setHeader === 'function' &&
+ typeof (val as NextApiResponse).status === 'function' &&
+ typeof (val as NextApiResponse).send === 'function'
+ );
+};
+
/**
* In Next.js, serve and register any declared workflows with Novu, making
* them available to be triggered by events.
@@ -45,10 +66,10 @@ export const frameworkName: SupportedFrameworkName = 'next';
*/
export const serve = (
options: ServeHandlerOptions
-): ((expectedReq: NextRequest, res: NextApiResponse) => Promise) & {
- GET: (expectedReq: NextRequest, res: NextApiResponse) => Promise;
- POST: (expectedReq: NextRequest, res: NextApiResponse) => Promise;
- OPTIONS: (expectedReq: NextRequest, res: NextApiResponse) => Promise;
+): RequestHandler & {
+ GET: RequestHandler;
+ POST: RequestHandler;
+ OPTIONS: RequestHandler;
} => {
const novuHandler = new NovuRequestHandler({
frameworkName,
@@ -56,7 +77,7 @@ export const serve = (
handler: (
requestMethod: 'GET' | 'POST' | 'OPTIONS' | undefined,
incomingRequest: NextRequest,
- response: NextApiResponse
+ response: unknown
) => {
const request = incomingRequest as Either;
@@ -138,13 +159,11 @@ export const serve = (
* Carefully attempt to set headers and data on the response object
* for Next.js 12 support.
*/
- if (typeof response?.setHeader === 'function') {
+ if (isNext12ApiResponse(response)) {
Object.entries(headers).forEach(([headerName, headerValue]) => {
response.setHeader(headerName, headerValue);
});
- }
- if (typeof response?.status === 'function' && typeof response?.send === 'function') {
response.status(status).send(body);
/**
diff --git a/packages/framework/src/utils/crypto.utils.test.ts b/packages/framework/src/utils/crypto.utils.test.ts
new file mode 100644
index 00000000000..a8f9e0930b6
--- /dev/null
+++ b/packages/framework/src/utils/crypto.utils.test.ts
@@ -0,0 +1,18 @@
+import { createHmac } from 'node:crypto';
+import { describe, expect, it } from 'vitest';
+import { createHmacSubtle } from './crypto.utils';
+
+describe('crypto utils', () => {
+ describe('createHmacSubtle', () => {
+ const createHmacNode = (secretKey: string, data: string): string => {
+ return createHmac('sha256', secretKey).update(data).digest('hex');
+ };
+
+ it('should create an HMAC equivalent to node crypto createHmac', async () => {
+ const hmacSubtle = await createHmacSubtle('secret', 'data');
+ const hmacNode = createHmacNode('secret', 'data');
+
+ expect(hmacSubtle).toEqual(hmacNode);
+ });
+ });
+});
diff --git a/packages/framework/src/utils/crypto.utils.ts b/packages/framework/src/utils/crypto.utils.ts
new file mode 100644
index 00000000000..fe08c9a282c
--- /dev/null
+++ b/packages/framework/src/utils/crypto.utils.ts
@@ -0,0 +1,32 @@
+/**
+ * Create HMAC using subtle crypto.
+ *
+ * `crypto.subtle` is a Web Crypto API this is available in browsers,
+ * Node.js, and most edge runtimes, such as Cloudflare Workers.
+ *
+ * @param secretKey - The secret key.
+ * @param data - The data to hash.
+ * @returns The HMAC.
+ */
+export const createHmacSubtle = async (secretKey: string, data: string): Promise => {
+ const encoder = new TextEncoder();
+ const keyData = encoder.encode(secretKey);
+ const dataBuffer = encoder.encode(data);
+
+ const cryptoKey = await crypto.subtle.importKey(
+ 'raw',
+ keyData,
+ {
+ name: 'HMAC',
+ hash: { name: 'SHA-256' },
+ },
+ false,
+ ['sign']
+ );
+
+ const signature = await crypto.subtle.sign('HMAC', cryptoKey, dataBuffer);
+
+ return Array.from(new Uint8Array(signature))
+ .map((byte) => byte.toString(16).padStart(2, '0'))
+ .join('');
+};
diff --git a/packages/framework/src/utils/index.ts b/packages/framework/src/utils/index.ts
index 266a16d37fe..573e95c9f48 100644
--- a/packages/framework/src/utils/index.ts
+++ b/packages/framework/src/utils/index.ts
@@ -1,3 +1,4 @@
+export * from './crypto.utils';
export * from './env.utils';
export * from './http.utils';
export * from './log.utils';
diff --git a/packages/framework/src/validators/zod.validator.ts b/packages/framework/src/validators/zod.validator.ts
index 5d383111cb0..e428b9bf82e 100644
--- a/packages/framework/src/validators/zod.validator.ts
+++ b/packages/framework/src/validators/zod.validator.ts
@@ -31,8 +31,8 @@ export class ZodValidator implements Validator {
try {
const { zodToJsonSchema } = await import('zod-to-json-schema');
- // @ts-expect-error - zod-to-json-schema is not using JSONSchema7
- return zodToJsonSchema(schema);
+ // TODO: zod-to-json-schema is not using JSONSchema7
+ return zodToJsonSchema(schema) as JsonSchema;
} catch (error) {
if ((error as Error)?.message?.includes('Cannot find module')) {
// eslint-disable-next-line no-console