From 612b0d9ce9e74f0151b49a6c0a6f2b310f9e4516 Mon Sep 17 00:00:00 2001 From: Denis Kralj <168424106+denis-kralj-novu@users.noreply.github.com> Date: Tue, 11 Jun 2024 18:29:48 +0200 Subject: [PATCH] feat(root): include local tunnel work (#5698) * feat: include local tunnel work Add local tunnel script to novu app Add storage of local tunnel url * feat: include local tunnel work Add test for user registration case in enterprise * feat: include local tunnel work Consolidate scripts to one source * feat: include local tunnel work Update modules to be typescript instead of javascript Update output package json command Update build command to clean dist before building * feat: include local tunnel work Update formatting Add tunnel interface to conform to * feat: include local tunnel work Adjust per PR comments and discussion * feat: include local tunnel work Subdomain calculated from ApiKey on launch instead on scaffold Run script with tsx * feat: include local tunnel work Adjust how concurrently handles tasks Adjust text in output * feat: include local tunnel work Regenerate echo url on api key rotation * feat: include local tunnel work Remove unused package * feat: include local tunnel work Update lock file * feat: include local tunnel work Update lock file * feat: include local tunnel work Update lock file * feat: include local tunnel work Update lock file * feat: include local tunnel work PR suggestions * feat: include local tunnel work Update restart cooldown to be 1sec * feat: include local tunnel work Add error handler logic that enables restart on tunnel fail * feat: include local tunnel work update ee package reference --- apps/api/src/.env.test | 2 + apps/api/src/.example.env | 2 + .../app/auth/e2e/user-registration.e2e-ee.ts | 34 ++++++++++++++ .../dtos/update-environment-request.dto.ts | 10 ++++ .../e2e/regenerate-api-keys.e2e-ee.ts | 32 +++++++++++++ .../environments/environments.controller.ts | 1 + .../create-environment.usecase.ts | 43 +++++++++++++++-- .../regenerate-api-keys.usecase.ts | 43 +++++++++++++++-- .../update-environment.command.ts | 3 ++ .../update-environment.e2e-ee.ts | 25 ++++++++++ .../update-environment.e2e.ts | 1 - .../update-environment.usecase.ts | 31 ++++++++++++- libs/application-generic/src/index.ts | 1 + .../src/utils/buildBridgeEndpointUrl.ts | 22 +++++++++ packages/create-novu-app/create-app.ts | 8 +++- packages/create-novu-app/index.ts | 31 ++++++++++++- packages/create-novu-app/package.json | 2 +- packages/create-novu-app/templates/index.ts | 46 +++++++++++++++++-- .../templates/tunnelScripts/tunnel.mts | 40 ++++++++++++++++ .../templates/tunnelScripts/tunnelWrapper.mts | 46 +++++++++++++++++++ packages/create-novu-app/templates/types.ts | 2 + 21 files changed, 410 insertions(+), 15 deletions(-) create mode 100644 apps/api/src/app/auth/e2e/user-registration.e2e-ee.ts create mode 100644 apps/api/src/app/environments/e2e/regenerate-api-keys.e2e-ee.ts create mode 100644 apps/api/src/app/environments/usecases/update-environment/update-environment.e2e-ee.ts create mode 100644 libs/application-generic/src/utils/buildBridgeEndpointUrl.ts create mode 100644 packages/create-novu-app/templates/tunnelScripts/tunnel.mts create mode 100644 packages/create-novu-app/templates/tunnelScripts/tunnelWrapper.mts diff --git a/apps/api/src/.env.test b/apps/api/src/.env.test index c9a407796a8..e2d256f730b 100644 --- a/apps/api/src/.env.test +++ b/apps/api/src/.env.test @@ -110,3 +110,5 @@ IS_USE_MERGED_DIGEST_ID_ENABLED=true HUBSPOT_INVITE_NUDGE_EMAIL_USER_LIST_ID= HUBSPOT_PRIVATE_APP_ACCESS_TOKEN= + +TUNNEL_BASE_ADDRESS=example.com diff --git a/apps/api/src/.example.env b/apps/api/src/.example.env index 5813cddf50b..6b25cf1fd4d 100644 --- a/apps/api/src/.example.env +++ b/apps/api/src/.example.env @@ -78,3 +78,5 @@ API_RATE_LIMIT_MAXIMUM_UNLIMITED_GLOBAL= HUBSPOT_INVITE_NUDGE_EMAIL_USER_LIST_ID= HUBSPOT_PRIVATE_APP_ACCESS_TOKEN= + +TUNNEL_BASE_ADDRESS= diff --git a/apps/api/src/app/auth/e2e/user-registration.e2e-ee.ts b/apps/api/src/app/auth/e2e/user-registration.e2e-ee.ts new file mode 100644 index 00000000000..3656f16ab80 --- /dev/null +++ b/apps/api/src/app/auth/e2e/user-registration.e2e-ee.ts @@ -0,0 +1,34 @@ +import { EnvironmentRepository } from '@novu/dal'; +import { UserSession } from '@novu/testing'; +import * as jwt from 'jsonwebtoken'; +import { expect } from 'chai'; +import { IJwtPayload } from '@novu/shared'; + +describe('User registration in enterprise - /auth/register (POST)', async () => { + let session: UserSession; + const environmentRepository = new EnvironmentRepository(); + + before(async () => { + session = new UserSession(); + await session.initialize(); + }); + + it('registered user should have the bridge url set on their environment', async () => { + const { body } = await session.testAgent.post('/v1/auth/register').send({ + email: 'Testy.test-org@gmail.com', + firstName: 'Test', + lastName: 'User', + password: '123@Qwerty', + organizationName: 'Sample org', + }); + + expect(body.data.token).to.be.ok; + + const jwtContent = (await jwt.decode(body.data.token)) as IJwtPayload; + + expect(jwtContent.environmentId).to.be.ok; + const environment = await environmentRepository.findOne({ _id: jwtContent.environmentId }); + + expect(environment.echo.url).to.be.ok; + }); +}); diff --git a/apps/api/src/app/environments/dtos/update-environment-request.dto.ts b/apps/api/src/app/environments/dtos/update-environment-request.dto.ts index d48398c6b23..4502a82e5d7 100644 --- a/apps/api/src/app/environments/dtos/update-environment-request.dto.ts +++ b/apps/api/src/app/environments/dtos/update-environment-request.dto.ts @@ -6,6 +6,11 @@ export class InBoundParseDomainDto { inboundParseDomain?: string; } +export class BridgeConfigurationDto { + @ApiPropertyOptional({ type: String }) + url?: string; +} + export class UpdateEnvironmentRequestDto { @ApiProperty() @IsOptional() @@ -26,4 +31,9 @@ export class UpdateEnvironmentRequestDto { type: InBoundParseDomainDto, }) dns?: InBoundParseDomainDto; + + @ApiPropertyOptional({ + type: BridgeConfigurationDto, + }) + bridge?: BridgeConfigurationDto; } diff --git a/apps/api/src/app/environments/e2e/regenerate-api-keys.e2e-ee.ts b/apps/api/src/app/environments/e2e/regenerate-api-keys.e2e-ee.ts new file mode 100644 index 00000000000..6547f2f6e65 --- /dev/null +++ b/apps/api/src/app/environments/e2e/regenerate-api-keys.e2e-ee.ts @@ -0,0 +1,32 @@ +import { UserSession } from '@novu/testing'; +import { expect } from 'chai'; +import { UpdateEnvironmentRequestDto } from '../dtos/update-environment-request.dto'; + +describe('Environment - Regenerate Api Key', async () => { + let session: UserSession; + + before(async () => { + session = new UserSession(); + await session.initialize(); + }); + + it('should regenerate echo url on api key regeneration as well', async () => { + const updatePayload: UpdateEnvironmentRequestDto = { + name: 'Development', + bridge: { url: 'http://example.com' }, + }; + + await session.testAgent.put(`/v1/environments/${session.environment._id}`).send(updatePayload).expect(200); + + const firstResponse = await session.testAgent.get('/v1/environments/me'); + + const oldEchoUrl = firstResponse.body.data.echo.url; + + await session.testAgent.post('/v1/environments/api-keys/regenerate').send({}); + const secondResponse = await session.testAgent.get('/v1/environments/me'); + + const updatedEchoUrl = secondResponse.body.data.echo.url; + + expect(updatedEchoUrl).to.not.equal(oldEchoUrl); + }); +}); diff --git a/apps/api/src/app/environments/environments.controller.ts b/apps/api/src/app/environments/environments.controller.ts index 39affececaa..cb87c723e5b 100644 --- a/apps/api/src/app/environments/environments.controller.ts +++ b/apps/api/src/app/environments/environments.controller.ts @@ -116,6 +116,7 @@ export class EnvironmentsController { identifier: payload.identifier, _parentId: payload.parentId, dns: payload.dns, + bridge: payload.bridge, }) ); } diff --git a/apps/api/src/app/environments/usecases/create-environment/create-environment.usecase.ts b/apps/api/src/app/environments/usecases/create-environment/create-environment.usecase.ts index 882ac67f2ac..6749d11fb68 100644 --- a/apps/api/src/app/environments/usecases/create-environment/create-environment.usecase.ts +++ b/apps/api/src/app/environments/usecases/create-environment/create-environment.usecase.ts @@ -1,9 +1,10 @@ import { nanoid } from 'nanoid'; -import { Injectable } from '@nestjs/common'; +import { Injectable, InternalServerErrorException, Logger } from '@nestjs/common'; +import { ModuleRef } from '@nestjs/core'; import { createHash } from 'crypto'; import { EnvironmentRepository } from '@novu/dal'; -import { encryptApiKey } from '@novu/application-generic'; +import { ApiException, encryptApiKey, buildBridgeEndpointUrl } from '@novu/application-generic'; import { CreateEnvironmentCommand } from './create-environment.command'; import { GenerateUniqueApiKey } from '../generate-unique-api-key/generate-unique-api-key.usecase'; @@ -18,7 +19,8 @@ export class CreateEnvironment { private environmentRepository: EnvironmentRepository, private createNotificationGroup: CreateNotificationGroup, private generateUniqueApiKey: GenerateUniqueApiKey, - private createDefaultLayoutUsecase: CreateDefaultLayout + private createDefaultLayoutUsecase: CreateDefaultLayout, + protected moduleRef: ModuleRef ) {} async execute(command: CreateEnvironmentCommand) { @@ -40,6 +42,10 @@ export class CreateEnvironment { ], }); + if (command.name === 'Development') { + await this.storeDefaultTunnelUrl(command.userId, command.organizationId, environment._id, key); + } + if (!command.parentEnvironmentId) { await this.createNotificationGroup.execute( CreateNotificationGroupCommand.create({ @@ -61,4 +67,35 @@ export class CreateEnvironment { return environment; } + + private async storeDefaultTunnelUrl(userId: string, organizationId: string, environmentId: string, apiKey: string) { + try { + if (process.env.NOVU_ENTERPRISE === 'true' || process.env.CI_EE_TEST === 'true') { + if (!require('@novu/ee-echo-api')?.StoreBridgeConfiguration) { + throw new ApiException('Echo api module is not loaded'); + } + + const baseUrl = process.env.TUNNEL_BASE_ADDRESS; + + if (baseUrl === undefined || baseUrl === '') { + throw new InternalServerErrorException('Base tunnel url not configured'); + } + + const bridgeUrl = buildBridgeEndpointUrl(apiKey, baseUrl); + + const usecase = this.moduleRef.get(require('@novu/ee-echo-api')?.StoreBridgeConfiguration, { + strict: false, + }); + + await usecase.execute({ + userId, + organizationId, + environmentId, + bridgeUrl, + }); + } + } catch (e) { + Logger.error(e, `Unexpected error while importing enterprise modules`, 'StoreBridgeConfiguration'); + } + } } diff --git a/apps/api/src/app/environments/usecases/regenerate-api-keys/regenerate-api-keys.usecase.ts b/apps/api/src/app/environments/usecases/regenerate-api-keys/regenerate-api-keys.usecase.ts index 91013dd775f..19266d6f88c 100644 --- a/apps/api/src/app/environments/usecases/regenerate-api-keys/regenerate-api-keys.usecase.ts +++ b/apps/api/src/app/environments/usecases/regenerate-api-keys/regenerate-api-keys.usecase.ts @@ -1,8 +1,9 @@ import { createHash } from 'crypto'; -import { Injectable } from '@nestjs/common'; +import { Injectable, InternalServerErrorException, Logger } from '@nestjs/common'; +import { ModuleRef } from '@nestjs/core'; import { EnvironmentRepository } from '@novu/dal'; -import { decryptApiKey, encryptApiKey } from '@novu/application-generic'; +import { buildBridgeEndpointUrl, decryptApiKey, encryptApiKey } from '@novu/application-generic'; import { ApiException } from '../../../shared/exceptions/api.exception'; import { GenerateUniqueApiKey } from '../generate-unique-api-key/generate-unique-api-key.usecase'; @@ -13,7 +14,8 @@ import { IApiKeyDto } from '../../dtos/environment-response.dto'; export class RegenerateApiKeys { constructor( private environmentRepository: EnvironmentRepository, - private generateUniqueApiKey: GenerateUniqueApiKey + private generateUniqueApiKey: GenerateUniqueApiKey, + private moduleRef: ModuleRef ) {} async execute(command: GetApiKeysCommand): Promise { @@ -34,6 +36,10 @@ export class RegenerateApiKeys { hashedApiKey ); + if (environment.name === 'Development') { + this.storeDefaultTunnelUrl(command.userId, command.organizationId, command.environmentId, key); + } + return environments.map((item) => { return { _userId: item._userId, @@ -41,4 +47,35 @@ export class RegenerateApiKeys { }; }); } + + private async storeDefaultTunnelUrl(userId: string, organizationId: string, environmentId: string, apiKey: string) { + try { + if (process.env.NOVU_ENTERPRISE === 'true' || process.env.CI_EE_TEST === 'true') { + if (!require('@novu/ee-echo-api')?.StoreBridgeConfiguration) { + throw new ApiException('Echo api module is not loaded'); + } + + const baseUrl = process.env.TUNNEL_BASE_ADDRESS; + + if (baseUrl === undefined || baseUrl === '') { + throw new InternalServerErrorException('Base tunnel url not configured'); + } + + const bridgeUrl = buildBridgeEndpointUrl(apiKey, baseUrl); + + const usecase = this.moduleRef.get(require('@novu/ee-echo-api')?.StoreBridgeConfiguration, { + strict: false, + }); + + await usecase.execute({ + userId, + organizationId, + environmentId, + bridgeUrl, + }); + } + } catch (e) { + Logger.error(e, `Unexpected error while importing enterprise modules`, 'StoreBridgeConfiguration'); + } + } } diff --git a/apps/api/src/app/environments/usecases/update-environment/update-environment.command.ts b/apps/api/src/app/environments/usecases/update-environment/update-environment.command.ts index c5ac877c369..128667b87d3 100644 --- a/apps/api/src/app/environments/usecases/update-environment/update-environment.command.ts +++ b/apps/api/src/app/environments/usecases/update-environment/update-environment.command.ts @@ -22,4 +22,7 @@ export class UpdateEnvironmentCommand extends OrganizationCommand { @IsOptional() dns?: { inboundParseDomain?: string }; + + @IsOptional() + bridge?: { url?: string }; } diff --git a/apps/api/src/app/environments/usecases/update-environment/update-environment.e2e-ee.ts b/apps/api/src/app/environments/usecases/update-environment/update-environment.e2e-ee.ts new file mode 100644 index 00000000000..a39c23a25b0 --- /dev/null +++ b/apps/api/src/app/environments/usecases/update-environment/update-environment.e2e-ee.ts @@ -0,0 +1,25 @@ +import { UserSession } from '@novu/testing'; +import { expect } from 'chai'; +import { UpdateEnvironmentRequestDto } from '../../dtos/update-environment-request.dto'; + +describe('Update Environment - /environments (PUT)', async () => { + let session: UserSession; + + before(async () => { + session = new UserSession(); + await session.initialize(); + }); + + it('should update bridge data correctly', async () => { + const updatePayload: UpdateEnvironmentRequestDto = { + name: 'Development', + bridge: { url: 'http://example.com' }, + }; + + await session.testAgent.put(`/v1/environments/${session.environment._id}`).send(updatePayload).expect(200); + const { body } = await session.testAgent.get('/v1/environments/me'); + + expect(body.data.name).to.eq(updatePayload.name); + expect(body.data.echo.url).to.equal(updatePayload.bridge?.url); + }); +}); diff --git a/apps/api/src/app/environments/usecases/update-environment/update-environment.e2e.ts b/apps/api/src/app/environments/usecases/update-environment/update-environment.e2e.ts index 5fff4c1cc47..ba6fb6ac062 100644 --- a/apps/api/src/app/environments/usecases/update-environment/update-environment.e2e.ts +++ b/apps/api/src/app/environments/usecases/update-environment/update-environment.e2e.ts @@ -1,4 +1,3 @@ -import { EnvironmentEntity, EnvironmentRepository } from '@novu/dal'; import { UserSession } from '@novu/testing'; import { expect } from 'chai'; import { UpdateEnvironmentRequestDto } from '../../dtos/update-environment-request.dto'; diff --git a/apps/api/src/app/environments/usecases/update-environment/update-environment.usecase.ts b/apps/api/src/app/environments/usecases/update-environment/update-environment.usecase.ts index adda90b4f59..a812a24cf45 100644 --- a/apps/api/src/app/environments/usecases/update-environment/update-environment.usecase.ts +++ b/apps/api/src/app/environments/usecases/update-environment/update-environment.usecase.ts @@ -20,10 +20,19 @@ export class UpdateEnvironment { updatePayload.identifier = command.identifier; } - if (command.dns && command.dns.inboundParseDomain !== '') { + if (command.dns && command.dns.inboundParseDomain && command.dns.inboundParseDomain !== '') { updatePayload[`dns.inboundParseDomain`] = command.dns.inboundParseDomain; } + if ( + (await this.shouldUpdateEchoConfiguration(command)) && + command.bridge && + command.bridge.url && + command.bridge.url !== '' + ) { + updatePayload['echo.url'] = command.bridge.url; + } + return await this.environmentRepository.update( { _id: command.environmentId, @@ -32,4 +41,24 @@ export class UpdateEnvironment { { $set: updatePayload } ); } + async shouldUpdateEchoConfiguration(command: UpdateEnvironmentCommand): Promise { + if (process.env.NOVU_ENTERPRISE === 'true' || process.env.CI_EE_TEST === 'true') { + let name: string; + if (command.name && command.name !== '') { + name = command.name; + } else { + const env = await this.environmentRepository.findOne({ _id: command.environmentId }); + + if (!env) { + return false; + } + + name = env.name; + } + + return name === 'Development'; + } else { + return false; + } + } } diff --git a/libs/application-generic/src/index.ts b/libs/application-generic/src/index.ts index 7cafca1c3fe..b3f9fbdadcb 100644 --- a/libs/application-generic/src/index.ts +++ b/libs/application-generic/src/index.ts @@ -21,6 +21,7 @@ export * from './utils/hmac'; export * from './utils/novu-integrations'; export * from './utils/require-inject'; export * from './utils/variants'; +export * from './utils/buildBridgeEndpointUrl'; export * from './decorators'; export * from './tracing'; export * from './dtos'; diff --git a/libs/application-generic/src/utils/buildBridgeEndpointUrl.ts b/libs/application-generic/src/utils/buildBridgeEndpointUrl.ts new file mode 100644 index 00000000000..0676b97ae6f --- /dev/null +++ b/libs/application-generic/src/utils/buildBridgeEndpointUrl.ts @@ -0,0 +1,22 @@ +import { createHash } from 'crypto'; + +/* + * Creates a bridge endpoint url to be used for request from novu cloud to the local + * workflow definition + */ +export const buildBridgeEndpointUrl = ( + apiKey: string, + baseAddress: string +): string => { + return `${buildBridgeSubdomain(apiKey)}.${baseAddress}`; +}; + +/* + * Creates a bridge subdomain based on the apiKey provided. This function is used in several + * places, including packages/create-novu-app/templates/index.ts when generating the + * subdomain in the bridge application. Developers should take care to keep changes + * in sync. + */ +export const buildBridgeSubdomain = (apiKey: string): string => { + return createHash('md5').update(apiKey).digest('hex'); +}; diff --git a/packages/create-novu-app/create-app.ts b/packages/create-novu-app/create-app.ts index 3e18f727643..6220e0d24be 100644 --- a/packages/create-novu-app/create-app.ts +++ b/packages/create-novu-app/create-app.ts @@ -33,6 +33,8 @@ export async function createApp({ eslint, srcDir, importAlias, + apiKey, + tunnelHost, }: { appPath: string; packageManager: PackageManager; @@ -43,6 +45,8 @@ export async function createApp({ eslint: boolean; srcDir: boolean; importAlias: string; + apiKey: string; + tunnelHost: string; }): Promise { let repoInfo: RepoInfo | undefined; const mode: TemplateMode = typescript ? 'ts' : 'js'; @@ -121,7 +125,7 @@ export async function createApp({ const isOnline = !useYarn || (await getOnline()); const originalDirectory = process.cwd(); - console.log(`Creating a new Echo app in ${green(root)}.`); + console.log(`Creating a new Novu app in ${green(root)}.`); console.log(); process.chdir(root); @@ -193,6 +197,8 @@ export async function createApp({ eslint, srcDir, importAlias, + apiKey, + tunnelHost, }); } diff --git a/packages/create-novu-app/index.ts b/packages/create-novu-app/index.ts index cb12a466247..01003b08662 100644 --- a/packages/create-novu-app/index.ts +++ b/packages/create-novu-app/index.ts @@ -19,6 +19,8 @@ let projectPath = ''; const handleSigTerm = () => process.exit(0); +const defaultTunnelHost = 'https://localtunnel.me'; + process.on('SIGINT', handleSigTerm); process.on('SIGTERM', handleSigTerm); @@ -41,6 +43,22 @@ const program = new Commander.Command(packageJson.name) .action((name) => { projectPath = name; }) + .option( + '-k, --api-key [apiKey]', + ` + + Your Novu Development environment apiKey. Note that your novu app won't + work with a non development environment apiKey. +` + ) + .option( + '-t, --tunnel-host', + ` + + Set's the tunnel host url that will be used to request local tunnels, + defaults to ${defaultTunnelHost} +` + ) .option( '--ts, --typescript', ` @@ -113,7 +131,7 @@ const packageManager = !!program.useNpm : getPkgManager(); async function run(): Promise { - const conf = new Conf({ projectName: 'create-echo-app' }); + const conf = new Conf({ projectName: 'create-novu-app' }); if (program.resetPreferences) { conf.clear(); @@ -132,7 +150,7 @@ async function run(): Promise { type: 'text', name: 'path', message: 'What is your project named?', - initial: 'my-echo-app', + initial: 'my-novu-app', validate: (name) => { const validation = validateNpmName(path.basename(path.resolve(name))); if (validation.valid) { @@ -175,6 +193,11 @@ async function run(): Promise { process.exit(1); } + if (program.apiKey === true || !program.apiKey) { + console.error('Please provide a valid apiKey value.'); + process.exit(1); + } + /** * Verify the project dir is empty or doesn't exist */ @@ -260,6 +283,8 @@ async function run(): Promise { eslint: program.eslint, srcDir: program.srcDir, importAlias: program.importAlias, + apiKey: program.apiKey, + tunnelHost: program.tunnelHost ? program.tunnelHost : defaultTunnelHost, }); } catch (reason) { if (!(reason instanceof DownloadError)) { @@ -287,6 +312,8 @@ async function run(): Promise { reactEmail: program.reactEmail, srcDir: program.srcDir, importAlias: program.importAlias, + apiKey: program.apiKey, + tunnelHost: program.tunnelHost ? program.tunnelHost : defaultTunnelHost, }); } conf.set('preferences', preferences); diff --git a/packages/create-novu-app/package.json b/packages/create-novu-app/package.json index 892dade0b24..96dbb9fe2fc 100644 --- a/packages/create-novu-app/package.json +++ b/packages/create-novu-app/package.json @@ -19,7 +19,7 @@ ], "scripts": { "start": "node dist/index.js", - "dev": "ncc build ./index.ts -w -o dist/", + "dev": "rm -rf dist/ && ncc build ./index.ts -w -o dist/", "prerelease": "rimraf dist", "release": "ncc build ./index.ts -o ./dist/ --minify --no-cache --no-source-map-register", "build": "pnpm release", diff --git a/packages/create-novu-app/templates/index.ts b/packages/create-novu-app/templates/index.ts index 6cc6526f2af..c5803bbdef2 100644 --- a/packages/create-novu-app/templates/index.ts +++ b/packages/create-novu-app/templates/index.ts @@ -3,15 +3,14 @@ import { install } from "../helpers/install"; import { copy } from "../helpers/copy"; import { async as glob } from "fast-glob"; +import { createHash } from "crypto"; import os from "os"; import fs from "fs/promises"; import path from "path"; import { cyan, bold } from "picocolors"; import { Sema } from "async-sema"; -import pkg from "../package.json"; import { GetTemplateFileArgs, InstallTemplateArgs } from "./types"; - /** * Get the file path for a given file in a template, e.g. "next.config.js". */ @@ -39,6 +38,8 @@ export const installTemplate = async ({ eslint, srcDir, importAlias, + apiKey, + tunnelHost, }: InstallTemplateArgs) => { console.log(bold(`Using ${packageManager}.`)); @@ -76,6 +77,11 @@ export const installTemplate = async ({ } }, }); + // move tunnel scripts to the project folder + await copy(copySource, `${root}/scripts`, { + parents: true, + cwd: path.join(__dirname, `tunnelScripts`), + }); const tsconfigFile = path.join(root, "tsconfig.json"); await fs.writeFile( @@ -166,6 +172,18 @@ export const installTemplate = async ({ } } + /* write .env file */ + const port = 4000; + const val = Object.entries({ + PORT: port, + API_KEY: apiKey, + TUNNEL_HOST: tunnelHost, + }).reduce((acc, [key, value]) => { + return `${acc}${key}=${value}${os.EOL}`; + }, ""); + + await fs.writeFile(path.join(root, ".env"), val); + /** Copy the version from package.json or override for tests. */ const version = "14.2.3"; @@ -175,7 +193,9 @@ export const installTemplate = async ({ version: "0.1.0", private: true, scripts: { - dev: "next dev --port=4000", + tunnel: "tsx scripts/tunnel.mts", + "next-dev": `next dev --port=${port}`, + dev: 'concurrently -k --restart-tries 5 --restart-after 1000 --names "📡 TUNNEL,🖥️ SERVER" -c "bgBlue.bold,bgMagenta.bold" "npm:tunnel" "npm:next-dev"', build: "next build", start: "next start", lint: "next lint", @@ -199,6 +219,7 @@ export const installTemplate = async ({ packageJson.devDependencies = { ...packageJson.devDependencies, typescript: "^5", + tsx: "^4.15.1", "@types/node": "^20", "@types/react": "^18", "@types/react-dom": "^18", @@ -230,6 +251,25 @@ export const installTemplate = async ({ }; } + /* local tunnel */ + packageJson.devDependencies = { + ...packageJson.devDependencies, + "@types/localtunnel": "^2.0.4", + localtunnel: "^2.0.2", + }; + + /* dotenv */ + packageJson.devDependencies = { + ...packageJson.devDependencies, + dotenv: "^16.4.5", + }; + + /* concurrently */ + packageJson.devDependencies = { + ...packageJson.devDependencies, + concurrently: "^8.2.2", + }; + const devDeps = Object.keys(packageJson.devDependencies).length; if (!devDeps) delete packageJson.devDependencies; diff --git a/packages/create-novu-app/templates/tunnelScripts/tunnel.mts b/packages/create-novu-app/templates/tunnelScripts/tunnel.mts new file mode 100644 index 00000000000..bc8e30c10ec --- /dev/null +++ b/packages/create-novu-app/templates/tunnelScripts/tunnel.mts @@ -0,0 +1,40 @@ +import { createHash } from "crypto"; +import LocalTunnelWrapper from "./tunnelWrapper.mjs"; +import dotenv from "dotenv"; + +dotenv.config(); + +(async () => { + if ( + process.env.PORT === undefined || + Number.isNaN(parseInt(process.env.PORT)) + ) { + throw new Error( + "PORT environment variable value is required and should be a positive integer", + ); + } + + if (process.env.API_KEY === undefined || process.env.API_KEY === "") { + throw new Error("API_KEY environment variable value is required"); + } + + if (process.env.TUNNEL_HOST === undefined || process.env.TUNNEL_HOST === "") { + throw new Error("TUNNEL_HOST environment variable value is required"); + } + + // This function is also present in @novu/application-generic and is used + // to generate the subdomain per api key in the buildBridgeSubdomain() function. + // It should be kept in sync with the implementation in the library + const subdomain = createHash("md5").update(process.env.API_KEY).digest("hex"); + + const port = parseInt(process.env.PORT); + const wrapper = new LocalTunnelWrapper( + port, + subdomain, + process.env.TUNNEL_HOST, + ); + await wrapper.connect(); + console.log( + `Your app is available on the following address: ${wrapper.getUrl()}`, + ); +})(); diff --git a/packages/create-novu-app/templates/tunnelScripts/tunnelWrapper.mts b/packages/create-novu-app/templates/tunnelScripts/tunnelWrapper.mts new file mode 100644 index 00000000000..f58e2bbc9fa --- /dev/null +++ b/packages/create-novu-app/templates/tunnelScripts/tunnelWrapper.mts @@ -0,0 +1,46 @@ +import localtunnel from "localtunnel"; + +export interface ITunnelWrapper { + connect(): Promise; + getUrl(): string | undefined; +} + +export default class LocalTunnelWrapper implements ITunnelWrapper { + private port: number; + private subdomain: string; + private tunnel: localtunnel.Tunnel | undefined; + private host: string; + + constructor(port: number, subdomain: string, host: string) { + this.port = port; + this.subdomain = subdomain; + this.host = host; + } + + async connect() { + this.tunnel = await localtunnel({ + port: this.port, + subdomain: this.subdomain, + host: this.host, + }); + + this.tunnel.on("error", this.errorHandler); + this.tunnel.on("close", this.closeHandler); + } + + public getUrl() { + return this.tunnel?.url; + } + + private errorHandler(err: Error) { + console.log(err); + console.log( + "Localtunnel seems to have crashed, the process will attempt to restart...", + ); + process.exit(1); + } + + private closeHandler() { + console.log("Successfully closed the tunnel."); + } +} diff --git a/packages/create-novu-app/templates/types.ts b/packages/create-novu-app/templates/types.ts index ce92fb357b2..0ab61394274 100644 --- a/packages/create-novu-app/templates/types.ts +++ b/packages/create-novu-app/templates/types.ts @@ -26,4 +26,6 @@ export interface InstallTemplateArgs { reactEmail: boolean; srcDir: boolean; importAlias: string; + apiKey: string; + tunnelHost: string; }