Skip to content

Commit

Permalink
feat(framework): Add NestJS serve handler (novuhq#6654)
Browse files Browse the repository at this point in the history
  • Loading branch information
rifont authored Oct 9, 2024
1 parent 907d3ee commit 0e88116
Show file tree
Hide file tree
Showing 29 changed files with 1,927 additions and 944 deletions.
1 change: 1 addition & 0 deletions .cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -651,6 +651,7 @@
"unarchived",
"Unarchived",
"Unfetch",
"unplugin",
"Unpromoted",
"unpublish",
"unsub",
Expand Down
10 changes: 10 additions & 0 deletions packages/framework/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,11 @@
"import": "./dist/servers/express.js",
"types": "./dist/servers/express.d.ts"
},
"./nest": {
"require": "./dist/servers/nest.js",
"import": "./dist/servers/nest.js",
"types": "./dist/servers/nest.d.ts"
},
"./next": {
"require": "./dist/servers/next.js",
"import": "./dist/servers/next.js",
Expand Down Expand Up @@ -92,6 +97,7 @@
}
},
"peerDependencies": {
"@nestjs/common": ">=10.0.0",
"@sveltejs/kit": ">=1.27.3",
"@vercel/node": ">=2.15.9",
"aws-lambda": ">=1.0.7",
Expand All @@ -102,6 +108,9 @@
"zod-to-json-schema": ">=3.0.0"
},
"peerDependenciesMeta": {
"@nestjs/common": {
"optional": true
},
"@sveltejs/kit": {
"optional": true
},
Expand Down Expand Up @@ -132,6 +141,7 @@
},
"devDependencies": {
"@apidevtools/json-schema-ref-parser": "11.6.4",
"@nestjs/common": "10.4.1",
"@sveltejs/kit": "^1.27.3",
"@types/aws-lambda": "^8.10.141",
"@types/express": "^4.17.13",
Expand Down
7 changes: 7 additions & 0 deletions packages/framework/src/servers/nest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export * from './nest/nest.constants';
export * from './nest/nest.controller';
export * from './nest/nest.interface';
export * from './nest/nest.module';
export * from './nest/nest.register-api-path';
export * from './nest/nest.client';
export * from './nest/nest.handler';
29 changes: 29 additions & 0 deletions packages/framework/src/servers/nest/nest.client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { Injectable, Inject } from '@nestjs/common';
import type { Request, Response } from 'express';

import { NovuRequestHandler, type ServeHandlerOptions } from '../../handler';
import type { SupportedFrameworkName } from '../../types';
import { NOVU_OPTIONS } from './nest.constants';
import { NovuHandler } from './nest.handler';

export const frameworkName: SupportedFrameworkName = 'nest';

@Injectable()
export class NovuClient {
public novuRequestHandler: NovuRequestHandler;

constructor(
@Inject(NOVU_OPTIONS) private options: ServeHandlerOptions,
@Inject(NovuHandler) private novuHandler: NovuHandler
) {
this.novuRequestHandler = new NovuRequestHandler({
frameworkName,
...this.options,
handler: this.novuHandler.handler,
});
}

public async handleRequest(req: Request, res: Response) {
await this.novuRequestHandler.createHandler()(req, res);
}
}
2 changes: 2 additions & 0 deletions packages/framework/src/servers/nest/nest.constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export const REGISTER_API_PATH = 'REGISTER_API_PATH';
export { NOVU_OPTIONS } from './nest.module-definition';
23 changes: 23 additions & 0 deletions packages/framework/src/servers/nest/nest.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { Controller, Req, Res, Inject, Get, Post, Options } from '@nestjs/common';
import { Request, Response } from 'express';
import { NovuClient } from './nest.client';

@Controller()
export class NovuController {
constructor(@Inject(NovuClient) private novuService: NovuClient) {}

@Get()
async handleGet(@Req() req: Request, @Res() res: Response) {
await this.novuService.handleRequest(req, res);
}

@Post()
async handlePost(@Req() req: Request, @Res() res: Response) {
await this.novuService.handleRequest(req, res);
}

@Options()
async handleOptions(@Req() req: Request, @Res() res: Response) {
await this.novuService.handleRequest(req, res);
}
}
48 changes: 48 additions & 0 deletions packages/framework/src/servers/nest/nest.handler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { type VercelRequest, type VercelResponse } from '@vercel/node';
import { Injectable } from '@nestjs/common';
import type { Request, Response } from 'express';

import { type INovuRequestHandlerOptions } from '../../handler';
import type { Either } from '../../types';

@Injectable()
export class NovuHandler {
public handler(
incomingRequest: Either<VercelRequest, Request>,
response: Either<Response, VercelResponse>
): ReturnType<INovuRequestHandlerOptions['handler']> {
const extractHeader = (key: string): string | null | undefined => {
const header = incomingRequest.headers[key.toLowerCase()];

return Array.isArray(header) ? header[0] : header;
};

return {
body: () => incomingRequest.body,
headers: extractHeader,
method: () => incomingRequest.method || 'GET',
queryString: (key) => {
const qs = incomingRequest.query[key];

return Array.isArray(qs) ? qs[0] : qs;
},
url: () => {
// `req.hostname` can filter out port numbers; beware!
const hostname = incomingRequest.headers.host || '';

const protocol = hostname?.includes('://') ? '' : `${incomingRequest.protocol || 'https'}://`;

const url = new URL(incomingRequest.originalUrl || incomingRequest.url || '', `${protocol}${hostname || ''}`);

return url;
},
transformResponse: ({ body, headers, status }) => {
Object.entries(headers).forEach(([headerName, headerValue]) => {
response.setHeader(headerName, headerValue as string);
});

return response.status(status).send(body);
},
};
}
}
5 changes: 5 additions & 0 deletions packages/framework/src/servers/nest/nest.interface.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import type { ServeHandlerOptions } from '../../handler';

export type NovuModuleOptions = ServeHandlerOptions & {
apiPath: string;
};
17 changes: 17 additions & 0 deletions packages/framework/src/servers/nest/nest.module-definition.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { ConfigurableModuleBuilder } from '@nestjs/common';
import { NovuModuleOptions } from './nest.interface';

// use ConfigurableModuleBuilder, because building dynamic modules from scratch is painful
export const {
ConfigurableModuleClass: NovuBaseModule,
MODULE_OPTIONS_TOKEN: NOVU_OPTIONS,
OPTIONS_TYPE,
ASYNC_OPTIONS_TYPE,
} = new ConfigurableModuleBuilder<NovuModuleOptions>()
.setClassMethodName('register')
.setFactoryMethodName('createNovuModuleOptions')
.setExtras((definition: NovuModuleOptions) => ({
...definition,
isGlobal: true,
}))
.build();
69 changes: 69 additions & 0 deletions packages/framework/src/servers/nest/nest.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { Module, Provider } from '@nestjs/common';
import { NovuClient } from './nest.client';
import { NovuController } from './nest.controller';
import { registerApiPath } from './nest.register-api-path';
import { ASYNC_OPTIONS_TYPE, NovuBaseModule, OPTIONS_TYPE } from './nest.module-definition';
import { NovuHandler } from './nest.handler';

/**
* In NestJS, serve and register any declared workflows with Novu, making
* them available to be triggered by events.
*
* @example
* ```ts
* import { NovuModule } from "@novu/framework/nest";
* import { myWorkflow } from "./src/novu/workflows"; // Your workflows
*
* @Module({
* imports: [
* // Expose the middleware on our recommended path at `/api/novu`.
* NovuModule.register({
* apiPath: '/api/novu',
* workflows: [myWorkflow]
* })
* ]
* })
* export class AppModule {}
*
* const app = await NestFactory.create(AppModule);
*
* // Important: ensure you add JSON middleware to process incoming JSON POST payloads.
* app.use(express.json());
* ```
*/
@Module({})
export class NovuModule extends NovuBaseModule {
/**
* Register the Novu module
*
* @param options - The options to register the Novu module
* @param customProviders - Custom providers to register. These will be merged with the default providers.
* @returns The Novu module
*/
static register(options: typeof OPTIONS_TYPE, customProviders?: Provider[]) {
const superModule = super.register(options);

superModule.controllers = [NovuController];
superModule.providers?.push(registerApiPath, NovuClient, NovuHandler, ...(customProviders || []));
superModule.exports = [NovuClient, NovuHandler];

return superModule;
}

/**
* Register the Novu module asynchronously
*
* @param options - The options to register the Novu module
* @param customProviders - Custom providers to register. These will be merged with the default providers.
* @returns The Novu module
*/
static registerAsync(options: typeof ASYNC_OPTIONS_TYPE, customProviders?: Provider[]) {
const superModule = super.registerAsync(options);

superModule.controllers = [NovuController];
superModule.providers?.push(registerApiPath, NovuClient, NovuHandler, ...(customProviders || []));
superModule.exports = [NovuClient, NovuHandler];

return superModule;
}
}
26 changes: 26 additions & 0 deletions packages/framework/src/servers/nest/nest.register-api-path.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { FactoryProvider } from '@nestjs/common';
import { PATH_METADATA } from '@nestjs/common/constants';
import { NovuController } from './nest.controller';
import { REGISTER_API_PATH, NOVU_OPTIONS } from './nest.constants';
import { OPTIONS_TYPE } from './nest.module-definition';

/**
* Workaround to dynamically set the path for the controller.
*
* A custom provider is necessary to ensure that the controller path is set during
* application initialization, because NestJS does not support declaration of
* paths after the application has been initialized.
*
* @see https://github.com/nestjs/nest/issues/1438#issuecomment-863446608
*/
export const registerApiPath: FactoryProvider = {
provide: REGISTER_API_PATH,
useFactory: (options: typeof OPTIONS_TYPE) => {
if (!options.apiPath) {
throw new Error('`apiPath` must be provided to set the controller path');
}

Reflect.defineMetadata(PATH_METADATA, options.apiPath, NovuController);
},
inject: [NOVU_OPTIONS],
};
2 changes: 1 addition & 1 deletion packages/framework/src/types/server.types.ts
Original file line number Diff line number Diff line change
@@ -1 +1 @@
export type SupportedFrameworkName = 'next' | 'express' | 'nuxt' | 'h3' | 'sveltekit' | 'remix' | 'lambda';
export type SupportedFrameworkName = 'next' | 'express' | 'nuxt' | 'h3' | 'sveltekit' | 'remix' | 'lambda' | 'nest';
1 change: 1 addition & 0 deletions packages/framework/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"sourceMap": true,
"rootDir": ".",
"outDir": "./dist",
"experimentalDecorators": true,
"strict": true
},
"include": ["./src/**/*", "./package.json", "scripts/devtool.ts"],
Expand Down
2 changes: 1 addition & 1 deletion packages/framework/tsup.config.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { defineConfig } from 'tsup';
import { type SupportedFrameworkName } from './src';

