diff --git a/packages/client/src/__tests__/import-diagnostics.test.ts b/packages/client/src/__tests__/import-diagnostics.test.ts new file mode 100644 index 00000000..0af81b1c --- /dev/null +++ b/packages/client/src/__tests__/import-diagnostics.test.ts @@ -0,0 +1,168 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + describeImportFailure, + esmExternalizationDiagnostic, + isEsmInteropError, + isModuleNotFoundError, + moduleNameFromError, + resetEsmExternalizationWarning, + warnEsmExternalizationOnce, +} from '../import-diagnostics.js'; + +const MISSING_NODE_SDK = + '[LaunchDarkly] @launchdarkly/node-server-sdk is not installed. ' + + 'Either install it (npm install @launchdarkly/node-server-sdk) or pass a ' + + 'pre-initialized LD client to initClient().'; + +function errorWithCode(message: string, code?: string): Error { + const error = new Error(message); + if (code) Object.assign(error, { code }); + return error; +} + +describe('import diagnostics', () => { + beforeEach(() => { + resetEsmExternalizationWarning(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('isEsmInteropError', () => { + it.each([ + ['ERR_REQUIRE_ESM by code', errorWithCode('boom', 'ERR_REQUIRE_ESM')], + ['ERR_PACKAGE_PATH_NOT_EXPORTED by code', errorWithCode('boom', 'ERR_PACKAGE_PATH_NOT_EXPORTED')], + [ + 'require() of ES Module message', + new Error('require() of ES Module /app/node_modules/@launchdarkly/ai-server/dist/index.js is not supported'), + ], + ['import-only message', new Error('Must use import to load ES Module: /app/node_modules/foo/index.js')], + ['missing exports main', new Error('No "exports" main defined in /app/node_modules/foo/package.json')], + ['subpath not exported', new Error(`Package subpath './dist' is not defined by "exports"`)], + ])('recognizes %s', (_label, error) => { + expect(isEsmInteropError(error)).toBe(true); + }); + + it.each([ + ['a genuinely missing module', errorWithCode("Cannot find module 'foo'", 'MODULE_NOT_FOUND')], + ['an unrelated failure', new TypeError('Reading config of undefined')], + ['a non-error throw', 'something went wrong'], + ])('does not recognize %s', (_label, error) => { + expect(isEsmInteropError(error)).toBe(false); + }); + }); + + describe('isModuleNotFoundError', () => { + it.each(['MODULE_NOT_FOUND', 'ERR_MODULE_NOT_FOUND'])('recognizes %s', (code) => { + expect(isModuleNotFoundError(errorWithCode('nope', code))).toBe(true); + }); + + it('recognizes the message form when no code is attached', () => { + expect(isModuleNotFoundError(new Error("Cannot find package '@launchdarkly/node-server-sdk'"))).toBe(true); + }); + + it('does not recognize an interoperability failure', () => { + expect(isModuleNotFoundError(errorWithCode('boom', 'ERR_REQUIRE_ESM'))).toBe(false); + }); + }); + + describe('moduleNameFromError', () => { + it('reads the specifier from a missing-module message', () => { + expect(moduleNameFromError(new Error("Cannot find module '@opentelemetry/core'"), 'fallback')).toBe( + '@opentelemetry/core', + ); + }); + + it('reads the package name from a node_modules path', () => { + const error = new Error('require() of ES Module /app/node_modules/@opentelemetry/sdk-trace-node/build/index.js'); + expect(moduleNameFromError(error, 'fallback')).toBe('@opentelemetry/sdk-trace-node'); + }); + + it('reads the package name from a pnpm path', () => { + const error = new Error( + 'require() of ES Module /app/node_modules/.pnpm/@opentelemetry+core@1.30.1/node_modules/@opentelemetry/core/build/index.js', + ); + expect(moduleNameFromError(error, 'fallback')).toBe('@opentelemetry/core'); + }); + + it('names the failing package, not the importer', () => { + const error = new Error( + 'require() of ES Module /app/node_modules/@opentelemetry/core/build/index.js from /app/node_modules/@launchdarkly/ai-server/dist/index.cjs is not supported', + ); + expect(moduleNameFromError(error, 'fallback')).toBe('@opentelemetry/core'); + }); + + it('falls back when the message names no package', () => { + expect(moduleNameFromError(new Error('boom'), 'an OpenTelemetry SDK package')).toBe( + 'an OpenTelemetry SDK package', + ); + }); + }); + + describe('esmExternalizationDiagnostic', () => { + it('names the module and carries the remediation and recipe link', () => { + const message = esmExternalizationDiagnostic('@launchdarkly/node-server-sdk'); + expect(message).toContain('@launchdarkly/node-server-sdk'); + expect(message).toContain('ESM package was externalized into CommonJS output'); + expect(message).toContain('/^@launchdarkly\\/ai-/'); + expect(message).toContain( + 'https://github.com/launchdarkly/js-ai-sdk/blob/main/packages/ai-node/README.md#commonjs-aws-lambda-and-webpack', + ); + }); + }); + + describe('describeImportFailure', () => { + it('returns the externalization diagnostic with the original error as cause', () => { + const original = errorWithCode('require() of ES Module', 'ERR_REQUIRE_ESM'); + const described = describeImportFailure('@launchdarkly/node-server-sdk', original, MISSING_NODE_SDK); + + expect(described.message).toBe(esmExternalizationDiagnostic('@launchdarkly/node-server-sdk')); + expect(described.cause).toBe(original); + }); + + it('keeps the missing-dependency guidance for a genuine MODULE_NOT_FOUND', () => { + const original = errorWithCode("Cannot find module '@launchdarkly/node-server-sdk'", 'MODULE_NOT_FOUND'); + const described = describeImportFailure('@launchdarkly/node-server-sdk', original, MISSING_NODE_SDK); + + expect(described.message).toBe(MISSING_NODE_SDK); + expect(described.message).not.toContain('externalized'); + expect(described.cause).toBe(original); + }); + + it('passes an unrelated failure through unchanged', () => { + const original = new TypeError('Reading config of undefined'); + expect(describeImportFailure('@launchdarkly/node-server-sdk', original, MISSING_NODE_SDK)).toBe(original); + }); + + it('wraps a non-error throw without labeling it', () => { + const described = describeImportFailure('@launchdarkly/node-server-sdk', 'kaboom', MISSING_NODE_SDK); + expect(described.message).toBe('kaboom'); + }); + }); + + describe('warnEsmExternalizationOnce', () => { + it('warns with the diagnostic and the original error message', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + warnEsmExternalizationOnce('@opentelemetry/sdk-trace-node', new Error('require() of ES Module')); + + const message = warn.mock.calls[0][0] as string; + expect(message).toContain('@opentelemetry/sdk-trace-node'); + expect(message).toContain('ESM package was externalized into CommonJS output'); + expect(message).toContain('Original error: require() of ES Module'); + }); + + it('warns only once until the warning state is reset', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const error = new Error('require() of ES Module'); + + warnEsmExternalizationOnce('@opentelemetry/core', error); + warnEsmExternalizationOnce('@opentelemetry/core', error); + expect(warn).toHaveBeenCalledTimes(1); + + resetEsmExternalizationWarning(); + warnEsmExternalizationOnce('@opentelemetry/core', error); + expect(warn).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/packages/client/src/import-diagnostics.ts b/packages/client/src/import-diagnostics.ts new file mode 100644 index 00000000..ecb33c83 --- /dev/null +++ b/packages/client/src/import-diagnostics.ts @@ -0,0 +1,89 @@ +const LD_BUNDLER_RECIPE_URL = + 'https://github.com/launchdarkly/js-ai-sdk/blob/main/packages/ai-node/README.md#commonjs-aws-lambda-and-webpack'; + +const ESM_INTEROP_ERROR_CODES = new Set(['ERR_REQUIRE_ESM', 'ERR_PACKAGE_PATH_NOT_EXPORTED']); + +const ESM_INTEROP_MESSAGE_PATTERNS = [ + /ERR_REQUIRE_ESM/, + /ERR_PACKAGE_PATH_NOT_EXPORTED/, + /require\(\) of ES Module/i, + /Must use import to load ES ?Module/i, + /No "exports" main defined/i, + /is not defined by "exports"/i, +]; + +const MODULE_NOT_FOUND_ERROR_CODES = new Set(['MODULE_NOT_FOUND', 'ERR_MODULE_NOT_FOUND']); + +let esmExternalizationWarned = false; + +function errorCode(error: unknown): string | undefined { + const code = (error as { code?: unknown } | null)?.code; + return typeof code === 'string' ? code : undefined; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +/** + * Recognizes the Node module-interoperability failures produced when a bundler + * downlevels an `await import()` of an ESM-only package into `require()`. + */ +export function isEsmInteropError(error: unknown): boolean { + const code = errorCode(error); + if (code && ESM_INTEROP_ERROR_CODES.has(code)) return true; + const message = errorMessage(error); + return ESM_INTEROP_MESSAGE_PATTERNS.some((pattern) => pattern.test(message)); +} + +export function isModuleNotFoundError(error: unknown): boolean { + const code = errorCode(error); + if (code && MODULE_NOT_FOUND_ERROR_CODES.has(code)) return true; + return /Cannot find (?:module|package)/i.test(errorMessage(error)); +} + +export function moduleNameFromError(error: unknown, fallback: string): string { + const message = errorMessage(error); + const specifier = message.match(/Cannot find (?:module|package) '([^']+)'/); + if (specifier) return specifier[1]; + const fromPath = [...message.matchAll(/node_modules\/((?:@[^/]+\/)?[^/]+)/g)] + .map((match) => match[1]) + .filter((name) => name !== '.pnpm'); + return fromPath[0] ?? fallback; +} + +export function esmExternalizationDiagnostic(moduleName: string): string { + return ( + `[LaunchDarkly] Could not load "${moduleName}" during initialization because an ` + + 'ESM package was externalized into CommonJS output, so the bundled require() cannot ' + + 'resolve it. Allowlist the LaunchDarkly AI packages so the bundler inlines them:\n' + + ' externals: [nodeExternals({ allowlist: [/^@launchdarkly\\/ai-/] })]\n' + + ` AWS Lambda + Webpack recipe: ${LD_BUNDLER_RECIPE_URL}` + ); +} + +/** + * Classifies a failed optional dynamic import. ESM-externalization and genuinely + * missing dependencies get actionable guidance; anything else is passed through + * unchanged so unrelated failures are not mislabeled. + */ +export function describeImportFailure(moduleName: string, error: unknown, missingDependencyMessage: string): Error { + if (isEsmInteropError(error)) { + return new Error(esmExternalizationDiagnostic(moduleName), { cause: error }); + } + if (isModuleNotFoundError(error)) { + return new Error(missingDependencyMessage, { cause: error }); + } + return error instanceof Error ? error : new Error(errorMessage(error)); +} + +export function warnEsmExternalizationOnce(moduleName: string, error: unknown): void { + if (esmExternalizationWarned) return; + esmExternalizationWarned = true; + // biome-ignore lint/suspicious/noConsole: intentional warning for a bundler misconfiguration + console.warn(`${esmExternalizationDiagnostic(moduleName)}\n Original error: ${errorMessage(error)}`); +} + +export function resetEsmExternalizationWarning(): void { + esmExternalizationWarned = false; +} diff --git a/packages/client/src/lifecycle.ts b/packages/client/src/lifecycle.ts index 06733bc5..9187172b 100644 --- a/packages/client/src/lifecycle.ts +++ b/packages/client/src/lifecycle.ts @@ -1,6 +1,13 @@ import 'dotenv/config'; import { trace } from '@opentelemetry/api'; import { ConversationIdSpanProcessor } from './conversation.js'; +import { + describeImportFailure, + isEsmInteropError, + moduleNameFromError, + resetEsmExternalizationWarning, + warnEsmExternalizationOnce, +} from './import-diagnostics.js'; import { flushAiSdkInfo, resetAiSdkInfo } from './sdk-info.js'; import type { AiConfigRep, InitBaseClientOptions, LDClientInterface, LDContext, VariationMeta } from './types.js'; import { parseAiConfig } from './types.js'; @@ -65,7 +72,11 @@ async function setupTelemetry(options: InitBaseClientOptions, sdkKey: string): P ({ resourceFromAttributes } = await import('@opentelemetry/resources')); ({ AsyncLocalStorageContextManager } = await import('@opentelemetry/context-async-hooks')); ({ CompositePropagator, W3CBaggagePropagator, W3CTraceContextPropagator } = await import('@opentelemetry/core')); - } catch { + } catch (err) { + if (isEsmInteropError(err)) { + warnEsmExternalizationOnce(moduleNameFromError(err, 'an OpenTelemetry SDK package'), err); + return; + } // biome-ignore lint/suspicious/noConsole: intentional warning when optional OTel peer deps are missing console.warn( '[LaunchDarkly] Telemetry is disabled because one or more OpenTelemetry SDK ' + @@ -131,6 +142,7 @@ export async function waitForTelemetry(timeoutMs = 5000): Promise { * Must be called before process.exit() to ensure all pending spans are exported. */ export async function shutdownTelemetry(): Promise { + resetEsmExternalizationWarning(); if (tracerProvider) { await tracerProvider.shutdown(); tracerProvider = null; @@ -175,8 +187,10 @@ async function initBaseClient(options: InitBaseClientOptions = {}): Promise