Repository navigation
feat(AIC-3416): say the real reason when a bundler breaks an optional import [3/4] #78
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
tonytrinh3
merged 5 commits into
main
from
tonytrinh3/AIC-3416-esm-externalization-diagnostics
Oct 8, 2026
Merged
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
3e10204
feat(AIC-3416): diagnose ESM externalization during initialization
tonytrinh3 d073901
Merge branch 'tonytrinh3/AIC-3415-dual-esm-cjs-output' into tonytrinh…
tonytrinh3 b67dc9c
Merge branch 'main' into tonytrinh3/AIC-3416-esm-externalization-diag…
tonytrinh3 f90b97a
fix(AIC-3416): read the real package name from a pnpm path
tonytrinh3 dc16a78
fix(AIC-3416): name the failing package, not the importer
tonytrinh3 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
168 changes: 168 additions & 0 deletions
168
packages/client/src/__tests__/import-diagnostics.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
| }); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | ||
| } | ||
|
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; | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.