diff --git a/sdks/typescript/src/step.ts b/sdks/typescript/src/step.ts index d5db923215..b9959d3862 100644 --- a/sdks/typescript/src/step.ts +++ b/sdks/typescript/src/step.ts @@ -1,9 +1,5 @@ +import { emitV0RemovedWarning } from './util/v0-deprecation-warning'; + export * from './legacy/step'; -console.warn( - '\x1b[31mDeprecation warning: The v0 sdk, including the step module has been deprecated and has been removed in release v1.14.0.\x1b[0m' -); -console.warn( - '\x1b[32mPlease migrate to v1 SDK instead: https://docs.hatchet.run/home/v1-sdk-improvements\x1b[0m' -); -console.warn('--------------------------------'); +emitV0RemovedWarning('step'); diff --git a/sdks/typescript/src/util/v0-deprecation-warning.test.ts b/sdks/typescript/src/util/v0-deprecation-warning.test.ts new file mode 100644 index 0000000000..248a942d32 --- /dev/null +++ b/sdks/typescript/src/util/v0-deprecation-warning.test.ts @@ -0,0 +1,117 @@ +import { + V0_DEPRECATION_CODE, + _resetEmittedV0Warnings, + emitV0RemovedWarning, +} from './v0-deprecation-warning'; + +describe('emitV0RemovedWarning', () => { + let emitWarningSpy: jest.SpyInstance; + + beforeEach(() => { + _resetEmittedV0Warnings(); + emitWarningSpy = jest.spyOn(process, 'emitWarning').mockImplementation(() => {}); + }); + + afterEach(() => { + emitWarningSpy.mockRestore(); + }); + + it('emits via process.emitWarning with the stable HATCHET_V0_REMOVED code', () => { + emitV0RemovedWarning('workflow'); + + expect(emitWarningSpy).toHaveBeenCalledTimes(1); + const [[message, opts]] = emitWarningSpy.mock.calls; + expect(message).toContain('workflow module'); + expect(message).toContain('v1.14.0'); + expect(message).toContain('https://docs.hatchet.run/home/v1-sdk-improvements'); + expect(opts).toMatchObject({ + type: 'DeprecationWarning', + code: V0_DEPRECATION_CODE, + }); + }); + + it('passes the optional detail string through to process.emitWarning', () => { + emitV0RemovedWarning('workflow', 'ConcurrencyLimitStrategy has moved.'); + + expect(emitWarningSpy).toHaveBeenCalledTimes(1); + const [[, opts]] = emitWarningSpy.mock.calls; + expect(opts.detail).toBe('ConcurrencyLimitStrategy has moved.'); + }); + + it('deduplicates per submodule across repeated calls', () => { + emitV0RemovedWarning('workflow'); + emitV0RemovedWarning('workflow'); + emitV0RemovedWarning('workflow'); + + expect(emitWarningSpy).toHaveBeenCalledTimes(1); + }); + + it('emits separate warnings for different submodules', () => { + emitV0RemovedWarning('workflow'); + emitV0RemovedWarning('step'); + + expect(emitWarningSpy).toHaveBeenCalledTimes(2); + expect(emitWarningSpy.mock.calls[0][0]).toContain('workflow module'); + expect(emitWarningSpy.mock.calls[1][0]).toContain('step module'); + }); + + it('routes to console.warn when process.throwDeprecation is set, bypassing emitWarning', () => { + // Under --throw-deprecation or process.throwDeprecation=true, Node queues + // `throw warning` on the next tick AFTER emitWarning returns, so a + // try/catch around the call cannot intercept it. The helper must check + // the flag up front and avoid calling emitWarning at all. + const originalThrowDeprecation = process.throwDeprecation; + const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + process.throwDeprecation = true; + + try { + expect(() => emitV0RemovedWarning('workflow')).not.toThrow(); + expect(emitWarningSpy).not.toHaveBeenCalled(); + expect(consoleWarnSpy).toHaveBeenCalledTimes(1); + expect(consoleWarnSpy.mock.calls[0][0]).toContain(V0_DEPRECATION_CODE); + expect(consoleWarnSpy.mock.calls[0][0]).toContain('workflow module'); + } finally { + process.throwDeprecation = originalThrowDeprecation; + consoleWarnSpy.mockRestore(); + } + }); + + it('falls back to console.warn if emitWarning throws synchronously (non-Node hosts)', () => { + // Defense-in-depth: a polyfilled `process.emitWarning` in a non-Node + // host could throw synchronously. Real Node throws asynchronously under + // --throw-deprecation; that path is exercised by the test above. + emitWarningSpy.mockImplementation(() => { + throw new Error('synchronous emitWarning failure'); + }); + const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + + try { + expect(() => emitV0RemovedWarning('workflow')).not.toThrow(); + expect(emitWarningSpy).toHaveBeenCalledTimes(1); + expect(consoleWarnSpy).toHaveBeenCalledTimes(1); + expect(consoleWarnSpy.mock.calls[0][0]).toContain(V0_DEPRECATION_CODE); + } finally { + consoleWarnSpy.mockRestore(); + } + }); + + it('falls back to console.warn when process.emitWarning is unavailable', () => { + emitWarningSpy.mockRestore(); + const original = process.emitWarning; + // Simulate a runtime that doesn't expose emitWarning. + (process as unknown as { emitWarning?: typeof process.emitWarning }).emitWarning = + undefined as unknown as typeof process.emitWarning; + const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + + try { + emitV0RemovedWarning('step'); + + expect(consoleWarnSpy).toHaveBeenCalledTimes(1); + expect(consoleWarnSpy.mock.calls[0][0]).toContain(V0_DEPRECATION_CODE); + expect(consoleWarnSpy.mock.calls[0][0]).toContain('step module'); + } finally { + consoleWarnSpy.mockRestore(); + (process as unknown as { emitWarning: typeof process.emitWarning }).emitWarning = original; + } + }); +}); diff --git a/sdks/typescript/src/util/v0-deprecation-warning.ts b/sdks/typescript/src/util/v0-deprecation-warning.ts new file mode 100644 index 0000000000..ce25bc526e --- /dev/null +++ b/sdks/typescript/src/util/v0-deprecation-warning.ts @@ -0,0 +1,84 @@ +/** + * v0 SDK root-import deprecation warnings. + * + * The legacy `workflow` and `step` re-exports under the root specifier need + * to keep nagging consumers to migrate to v1, but the original implementation + * used `console.warn` at module evaluation time, which: + * - cannot be silenced by Node's standard `--no-deprecation`, + * `--no-warnings`, or `--no-warnings=DeprecationWarning` flags; + * - has no stable `code` for `process.on('warning', ...)` handlers; and + * - is duplicated when both submodules are loaded (which happens for + * anyone importing from the root, since `index.ts` re-exports both). + * + * Switching to `process.emitWarning` with a fixed code makes the warnings + * filterable, dedupable, and consistent with the rest of Node's deprecation + * surface, while still being visible by default. + */ + +export const V0_DEPRECATION_CODE = 'HATCHET_V0_REMOVED'; +const MIGRATION_URL = 'https://docs.hatchet.run/home/v1-sdk-improvements'; + +const emittedSubmodules = new Set(); + +/** Reset hook for tests. Not part of the public API. */ +export function _resetEmittedV0Warnings(): void { + emittedSubmodules.clear(); +} + +function fallbackConsoleWarn(message: string, detail?: string): void { + console.warn(`[${V0_DEPRECATION_CODE}] ${message}${detail ? `\n${detail}` : ''}`); +} + +/** + * Emit a deduplicated v0-removal deprecation warning for a given submodule. + * + * Each unique `submodule` is emitted at most once per process. Uses + * `process.emitWarning` when available so consumers can suppress or filter + * via standard Node mechanisms. + * + * Importing the SDK must never abort module evaluation, since the root + * specifier still re-exports v0 `workflow` and `step` for transitive + * consumers who only use v1 APIs. Two cases would otherwise crash them: + * + * 1. `process.throwDeprecation` (set by `--throw-deprecation` or directly). + * Node queues a `throw warning` on the next tick after `emitWarning` + * returns, so a `try`/`catch` around the call would not catch it. We + * check the flag up front and route to `console.warn` instead. + * 2. Runtimes that don't expose `process.emitWarning` at all (older + * browsers, certain bundler shims). Same fallback applies. + * + * The remaining `try`/`catch` is belt-and-suspenders for non-Node hosts + * where a polyfilled `emitWarning` could throw synchronously. + * + * @param submodule - The legacy v0 submodule being imported (e.g. "workflow", "step"). + * @param detail - Optional follow-up sentence appended to the warning detail. + */ +export function emitV0RemovedWarning(submodule: string, detail?: string): void { + if (emittedSubmodules.has(submodule)) { + return; + } + emittedSubmodules.add(submodule); + + const message = + `The v0 SDK, including the ${submodule} module, has been deprecated and was removed in v1.14.0. ` + + `Please migrate to the v1 SDK: ${MIGRATION_URL}`; + + const hasProcess = typeof process !== 'undefined'; + const hasEmitWarning = hasProcess && typeof process.emitWarning === 'function'; + const willThrowAsync = hasProcess && process.throwDeprecation === true; + + if (!hasEmitWarning || willThrowAsync) { + fallbackConsoleWarn(message, detail); + return; + } + + try { + process.emitWarning(message, { + type: 'DeprecationWarning', + code: V0_DEPRECATION_CODE, + detail, + }); + } catch { + fallbackConsoleWarn(message, detail); + } +} diff --git a/sdks/typescript/src/workflow.ts b/sdks/typescript/src/workflow.ts index 40e7e0a7f3..d6c38d7453 100644 --- a/sdks/typescript/src/workflow.ts +++ b/sdks/typescript/src/workflow.ts @@ -1,16 +1,11 @@ import { ConcurrencyLimitStrategy, StickyStrategy } from '@hatchet/v1'; +import { emitV0RemovedWarning } from './util/v0-deprecation-warning'; export * from './legacy/workflow'; export { ConcurrencyLimitStrategy, StickyStrategy }; -console.warn( - '\x1b[31mDeprecation warning: The v0 sdk, including the workflow module has been deprecated and has been removed in release v1.14.0.\x1b[0m' +emitV0RemovedWarning( + 'workflow', + 'ConcurrencyLimitStrategy and StickyStrategy have been moved to @hatchet-dev/typescript-sdk/v1.' ); -console.warn( - '\x1b[31mPlease migrate to v1 SDK instead: https://docs.hatchet.run/home/v1-sdk-improvements\x1b[0m' -); -console.warn( - 'ConcurrencyLimitStrategy, StickyStrategy have been moved to @hatchet-dev/typescript-sdk/v1' -); -console.warn('--------------------------------');