diff --git a/package-lock.json b/package-lock.json index 4774932b..5b40e33e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -549,6 +549,10 @@ "resolved": "recipes/slow-buffer-to-buffer-alloc-unsafe-slow", "link": true }, + "node_modules/@nodejs/stream-thenable-to-callback": { + "resolved": "recipes/stream-thenable-to-callback", + "link": true + }, "node_modules/@nodejs/timers-deprecations": { "resolved": "recipes/timers-deprecations", "link": true @@ -1254,6 +1258,15 @@ "@codemod.com/jssg-types": "^1.6.3" } }, + "recipes/stream-thenable-to-callback": { + "name": "@nodejs/stream-thenable-to-callback", + "dependencies": { + "@nodejs/codemod-utils": "*" + }, + "devDependencies": { + "@codemod.com/jssg-types": "^1.6.3" + } + }, "recipes/timers-deprecations": { "name": "@nodejs/timers-deprecations", "dependencies": { diff --git a/recipes/stream-thenable-to-callback/README.md b/recipes/stream-thenable-to-callback/README.md new file mode 100644 index 00000000..c91bffd5 --- /dev/null +++ b/recipes/stream-thenable-to-callback/README.md @@ -0,0 +1,108 @@ +--- +authors: Harshall25 +--- + +# DEP0157: Thenable Support in Streams + +Node.js removed the undocumented thenable support in streams, so an `async` stream implementation method no longer has its returned promise awaited by the stream machinery: rejections are swallowed and the stream can stall. See [DEP0157](https://nodejs.org/api/deprecations.html#DEP0157). This codemod rewrites `async` implementation methods into the callback form the streams API expects, turning each sequential `await` into a `.then()` link and routing failures to a trailing `.catch()`. It covers `_write`, `_writev`, `_read`, `_transform`, `_flush` and `_final` on classes that extend a stream, and `write`, `writev`, `read`, `transform`, `flush` and `final` in the options object passed to `Writable`, `Readable`, `Transform`, `Duplex` and `PassThrough`, in both CommonJS and ESM. + +## Usage + +Run this codemod with: + +```sh +npx codemod @nodejs/stream-thenable-to-callback +``` + +## Examples + +### Example 1 + +A single `await` followed by the callback - the `async` keyword is dropped and errors are routed to `callback(err)`: + +```diff + const writable = new Writable({ +- async write(chunk, encoding, callback) { +- await someAsyncOperation(chunk); +- callback(); ++ write(chunk, encoding, callback) { ++ someAsyncOperation(chunk) ++ .then(() => callback()) ++ .catch(err => callback(err)); + } + }); +``` + +### Example 2 + +Sequential `await`s - each step becomes a `.then()` link, and the awaited binding becomes the parameter of the next one: + +```diff + const transform = new Transform({ +- async transform(chunk, encoding, callback) { +- const step1 = await operation1(chunk); +- const step2 = await operation2(step1); +- callback(null, step2); ++ transform(chunk, encoding, callback) { ++ operation1(chunk) ++ .then(step1 => operation2(step1)) ++ .then(step2 => callback(null, step2)) ++ .catch(err => callback(err)); + } + }); +``` + +### Example 3 + +`read` and `_read` take no callback, so the chain ends in `this.push()` and rejections go to `this.destroy()`: + +```diff + const readable = new Readable({ +- async read(size) { +- const data = await fetchData(size); +- this.push(data); ++ read(size) { ++ fetchData(size) ++ .then(data => this.push(data)) ++ .catch(err => this.destroy(err)); + } + }); +``` + +### Example 4 + +An existing `try`/`catch` whose handler only forwards the error is folded into the `.catch()` link: + +```diff + const writable = new Writable({ +- async write(chunk, encoding, callback) { +- try { +- await someAsyncWork(chunk); +- callback(); +- } catch (err) { +- callback(err); +- } ++ write(chunk, encoding, callback) { ++ someAsyncWork(chunk) ++ .then(() => callback()) ++ .catch(err => callback(err)); + } + }); +``` + +## Notes + +A method is only treated as a stream implementation when the stream class is resolved from a `node:stream` import in the same file: a class that extends it, or an options object passed to `new` on it. A plain object or an unrelated class that happens to have an `async write()` method is left alone, because it is not a stream. + +Indentation of the generated chain is read from the surrounding source, and the file's existing line endings are preserved. + +### Limitations + +The rewrite is skipped, and the original `async` method left untouched, whenever the chain could not be reproduced faithfully: + +- **The first awaited expression is optionally chained.** `await this.connection?.close()` short-circuits to `undefined` when the receiver is nullish, so `.then()` would throw instead of calling back. +- **A binding is used more than one step later.** In a `.then()` chain each step only sees the previous step's value, so a variable read two statements after it was declared cannot be expressed without restructuring. +- **The `catch` block does more than forward the error.** A handler that also logs, retries or branches is not collapsed into `.catch(err => callback(err))`, and a `finally` block is never rewritten. +- **An `await` is not a whole statement.** Only `await expr;` and `const name = await expr;` are chained; something like `log(await save(chunk));` is not. +- **Async generators.** `async *_read()` is a different protocol and is never rewritten. +- **A step rebinds the callback parameter**, which would change what `.catch()` ends up calling. diff --git a/recipes/stream-thenable-to-callback/codemod.yaml b/recipes/stream-thenable-to-callback/codemod.yaml new file mode 100644 index 00000000..21fba744 --- /dev/null +++ b/recipes/stream-thenable-to-callback/codemod.yaml @@ -0,0 +1,23 @@ +schema_version: "1.0" +name: "@nodejs/stream-thenable-to-callback" +version: 1.0.0 +description: "Replace async stream implementation methods with callbacks (DEP0157)." +author: Harshal_25 +license: MIT +workflow: workflow.yaml +category: migration +repository: https://github.com/nodejs/userland-migrations + +targets: + languages: + - javascript + - typescript + +keywords: + - transformation + - migration + - nodejs + +registry: + access: public + visibility: public diff --git a/recipes/stream-thenable-to-callback/package.json b/recipes/stream-thenable-to-callback/package.json new file mode 100644 index 00000000..253ee59a --- /dev/null +++ b/recipes/stream-thenable-to-callback/package.json @@ -0,0 +1,13 @@ +{ + "name": "@nodejs/stream-thenable-to-callback", + "type": "module", + "scripts": { + "test": "npx codemod jssg test -l typescript ./src/workflow.ts" + }, + "devDependencies": { + "@codemod.com/jssg-types": "^1.6.3" + }, + "dependencies": { + "@nodejs/codemod-utils": "*" + } +} diff --git a/recipes/stream-thenable-to-callback/src/workflow.ts b/recipes/stream-thenable-to-callback/src/workflow.ts new file mode 100644 index 00000000..12e8294b --- /dev/null +++ b/recipes/stream-thenable-to-callback/src/workflow.ts @@ -0,0 +1,605 @@ +import type { Edit, SgNode, SgRoot } from '@codemod.com/jssg-types/main'; +import type Js from '@codemod.com/jssg-types/langs/javascript'; +import { getModuleDependencies } from '@nodejs/codemod-utils/ast-grep/module-dependencies'; +import { resolveBindingPath } from '@nodejs/codemod-utils/ast-grep/resolve-binding-path'; +import { detectIndentUnit, getLineIndent } from '@nodejs/codemod-utils/ast-grep/indent'; +import { EOL } from 'node:os'; + +const STREAM_CLASSES = [ + 'Writable', 'Readable', 'Transform', 'Duplex', 'PassThrough', +]; + +const OPTION_METHODS = new Set([ + 'write', 'writev', 'read', 'transform', 'flush', 'final', +]); + +const CLASS_METHODS = new Set([ + '_write', '_writev', '_read', '_transform', '_flush', '_final', +]); + +const READ_METHODS = new Set(['read', '_read']); + +const FUNCTION_KINDS = new Set([ + 'method_definition', 'function_expression', 'function', + 'function_declaration', 'arrow_function', 'generator_function', + 'generator_function_declaration', +]); + +const FUNCTION_VALUE_KINDS = new Set([ + 'function_expression', 'function', 'arrow_function', +]); + +const CHAINABLE_KINDS = new Set([ + 'call_expression', 'member_expression', 'subscript_expression', + 'identifier', 'parenthesized_expression', +]); + +const NEVER_THENABLE_KINDS = new Set([ + 'null', 'undefined', 'true', 'false', 'number', 'string', + 'template_string', 'regex', 'array', 'arrow_function', + 'function_expression', 'function', 'class', +]); + +const ARROW_BODY_NEEDS_PARENS = new Set(['object', 'sequence_expression']); + +type StreamFunction = { fn: SgNode; name: string; }; + +type Step = { + statement: SgNode; + argument: SgNode; + name: SgNode | null; +}; + +type ExtractedBody = { + statements: SgNode[]; + comments: SgNode[]; + catchParam: string | null; + catchStatement: SgNode | null; +}; + +type Replacement = { start: number; end: number; text: string }; + +type FileContext = { + source: string; + indentUnit: string; +}; + +/** + * Transform function that converts async stream implementation methods to the + * callback form the streams API expects. + * + * See DEP0157: https://nodejs.org/api/deprecations.html#DEP0157 + * + * Handles: + * 1. async write(chunk, enc, cb) { await a(chunk); cb(); } → a(chunk).then(() => cb()).catch(err => cb(err)) + * 2. Sequential awaits → one .then() link per await, value passed to the next + * 3. async read(size) { this.push(await d()); } → d().then(v => this.push(v)).catch(err => this.destroy(err)) + * 4. try { await a(); cb(); } catch (err) { cb(err); } → a().then(() => cb()).catch(err => cb(err)) + * 5. write: async function () {} / write: async () => {} (options properties) + * 6. _write/_writev/_read/_transform/_flush/_final on classes extending a stream + * 7. Promise return annotations narrowed to void (TypeScript) + */ +export default function transform(root: SgRoot): string | null { + const rootNode = root.root(); + + // Leverage the utils to find exactly what node:stream exposes here + const streamNames = getStreamClassNames(root); + if (!streamNames.size) return null; + + const source = rootNode.text(); + const context: FileContext = { + source, + indentUnit: detectIndentUnit(source), + }; + + const edits: Edit[] = []; + const edited: Array<[number, number]> = []; + + for (const target of findStreamFunctions(rootNode, streamNames)) { + const { start, end } = rangeOf(target.fn); + + if (edited.some(([s, e]) => start < e && s < end)) continue; + + const edit = transformStream(target, context); + if (!edit) continue; + + edits.push(edit); + edited.push([start, end]); + } + + if (!edits.length) return null; + + return rootNode.commitEdits(edits); +} + +//find stream classes only +function getStreamClassNames(root: SgRoot): Set { + const names = new Set(); + + for (const statement of getModuleDependencies(root, 'stream')) { + for (const className of STREAM_CLASSES) { + const localPath = resolveBindingPath(statement, `$.${className}`); + if (localPath) names.add(localPath); + } + } + + return names; +} + +//find methods in the stream classes +function findStreamFunctions(rootNode: SgNode, streamNames: Set): StreamFunction[] { + const found: StreamFunction[] = []; + const methods = rootNode.findAll({ rule: { kind: 'method_definition' } }); + + for (const method of methods) { + const name = method.field('name')?.text(); + if (!name) continue; + + if (CLASS_METHODS.has(name) && extendsStream(method, streamNames)) { + found.push({ fn: method, name }); + } else if ( + OPTION_METHODS.has(name) && + isStreamOptionsMember(method, streamNames) + ) { + found.push({ fn: method, name }); + } + } + + const pairs = rootNode.findAll({ rule: { kind: 'pair' } }); + + //check if key -> name & value -> function expression + for (const pair of pairs) { + const key = pair.field('key'); + const value = pair.field('value'); + if (!key || !value) continue; + + const name = key.find({ rule: { kind: 'string_fragment' } })?.text() ?? key.text(); + if ( + OPTION_METHODS.has(name) && + FUNCTION_VALUE_KINDS.has(value.kind()) && + isStreamOptionsMember(pair, streamNames) + ) { + found.push({ fn: value, name }); + } + } + + return found; +} + +//check if a class method belongs to a class that extends one of stream classes +function extendsStream(member: SgNode, streamNames: Set): boolean { + const classBody = member.parent(); + if (!classBody?.is('class_body')) return false; + + const classNode = classBody.parent(); + if (!classNode) return false; + + const heritage = classNode.find({ rule: { kind: 'class_heritage' } }); + if (!heritage) return false; + + const parts = heritage.children().filter((c) => !c.is('extends')); + const superclass = parts[parts.length - 1]; + + return !!superclass && streamNames.has(withoutSpaces(superclass.text())); +} + +// Checks if an object method property belongs to options passed +function isStreamOptionsMember(member: SgNode, streamNames: Set): boolean { + const object = member.parent(); + if (!object?.is('object')) return false; + + const args = object.parent(); + if (!args?.is('arguments')) return false; + + const newExpression = args.parent(); + if (!newExpression?.is('new_expression')) return false; + + const ctor = newExpression.field('constructor'); + if (!ctor) return false; + + return streamNames.has(withoutSpaces(ctor.text())); +} + +//main transromation is done here. +function transformStream({ fn, name }: StreamFunction, context: FileContext): Edit | null { + const fnChildren = fn.children(); + + const asyncToken = fnChildren.find((child) => child.is('async')); + if (!asyncToken) return null; + + if (fnChildren.some((child) => child.is('*'))) return null; + + const body = fn.field('body'); + if (!body?.is('statement_block')) return null; + + const awaits = body + .findAll({ rule: { kind: 'await_expression' } }) + .filter((node) => belongsTo(node, fn)); + + if (!awaits.length) return null; + + const isRead = READ_METHODS.has(name); + const callbackName = isRead ? null : getCallbackParamName(fn); + + if (!isRead && !callbackName) return null; + const extracted = extractBody(body); + if (!extracted) return null; + + const { statements, comments, catchParam, catchStatement } = extracted; + if (statements.length < 2) return null; + + const steps: Step[] = []; + + for (const statement of statements.slice(0, -1)) { + const step = getStep(statement); + if (!step) return null; + + steps.push(step); + } + + if (steps.length !== awaits.length) return null; + + const first = steps[0].argument; + if (NEVER_THENABLE_KINDS.has(first.kind())) return null; + + if (first.text().includes('?.')) return null; + + if (callbackName) { + const rebindsCallback = steps.some((step) => + step.name && getBoundNames(step.name).includes(callbackName) + ); + + if (rebindsCallback) return null; + } + + const finalStatement = statements[statements.length - 1]; + + const finalCall = isRead ? getPushCall(finalStatement) + : getCallbackCall(finalStatement, callbackName as string); + + if (!finalCall) return null; + + const errorName = catchParam ?? 'err'; + const errorHandler = isRead + ? `this.destroy(${errorName})` + : `${callbackName}(${errorName})`; + + if (catchStatement && !isCallStatement(catchStatement, errorHandler)) { + return null; + } + + if (!hasSafeScoping(steps, finalStatement)) return null; + + // Inlined indentation logic using the provided getLineIndent util + const bodyRange = rangeOf(body); + const closingIndent = getLineIndent(context.source, bodyRange.end - 1); + const bodyIndent = closingIndent + context.indentUnit; + const chainIndent = bodyIndent + context.indentUnit; + + const lines: string[] = comments.map( + (c) => `${bodyIndent}${c.text().trim()}`, + ); + + lines.push(`${bodyIndent}${asChainTarget(first)}`); + + for (let i = 1; i <= steps.length; i++) { + const previous = steps[i - 1]; + const isLast = i === steps.length; + const consumer = isLast ? finalStatement : steps[i].statement; + + const arrowParam = getArrowParameter(previous, consumer); + const arrowBody = isLast + ? finalCall + : asArrowBody(steps[i].argument); + + lines.push(`${chainIndent}.then(${arrowParam} => ${arrowBody})`); + } + + lines.push(`${chainIndent}.catch(${errorName} => ${errorHandler});`); + + const replacements: Replacement[] = [ + { + ...bodyRange, + text: `{${EOL}${lines.join(EOL)}${EOL}${closingIndent}}`, + }, + removeToken(asyncToken, context.source), + ]; + + const returnType = fn.field('return_type'); + + if (returnType) { + const replaced = replacePromiseReturnType(returnType); + if (replaced) replacements.push(replaced); + } + return fn.replace(applyReplacements(fn, replacements)); +} + +//extract body and catch information +function extractBody(body: SgNode): ExtractedBody | null { + const statements = getStatements(body); + const comments = getComments(body); + + if (statements.length === 1 && statements[0].is('try_statement')) { + const tryStmt = statements[0]; + + if (tryStmt.field('finalizer')) { + return null; + } + + const tryBlock = tryStmt.field('body'); + const handler = tryStmt.field('handler'); + + if (!tryBlock || !handler) return null; + + const parameter = handler.field('parameter'); + + if (!parameter?.is('identifier')) return null; + + const handlerBody = handler.field('body'); + if (!handlerBody) return null; + + const handlerStatements = getStatements(handlerBody); + if (handlerStatements.length !== 1) return null; + + return { + statements: getStatements(tryBlock), + comments: [ + ...comments, + ...getComments(tryBlock), + ...getComments(handlerBody) + ], + catchParam: parameter.text(), + catchStatement: handlerStatements[0], + }; + } + return { statements, comments, catchParam: null, catchStatement: null }; +} + +//find the awaited expression and variable assigned to it +function getStep(statement: SgNode): Step | null { + let awaitedNode: SgNode | null = null; + let boundName: SgNode | null = null; + + if (statement.is('expression_statement')) { + awaitedNode = statement.child(0) ?? null; + } else if (statement.is('lexical_declaration') || statement.is('variable_declaration')) { + const decls = statement + .children() + .filter((child) => child.is('variable_declarator')); + + if (decls.length !== 1) return null; + + awaitedNode = decls[0].field('value') ?? null; + boundName = decls[0].field('name') ?? null; + } + if (!awaitedNode?.is('await_expression')) return null; + const argument = awaitedNode + .children() + .find((c) => !c.is('await') && !c.is('comment')); + + if (!argument) return null; + return { statement, argument, name: boundName }; +} + +//get callback parameter from function +function getCallbackParamName(fn: SgNode): string | null { + const params = fn.field('parameters'); + if (!params) return null; + + const list = params + .children() + .filter((c) => !['(', ')', ',', 'comment'].includes(c.kind())); + + const last = list[list.length - 1]; + if (!last) return null; + + if (last.is('identifier')) return last.text(); + + if (last.is('required_parameter') || last.is('optional_parameter')) { + const pattern = last.child(0); + return pattern?.is('identifier') ? pattern.text() : null; + } + return null; +} + + +//removes spaces from sourceText +const withoutSpaces = (text: string) => text.replace(/\s+/g, ''); + +//get call expression from statement +function getStatementCall(statement: SgNode): SgNode | null { + if (!statement.is('expression_statement') && !statement.is('return_statement')) { + return null; + } + + const call = statement + .children() + .find((c) => !['return', ';', 'comment'].includes(c.kind())); + + return call?.is('call_expression') ? call : null; +} + +//check if call uses expected callee +function getCallWithCallee( + statement: SgNode, + matches: (callee: SgNode) => boolean, +): string | null { + const call = getStatementCall(statement); + const callee = call?.field('function'); + + return call && callee && matches(callee) ? call.text() : null; +} + +//find callback call from statement +const getCallbackCall = (statement: SgNode, cbName: string) => + getCallWithCallee( + statement, + (callee) => callee.is('identifier') && callee.text() === cbName, + ); + +//find this.push call from statement +const getPushCall = (statement: SgNode) => + statement.is('expression_statement') + ? getCallWithCallee( + statement, + (callee) => withoutSpaces(callee.text()) === 'this.push', + ) + : null; + +//check if statement is expected call +function isCallStatement(statement: SgNode, expected: string): boolean { + if (!statement.is('expression_statement')) return false; + + const call = getStatementCall(statement); + + return !!call && withoutSpaces(call.text()) === withoutSpaces(expected); +} + +//check if variables are safely scoped after conversion +function hasSafeScoping(steps: Step[], finalStatement: SgNode): boolean { + const consumers = [ + ...steps.slice(1).map((s) => s.statement), + finalStatement, + ]; + + for (let i = 0; i < steps.length; i++) { + const name = steps[i].name; + if (!name) continue; + + const bound = getBoundNames(name); + + for (const later of consumers.slice(i + 1)) { + if (referencesAny(later, bound)) return false; + } + } + + return true; +} + +//get variables bound by a node +function getBoundNames(name: SgNode): string[] { + if (name.is('identifier')) return [name.text()]; + + return name + .findAll({ + rule: { + any: [ + { kind: 'identifier' }, + { kind: 'shorthand_property_identifier_pattern' }, + ], + }, + }) + .map((node) => node.text()); +} + +//check if node references any of the supplied names +function referencesAny(node: SgNode, names: string[]): boolean { + return node + .findAll({ + rule: { + any: [ + { kind: 'identifier' }, + { kind: 'shorthand_property_identifier' }, + ], + }, + }) + .some((id) => names.includes(id.text())); +} + +//get parameter for generated then callback +function getArrowParameter(step: Step, consumer: SgNode): string { + if (!step.name) return '()'; + + if (!referencesAny(consumer, getBoundNames(step.name))) { + return '()'; + } + + if (step.name.is('identifier')) { + return step.name.text(); + } + return `(${step.name.text()})`; +} + +//convert expression into valid chain target +function asChainTarget(node: SgNode): string { + return CHAINABLE_KINDS.has(node.kind()) ? node.text() : `(${node.text()})`; +} + +//convert expression into valid arrow body +function asArrowBody(node: SgNode): string { + return ARROW_BODY_NEEDS_PARENS.has(node.kind()) + ? `(${node.text()})` + : node.text(); +} + +//finds who's the parent of passed node. +function belongsTo(node: SgNode, fn: SgNode): boolean { + let current = node.parent(); + + while (current) { + if (FUNCTION_KINDS.has(current.kind())) return sameRange(current, fn); + + current = current.parent(); + } + + return false; +} + +//get direct statements from block +function getStatements(block: SgNode): SgNode[] { + return block + .children() + .filter((c) => !c.is('{') && !c.is('}') && !c.is('comment')); +} + +//get comments directly inside block +function getComments(block: SgNode): SgNode[] { + return block.children().filter((c) => c.is('comment')); +} + +//get source range of node +function rangeOf(node: SgNode): { start: number; end: number } { + const range = node.range(); + + return { start: range.start.index, end: range.end.index }; +} + +//compare nodes using source range +function sameRange(a: SgNode, b: SgNode): boolean { + const ra = rangeOf(a); + const rb = rangeOf(b); + + return ra.start === rb.start && ra.end === rb.end; +} + +//remove async token and following whitespace +function removeToken(token: SgNode, source: string): Replacement { + const { start, end } = rangeOf(token); + const match = source.slice(end).match(/^[ \t]*/); + const trailing = match ? match[0].length : 0; + + return { start, end: end + trailing, text: '' }; +} + +//replace Promise return type with void +function replacePromiseReturnType(returnType: SgNode,): Replacement | null { + const text = returnType.text(); + + if (!/^(:\s*)?Promise\s*, reps: Replacement[],): string { + const base = rangeOf(fn).start; + let text = fn.text(); + + for (const r of [...reps].sort((a, b) => b.start - a.start)) { + text = text.slice(0, r.start - base) + r.text + text.slice(r.end - base); + } + return text; +} diff --git a/recipes/stream-thenable-to-callback/tests/async-esm/expected.js b/recipes/stream-thenable-to-callback/tests/async-esm/expected.js new file mode 100644 index 00000000..73d36dbb --- /dev/null +++ b/recipes/stream-thenable-to-callback/tests/async-esm/expected.js @@ -0,0 +1,9 @@ +import { Writable } from "node:stream"; + +export const writable = new Writable({ + write(chunk, encoding, callback) { + database.insert(chunk.toString()) + .then(() => callback()) + .catch(err => callback(err)); + } +}); diff --git a/recipes/stream-thenable-to-callback/tests/async-esm/input.js b/recipes/stream-thenable-to-callback/tests/async-esm/input.js new file mode 100644 index 00000000..201dc505 --- /dev/null +++ b/recipes/stream-thenable-to-callback/tests/async-esm/input.js @@ -0,0 +1,8 @@ +import { Writable } from "node:stream"; + +export const writable = new Writable({ + async write(chunk, encoding, callback) { + await database.insert(chunk.toString()); + callback(); + } +}); diff --git a/recipes/stream-thenable-to-callback/tests/async-final/expected.js b/recipes/stream-thenable-to-callback/tests/async-final/expected.js new file mode 100644 index 00000000..e1c2af66 --- /dev/null +++ b/recipes/stream-thenable-to-callback/tests/async-final/expected.js @@ -0,0 +1,13 @@ +const { Writable } = require("node:stream"); + +const writable = new Writable({ + write(chunk, encoding, callback) { + // write implementation + callback(); + }, + final(callback) { + cleanup() + .then(() => callback()) + .catch(err => callback(err)); + } +}); diff --git a/recipes/stream-thenable-to-callback/tests/async-final/input.js b/recipes/stream-thenable-to-callback/tests/async-final/input.js new file mode 100644 index 00000000..cfb414ef --- /dev/null +++ b/recipes/stream-thenable-to-callback/tests/async-final/input.js @@ -0,0 +1,12 @@ +const { Writable } = require("node:stream"); + +const writable = new Writable({ + write(chunk, encoding, callback) { + // write implementation + callback(); + }, + async final(callback) { + await cleanup(); + callback(); + } +}); diff --git a/recipes/stream-thenable-to-callback/tests/async-flush/expected.js b/recipes/stream-thenable-to-callback/tests/async-flush/expected.js new file mode 100644 index 00000000..7f260156 --- /dev/null +++ b/recipes/stream-thenable-to-callback/tests/async-flush/expected.js @@ -0,0 +1,13 @@ +const { Transform } = require("node:stream"); + +const transform = new Transform({ + transform(chunk, encoding, callback) { + this.push(chunk); + callback(); + }, + flush(callback) { + finalizeStream() + .then(() => callback()) + .catch(err => callback(err)); + } +}); diff --git a/recipes/stream-thenable-to-callback/tests/async-flush/input.js b/recipes/stream-thenable-to-callback/tests/async-flush/input.js new file mode 100644 index 00000000..4990a2ae --- /dev/null +++ b/recipes/stream-thenable-to-callback/tests/async-flush/input.js @@ -0,0 +1,12 @@ +const { Transform } = require("node:stream"); + +const transform = new Transform({ + transform(chunk, encoding, callback) { + this.push(chunk); + callback(); + }, + async flush(callback) { + await finalizeStream(); + callback(); + } +}); diff --git a/recipes/stream-thenable-to-callback/tests/async-multiple/expected.js b/recipes/stream-thenable-to-callback/tests/async-multiple/expected.js new file mode 100644 index 00000000..b1130906 --- /dev/null +++ b/recipes/stream-thenable-to-callback/tests/async-multiple/expected.js @@ -0,0 +1,10 @@ +const { Transform } = require("node:stream"); + +const transform = new Transform({ + transform(chunk, encoding, callback) { + operation1(chunk) + .then(step1 => operation2(step1)) + .then(step2 => callback(null, step2)) + .catch(err => callback(err)); + } +}); diff --git a/recipes/stream-thenable-to-callback/tests/async-multiple/input.js b/recipes/stream-thenable-to-callback/tests/async-multiple/input.js new file mode 100644 index 00000000..3d624afe --- /dev/null +++ b/recipes/stream-thenable-to-callback/tests/async-multiple/input.js @@ -0,0 +1,9 @@ +const { Transform } = require("node:stream"); + +const transform = new Transform({ + async transform(chunk, encoding, callback) { + const step1 = await operation1(chunk); + const step2 = await operation2(step1); + callback(null, step2); + } +}); diff --git a/recipes/stream-thenable-to-callback/tests/async-pair-double-quoted/expected.js b/recipes/stream-thenable-to-callback/tests/async-pair-double-quoted/expected.js new file mode 100644 index 00000000..2bf669f1 --- /dev/null +++ b/recipes/stream-thenable-to-callback/tests/async-pair-double-quoted/expected.js @@ -0,0 +1,10 @@ +import { Writable } from 'node:stream'; + +const stream = new Writable({ + "write": function (chunk, encoding, callback) + { + save(chunk) + .then(() => callback()) + .catch(err => callback(err)); + }, +}); diff --git a/recipes/stream-thenable-to-callback/tests/async-pair-double-quoted/input.js b/recipes/stream-thenable-to-callback/tests/async-pair-double-quoted/input.js new file mode 100644 index 00000000..9173d074 --- /dev/null +++ b/recipes/stream-thenable-to-callback/tests/async-pair-double-quoted/input.js @@ -0,0 +1,9 @@ +import { Writable } from 'node:stream'; + +const stream = new Writable({ + "write": async function (chunk, encoding, callback) + { + await save(chunk); + callback(); + }, +}); diff --git a/recipes/stream-thenable-to-callback/tests/async-pair-no-qoutes/expected.js b/recipes/stream-thenable-to-callback/tests/async-pair-no-qoutes/expected.js new file mode 100644 index 00000000..5fa6587c --- /dev/null +++ b/recipes/stream-thenable-to-callback/tests/async-pair-no-qoutes/expected.js @@ -0,0 +1,10 @@ +import { Writable } from 'node:stream'; + +const stream = new Writable({ + write: function (chunk, encoding, callback) { + save(chunk) + .then(() => callback()) + .catch(err => callback(err)); + }, + objectMode: true +}); diff --git a/recipes/stream-thenable-to-callback/tests/async-pair-no-qoutes/input.js b/recipes/stream-thenable-to-callback/tests/async-pair-no-qoutes/input.js new file mode 100644 index 00000000..a92dd4c8 --- /dev/null +++ b/recipes/stream-thenable-to-callback/tests/async-pair-no-qoutes/input.js @@ -0,0 +1,9 @@ +import { Writable } from 'node:stream'; + +const stream = new Writable({ + write: async function (chunk, encoding, callback) { + await save(chunk); + callback(); + }, + objectMode: true +}); diff --git a/recipes/stream-thenable-to-callback/tests/async-pair-single-quoted/expected.js b/recipes/stream-thenable-to-callback/tests/async-pair-single-quoted/expected.js new file mode 100644 index 00000000..afc78120 --- /dev/null +++ b/recipes/stream-thenable-to-callback/tests/async-pair-single-quoted/expected.js @@ -0,0 +1,10 @@ +import { Writable } from 'node:stream'; + +const stream = new Writable({ + 'write': function (chunk, encoding, callback) + { + save(chunk) + .then(() => callback()) + .catch(err => callback(err)); + }, +}); diff --git a/recipes/stream-thenable-to-callback/tests/async-pair-single-quoted/input.js b/recipes/stream-thenable-to-callback/tests/async-pair-single-quoted/input.js new file mode 100644 index 00000000..537a7af4 --- /dev/null +++ b/recipes/stream-thenable-to-callback/tests/async-pair-single-quoted/input.js @@ -0,0 +1,9 @@ +import { Writable } from 'node:stream'; + +const stream = new Writable({ + 'write': async function (chunk, encoding, callback) + { + await save(chunk); + callback(); + }, +}); diff --git a/recipes/stream-thenable-to-callback/tests/async-promisify-pattern/expected.js b/recipes/stream-thenable-to-callback/tests/async-promisify-pattern/expected.js new file mode 100644 index 00000000..a2e68c91 --- /dev/null +++ b/recipes/stream-thenable-to-callback/tests/async-promisify-pattern/expected.js @@ -0,0 +1,9 @@ +const { Writable } = require("node:stream"); + +const writable = new Writable({ + write(chunk, encoding, callback) { + someAsyncWork(chunk) + .then(() => callback()) + .catch(err => callback(err)); + } +}); diff --git a/recipes/stream-thenable-to-callback/tests/async-promisify-pattern/input.js b/recipes/stream-thenable-to-callback/tests/async-promisify-pattern/input.js new file mode 100644 index 00000000..fac7c3e3 --- /dev/null +++ b/recipes/stream-thenable-to-callback/tests/async-promisify-pattern/input.js @@ -0,0 +1,12 @@ +const { Writable } = require("node:stream"); + +const writable = new Writable({ + async write(chunk, encoding, callback) { + try { + await someAsyncWork(chunk); + callback(); + } catch (err) { + callback(err); + } + } +}); diff --git a/recipes/stream-thenable-to-callback/tests/async-read/expected.js b/recipes/stream-thenable-to-callback/tests/async-read/expected.js new file mode 100644 index 00000000..9eb0692e --- /dev/null +++ b/recipes/stream-thenable-to-callback/tests/async-read/expected.js @@ -0,0 +1,9 @@ +const { Readable } = require("node:stream"); + +const readable = new Readable({ + read(size) { + fetchData(size) + .then(data => this.push(data)) + .catch(err => this.destroy(err)); + } +}); diff --git a/recipes/stream-thenable-to-callback/tests/async-read/input.js b/recipes/stream-thenable-to-callback/tests/async-read/input.js new file mode 100644 index 00000000..2dfa319c --- /dev/null +++ b/recipes/stream-thenable-to-callback/tests/async-read/input.js @@ -0,0 +1,8 @@ +const { Readable } = require("node:stream"); + +const readable = new Readable({ + async read(size) { + const data = await fetchData(size); + this.push(data); + } +}); diff --git a/recipes/stream-thenable-to-callback/tests/async-transform/expected.js b/recipes/stream-thenable-to-callback/tests/async-transform/expected.js new file mode 100644 index 00000000..0c107b81 --- /dev/null +++ b/recipes/stream-thenable-to-callback/tests/async-transform/expected.js @@ -0,0 +1,9 @@ +const { Transform } = require("node:stream"); + +const transform = new Transform({ + transform(chunk, encoding, callback) { + processChunk(chunk) + .then(result => callback(null, result)) + .catch(err => callback(err)); + } +}); diff --git a/recipes/stream-thenable-to-callback/tests/async-transform/input.js b/recipes/stream-thenable-to-callback/tests/async-transform/input.js new file mode 100644 index 00000000..c9f5897a --- /dev/null +++ b/recipes/stream-thenable-to-callback/tests/async-transform/input.js @@ -0,0 +1,8 @@ +const { Transform } = require("node:stream"); + +const transform = new Transform({ + async transform(chunk, encoding, callback) { + const result = await processChunk(chunk); + callback(null, result); + } +}); diff --git a/recipes/stream-thenable-to-callback/tests/async-within-class/expected.js b/recipes/stream-thenable-to-callback/tests/async-within-class/expected.js new file mode 100644 index 00000000..cc7ba359 --- /dev/null +++ b/recipes/stream-thenable-to-callback/tests/async-within-class/expected.js @@ -0,0 +1,13 @@ +const { Writable } = require("node:stream"); + +class MyWritable extends Writable { + _write(chunk, encoding, callback) { + this.processChunk(chunk) + .then(() => callback()) + .catch(err => callback(err)); + } + + async processChunk(chunk) { + // async processing + } +} diff --git a/recipes/stream-thenable-to-callback/tests/async-within-class/input.js b/recipes/stream-thenable-to-callback/tests/async-within-class/input.js new file mode 100644 index 00000000..cc7ba359 --- /dev/null +++ b/recipes/stream-thenable-to-callback/tests/async-within-class/input.js @@ -0,0 +1,13 @@ +const { Writable } = require("node:stream"); + +class MyWritable extends Writable { + _write(chunk, encoding, callback) { + this.processChunk(chunk) + .then(() => callback()) + .catch(err => callback(err)); + } + + async processChunk(chunk) { + // async processing + } +} diff --git a/recipes/stream-thenable-to-callback/tests/async-write/expected.js b/recipes/stream-thenable-to-callback/tests/async-write/expected.js new file mode 100644 index 00000000..c231d215 --- /dev/null +++ b/recipes/stream-thenable-to-callback/tests/async-write/expected.js @@ -0,0 +1,9 @@ +const { Writable } = require("node:stream"); + +const writable = new Writable({ + write(chunk, encoding, callback) { + someAsyncOperation(chunk) + .then(() => callback()) + .catch(err => callback(err)); + } +}); diff --git a/recipes/stream-thenable-to-callback/tests/async-write/input.js b/recipes/stream-thenable-to-callback/tests/async-write/input.js new file mode 100644 index 00000000..b550b82f --- /dev/null +++ b/recipes/stream-thenable-to-callback/tests/async-write/input.js @@ -0,0 +1,8 @@ +const { Writable } = require("node:stream"); + +const writable = new Writable({ + async write(chunk, encoding, callback) { + await someAsyncOperation(chunk); + callback(); + } +}); diff --git a/recipes/stream-thenable-to-callback/workflow.yaml b/recipes/stream-thenable-to-callback/workflow.yaml new file mode 100644 index 00000000..02759a70 --- /dev/null +++ b/recipes/stream-thenable-to-callback/workflow.yaml @@ -0,0 +1,25 @@ +version: "1" + +nodes: + - id: apply-transforms + name: Apply AST Transformations + type: automatic + runtime: + type: direct + steps: + - name: "Replace thenable stream methods with callbacks (DEP0157)" + js-ast-grep: + js_file: src/workflow.ts + base_path: . + include: + - "**/*.js" + - "**/*.jsx" + - "**/*.mjs" + - "**/*.cjs" + - "**/*.cts" + - "**/*.mts" + - "**/*.ts" + - "**/*.tsx" + exclude: + - "**/node_modules/**" + language: typescript