diff --git a/package-lock.json b/package-lock.json index e5c099ae..0c422a9a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1424,6 +1424,10 @@ "resolved": "recipes/fs-access-mode-constants", "link": true }, + "node_modules/@nodejs/http-classes-with-new": { + "resolved": "recipes/http-classes-with-new", + "link": true + }, "node_modules/@nodejs/import-assertions-to-attributes": { "resolved": "recipes/import-assertions-to-attributes", "link": true @@ -4095,6 +4099,17 @@ "@codemod.com/jssg-types": "^1.0.3" } }, + "recipes/http-classes-with-new": { + "name": "@nodejs/http-classes-with-new", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "@nodejs/codemod-utils": "*" + }, + "devDependencies": { + "@codemod.com/jssg-types": "^1.0.3" + } + }, "recipes/import-assertions-to-attributes": { "name": "@nodejs/import-assertions-to-attributes", "version": "1.0.0", diff --git a/recipes/http-classes-with-new/README.md b/recipes/http-classes-with-new/README.md new file mode 100644 index 00000000..8276c55d --- /dev/null +++ b/recipes/http-classes-with-new/README.md @@ -0,0 +1,33 @@ +# `http.request` DEP0195 + +This recipe provides a guide for migrating from the deprecated `http.request` and its synchronous and promise-based counterparts to the new `http.request` method in Node.js. + +See [DEP0195](https://nodejs.org/api/deprecations.html#DEP0195). + +## Examples + +**Before:** + +```js +// import { IncomingMessage, ClientRequest } from "node:http"; +// const http = require("node:http"); + +const message = http.OutgoingMessage(); +const response = http.ServerResponse(socket); + +const incoming = IncomingMessage(socket); +const request = ClientRequest(options); +``` + +**After:** + +```js +// import { IncomingMessage, ClientRequest } from "node:http"; +// const http = require("node:http"); + +const message = new http.OutgoingMessage(); +const response = new http.ServerResponse(socket); + +const incoming = new IncomingMessage(socket); +const request = new ClientRequest(options); +``` diff --git a/recipes/http-classes-with-new/codemod.yaml b/recipes/http-classes-with-new/codemod.yaml new file mode 100644 index 00000000..fcbdabc8 --- /dev/null +++ b/recipes/http-classes-with-new/codemod.yaml @@ -0,0 +1,21 @@ +schema_version: "1.0" +name: "@nodejs/http-classes-with-new" +version: 1.0.0 +description: "Handle DEDEP0195: Instantiating node:http classes without new" +author: Usman S. +license: MIT +workflow: workflow.yaml +category: migration + +targets: + languages: + - javascript + - typescript + +keywords: + - transformation + - migration + +registry: + access: public + visibility: public diff --git a/recipes/http-classes-with-new/package.json b/recipes/http-classes-with-new/package.json new file mode 100644 index 00000000..9130bc3f --- /dev/null +++ b/recipes/http-classes-with-new/package.json @@ -0,0 +1,24 @@ +{ + "name": "@nodejs/http-classes-with-new", + "version": "1.0.0", + "description": "Handle DEP0195: Instantiating node:http classes without new.", + "type": "module", + "scripts": { + "test": "npx codemod jssg test -l typescript ./src/workflow.ts ./" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/nodejs/userland-migrations.git", + "directory": "recipes/http-classes-with-new", + "bugs": "https://github.com/nodejs/userland-migrations/issues" + }, + "author": "Usman S.", + "license": "MIT", + "homepage": "https://github.com/nodejs/userland-migrations/blob/main/recipes/http-classes-with-new/README.md", + "devDependencies": { + "@codemod.com/jssg-types": "^1.0.3" + }, + "dependencies": { + "@nodejs/codemod-utils": "*" + } +} diff --git a/recipes/http-classes-with-new/src/workflow.ts b/recipes/http-classes-with-new/src/workflow.ts new file mode 100644 index 00000000..67af8c76 --- /dev/null +++ b/recipes/http-classes-with-new/src/workflow.ts @@ -0,0 +1,71 @@ +import { getNodeImportStatements } from '@nodejs/codemod-utils/ast-grep/import-statement'; +import { getNodeRequireCalls } from '@nodejs/codemod-utils/ast-grep/require-call'; +import { resolveBindingPath } from '@nodejs/codemod-utils/ast-grep/resolve-binding-path'; +import type { SgRoot, Edit, SgNode } from '@codemod.com/jssg-types/main'; + +/** + * Classes of the http module + */ +const CLASS_NAMES = [ + 'Agent', + 'ClientRequest', + 'IncomingMessage', + 'OutgoingMessage', + 'Server', + 'ServerResponse', +]; + +/** + * Transform function that converts deprecated node:http classes to use the `new` keyword + * + * Handles: + * 1. `http.Agent()` → `new http.Agent()` + * 2. `http.ClientRequest()` → `new http.ClientRequest()` + * 3. `http.IncomingMessage()` → `new http.IncomingMessage()` + * 4. `http.OutgoingMessage()` → `new http.OutgoingMessage()` + * 5. `http.Server()` → `new http.Server()` + * 6. `http.ServerResponse() → `new http.ServerResponse()` + */ +export default function transform(root: SgRoot): string | null { + const rootNode = root.root(); + const edits: Edit[] = []; + + const importNodes = getNodeImportStatements(root, 'http'); + const requireNodes = getNodeRequireCalls(root, 'http'); + const allStatementNodes = [...importNodes, ...requireNodes]; + const classes = new Set(getHttpClassBasePaths(allStatementNodes)); + + for (const cls of classes) { + const classesWithoutNew = rootNode.findAll({ + rule: { + not: { follows: { pattern: 'new' } }, + pattern: `${cls}($$$ARGS)`, + }, + }); + + for (const clsWithoutNew of classesWithoutNew) { + edits.push(clsWithoutNew.replace(`new ${clsWithoutNew.text()}`)); + } + } + + if (edits.length === 0) return null; + + return rootNode.commitEdits(edits); +} + +/** + * Get the base path of the http classes + * + * @param statements - The import & require statements to search for the http classes + * @returns The base path of the http classes + */ +function* getHttpClassBasePaths(statements: SgNode[]) { + for (const cls of CLASS_NAMES) { + for (const stmt of statements) { + const resolvedPath = resolveBindingPath(stmt, `$.${cls}`); + if (resolvedPath) { + yield resolvedPath; + } + } + } +} diff --git a/recipes/http-classes-with-new/tests/expected/file-1.js b/recipes/http-classes-with-new/tests/expected/file-1.js new file mode 100644 index 00000000..171c2036 --- /dev/null +++ b/recipes/http-classes-with-new/tests/expected/file-1.js @@ -0,0 +1,8 @@ +const http = require("node:http"); + +const agent = new http.Agent(); +const request = new http.ClientRequest(options); +const incoming = new http.IncomingMessage(socket); +const message = new http.OutgoingMessage(); +const server = new http.Server(); +const response = new http.ServerResponse(socket); diff --git a/recipes/http-classes-with-new/tests/expected/file-2.js b/recipes/http-classes-with-new/tests/expected/file-2.js new file mode 100644 index 00000000..f40f2ace --- /dev/null +++ b/recipes/http-classes-with-new/tests/expected/file-2.js @@ -0,0 +1,15 @@ +import { + Agent, + ClientRequest, + IncomingMessage, + OutgoingMessage, + Server, + ServerResponse, +} from "node:http"; + +const agent = new Agent(); +const request = new ClientRequest(options); +const incoming = new IncomingMessage(socket); +const message = new OutgoingMessage(); +const server = new Server(); +const response = new ServerResponse(socket); diff --git a/recipes/http-classes-with-new/tests/input/file-1.js b/recipes/http-classes-with-new/tests/input/file-1.js new file mode 100644 index 00000000..79a15bf0 --- /dev/null +++ b/recipes/http-classes-with-new/tests/input/file-1.js @@ -0,0 +1,8 @@ +const http = require("node:http"); + +const agent = http.Agent(); +const request = http.ClientRequest(options); +const incoming = http.IncomingMessage(socket); +const message = http.OutgoingMessage(); +const server = http.Server(); +const response = http.ServerResponse(socket); diff --git a/recipes/http-classes-with-new/tests/input/file-2.js b/recipes/http-classes-with-new/tests/input/file-2.js new file mode 100644 index 00000000..e8d170fe --- /dev/null +++ b/recipes/http-classes-with-new/tests/input/file-2.js @@ -0,0 +1,15 @@ +import { + Agent, + ClientRequest, + IncomingMessage, + OutgoingMessage, + Server, + ServerResponse, +} from "node:http"; + +const agent = Agent(); +const request = ClientRequest(options); +const incoming = IncomingMessage(socket); +const message = OutgoingMessage(); +const server = Server(); +const response = ServerResponse(socket); diff --git a/recipes/http-classes-with-new/tsconfig.json b/recipes/http-classes-with-new/tsconfig.json new file mode 100644 index 00000000..92c12497 --- /dev/null +++ b/recipes/http-classes-with-new/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "allowImportingTsExtensions": true, + "allowJs": true, + "alwaysStrict": true, + "baseUrl": "./", + "declaration": true, + "declarationMap": true, + "emitDeclarationOnly": true, + "lib": ["ESNext", "DOM"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "noImplicitThis": true, + "removeComments": true, + "strict": true, + "stripInternal": true, + "target": "esnext" + }, + "include": ["./"], + "exclude": [ + "tests/**" + ] +} diff --git a/recipes/http-classes-with-new/workflow.yaml b/recipes/http-classes-with-new/workflow.yaml new file mode 100644 index 00000000..b35023aa --- /dev/null +++ b/recipes/http-classes-with-new/workflow.yaml @@ -0,0 +1,25 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/codemod-com/codemod/refs/heads/main/schemas/workflow.json + +version: "1" + +nodes: + - id: apply-transforms + name: Apply AST Transformations + type: automatic + steps: + - name: Handle DEDEP0195 Instantiating node:http classes without new. + js-ast-grep: + js_file: src/workflow.ts + base_path: . + include: + - "**/*.js" + - "**/*.jsx" + - "**/*.mjs" + - "**/*.cjs" + - "**/*.cts" + - "**/*.mts" + - "**/*.ts" + - "**/*.tsx" + exclude: + - "**/node_modules/**" + language: typescript