Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(framework): Add NestJS serve handler #6654

Merged
merged 16 commits into from
Oct 9, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Following best practices to use ConfigurableModuleBuilder per Dynamic Modules docs

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,
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Necessary to use NestJS decorators.

"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
Loading