From 1e319e2f672efc40e7296957440bc4e1b537d2e9 Mon Sep 17 00:00:00 2001 From: Richard Fontein <32132657+rifont@users.noreply.github.com> Date: Fri, 8 Nov 2024 12:07:18 +0100 Subject: [PATCH] feat(framework): Support Next.js 15 with Turbopack dev server (#6894) --- packages/framework/src/errors/base.errors.ts | 21 +++++++++-- .../framework/src/errors/guard.errors.test.ts | 20 ++++++++++- packages/framework/src/handler.ts | 14 +++----- packages/framework/src/servers/next.ts | 35 ++++++++++++++----- .../framework/src/utils/crypto.utils.test.ts | 18 ++++++++++ packages/framework/src/utils/crypto.utils.ts | 32 +++++++++++++++++ packages/framework/src/utils/index.ts | 1 + .../framework/src/validators/zod.validator.ts | 4 +-- 8 files changed, 122 insertions(+), 23 deletions(-) create mode 100644 packages/framework/src/utils/crypto.utils.test.ts create mode 100644 packages/framework/src/utils/crypto.utils.ts 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