Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
168 changes: 168 additions & 0 deletions packages/client/src/__tests__/import-diagnostics.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
89 changes: 89 additions & 0 deletions packages/client/src/import-diagnostics.ts
Comment thread
tonytrinh3 marked this conversation as resolved.
Resolved
Original file line number Diff line number Diff line change
@@ -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;
}
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved

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;
}
20 changes: 17 additions & 3 deletions packages/client/src/lifecycle.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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 ' +
Expand Down Expand Up @@ -131,6 +142,7 @@ export async function waitForTelemetry(timeoutMs = 5000): Promise<void> {
* Must be called before process.exit() to ensure all pending spans are exported.
*/
export async function shutdownTelemetry(): Promise<void> {
resetEsmExternalizationWarning();
if (tracerProvider) {
await tracerProvider.shutdown();
tracerProvider = null;
Expand Down Expand Up @@ -175,8 +187,10 @@ async function initBaseClient(options: InitBaseClientOptions = {}): Promise<LDCl
let init: any;
try {
({ init } = await import('@launchdarkly/node-server-sdk'));
} catch {
throw new Error(
} catch (err) {
throw describeImportFailure(
'@launchdarkly/node-server-sdk',
err,
'[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().',
Expand Down
Loading