From d1c386585c731ec5b91b0384eb62157ea8045865 Mon Sep 17 00:00:00 2001 From: Sokratis Vidros Date: Wed, 4 Dec 2024 14:16:59 +0200 Subject: [PATCH] Revert "Revert "fix(js): Remove @novu/shared dependency" (#7206)" This reverts commit adde8dd899d826abad39e9ab13c2b62f6321ba69. --- packages/js/jest.config.cjs | 5 ++ packages/js/jest.setup.ts | 2 - packages/js/package.json | 1 - packages/js/src/api/http-client.ts | 119 ++++++++++++++++++++++++++ packages/js/src/api/inbox-service.ts | 31 +++---- packages/js/src/base-module.test.ts | 71 ++++++++++++++++ packages/js/src/global.d.ts | 11 +-- packages/js/src/novu.test.ts | 121 ++++++++++----------------- packages/js/src/novu.ts | 8 +- packages/js/src/session/session.ts | 1 - packages/js/tsconfig.json | 2 +- packages/js/tsup.config.ts | 9 +- pnpm-lock.yaml | 3 - 13 files changed, 272 insertions(+), 112 deletions(-) create mode 100644 packages/js/src/api/http-client.ts create mode 100644 packages/js/src/base-module.test.ts diff --git a/packages/js/jest.config.cjs b/packages/js/jest.config.cjs index bde0ef6b7f8..a03abac029d 100644 --- a/packages/js/jest.config.cjs +++ b/packages/js/jest.config.cjs @@ -1,4 +1,9 @@ module.exports = { preset: 'ts-jest', setupFiles: ['./jest.setup.ts'], + globals: { + NOVU_API_VERSION: '2024-06-26', + PACKAGE_NAME: '@novu/js', + PACKAGE_VERSION: 'test', + }, }; diff --git a/packages/js/jest.setup.ts b/packages/js/jest.setup.ts index 85cf83a3672..e69de29bb2d 100644 --- a/packages/js/jest.setup.ts +++ b/packages/js/jest.setup.ts @@ -1,2 +0,0 @@ -global.PACKAGE_VERSION = 'test-version'; -global.PACKAGE_NAME = 'test-package'; diff --git a/packages/js/package.json b/packages/js/package.json index 18b739b6201..02b14a5fea2 100644 --- a/packages/js/package.json +++ b/packages/js/package.json @@ -126,7 +126,6 @@ }, "dependencies": { "@floating-ui/dom": "^1.6.7", - "@novu/client": "workspace:*", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", "mitt": "^3.0.1", diff --git a/packages/js/src/api/http-client.ts b/packages/js/src/api/http-client.ts new file mode 100644 index 00000000000..b0cb50566ee --- /dev/null +++ b/packages/js/src/api/http-client.ts @@ -0,0 +1,119 @@ +export type HttpClientOptions = { + apiVersion?: string; + backendUrl?: string; + userAgent?: string; +}; + +const DEFAULT_API_VERSION = 'v1'; +const DEFAULT_BACKEND_URL = 'https://api.novu.co'; +const DEFAULT_USER_AGENT = `${PACKAGE_NAME}@${PACKAGE_VERSION}`; + +export class HttpClient { + private backendUrl: string; + private apiVersion: string; + private headers: Record; + + constructor(options: HttpClientOptions = {}) { + const { + apiVersion = DEFAULT_API_VERSION, + backendUrl = DEFAULT_BACKEND_URL, + userAgent = DEFAULT_USER_AGENT, + } = options || {}; + this.apiVersion = apiVersion; + this.backendUrl = `${backendUrl}/${this.apiVersion}`; + this.headers = { + 'Novu-API-Version': NOVU_API_VERSION, + 'Content-Type': 'application/json', + 'User-Agent': userAgent, + }; + } + + setAuthorizationToken(token: string) { + this.headers.Authorization = `Bearer ${token}`; + } + + setHeaders(headers: Record) { + this.headers = { + ...this.headers, + ...headers, + }; + } + + async get(path: string, searchParams?: URLSearchParams, unwrapEnvelope = true) { + return this.doFetch({ + path, + searchParams, + options: { + method: 'GET', + }, + unwrapEnvelope, + }); + } + + async post(path: string, body?: any) { + return this.doFetch({ + path, + options: { + method: 'POST', + body, + }, + }); + } + + async patch(path: string, body?: any) { + return this.doFetch({ + path, + options: { + method: 'PATCH', + body, + }, + }); + } + + async delete(path: string, body?: any) { + return this.doFetch({ + path, + options: { + method: 'DELETE', + body, + }, + }); + } + + private async doFetch({ + path, + searchParams, + options, + unwrapEnvelope = true, + }: { + path: string; + searchParams?: URLSearchParams; + options?: RequestInit; + unwrapEnvelope?: boolean; + }) { + const fullUrl = combineUrl(this.backendUrl, path, searchParams ? `?${searchParams.toString()}` : ''); + const reqInit = { + method: options?.method || 'GET', + headers: { ...this.headers, ...(options?.headers || {}) }, + body: options?.body ? JSON.stringify(options.body) : undefined, + }; + + const response = await fetch(fullUrl, reqInit); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(`${this.headers['User-Agent']} error. Status: ${response.status}, Message: ${errorData.message}`); + } + if (response.status === 204) { + return undefined as unknown as T; + } + + const res = await response.json(); + + return (unwrapEnvelope ? res.data : res) as Promise; + } +} + +function combineUrl(...args: string[]): string { + return args.map((part) => part.replace(/^\/+|\/+$/g, '')).join('/'); +} diff --git a/packages/js/src/api/inbox-service.ts b/packages/js/src/api/inbox-service.ts index e375934c3ac..70573f4f439 100644 --- a/packages/js/src/api/inbox-service.ts +++ b/packages/js/src/api/inbox-service.ts @@ -1,4 +1,3 @@ -import { ApiOptions, HttpClient } from '@novu/client'; import type { ActionTypeEnum, ChannelPreference, @@ -7,10 +6,10 @@ import type { PreferencesResponse, Session, } from '../types'; +import { HttpClient, HttpClientOptions } from './http-client'; -export type InboxServiceOptions = ApiOptions; +export type InboxServiceOptions = HttpClientOptions; -const NOVU_API_VERSION = '2024-06-26'; const INBOX_ROUTE = '/inbox'; const INBOX_NOTIFICATIONS_ROUTE = `${INBOX_ROUTE}/notifications`; @@ -20,10 +19,6 @@ export class InboxService { constructor(options: InboxServiceOptions = {}) { this.#httpClient = new HttpClient(options); - this.#httpClient.updateHeaders({ - 'Novu-API-Version': NOVU_API_VERSION, - 'Novu-User-Agent': options.userAgent || '@novu/js', - }); } async initializeSession({ @@ -61,24 +56,24 @@ export class InboxService { after?: string; offset?: number; }): Promise<{ data: InboxNotification[]; hasMore: boolean; filter: NotificationFilter }> { - const queryParams = new URLSearchParams(`limit=${limit}`); + const searchParams = new URLSearchParams(`limit=${limit}`); if (after) { - queryParams.append('after', after); + searchParams.append('after', after); } if (offset) { - queryParams.append('offset', `${offset}`); + searchParams.append('offset', `${offset}`); } if (tags) { - tags.forEach((tag) => queryParams.append('tags[]', tag)); + tags.forEach((tag) => searchParams.append('tags[]', tag)); } if (read !== undefined) { - queryParams.append('read', `${read}`); + searchParams.append('read', `${read}`); } if (archived !== undefined) { - queryParams.append('archived', `${archived}`); + searchParams.append('archived', `${archived}`); } - return this.#httpClient.getFullResponse(`${INBOX_NOTIFICATIONS_ROUTE}?${queryParams.toString()}`); + return this.#httpClient.get(INBOX_NOTIFICATIONS_ROUTE, searchParams, false); } count({ filters }: { filters: Array<{ tags?: string[]; read?: boolean; archived?: boolean }> }): Promise<{ @@ -87,7 +82,13 @@ export class InboxService { filter: NotificationFilter; }>; }> { - return this.#httpClient.getFullResponse(`${INBOX_NOTIFICATIONS_ROUTE}/count?filters=${JSON.stringify(filters)}`); + return this.#httpClient.get( + `${INBOX_NOTIFICATIONS_ROUTE}/count`, + new URLSearchParams({ + filters: JSON.stringify(filters), + }), + false + ); } read(notificationId: string): Promise { diff --git a/packages/js/src/base-module.test.ts b/packages/js/src/base-module.test.ts new file mode 100644 index 00000000000..58f2a43287d --- /dev/null +++ b/packages/js/src/base-module.test.ts @@ -0,0 +1,71 @@ +import { InboxService } from './api'; +import { BaseModule } from './base-module'; +import { NovuEventEmitter } from './event-emitter'; + +beforeAll(() => jest.spyOn(global, 'fetch')); +afterAll(() => jest.restoreAllMocks()); + +describe('callWithSession(fn)', () => { + test('should invoke callback function immediately if session is initialized', async () => { + const emitter = new NovuEventEmitter(); + const bm = new BaseModule({ + inboxServiceInstance: { + isSessionInitialized: true, + } as InboxService, + eventEmitterInstance: emitter, + }); + + const cb = jest.fn(); + bm.callWithSession(cb); + expect(cb).toHaveBeenCalled(); + }); + + test('should invoke callback function as soon as session is initialized', async () => { + const emitter = new NovuEventEmitter(); + const bm = new BaseModule({ + inboxServiceInstance: {} as InboxService, + eventEmitterInstance: emitter, + }); + + const cb = jest.fn(); + + bm.callWithSession(cb); + expect(cb).not.toHaveBeenCalled(); + + emitter.emit('session.initialize.resolved', { + args: { + applicationIdentifier: 'foo', + subscriberId: 'bar', + }, + data: { + token: 'cafebabe', + totalUnreadCount: 10, + removeNovuBranding: true, + }, + }); + + expect(cb).toHaveBeenCalled(); + }); + + test('should return an error if session initialization failed', async () => { + const emitter = new NovuEventEmitter(); + const bm = new BaseModule({ + inboxServiceInstance: {} as InboxService, + eventEmitterInstance: emitter, + }); + + emitter.emit('session.initialize.resolved', { + args: { + applicationIdentifier: 'foo', + subscriberId: 'bar', + }, + error: new Error('Failed to initialize session'), + }); + + const cb = jest.fn(); + const result = await bm.callWithSession(cb); + expect(result).toEqual({ + error: new Error('Failed to initialize session, please contact the support'), + }); + }); +}); diff --git a/packages/js/src/global.d.ts b/packages/js/src/global.d.ts index 9741972a4ed..2ea80da406b 100644 --- a/packages/js/src/global.d.ts +++ b/packages/js/src/global.d.ts @@ -1,10 +1,11 @@ -/* eslint-disable vars-on-top */ -/* eslint-disable no-var */ -import { Novu } from './novu'; +import type { Novu } from './novu'; + +export {}; declare global { - var PACKAGE_NAME: string; - var PACKAGE_VERSION: string; + const NOVU_API_VERSION: string; + const PACKAGE_NAME: string; + const PACKAGE_VERSION: string; interface Window { Novu: typeof Novu; } diff --git a/packages/js/src/novu.test.ts b/packages/js/src/novu.test.ts index 49fa73342b6..5e134a646ed 100644 --- a/packages/js/src/novu.test.ts +++ b/packages/js/src/novu.test.ts @@ -1,6 +1,6 @@ -import { ListNotificationsArgs } from './notifications'; import { Novu } from './novu'; -import { NovuError } from './utils/errors'; + +const mockSessionResponse = { data: { token: 'cafebabe' } }; const mockNotificationsResponse = { data: [], @@ -8,100 +8,71 @@ const mockNotificationsResponse = { filter: { tags: [], read: false, archived: false }, }; -const post = jest.fn().mockResolvedValue({ token: 'token', profile: 'profile' }); -const getFullResponse = jest.fn(() => mockNotificationsResponse); -const updateHeaders = jest.fn(); -const setAuthorizationToken = jest.fn(); - -jest.mock('@novu/client', () => ({ - ...jest.requireActual('@novu/client'), - HttpClient: jest.fn().mockImplementation(() => { - const httpClient = { - post, - getFullResponse, - updateHeaders, - setAuthorizationToken, +async function mockFetch(url: string, reqInit: Request) { + if (url.includes('/session')) { + return { + ok: true, + status: 200, + json: async () => mockSessionResponse, }; + } + if (url.includes('/notifications')) { + return { + ok: true, + status: 200, + json: async () => mockNotificationsResponse, + }; + } + throw new Error(`Unmocked request: ${url}`); +} - return httpClient; - }), -})); +beforeAll(() => jest.spyOn(global, 'fetch')); +afterAll(() => jest.restoreAllMocks()); describe('Novu', () => { + const applicationIdentifier = 'foo'; + const subscriberId = 'bar'; + beforeEach(() => { - jest.clearAllMocks(); + // @ts-ignore + global.fetch.mockImplementation(mockFetch) as jest.Mock; }); - describe('lazy session initialization', () => { - test('should call the queued notifications.list after the session is initialized', async () => { + describe('http client', () => { + test('should call the notifications.list after the session is initialized', async () => { const options = { limit: 10, offset: 0, }; - const novu = new Novu({ applicationIdentifier: 'applicationIdentifier', subscriberId: 'subscriberId' }); - const { data } = await novu.notifications.list(options); - expect(post).toHaveBeenCalledTimes(1); - expect(getFullResponse).toHaveBeenCalledWith('/inbox/notifications?limit=10'); - expect(data).toEqual({ - notifications: mockNotificationsResponse.data, - hasMore: mockNotificationsResponse.hasMore, - filter: mockNotificationsResponse.filter, + const novu = new Novu({ applicationIdentifier, subscriberId }); + expect(fetch).toHaveBeenNthCalledWith(1, 'https://api.novu.co/v1/inbox/session/', { + method: 'POST', + body: JSON.stringify({ applicationIdentifier, subscriberId }), + headers: { + 'Content-Type': 'application/json', + 'Novu-API-Version': '2024-06-26', + 'User-Agent': '@novu/js@test', + }, }); - }); - test('should call the notifications.list right away when session is already initialized', async () => { - const options: ListNotificationsArgs = { - limit: 10, - offset: 0, - }; - const novu = new Novu({ applicationIdentifier: 'applicationIdentifier', subscriberId: 'subscriberId' }); - // await for session initialization - await new Promise((resolve) => { - setTimeout(resolve, 10); + const { data } = await novu.notifications.list(options); + expect(fetch).toHaveBeenNthCalledWith(2, 'https://api.novu.co/v1/inbox/notifications/?limit=10', { + method: 'GET', + body: undefined, + headers: { + Authorization: 'Bearer cafebabe', + 'Content-Type': 'application/json', + 'Novu-API-Version': '2024-06-26', + 'User-Agent': '@novu/js@test', + }, }); - const { data } = await novu.notifications.list({ limit: 10, offset: 0 }); - - expect(post).toHaveBeenCalledTimes(1); - expect(getFullResponse).toHaveBeenCalledWith('/inbox/notifications?limit=10'); expect(data).toEqual({ notifications: mockNotificationsResponse.data, hasMore: mockNotificationsResponse.hasMore, filter: mockNotificationsResponse.filter, }); }); - - test('should reject the queued notifications.list if session initialization fails', async () => { - const options = { - limit: 10, - offset: 0, - }; - const expectedError = 'reason'; - post.mockRejectedValueOnce(expectedError); - const novu = new Novu({ applicationIdentifier: 'applicationIdentifier', subscriberId: 'subscriberId' }); - - const { error } = await novu.notifications.list(options); - - expect(error).toEqual(new NovuError('Failed to initialize session, please contact the support', expectedError)); - }); - - test('should reject the notifications.list right away when session initialization has failed', async () => { - const options = { - limit: 10, - offset: 0, - }; - const expectedError = 'reason'; - post.mockRejectedValueOnce(expectedError); - const novu = new Novu({ applicationIdentifier: 'applicationIdentifier', subscriberId: 'subscriberId' }); - // await for session initialization - await new Promise((resolve) => { - setTimeout(resolve, 10); - }); - - const { error } = await novu.notifications.list(options); - - expect(error).toEqual(new NovuError('Failed to initialize session, please contact the support', expectedError)); - }); }); }); diff --git a/packages/js/src/novu.ts b/packages/js/src/novu.ts index 01c769e5e53..feebd4564c3 100644 --- a/packages/js/src/novu.ts +++ b/packages/js/src/novu.ts @@ -8,12 +8,6 @@ import { PRODUCTION_BACKEND_URL } from './utils/config'; import type { NovuOptions } from './types'; import { InboxService } from './api'; -// @ts-ignore -const version = PACKAGE_VERSION; -// @ts-ignore -const name = PACKAGE_NAME; -const userAgent = `${name}@${version}`; - export class Novu implements Pick { #emitter: NovuEventEmitter; #session: Session; @@ -32,7 +26,7 @@ export class Novu implements Pick { constructor(options: NovuOptions) { this.#inboxService = new InboxService({ backendUrl: options.backendUrl ?? PRODUCTION_BACKEND_URL, - userAgent: options.__userAgent ?? userAgent, + userAgent: options.__userAgent, }); this.#emitter = new NovuEventEmitter(); this.#session = new Session( diff --git a/packages/js/src/session/session.ts b/packages/js/src/session/session.ts index bef8f3ac154..1e72a768606 100644 --- a/packages/js/src/session/session.ts +++ b/packages/js/src/session/session.ts @@ -21,7 +21,6 @@ export class Session { try { const { applicationIdentifier, subscriberId, subscriberHash } = this.#options; this.#emitter.emit('session.initialize.pending', { args: this.#options }); - const response = await this.#inboxService.initializeSession({ applicationIdentifier, subscriberId, diff --git a/packages/js/tsconfig.json b/packages/js/tsconfig.json index 0e5d3b81e19..4a83a390ddf 100644 --- a/packages/js/tsconfig.json +++ b/packages/js/tsconfig.json @@ -19,5 +19,5 @@ "removeComments": false }, "include": ["src/**/*", "src/**/*.d.ts"], - "exclude": ["src/**/*.test.ts", "src/*.test.ts", "node_modules", "**/node_modules/*"] + "exclude": ["node_modules", "**/node_modules/*"] } diff --git a/packages/js/tsup.config.ts b/packages/js/tsup.config.ts index 52cae64f1e5..ab3d4638c22 100644 --- a/packages/js/tsup.config.ts +++ b/packages/js/tsup.config.ts @@ -22,14 +22,13 @@ const buildCSS = async () => { fs.writeFileSync(destinationCssFilePath, processedCss); }; -const isProd = process.env?.NODE_ENV === 'production'; +const isProd = process.env.NODE_ENV === 'production'; const baseConfig: Options = { splitting: true, sourcemap: false, clean: true, esbuildPlugins: [solidPlugin()], - define: { PACKAGE_NAME: `"${name}"`, PACKAGE_VERSION: `"${version}"`, __DEV__: `${!isProd}` }, }; const baseModuleConfig: Options = { @@ -42,6 +41,12 @@ const baseModuleConfig: Options = { 'themes/index': './src/ui/themes/index.ts', 'internal/index': './src/ui/internal/index.ts', }, + define: { + NOVU_API_VERSION: `"2024-06-26"`, + PACKAGE_NAME: `"${name}"`, + PACKAGE_VERSION: `"${version}"`, + __DEV__: `${isProd ? false : true}`, + }, }; export default defineConfig((config: Options) => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b9b5c56ea66..e7421d217e9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3647,9 +3647,6 @@ importers: '@floating-ui/dom': specifier: ^1.6.7 version: 1.6.7 - '@novu/client': - specifier: workspace:* - version: link:../client class-variance-authority: specifier: ^0.7.0 version: 0.7.0