diff --git a/packages/audience/pixel/src/attribution.test.ts b/packages/audience/core/src/attribution.test.ts similarity index 100% rename from packages/audience/pixel/src/attribution.test.ts rename to packages/audience/core/src/attribution.test.ts diff --git a/packages/audience/pixel/src/attribution.ts b/packages/audience/core/src/attribution.ts similarity index 100% rename from packages/audience/pixel/src/attribution.ts rename to packages/audience/core/src/attribution.ts diff --git a/packages/audience/core/src/config.ts b/packages/audience/core/src/config.ts index df6dd0f552..f1f775f317 100644 --- a/packages/audience/core/src/config.ts +++ b/packages/audience/core/src/config.ts @@ -15,5 +15,6 @@ export const FLUSH_SIZE = 20; export const COOKIE_NAME = 'imtbl_anon_id'; export const SESSION_COOKIE = '_imtbl_sid'; export const COOKIE_MAX_AGE_SECONDS = 365 * 24 * 60 * 60 * 2; // 2 years +export const SESSION_MAX_AGE = 30 * 60; // 30 minutes in seconds export const getBaseUrl = (environment: Environment): string => BASE_URLS[environment]; diff --git a/packages/audience/core/src/consent.test.ts b/packages/audience/core/src/consent.test.ts new file mode 100644 index 0000000000..a52a18cbae --- /dev/null +++ b/packages/audience/core/src/consent.test.ts @@ -0,0 +1,114 @@ +import { createConsentManager } from './consent'; +import { httpSend } from './transport'; + +jest.mock('./transport', () => ({ + httpSend: jest.fn().mockResolvedValue(true), +})); + +const mockHttpSend = httpSend as jest.MockedFunction; + +function createMockQueue() { + return { + purge: jest.fn(), + transform: jest.fn(), + enqueue: jest.fn(), + flush: jest.fn(), + flushUnload: jest.fn(), + start: jest.fn(), + stop: jest.fn(), + destroy: jest.fn(), + clear: jest.fn(), + get length() { return 0; }, + } as any; +} + +beforeEach(() => { + jest.clearAllMocks(); +}); + +describe('createConsentManager', () => { + it('defaults to none when no initial level provided', () => { + const queue = createMockQueue(); + const manager = createConsentManager(queue, 'pk_test', 'anon-1', 'dev', 'pixel'); + expect(manager.level).toBe('none'); + }); + + it('uses the initial level when provided', () => { + const queue = createMockQueue(); + const manager = createConsentManager(queue, 'pk_test', 'anon-1', 'dev', 'pixel', 'anonymous'); + expect(manager.level).toBe('anonymous'); + }); + + it('upgrades consent without modifying queue', () => { + const queue = createMockQueue(); + const manager = createConsentManager(queue, 'pk_test', 'anon-1', 'dev', 'pixel', 'none'); + + manager.setLevel('anonymous'); + expect(manager.level).toBe('anonymous'); + expect(queue.purge).not.toHaveBeenCalled(); + expect(queue.transform).not.toHaveBeenCalled(); + }); + + it('purges queue on downgrade to none', () => { + const queue = createMockQueue(); + const manager = createConsentManager(queue, 'pk_test', 'anon-1', 'dev', 'pixel', 'full'); + + manager.setLevel('none'); + expect(manager.level).toBe('none'); + expect(queue.purge).toHaveBeenCalledWith(expect.any(Function)); + + // Verify the purge predicate matches all messages + const purgeFn = queue.purge.mock.calls[0][0]; + expect(purgeFn({ type: 'page' })).toBe(true); + }); + + it('strips userId on downgrade from full to anonymous', () => { + const queue = createMockQueue(); + const manager = createConsentManager(queue, 'pk_test', 'anon-1', 'dev', 'pixel', 'full'); + + manager.setLevel('anonymous'); + expect(manager.level).toBe('anonymous'); + expect(queue.transform).toHaveBeenCalledWith(expect.any(Function)); + + // Verify the transform strips userId + const transformFn = queue.transform.mock.calls[0][0]; + const withUserId = { type: 'page', userId: 'u-1', anonymousId: 'a-1' }; + const result = transformFn(withUserId); + expect(result.userId).toBeUndefined(); + expect(result.anonymousId).toBe('a-1'); + }); + + it('fires PUT to consent endpoint on level change', () => { + const queue = createMockQueue(); + const manager = createConsentManager(queue, 'pk_test', 'anon-1', 'dev', 'pixel', 'none'); + + manager.setLevel('anonymous'); + + expect(mockHttpSend).toHaveBeenCalledWith( + 'https://api.dev.immutable.com/v1/audience/tracking-consent', + 'pk_test', + { anonymousId: 'anon-1', status: 'anonymous', source: 'pixel' }, + { method: 'PUT', keepalive: true }, + ); + }); + + it('does nothing when setting the same level', () => { + const queue = createMockQueue(); + const manager = createConsentManager(queue, 'pk_test', 'anon-1', 'dev', 'pixel', 'anonymous'); + + manager.setLevel('anonymous'); + expect(queue.purge).not.toHaveBeenCalled(); + expect(queue.transform).not.toHaveBeenCalled(); + expect(mockHttpSend).not.toHaveBeenCalled(); + }); + + it('respects DNT by defaulting to none', () => { + Object.defineProperty(navigator, 'doNotTrack', { value: '1', configurable: true }); + + const queue = createMockQueue(); + const manager = createConsentManager(queue, 'pk_test', 'anon-1', 'dev', 'pixel'); + expect(manager.level).toBe('none'); + + Object.defineProperty(navigator, 'doNotTrack', { value: '0', configurable: true }); + }); +}); diff --git a/packages/audience/core/src/consent.ts b/packages/audience/core/src/consent.ts new file mode 100644 index 0000000000..8d4d30ee26 --- /dev/null +++ b/packages/audience/core/src/consent.ts @@ -0,0 +1,82 @@ +import type { + ConsentLevel, ConsentUpdatePayload, Message, Environment, +} from './types'; +import type { MessageQueue } from './queue'; +import { CONSENT_PATH, getBaseUrl } from './config'; +import { httpSend } from './transport'; + +export interface ConsentManager { + level: ConsentLevel; + setLevel(next: ConsentLevel): void; +} + +export function detectDoNotTrack(): boolean { + if (typeof navigator === 'undefined') return false; + // DNT header + if (navigator.doNotTrack === '1') return true; + // Global Privacy Control + if ((navigator as unknown as Record).globalPrivacyControl === true) return true; + return false; +} + +/** + * Create a consent state machine. + * + * - Default level is `'none'` (no collection). + * - If DNT or GPC is detected and no explicit consent is provided, stays `'none'`. + * - On downgrade (e.g. full -> anonymous), strips `userId` from queued messages. + * - On downgrade to `'none'`, purges the queue entirely. + * - Fires PUT to `/v1/audience/tracking-consent` on every state change. + */ +export function createConsentManager( + queue: MessageQueue, + publishableKey: string, + anonymousId: string, + environment: Environment, + source: string, + initialLevel?: ConsentLevel, +): ConsentManager { + const dntDetected = detectDoNotTrack(); + let current: ConsentLevel = initialLevel ?? (dntDetected ? 'none' : 'none'); + + const LEVELS: Record = { none: 0, anonymous: 1, full: 2 }; + + function notifyBackend(level: ConsentLevel): void { + const url = `${getBaseUrl(environment)}${CONSENT_PATH}`; + const payload: ConsentUpdatePayload = { anonymousId, status: level, source }; + httpSend(url, publishableKey, payload, { method: 'PUT', keepalive: true }); + } + + const manager: ConsentManager = { + get level() { + return current; + }, + + setLevel(next: ConsentLevel): void { + if (next === current) return; + + const isDowngrade = LEVELS[next] < LEVELS[current]; + + if (isDowngrade) { + if (next === 'none') { + // Purge all queued messages + queue.purge(() => true); + } else if (next === 'anonymous') { + // Strip userId from queued messages + queue.transform((msg: Message) => { + if ('userId' in msg) { + const { userId, ...rest } = msg; + return rest as Message; + } + return msg; + }); + } + } + + current = next; + notifyBackend(next); + }, + }; + + return manager; +} diff --git a/packages/audience/core/src/index.ts b/packages/audience/core/src/index.ts index 0ae370d0d8..d5e77b568e 100644 --- a/packages/audience/core/src/index.ts +++ b/packages/audience/core/src/index.ts @@ -13,6 +13,7 @@ export type { BatchPayload, ConsentLevel, ConsentStatus, + ConsentUpdatePayload, } from './types'; export { @@ -32,11 +33,12 @@ export { FLUSH_SIZE, COOKIE_NAME, SESSION_COOKIE, + SESSION_MAX_AGE, } from './config'; export { generateId, getTimestamp, isBrowser } from './utils'; -export type { Transport } from './transport'; +export type { Transport, TransportOptions } from './transport'; export { httpTransport, httpSend } from './transport'; export { MessageQueue } from './queue'; export { collectContext } from './context'; @@ -46,3 +48,12 @@ export { truncate, truncateSource, } from './validation'; + +export { getOrCreateSession, getOrCreateSessionId, getSessionId } from './session'; +export type { SessionResult } from './session'; + +export { collectAttribution, clearAttribution } from './attribution'; +export type { Attribution } from './attribution'; + +export { createConsentManager, detectDoNotTrack } from './consent'; +export type { ConsentManager } from './consent'; diff --git a/packages/audience/core/src/session.test.ts b/packages/audience/core/src/session.test.ts new file mode 100644 index 0000000000..e0320ffdfd --- /dev/null +++ b/packages/audience/core/src/session.test.ts @@ -0,0 +1,76 @@ +import { getOrCreateSession, getOrCreateSessionId, getSessionId } from './session'; + +const SESSION_COOKIE = '_imtbl_sid'; + +// Mock internal modules +const mockGetCookie = jest.fn(); +const mockSetCookie = jest.fn(); +const mockGenerateId = jest.fn(); + +jest.mock('./cookie', () => ({ + getCookie: (...args: unknown[]) => mockGetCookie(...args), + setCookie: (...args: unknown[]) => mockSetCookie(...args), +})); + +jest.mock('./utils', () => ({ + generateId: (...args: unknown[]) => mockGenerateId(...args), +})); + +beforeEach(() => { + jest.clearAllMocks(); + mockGenerateId.mockReturnValue('new-session-id'); +}); + +describe('getOrCreateSession', () => { + it('creates a new session when no cookie exists', () => { + mockGetCookie.mockReturnValue(undefined); + + const result = getOrCreateSession(); + expect(result.sessionId).toBe('new-session-id'); + expect(result.isNew).toBe(true); + expect(mockSetCookie).toHaveBeenCalledWith(SESSION_COOKIE, 'new-session-id', 1800, undefined); + }); + + it('returns existing session and refreshes expiry', () => { + mockGetCookie.mockReturnValue('existing-sid'); + + const result = getOrCreateSession(); + expect(result.sessionId).toBe('existing-sid'); + expect(result.isNew).toBe(false); + expect(mockSetCookie).toHaveBeenCalledWith(SESSION_COOKIE, 'existing-sid', 1800, undefined); + expect(mockGenerateId).not.toHaveBeenCalled(); + }); + + it('passes domain to setCookie', () => { + mockGetCookie.mockReturnValue(undefined); + + getOrCreateSession('.example.com'); + expect(mockSetCookie).toHaveBeenCalledWith(SESSION_COOKIE, 'new-session-id', 1800, '.example.com'); + }); +}); + +describe('getOrCreateSessionId', () => { + it('returns the session ID string', () => { + mockGetCookie.mockReturnValue(undefined); + + const id = getOrCreateSessionId(); + expect(id).toBe('new-session-id'); + }); + + it('returns existing session ID', () => { + mockGetCookie.mockReturnValue('existing-sid'); + expect(getOrCreateSessionId()).toBe('existing-sid'); + }); +}); + +describe('getSessionId', () => { + it('returns the session ID from cookie', () => { + mockGetCookie.mockReturnValue('existing-sid'); + expect(getSessionId()).toBe('existing-sid'); + }); + + it('returns undefined when no session cookie exists', () => { + mockGetCookie.mockReturnValue(undefined); + expect(getSessionId()).toBeUndefined(); + }); +}); diff --git a/packages/audience/core/src/session.ts b/packages/audience/core/src/session.ts new file mode 100644 index 0000000000..df1dd18fd2 --- /dev/null +++ b/packages/audience/core/src/session.ts @@ -0,0 +1,36 @@ +import { getCookie, setCookie } from './cookie'; +import { generateId } from './utils'; +import { SESSION_COOKIE, SESSION_MAX_AGE } from './config'; + +export interface SessionResult { + sessionId: string; + isNew: boolean; +} + +/** + * Get or create a session ID. + * + * The session cookie has a 30-minute rolling expiry — each call refreshes it. + * Returns whether the session is new so the caller can fire a `session_start` event. + */ +export function getOrCreateSession(domain?: string): SessionResult { + const existing = getCookie(SESSION_COOKIE); + if (existing) { + // Refresh the rolling expiry + setCookie(SESSION_COOKIE, existing, SESSION_MAX_AGE, domain); + return { sessionId: existing, isNew: false }; + } + + const id = generateId(); + setCookie(SESSION_COOKIE, id, SESSION_MAX_AGE, domain); + return { sessionId: id, isNew: true }; +} + +/** Convenience wrapper that returns just the session ID string. */ +export function getOrCreateSessionId(domain?: string): string { + return getOrCreateSession(domain).sessionId; +} + +export function getSessionId(): string | undefined { + return getCookie(SESSION_COOKIE); +} diff --git a/packages/audience/core/src/transport.test.ts b/packages/audience/core/src/transport.test.ts index 22e9fd6324..a1717f947f 100644 --- a/packages/audience/core/src/transport.test.ts +++ b/packages/audience/core/src/transport.test.ts @@ -51,6 +51,20 @@ describe('httpSend', () => { })); }); + it('uses specified method from options', async () => { + const mockFetch = jest.fn().mockResolvedValue({ ok: true }); + global.fetch = mockFetch; + + const consentPayload = { anonymousId: 'anon-1', status: 'anonymous' as const, source: 'pixel' }; + await httpSend('https://example.com/consent', 'pk', consentPayload, { method: 'PUT', keepalive: true }); + + expect(mockFetch).toHaveBeenCalledWith('https://example.com/consent', expect.objectContaining({ + method: 'PUT', + keepalive: true, + body: JSON.stringify(consentPayload), + })); + }); + it('returns true on success', async () => { global.fetch = jest.fn().mockResolvedValue({ ok: true }); expect(await httpSend('https://example.com', 'pk', payload)).toBe(true); diff --git a/packages/audience/core/src/transport.ts b/packages/audience/core/src/transport.ts index 65a99d9609..6be431dafd 100644 --- a/packages/audience/core/src/transport.ts +++ b/packages/audience/core/src/transport.ts @@ -1,7 +1,8 @@ import { track, trackError } from '@imtbl/metrics'; -import type { BatchPayload } from './types'; +import type { BatchPayload, ConsentUpdatePayload } from './types'; export interface TransportOptions { + method?: string; keepalive?: boolean; } @@ -12,12 +13,12 @@ export interface Transport { export async function httpSend( url: string, publishableKey: string, - payload: BatchPayload, + payload: BatchPayload | ConsentUpdatePayload, options?: TransportOptions, ): Promise { try { const response = await fetch(url, { - method: 'POST', + method: options?.method ?? 'POST', headers: { 'Content-Type': 'application/json', 'x-immutable-publishable-key': publishableKey, diff --git a/packages/audience/core/src/types.ts b/packages/audience/core/src/types.ts index 6bc7e26b7c..198de03d55 100644 --- a/packages/audience/core/src/types.ts +++ b/packages/audience/core/src/types.ts @@ -97,3 +97,12 @@ export type ConsentLevel = 'none' | 'anonymous' | 'full'; * - `'full'` — User accepted full tracking. */ export type ConsentStatus = 'not_set' | 'none' | 'anonymous' | 'full'; + +/** + * PUT body for `/v1/audience/tracking-consent`. + */ +export interface ConsentUpdatePayload { + anonymousId: string; + status: ConsentLevel; + source: string; +} diff --git a/packages/audience/pixel/jest.config.ts b/packages/audience/pixel/jest.config.ts index 59981c1e5a..63750647c9 100644 --- a/packages/audience/pixel/jest.config.ts +++ b/packages/audience/pixel/jest.config.ts @@ -3,6 +3,7 @@ import type { Config } from 'jest'; const config: Config = { roots: ['/src'], moduleDirectories: ['node_modules', 'src'], + moduleNameMapper: { '^@imtbl/(.*)$': '/../../../node_modules/@imtbl/$1/src' }, testEnvironment: 'jsdom', transform: { '^.+\\.(t|j)sx?$': '@swc/jest', diff --git a/packages/audience/pixel/package.json b/packages/audience/pixel/package.json index 9a27876d2a..1c483beb1c 100644 --- a/packages/audience/pixel/package.json +++ b/packages/audience/pixel/package.json @@ -1,11 +1,13 @@ { "name": "@imtbl/pixel", "description": "Immutable Tracking Pixel — drop-in JavaScript snippet for device fingerprint, page view, and attribution data", - "version": "0.0.0", + "version": "1.0.0", "author": "Immutable", "private": true, "bugs": "https://github.com/immutable/ts-immutable-sdk/issues", - "dependencies": {}, + "dependencies": { + "@imtbl/audience-core": "workspace:*" + }, "devDependencies": { "@swc/core": "^1.4.2", "@swc/jest": "^0.2.37", @@ -33,10 +35,10 @@ "scripts": { "build": "pnpm transpile && pnpm typegen", "transpile": "tsup", - "typegen": "tsc --customConditions default --emitDeclarationOnly --outDir dist/types", + "typegen": "tsc --customConditions development --emitDeclarationOnly --outDir dist/types", "lint": "eslint ./src --ext .ts --max-warnings=0", "test": "jest --passWithNoTests", - "typecheck": "tsc --customConditions default --noEmit" + "typecheck": "tsc --customConditions development --noEmit" }, "type": "module", "types": "./dist/types/index.d.ts" diff --git a/packages/audience/pixel/src/globals.d.ts b/packages/audience/pixel/src/globals.d.ts new file mode 100644 index 0000000000..778b446e67 --- /dev/null +++ b/packages/audience/pixel/src/globals.d.ts @@ -0,0 +1,2 @@ +/** Injected at build time by tsup `define` — see tsup.config.ts */ +declare const PIXEL_VERSION_INJECTED: string; diff --git a/packages/audience/pixel/src/index.ts b/packages/audience/pixel/src/index.ts index d6d235bc7f..9deb78e510 100644 --- a/packages/audience/pixel/src/index.ts +++ b/packages/audience/pixel/src/index.ts @@ -1,5 +1,5 @@ -export { collectAttribution, clearAttribution } from './attribution'; -export type { Attribution } from './attribution'; +export { Pixel } from './pixel'; +export type { PixelInitOptions } from './pixel'; export { createLoader } from './loader'; export type { Command, ImtblGlobal } from './loader'; diff --git a/packages/audience/pixel/src/pixel.test.ts b/packages/audience/pixel/src/pixel.test.ts new file mode 100644 index 0000000000..dec2505901 --- /dev/null +++ b/packages/audience/pixel/src/pixel.test.ts @@ -0,0 +1,290 @@ +import { Pixel } from './pixel'; + +// Mock audience-core +const mockEnqueue = jest.fn(); +const mockStart = jest.fn(); +const mockDestroy = jest.fn(); +const mockPurge = jest.fn(); +const mockTransform = jest.fn(); +const mockGetOrCreateSession = jest.fn().mockReturnValue({ sessionId: 'session-abc', isNew: true }); + +jest.mock('@imtbl/audience-core', () => ({ + MessageQueue: jest.fn().mockImplementation(() => ({ + enqueue: mockEnqueue, + start: mockStart, + destroy: mockDestroy, + purge: mockPurge, + transform: mockTransform, + stop: jest.fn(), + flush: jest.fn(), + flushUnload: jest.fn(), + clear: jest.fn(), + get length() { return 0; }, + })), + httpTransport: { send: jest.fn().mockResolvedValue(true) }, + httpSend: jest.fn().mockResolvedValue(true), + getBaseUrl: jest.fn().mockReturnValue('https://api.dev.immutable.com'), + INGEST_PATH: '/v1/audience/messages', + CONSENT_PATH: '/v1/audience/tracking-consent', + FLUSH_INTERVAL_MS: 5000, + FLUSH_SIZE: 20, + getOrCreateAnonymousId: jest.fn().mockReturnValue('anon-123'), + collectContext: jest.fn().mockReturnValue({ + library: '@imtbl/pixel', + libraryVersion: '0.0.0', + userAgent: 'test', + }), + generateId: jest.fn().mockReturnValue('msg-uuid'), + getTimestamp: jest.fn().mockReturnValue('2026-04-07T00:00:00.000Z'), + isBrowser: jest.fn().mockReturnValue(true), + getCookie: jest.fn(), + setCookie: jest.fn(), + collectAttribution: jest.fn().mockReturnValue({ + utm_source: 'google', + landing_page: 'https://example.com', + }), + getOrCreateSession: (...args: unknown[]) => mockGetOrCreateSession(...args), + createConsentManager: jest.fn().mockImplementation( + (_queue: unknown, _key: unknown, _anonId: unknown, _env: unknown, _source: unknown, level?: string) => { + let current = level ?? 'none'; + return { + get level() { return current; }, + setLevel(next: string) { current = next; }, + }; + }, + ), +})); + +// Mock fetch globally +global.fetch = jest.fn().mockResolvedValue({ ok: true }); + +let activePixel: Pixel | null = null; + +beforeEach(() => { + jest.clearAllMocks(); + mockGetOrCreateSession.mockReturnValue({ sessionId: 'session-abc', isNew: true }); +}); + +afterEach(() => { + // Clean up any active pixel to remove event listeners + if (activePixel) { + activePixel.destroy(); + activePixel = null; + } +}); + +describe('Pixel', () => { + describe('init', () => { + it('creates queue, starts it, and fires page view + session_start when consent is anonymous', () => { + const pixel = new Pixel(); + activePixel = pixel; + pixel.init({ key: 'pk_test', environment: 'dev', consent: 'anonymous' }); + + expect(mockStart).toHaveBeenCalled(); + + // Should fire session_start (new session) then page view + const calls = mockEnqueue.mock.calls.map((c: unknown[]) => (c[0] as Record)); + const pageCall = calls.find((c) => c.type === 'page'); + const sessionStartCall = calls.find( + (c) => c.type === 'track' && c.eventName === 'session_start', + ); + + expect(pageCall).toBeDefined(); + expect(pageCall!.surface).toBe('pixel'); + expect(pageCall!.anonymousId).toBe('anon-123'); + expect((pageCall!.properties as Record).utm_source).toBe('google'); + + expect(sessionStartCall).toBeDefined(); + expect((sessionStartCall!.properties as Record).sessionId).toBe('session-abc'); + }); + + it('does not fire page view or session_start when consent is none', () => { + const pixel = new Pixel(); + activePixel = pixel; + pixel.init({ key: 'pk_test', environment: 'dev', consent: 'none' }); + + expect(mockStart).toHaveBeenCalled(); + expect(mockEnqueue).not.toHaveBeenCalled(); + }); + + it('does not fire session_start for existing sessions', () => { + mockGetOrCreateSession.mockReturnValue({ sessionId: 'session-abc', isNew: false }); + + const pixel = new Pixel(); + activePixel = pixel; + pixel.init({ key: 'pk_test', environment: 'dev', consent: 'anonymous' }); + + const calls = mockEnqueue.mock.calls.map((c: unknown[]) => (c[0] as Record)); + const sessionStartCall = calls.find( + (c) => c.type === 'track' && c.eventName === 'session_start', + ); + + expect(sessionStartCall).toBeUndefined(); + // Page view should still fire + expect(calls.find((c) => c.type === 'page')).toBeDefined(); + }); + + it('only initializes once', () => { + const pixel = new Pixel(); + activePixel = pixel; + pixel.init({ key: 'pk_test', environment: 'dev', consent: 'anonymous' }); + pixel.init({ key: 'pk_other', environment: 'dev', consent: 'anonymous' }); + + expect(mockStart).toHaveBeenCalledTimes(1); + }); + }); + + describe('page', () => { + it('enqueues a page message with attribution and session', () => { + mockGetOrCreateSession.mockReturnValue({ sessionId: 'session-abc', isNew: false }); + + const pixel = new Pixel(); + activePixel = pixel; + pixel.init({ key: 'pk_test', environment: 'dev', consent: 'anonymous' }); + mockEnqueue.mockClear(); + + pixel.page({ custom: 'prop' }); + + expect(mockEnqueue).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'page', + surface: 'pixel', + properties: expect.objectContaining({ + utm_source: 'google', + sessionId: 'session-abc', + custom: 'prop', + }), + }), + ); + }); + + it('does not enqueue when consent is none', () => { + const pixel = new Pixel(); + activePixel = pixel; + pixel.init({ key: 'pk_test', environment: 'dev', consent: 'none' }); + + pixel.page(); + expect(mockEnqueue).not.toHaveBeenCalled(); + }); + }); + + describe('identify', () => { + it('enqueues identify message at full consent', () => { + mockGetOrCreateSession.mockReturnValue({ sessionId: 'session-abc', isNew: false }); + + const pixel = new Pixel(); + activePixel = pixel; + pixel.init({ key: 'pk_test', environment: 'dev', consent: 'full' }); + mockEnqueue.mockClear(); + + pixel.identify('user-1', { email: 'test@example.com' }); + + expect(mockEnqueue).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'identify', + userId: 'user-1', + surface: 'pixel', + traits: expect.objectContaining({ + email: 'test@example.com', + sessionId: 'session-abc', + }), + }), + ); + }); + + it('does not enqueue identify at anonymous consent', () => { + const pixel = new Pixel(); + activePixel = pixel; + pixel.init({ key: 'pk_test', environment: 'dev', consent: 'anonymous' }); + + pixel.identify('user-1'); + // Only the auto page view + session_start, no identify + const calls = mockEnqueue.mock.calls.map((c: unknown[]) => (c[0] as Record)); + expect(calls.find((c) => c.type === 'identify')).toBeUndefined(); + }); + }); + + describe('session_end', () => { + it('fires session_end on pagehide when session is active', () => { + const pixel = new Pixel(); + activePixel = pixel; + pixel.init({ key: 'pk_test', environment: 'dev', consent: 'anonymous' }); + mockEnqueue.mockClear(); + + // Simulate pagehide + window.dispatchEvent(new Event('pagehide')); + + expect(mockEnqueue).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'track', + eventName: 'session_end', + properties: expect.objectContaining({ + sessionId: 'session-abc', + }), + }), + ); + }); + + it('includes duration in session_end', () => { + const dateNowSpy = jest.spyOn(Date, 'now').mockReturnValue(1000000); + + const pixel = new Pixel(); + activePixel = pixel; + pixel.init({ key: 'pk_test', environment: 'dev', consent: 'anonymous' }); + mockEnqueue.mockClear(); + + // Advance time by 15 seconds before triggering pagehide + dateNowSpy.mockReturnValue(1015000); + window.dispatchEvent(new Event('pagehide')); + + const sessionEndCall = mockEnqueue.mock.calls.find( + (c: unknown[]) => (c[0] as Record).eventName === 'session_end', + ); + expect(sessionEndCall).toBeDefined(); + expect((sessionEndCall![0] as Record).properties).toEqual( + expect.objectContaining({ duration: 15 }), + ); + + dateNowSpy.mockRestore(); + }); + + it('does not fire session_end when consent is none', () => { + const pixel = new Pixel(); + activePixel = pixel; + pixel.init({ key: 'pk_test', environment: 'dev', consent: 'none' }); + + window.dispatchEvent(new Event('pagehide')); + expect(mockEnqueue).not.toHaveBeenCalled(); + }); + }); + + describe('setConsent', () => { + it('updates consent level', () => { + const pixel = new Pixel(); + activePixel = pixel; + pixel.init({ key: 'pk_test', environment: 'dev', consent: 'none' }); + + pixel.setConsent('anonymous'); + + mockGetOrCreateSession.mockReturnValue({ sessionId: 'session-xyz', isNew: false }); + pixel.page(); + expect(mockEnqueue).toHaveBeenCalledWith(expect.objectContaining({ type: 'page' })); + }); + }); + + describe('destroy', () => { + it('destroys the queue and resets state', () => { + const pixel = new Pixel(); + activePixel = pixel; + pixel.init({ key: 'pk_test', environment: 'dev', consent: 'anonymous' }); + + pixel.destroy(); + expect(mockDestroy).toHaveBeenCalled(); + + // After destroy, page() should be a no-op + mockEnqueue.mockClear(); + pixel.page(); + expect(mockEnqueue).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/audience/pixel/src/pixel.ts b/packages/audience/pixel/src/pixel.ts new file mode 100644 index 0000000000..d646212baa --- /dev/null +++ b/packages/audience/pixel/src/pixel.ts @@ -0,0 +1,255 @@ +import type { + Environment, + ConsentLevel, + PageMessage, + TrackMessage, + IdentifyMessage, + UserTraits, + ConsentManager, +} from '@imtbl/audience-core'; +import { + MessageQueue, + httpTransport, + getBaseUrl, + INGEST_PATH, + FLUSH_INTERVAL_MS, + FLUSH_SIZE, + getOrCreateAnonymousId, + collectContext, + generateId, + getTimestamp, + isBrowser, + collectAttribution, + getOrCreateSession, + createConsentManager, +} from '@imtbl/audience-core'; + +// Replaced at build time by tsup `define` (see tsup.config.ts). +// In tests the global isn't defined, so we fall back to 'unknown'. +const PIXEL_VERSION: string = typeof PIXEL_VERSION_INJECTED !== 'undefined' + ? PIXEL_VERSION_INJECTED + : 'unknown'; + +export interface PixelInitOptions { + key: string; + environment?: Environment; + consent?: ConsentLevel; + domain?: string; +} + +export class Pixel { + private queue: MessageQueue | null = null; + + private consent: ConsentManager | null = null; + + private anonymousId = ''; + + private userId: string | undefined; + + private sessionId: string | undefined; + + private sessionStartTime: number | undefined; + + private environment: Environment = 'production'; + + private publishableKey = ''; + + private domain: string | undefined; + + private initialized = false; + + private unloadHandler?: () => void; + + init(options: PixelInitOptions): void { + if (this.initialized) return; + + const { + key, + environment = 'production', + consent: consentLevel, + domain, + } = options; + + this.publishableKey = key; + this.environment = environment; + this.domain = domain; + + const endpointUrl = `${getBaseUrl(environment)}${INGEST_PATH}`; + + this.queue = new MessageQueue( + httpTransport, + endpointUrl, + key, + FLUSH_INTERVAL_MS, + FLUSH_SIZE, + { storagePrefix: '__imtbl_pixel_' }, + ); + + this.anonymousId = getOrCreateAnonymousId(domain); + + this.consent = createConsentManager( + this.queue, + key, + this.anonymousId, + environment, + 'pixel', + consentLevel, + ); + + this.queue.start(); + this.initialized = true; + + // Auto-fire page view if consent allows + if (this.consent.level !== 'none') { + this.page(); + } + + this.registerSessionEnd(); + } + + page(properties?: Record): void { + if (!this.canTrack()) return; + + const { sessionId, isNew } = getOrCreateSession(this.domain); + this.refreshSession(sessionId, isNew); + const attribution = collectAttribution(); + + const message: PageMessage = { + ...this.buildBase(), + type: 'page', + properties: { + ...attribution, + sessionId, + ...properties, + }, + userId: this.consent!.level === 'full' ? this.userId : undefined, + }; + + this.queue!.enqueue(message); + } + + identify(userId: string, traits?: UserTraits): void { + if (!this.isReady() || this.consent!.level !== 'full') return; + + this.userId = userId; + const { sessionId, isNew } = getOrCreateSession(this.domain); + this.refreshSession(sessionId, isNew); + + const message: IdentifyMessage = { + ...this.buildBase(), + type: 'identify', + userId, + traits: { + ...traits, + sessionId, + } as UserTraits, + }; + + this.queue!.enqueue(message); + } + + setConsent(level: ConsentLevel): void { + if (!this.isReady()) return; + this.consent!.setLevel(level); + } + + destroy(): void { + this.removeSessionEnd(); + if (this.queue) { + this.queue.destroy(); + this.queue = null; + } + this.consent = null; + this.initialized = false; + } + + // -- Session lifecycle -------------------------------------------------- + + private refreshSession(sessionId: string, isNew: boolean): void { + this.sessionId = sessionId; + if (isNew) { + this.sessionStartTime = Date.now(); + this.fireSessionStart(sessionId); + } + } + + private fireSessionStart(sessionId: string): void { + if (!this.canTrack()) return; + + const message: TrackMessage = { + ...this.buildBase(), + type: 'track', + eventName: 'session_start', + properties: { sessionId }, + userId: this.consent!.level === 'full' ? this.userId : undefined, + }; + + this.queue!.enqueue(message); + } + + private fireSessionEnd(): void { + if (!this.canTrack() || !this.sessionId) return; + + const duration = this.sessionStartTime + ? Math.round((Date.now() - this.sessionStartTime) / 1000) + : undefined; + + const message: TrackMessage = { + ...this.buildBase(), + type: 'track', + eventName: 'session_end', + properties: { + sessionId: this.sessionId, + duration, + }, + userId: this.consent!.level === 'full' ? this.userId : undefined, + }; + + this.queue!.enqueue(message); + } + + private registerSessionEnd(): void { + if (!isBrowser()) return; + + this.unloadHandler = () => { + this.fireSessionEnd(); + }; + + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'hidden') { + this.unloadHandler?.(); + } + }); + window.addEventListener('pagehide', this.unloadHandler); + } + + private removeSessionEnd(): void { + if (this.unloadHandler) { + window.removeEventListener('pagehide', this.unloadHandler); + this.unloadHandler = undefined; + } + } + + // -- Helpers ------------------------------------------------------------ + + // eslint-disable-next-line class-methods-use-this + private buildBase() { + return { + messageId: generateId(), + eventTimestamp: getTimestamp(), + anonymousId: this.anonymousId, + surface: 'pixel' as const, + context: collectContext('@imtbl/pixel', PIXEL_VERSION), + }; + } + + // -- Guards ------------------------------------------------------------- + + private canTrack(): boolean { + return this.isReady() && this.consent!.level !== 'none'; + } + + private isReady(): boolean { + return this.initialized && this.queue !== null && this.consent !== null; + } +} diff --git a/packages/audience/pixel/src/stubs/metrics.ts b/packages/audience/pixel/src/stubs/metrics.ts new file mode 100644 index 0000000000..126946a544 --- /dev/null +++ b/packages/audience/pixel/src/stubs/metrics.ts @@ -0,0 +1,8 @@ +// No-op stubs for @imtbl/metrics — the pixel is a self-contained bundle +// and doesn't ship internal telemetry. +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export const track = (..._args: unknown[]): void => {}; +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export const trackError = (..._args: unknown[]): void => {}; +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export const trackDuration = (..._args: unknown[]): void => {}; diff --git a/packages/audience/pixel/tsconfig.json b/packages/audience/pixel/tsconfig.json index 4840acb09d..2e15eaf9c9 100644 --- a/packages/audience/pixel/tsconfig.json +++ b/packages/audience/pixel/tsconfig.json @@ -3,7 +3,10 @@ "compilerOptions": { "outDir": "./dist", "rootDirs": ["src"], - "customConditions": ["development"] + "customConditions": ["development"], + "paths": { + "@imtbl/metrics": ["./src/stubs/metrics.ts"] + } }, "include": ["src"], "exclude": ["dist", "node_modules", "src/**/*.test.ts"] diff --git a/packages/audience/pixel/tsup.config.ts b/packages/audience/pixel/tsup.config.ts index 9c12136721..6cc4c7459b 100644 --- a/packages/audience/pixel/tsup.config.ts +++ b/packages/audience/pixel/tsup.config.ts @@ -1,5 +1,9 @@ +import { resolve } from 'path'; +import { readFileSync } from 'fs'; import { defineConfig } from 'tsup'; +const pkg = JSON.parse(readFileSync(resolve(__dirname, 'package.json'), 'utf-8')); + export default defineConfig({ entry: ['src/index.ts'], outDir: 'dist', @@ -12,9 +16,21 @@ export default defineConfig({ splitting: false, sourcemap: false, clean: true, + noExternal: ['@imtbl/audience-core', '@imtbl/metrics'], + define: { + PIXEL_VERSION_INJECTED: JSON.stringify(pkg.version), + }, outExtension: () => ({ js: '.js' }), esbuildOptions(options) { options.outbase = 'src'; options.entryNames = 'imtbl'; + // Resolve @imtbl/audience-core from source so the pixel bundles + // a tree-shaken copy — no runtime dependency on the core package. + // @imtbl/metrics is stubbed out — the pixel is a self-contained + // snippet and doesn't need internal telemetry. + options.alias = { + '@imtbl/audience-core': resolve(__dirname, '../core/src/index.ts'), + '@imtbl/metrics': resolve(__dirname, 'src/stubs/metrics.ts'), + }; }, }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 94ad8bd3a4..bf636b2d46 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -952,6 +952,10 @@ importers: version: 5.6.2 packages/audience/pixel: + dependencies: + '@imtbl/audience-core': + specifier: workspace:* + version: link:../core devDependencies: '@swc/core': specifier: ^1.4.2 @@ -28362,7 +28366,7 @@ snapshots: dependencies: confusing-browser-globals: 1.0.11 eslint: 8.57.0 - eslint-plugin-import: 2.29.1(@typescript-eslint/parser@5.62.0(eslint@8.57.0)(typescript@5.6.2))(eslint-import-resolver-typescript@3.5.5)(eslint@8.57.0) + eslint-plugin-import: 2.29.1(@typescript-eslint/parser@5.62.0(eslint@8.57.0)(typescript@5.6.2))(eslint-import-resolver-typescript@3.5.5(@typescript-eslint/parser@5.62.0(eslint@8.57.0)(typescript@5.6.2))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(@typescript-eslint/parser@5.62.0(eslint@8.57.0)(typescript@5.6.2))(eslint@8.57.0))(eslint@8.57.0))(eslint@8.57.0) object.assign: 4.1.5 object.entries: 1.1.8 semver: 6.3.1 @@ -28373,13 +28377,13 @@ snapshots: '@typescript-eslint/parser': 5.62.0(eslint@8.57.0)(typescript@5.6.2) eslint: 8.57.0 eslint-config-airbnb-base: 15.0.0(eslint-plugin-import@2.29.1(@typescript-eslint/parser@5.62.0(eslint@8.57.0)(typescript@5.6.2))(eslint@8.57.0))(eslint@8.57.0) - eslint-plugin-import: 2.29.1(@typescript-eslint/parser@5.62.0(eslint@8.57.0)(typescript@5.6.2))(eslint-import-resolver-typescript@3.5.5)(eslint@8.57.0) + eslint-plugin-import: 2.29.1(@typescript-eslint/parser@5.62.0(eslint@8.57.0)(typescript@5.6.2))(eslint-import-resolver-typescript@3.5.5(@typescript-eslint/parser@5.62.0(eslint@8.57.0)(typescript@5.6.2))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(@typescript-eslint/parser@5.62.0(eslint@8.57.0)(typescript@5.6.2))(eslint@8.57.0))(eslint@8.57.0))(eslint@8.57.0) eslint-config-airbnb@19.0.4(eslint-plugin-import@2.29.1(@typescript-eslint/parser@5.62.0(eslint@8.57.0)(typescript@5.6.2))(eslint@8.57.0))(eslint-plugin-jsx-a11y@6.9.0(eslint@8.57.0))(eslint-plugin-react-hooks@5.0.0(eslint@8.57.0))(eslint-plugin-react@7.35.0(eslint@8.57.0))(eslint@8.57.0): dependencies: eslint: 8.57.0 eslint-config-airbnb-base: 15.0.0(eslint-plugin-import@2.29.1(@typescript-eslint/parser@5.62.0(eslint@8.57.0)(typescript@5.6.2))(eslint@8.57.0))(eslint@8.57.0) - eslint-plugin-import: 2.29.1(@typescript-eslint/parser@5.62.0(eslint@8.57.0)(typescript@5.6.2))(eslint-import-resolver-typescript@3.5.5)(eslint@8.57.0) + eslint-plugin-import: 2.29.1(@typescript-eslint/parser@5.62.0(eslint@8.57.0)(typescript@5.6.2))(eslint-import-resolver-typescript@3.5.5(@typescript-eslint/parser@5.62.0(eslint@8.57.0)(typescript@5.6.2))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(@typescript-eslint/parser@5.62.0(eslint@8.57.0)(typescript@5.6.2))(eslint@8.57.0))(eslint@8.57.0))(eslint@8.57.0) eslint-plugin-jsx-a11y: 6.9.0(eslint@8.57.0) eslint-plugin-react: 7.35.0(eslint@8.57.0) eslint-plugin-react-hooks: 5.0.0(eslint@8.57.0) @@ -28394,7 +28398,7 @@ snapshots: eslint: 8.57.0 eslint-import-resolver-node: 0.3.9 eslint-import-resolver-typescript: 3.5.5(@typescript-eslint/parser@5.62.0(eslint@8.57.0)(typescript@5.6.2))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1)(eslint@8.57.0) - eslint-plugin-import: 2.29.1(@typescript-eslint/parser@5.62.0(eslint@8.57.0)(typescript@5.6.2))(eslint-import-resolver-typescript@3.5.5)(eslint@8.57.0) + eslint-plugin-import: 2.29.1(@typescript-eslint/parser@5.62.0(eslint@8.57.0)(typescript@5.6.2))(eslint-import-resolver-typescript@3.5.5(@typescript-eslint/parser@5.62.0(eslint@8.57.0)(typescript@5.6.2))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(@typescript-eslint/parser@5.62.0(eslint@8.57.0)(typescript@5.6.2))(eslint@8.57.0))(eslint@8.57.0))(eslint@8.57.0) eslint-plugin-jsx-a11y: 6.9.0(eslint@8.57.0) eslint-plugin-react: 7.35.0(eslint@8.57.0) eslint-plugin-react-hooks: 4.6.0(eslint@8.57.0) @@ -28413,7 +28417,7 @@ snapshots: eslint: 8.57.0 eslint-import-resolver-node: 0.3.9 eslint-import-resolver-typescript: 3.5.5(@typescript-eslint/parser@5.62.0(eslint@8.57.0)(typescript@5.6.2))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(@typescript-eslint/parser@5.62.0(eslint@8.57.0)(typescript@5.6.2))(eslint@8.57.0))(eslint@8.57.0) - eslint-plugin-import: 2.29.1(@typescript-eslint/parser@5.62.0(eslint@8.57.0)(typescript@5.6.2))(eslint-import-resolver-typescript@3.5.5)(eslint@8.57.0) + eslint-plugin-import: 2.29.1(@typescript-eslint/parser@5.62.0(eslint@8.57.0)(typescript@5.6.2))(eslint-import-resolver-typescript@3.5.5(@typescript-eslint/parser@5.62.0(eslint@8.57.0)(typescript@5.6.2))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(@typescript-eslint/parser@5.62.0(eslint@8.57.0)(typescript@5.6.2))(eslint@8.57.0))(eslint@8.57.0))(eslint@8.57.0) eslint-plugin-jsx-a11y: 6.9.0(eslint@8.57.0) eslint-plugin-react: 7.35.0(eslint@8.57.0) eslint-plugin-react-hooks: 5.0.0-canary-7118f5dd7-20230705(eslint@8.57.0) @@ -28431,7 +28435,7 @@ snapshots: eslint: 8.57.0 eslint-import-resolver-node: 0.3.9 eslint-import-resolver-typescript: 3.5.5(@typescript-eslint/parser@5.62.0(eslint@8.57.0)(typescript@5.6.2))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1)(eslint@8.57.0) - eslint-plugin-import: 2.29.1(@typescript-eslint/parser@5.62.0(eslint@8.57.0)(typescript@5.6.2))(eslint-import-resolver-typescript@3.5.5)(eslint@8.57.0) + eslint-plugin-import: 2.29.1(@typescript-eslint/parser@5.62.0(eslint@8.57.0)(typescript@5.6.2))(eslint-import-resolver-typescript@3.5.5(@typescript-eslint/parser@5.62.0(eslint@8.57.0)(typescript@5.6.2))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(@typescript-eslint/parser@5.62.0(eslint@8.57.0)(typescript@5.6.2))(eslint@8.57.0))(eslint@8.57.0))(eslint@8.57.0) eslint-plugin-jsx-a11y: 6.9.0(eslint@8.57.0) eslint-plugin-react: 7.35.0(eslint@8.57.0) eslint-plugin-react-hooks: 5.0.0-canary-7118f5dd7-20230705(eslint@8.57.0) @@ -28449,7 +28453,7 @@ snapshots: eslint: 8.57.0 eslint-import-resolver-node: 0.3.9 eslint-import-resolver-typescript: 3.5.5(@typescript-eslint/parser@5.62.0(eslint@8.57.0)(typescript@5.6.2))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1)(eslint@8.57.0) - eslint-plugin-import: 2.29.1(@typescript-eslint/parser@5.62.0(eslint@8.57.0)(typescript@5.6.2))(eslint-import-resolver-typescript@3.5.5)(eslint@8.57.0) + eslint-plugin-import: 2.29.1(@typescript-eslint/parser@5.62.0(eslint@8.57.0)(typescript@5.6.2))(eslint-import-resolver-typescript@3.5.5(@typescript-eslint/parser@5.62.0(eslint@8.57.0)(typescript@5.6.2))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(@typescript-eslint/parser@5.62.0(eslint@8.57.0)(typescript@5.6.2))(eslint@8.57.0))(eslint@8.57.0))(eslint@8.57.0) eslint-plugin-jsx-a11y: 6.9.0(eslint@8.57.0) eslint-plugin-react: 7.35.0(eslint@8.57.0) eslint-plugin-react-hooks: 5.0.0-canary-7118f5dd7-20230705(eslint@8.57.0) @@ -28470,7 +28474,7 @@ snapshots: confusing-browser-globals: 1.0.11 eslint: 8.57.0 eslint-plugin-flowtype: 8.0.3(@babel/plugin-syntax-flow@7.24.7(@babel/core@7.26.10))(@babel/plugin-transform-react-jsx@7.25.9(@babel/core@7.26.10))(eslint@8.57.0) - eslint-plugin-import: 2.29.1(@typescript-eslint/parser@5.62.0(eslint@8.57.0)(typescript@5.6.2))(eslint-import-resolver-typescript@3.5.5)(eslint@8.57.0) + eslint-plugin-import: 2.29.1(@typescript-eslint/parser@5.62.0(eslint@8.57.0)(typescript@5.6.2))(eslint-import-resolver-typescript@3.5.5(@typescript-eslint/parser@5.62.0(eslint@8.57.0)(typescript@5.6.2))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(@typescript-eslint/parser@5.62.0(eslint@8.57.0)(typescript@5.6.2))(eslint@8.57.0))(eslint@8.57.0))(eslint@8.57.0) eslint-plugin-jest: 25.7.0(@typescript-eslint/eslint-plugin@5.62.0(@typescript-eslint/parser@5.62.0(eslint@8.57.0)(typescript@5.6.2))(eslint@8.57.0)(typescript@5.6.2))(eslint@8.57.0)(jest@27.5.1(bufferutil@4.0.8)(node-notifier@8.0.2)(ts-node@10.9.2(@swc/core@1.15.3(@swc/helpers@0.5.15))(@types/node@22.19.7)(typescript@5.6.2))(utf-8-validate@5.0.10))(typescript@5.6.2) eslint-plugin-jsx-a11y: 6.9.0(eslint@8.57.0) eslint-plugin-react: 7.35.0(eslint@8.57.0) @@ -28527,7 +28531,7 @@ snapshots: enhanced-resolve: 5.15.0 eslint: 8.57.0 eslint-module-utils: 2.8.1(@typescript-eslint/parser@5.62.0(eslint@8.57.0)(typescript@5.6.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.5.5(@typescript-eslint/parser@5.62.0(eslint@8.57.0)(typescript@5.6.2))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(@typescript-eslint/parser@5.62.0(eslint@8.57.0)(typescript@5.6.2))(eslint@8.57.0))(eslint@8.57.0))(eslint@8.57.0) - eslint-plugin-import: 2.29.1(@typescript-eslint/parser@5.62.0(eslint@8.57.0)(typescript@5.6.2))(eslint-import-resolver-typescript@3.5.5)(eslint@8.57.0) + eslint-plugin-import: 2.29.1(@typescript-eslint/parser@5.62.0(eslint@8.57.0)(typescript@5.6.2))(eslint-import-resolver-typescript@3.5.5(@typescript-eslint/parser@5.62.0(eslint@8.57.0)(typescript@5.6.2))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(@typescript-eslint/parser@5.62.0(eslint@8.57.0)(typescript@5.6.2))(eslint@8.57.0))(eslint@8.57.0))(eslint@8.57.0) get-tsconfig: 4.6.2 globby: 13.2.2 is-core-module: 2.15.0 @@ -28545,7 +28549,7 @@ snapshots: enhanced-resolve: 5.15.0 eslint: 8.57.0 eslint-module-utils: 2.8.1(@typescript-eslint/parser@5.62.0(eslint@8.57.0)(typescript@5.6.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.5.5)(eslint@8.57.0) - eslint-plugin-import: 2.29.1(@typescript-eslint/parser@5.62.0(eslint@8.57.0)(typescript@5.6.2))(eslint-import-resolver-typescript@3.5.5)(eslint@8.57.0) + eslint-plugin-import: 2.29.1(@typescript-eslint/parser@5.62.0(eslint@8.57.0)(typescript@5.6.2))(eslint-import-resolver-typescript@3.5.5(@typescript-eslint/parser@5.62.0(eslint@8.57.0)(typescript@5.6.2))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(@typescript-eslint/parser@5.62.0(eslint@8.57.0)(typescript@5.6.2))(eslint@8.57.0))(eslint@8.57.0))(eslint@8.57.0) get-tsconfig: 4.6.2 globby: 13.2.2 is-core-module: 2.15.0 @@ -28605,7 +28609,7 @@ snapshots: lodash: 4.17.21 string-natural-compare: 3.0.1 - eslint-plugin-import@2.29.1(@typescript-eslint/parser@5.62.0(eslint@8.57.0)(typescript@5.6.2))(eslint-import-resolver-typescript@3.5.5)(eslint@8.57.0): + eslint-plugin-import@2.29.1(@typescript-eslint/parser@5.62.0(eslint@8.57.0)(typescript@5.6.2))(eslint-import-resolver-typescript@3.5.5(@typescript-eslint/parser@5.62.0(eslint@8.57.0)(typescript@5.6.2))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(@typescript-eslint/parser@5.62.0(eslint@8.57.0)(typescript@5.6.2))(eslint@8.57.0))(eslint@8.57.0))(eslint@8.57.0): dependencies: array-includes: 3.1.8 array.prototype.findlastindex: 1.2.5