const frameworks: SupportedFrameworkName[] = ['h3', 'express', 'next', 'nuxt', 'sveltekit', 'remix', 'lambda'];
const frameworks: SupportedFrameworkName[] = ['h3', 'express', 'next', 'nuxt', 'sveltekit', 'remix', 'lambda', 'nest'];

export default defineConfig({
entry: ['src/index.ts', ...frameworks.map((framework) => `src/servers/${framework}.ts`)],
Expand Down
2 changes: 2 additions & 0 deletions playground/nestjs/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
NOVU_SECRET_KEY=
NOVU_API_URL=https://api.novu.co
2 changes: 2 additions & 0 deletions playground/nestjs/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
dist
.env
31 changes: 31 additions & 0 deletions playground/nestjs/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Novu NestJS Playground

This project is a simple example of how to use Novu Framework with NestJS.

## Quick start

This quickstart assumes you are running this application from the Novu monorepo and have already installed the dependencies.

Copy the `.env.example` file to `.env` and set the correct environment variables.

```bash
cp .env.example .env
```

Then, run the application:

```bash
pnpm start
```

Finally, start Novu Studio and follow the CLI instructions to start creating your NestJS notification workflows:

```bash
npx novu@latest dev
```

## Testing

```bash
pnpm test
```
33 changes: 33 additions & 0 deletions playground/nestjs/nest-cli.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"typeCheck": true,
"deleteOutDir": true,
"builder": {
"type": "swc",
"options": {
"stripLeadingPaths": true
}
},
"assets": [
{
"include": ".env",
"outDir": "dist"
},
{
"include": ".env.development",
"outDir": "dist"
},
{
"include": ".env.test",
"outDir": "dist"
},
{
"include": ".env.production",
"outDir": "dist"
}
]
}
}
Loading

0 comments on commit 0e88116

Please sign in to comment.