chore: import upstream snapshot with attribution
Deploy (to testing) and Test Playground Preview Worker / Deploy Playground Preview Worker (testing) (push) Has been skipped
Deploy Workers Shared Staging / Deploy Workers Shared Staging (push) Failing after 0s
Prerelease / build (push) Has been skipped
Handle Changesets / Handle Changesets (push) Has been cancelled
Semgrep OSS scan / semgrep-oss (push) Has been cancelled
Deploy (to testing) and Test Playground Preview Worker / Deploy Playground Preview Worker (testing) (push) Has been skipped
Deploy Workers Shared Staging / Deploy Workers Shared Staging (push) Failing after 0s
Prerelease / build (push) Has been skipped
Handle Changesets / Handle Changesets (push) Has been cancelled
Semgrep OSS scan / semgrep-oss (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1 @@
|
||||
dev-snippets-outputs
|
||||
@@ -0,0 +1,9 @@
|
||||
# @cloudflare/codemod
|
||||
|
||||
## 1.1.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- [#13102](https://github.com/cloudflare/workers-sdk/pull/13102) [`e200179`](https://github.com/cloudflare/workers-sdk/commit/e200179d8dbdb36d716e5df9e981b3219c93d974) Thanks [@dario-piotrowicz](https://github.com/dario-piotrowicz)! - First release of new package
|
||||
|
||||
Add `@cloudflare/codemod` package with internal AST-based codemod utilities (`parseJs`, `parseTs`, `parseFile`, `transformFile`, `mergeObjectProperties`) built on `recast`.
|
||||
@@ -0,0 +1,63 @@
|
||||
# @cloudflare/codemod
|
||||
|
||||
Internal codemod utilities for the Cloudflare Workers SDK.
|
||||
|
||||
## Developing Codemods
|
||||
|
||||
Writing codemods often requires trial and error. The package ships a dedicated dev workflow so you can iterate on transforms in isolation without writing throw-away scripts.
|
||||
|
||||
### Commands
|
||||
|
||||
| Command | Description |
|
||||
| --------------- | ---------------------------------------------------------- |
|
||||
| `pnpm dev` | Run `src/dev.ts` in watch mode (hot-reloads on every save) |
|
||||
| `pnpm dev:once` | Run `src/dev.ts` once without watching |
|
||||
|
||||
### Workflow
|
||||
|
||||
1. **Edit `dev-snippets/test.ts`** — put whatever source code you want to transform into this file. It is the default input used by the dev script. You can add more files under `dev-snippets/` and reference them from `src/dev.ts`.
|
||||
|
||||
2. **Edit `testCodemod()` in `src/dev.ts`** — this is your sandbox. Call `testTransform()` with the path to your snippet and a [`recast` visitor](https://github.com/benjamn/recast) that describes the transform:
|
||||
|
||||
```ts
|
||||
const testCodemod = () => {
|
||||
testTransform("../dev-snippets/test.ts", {
|
||||
visitIdentifier(n) {
|
||||
n.node.name = "MyNewName";
|
||||
return false;
|
||||
},
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
3. **Run `pnpm dev`** — the transformed code is printed to the console and written to `dev-snippets-outputs/test.ts` (gitignored). Inspect the output file to verify the transform behaves as expected.
|
||||
|
||||
### Key Files
|
||||
|
||||
| File | Purpose |
|
||||
| ----------------------- | -------------------------------------------------------------------------------------------- |
|
||||
| `src/dev.ts` | Dev entry point; exports `testTransform()` and contains the editable `testCodemod()` sandbox |
|
||||
| `dev-snippets/test.ts` | Default sample input — replace its contents freely |
|
||||
| `dev-snippets-outputs/` | Auto-generated transform output, gitignored — safe to inspect, never committed |
|
||||
|
||||
### `testTransform(filePath, methods)`
|
||||
|
||||
Mirrors the production `transformFile()` API but instead of silently writing in place it:
|
||||
|
||||
- Prints the transformed source to the console
|
||||
- Writes the result to the corresponding path under `dev-snippets-outputs/`
|
||||
|
||||
The `filePath` argument must point to a file inside `dev-snippets/`; paths outside that directory are rejected.
|
||||
|
||||
### Inspecting the AST
|
||||
|
||||
`src/dev.ts` includes a commented-out `_printSnippet()` helper. Uncomment its call at the bottom of the file to log the AST of an arbitrary snippet to the console — useful when you need to know the exact node shape to target in a visitor:
|
||||
|
||||
```ts
|
||||
const _printSnippet = () => {
|
||||
const snippet = `if (true) { console.log("potato"); }`;
|
||||
const program = parseTs(snippet).program;
|
||||
console.log(program.body[0]);
|
||||
};
|
||||
_printSnippet(); // uncomment to run
|
||||
```
|
||||
@@ -0,0 +1,5 @@
|
||||
// This file is used by `pnpm dev` as a sample input for testing codemods.
|
||||
// Feel free to replace its contents with whatever code you want to transform.
|
||||
|
||||
const greeting = "hello";
|
||||
console.log(greeting);
|
||||
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"name": "@cloudflare/codemod",
|
||||
"version": "1.1.0",
|
||||
"private": true,
|
||||
"description": "Internal codemod utilities",
|
||||
"homepage": "https://github.com/cloudflare/workers-sdk#readme",
|
||||
"bugs": {
|
||||
"url": "https://github.com/cloudflare/workers-sdk/issues"
|
||||
},
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"author": "workers-devprod@cloudflare.com",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/cloudflare/workers-sdk.git",
|
||||
"directory": "packages/codemod"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/index.mjs",
|
||||
"types": "./dist/index.d.mts"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"check:type": "tsc -p ./tsconfig.json",
|
||||
"deploy": "echo 'no deploy'",
|
||||
"dev": "tsx watch src/dev.ts",
|
||||
"dev:once": "tsx src/dev.ts",
|
||||
"test": "vitest",
|
||||
"test:ci": "vitest run",
|
||||
"type:tests": "tsc -p ./tests/tsconfig.json"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-tsconfig": "workspace:*",
|
||||
"@types/esprima": "^4.0.3",
|
||||
"@types/node": "catalog:default",
|
||||
"@vitest/ui": "catalog:default",
|
||||
"concurrently": "^8.2.2",
|
||||
"recast": "^0.23.11",
|
||||
"tsup": "8.3.0",
|
||||
"tsx": "^3.12.8",
|
||||
"typescript": "catalog:default",
|
||||
"vitest": "catalog:default"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"recast": "^0.23.11"
|
||||
},
|
||||
"volta": {
|
||||
"extends": "../../package.json"
|
||||
},
|
||||
"workers-sdk": {}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join, relative, resolve } from "node:path";
|
||||
import * as recast from "recast";
|
||||
import { parseFile, parseTs } from "./index";
|
||||
|
||||
/**
|
||||
* Writing code-mods often requires some trial and error.
|
||||
* Often manual testing can become a hassle. This script was meant
|
||||
* to help test and develop transforms in isolation without having to write a throw-away script.
|
||||
*
|
||||
* Replace your codemod below and run the script with `pnpm run dev`
|
||||
*/
|
||||
|
||||
/**
|
||||
* This function mocks the `transformFile` API but outputs it to the console and writes it
|
||||
* to a dedicated output file for easier testing.
|
||||
*/
|
||||
export const testTransform = (
|
||||
filePath: string,
|
||||
methods: recast.types.Visitor
|
||||
) => {
|
||||
const devSnippetsDir = resolve(__dirname, "../dev-snippets");
|
||||
const resolvedInput = resolve(__dirname, filePath);
|
||||
const relativeInput = relative(devSnippetsDir, resolvedInput);
|
||||
|
||||
if (
|
||||
relativeInput.startsWith("..") ||
|
||||
resolve(relativeInput) === relativeInput
|
||||
) {
|
||||
throw new Error(`Input file must be under dev-snippets/. Got: ${filePath}`);
|
||||
}
|
||||
|
||||
const ast = parseFile(resolvedInput);
|
||||
|
||||
if (ast) {
|
||||
recast.visit(ast, methods);
|
||||
const code = recast.print(ast).code;
|
||||
console.log(code);
|
||||
const outputPath = join(
|
||||
__dirname,
|
||||
"../dev-snippets-outputs",
|
||||
relativeInput
|
||||
);
|
||||
mkdirSync(dirname(outputPath), {
|
||||
recursive: true,
|
||||
});
|
||||
writeFileSync(outputPath, code);
|
||||
}
|
||||
};
|
||||
|
||||
// Use this function to experiment with a codemod in isolation
|
||||
const testCodemod = () => {
|
||||
testTransform("../dev-snippets/test.ts", {
|
||||
visitIdentifier(n) {
|
||||
n.node.name = "Potato";
|
||||
|
||||
return false;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
testCodemod();
|
||||
|
||||
// This function can be used to inspect the AST of a particular snippet
|
||||
const _printSnippet = () => {
|
||||
const snippet = `
|
||||
if(true) {
|
||||
console.log("potato");
|
||||
}
|
||||
`;
|
||||
|
||||
const program = parseTs(snippet).program;
|
||||
console.log(program.body[0]);
|
||||
};
|
||||
// _printSnippet();
|
||||
@@ -0,0 +1,124 @@
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import * as recast from "recast";
|
||||
import * as esprimaParser from "recast/parsers/esprima";
|
||||
import * as typescriptParser from "recast/parsers/typescript";
|
||||
import type { Program } from "esprima";
|
||||
|
||||
/*
|
||||
CODEMOD TIPS & TRICKS
|
||||
=====================
|
||||
|
||||
More info about parsing and transforming can be found in the `recast` docs:
|
||||
https://github.com/benjamn/recast
|
||||
|
||||
`recast` uses the `ast-types` library under the hood for basic AST operations
|
||||
and defining node types. If you need to manipulate or manually construct AST nodes as
|
||||
part of a code mod operation, be sure to check the `ast-types` documentation:
|
||||
https://github.com/benjamn/ast-types
|
||||
|
||||
Last but not least, AST viewers can be extremely helpful when trying to write
|
||||
a transformer:
|
||||
- https://astexplorer.net/
|
||||
- https://ts-ast-viewer.com/#
|
||||
|
||||
*/
|
||||
|
||||
// Parse an input string as javascript and return an ast
|
||||
export function parseJs(src: string) {
|
||||
src = src.trim();
|
||||
try {
|
||||
return recast.parse(src, { parser: esprimaParser });
|
||||
} catch {
|
||||
throw new Error("Error parsing js template.");
|
||||
}
|
||||
}
|
||||
|
||||
// Parse an input string as typescript and return an ast
|
||||
export function parseTs(src: string) {
|
||||
src = src.trim();
|
||||
try {
|
||||
return recast.parse(src, { parser: typescriptParser });
|
||||
} catch {
|
||||
throw new Error("Error parsing ts template.");
|
||||
}
|
||||
}
|
||||
|
||||
// Parse a provided file with recast and return an ast
|
||||
// Selects the correct parser based on the file extension
|
||||
export function parseFile(filePath: string) {
|
||||
const lang = path.extname(filePath).slice(1);
|
||||
const parser = lang === "js" ? esprimaParser : typescriptParser;
|
||||
|
||||
try {
|
||||
const fileContents = readFileSync(path.resolve(filePath), "utf-8");
|
||||
|
||||
if (fileContents) {
|
||||
return recast.parse(fileContents, { parser }).program as Program;
|
||||
}
|
||||
} catch {
|
||||
throw new Error(`Error parsing file: ${filePath}`);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Transform a file with the provided transformer methods and write it back to disk
|
||||
export function transformFile(filePath: string, methods: recast.types.Visitor) {
|
||||
const ast = parseFile(filePath);
|
||||
|
||||
if (ast) {
|
||||
recast.visit(ast, methods);
|
||||
writeFileSync(filePath, recast.print(ast).code);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* merges provided properties into a given object (updating the object itself), deeply merging them in case
|
||||
* some properties are object themselves
|
||||
*
|
||||
* @param sourceObject the object into which merge the new properties
|
||||
* @param newProperties the new properties to add/merge
|
||||
*/
|
||||
export function mergeObjectProperties(
|
||||
sourceObject: recast.types.namedTypes.ObjectExpression,
|
||||
newProperties: recast.types.namedTypes.ObjectProperty[]
|
||||
): void {
|
||||
newProperties.forEach((newProp) => {
|
||||
const newPropName = getPropertyName(newProp);
|
||||
if (!newPropName) {
|
||||
return false;
|
||||
}
|
||||
const indexOfExisting = sourceObject.properties.findIndex(
|
||||
(p) => p.type === "ObjectProperty" && getPropertyName(p) === newPropName
|
||||
);
|
||||
|
||||
const existing = sourceObject.properties[indexOfExisting];
|
||||
if (!existing) {
|
||||
sourceObject.properties.push(newProp);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
existing.type === "ObjectProperty" &&
|
||||
existing.value.type === "ObjectExpression" &&
|
||||
newProp.value.type === "ObjectExpression"
|
||||
) {
|
||||
mergeObjectProperties(
|
||||
existing.value,
|
||||
newProp.value.properties as recast.types.namedTypes.ObjectProperty[]
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
sourceObject.properties[indexOfExisting] = newProp;
|
||||
});
|
||||
}
|
||||
|
||||
function getPropertyName(newProp: recast.types.namedTypes.ObjectProperty) {
|
||||
return newProp.key.type === "Identifier"
|
||||
? newProp.key.name
|
||||
: newProp.key.type === "StringLiteral"
|
||||
? newProp.key.value
|
||||
: null;
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import * as recast from "recast";
|
||||
import parser from "recast/parsers/babel";
|
||||
import { describe, test } from "vitest";
|
||||
import { mergeObjectProperties } from "../src/index";
|
||||
|
||||
describe("mergeObjectProperties", () => {
|
||||
const tests = [
|
||||
{
|
||||
testName: "merges simple objects",
|
||||
sourcePropertiesObject: {
|
||||
propA: "_A_",
|
||||
},
|
||||
newPropertiesObject: {
|
||||
propB: "_B_",
|
||||
},
|
||||
expectedPropertiesObject: {
|
||||
propA: "_A_",
|
||||
propB: "_B_",
|
||||
},
|
||||
},
|
||||
{
|
||||
testName: "overrides existing non-object properties",
|
||||
sourcePropertiesObject: {
|
||||
__Prop0: true,
|
||||
propA: "_A_",
|
||||
propB: false,
|
||||
propC: 123,
|
||||
},
|
||||
newPropertiesObject: {
|
||||
propA: "_a_",
|
||||
propB: true,
|
||||
propC: 456,
|
||||
},
|
||||
expectedPropertiesObject: {
|
||||
__Prop0: true,
|
||||
propA: "_a_",
|
||||
propB: true,
|
||||
propC: 456,
|
||||
},
|
||||
},
|
||||
{
|
||||
testName: "deep merges object properties",
|
||||
sourcePropertiesObject: {
|
||||
propA: {
|
||||
propAA: "a",
|
||||
propAB: "b",
|
||||
propAC: {
|
||||
propAA: {
|
||||
propAAA: "this is quite nested 1",
|
||||
propAAC: {
|
||||
propAAAA: "this is even more nested",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
newPropertiesObject: {
|
||||
propA: {
|
||||
propAB: "B",
|
||||
propAC: {
|
||||
propAA: {
|
||||
propAAB: "this is quite nested 2",
|
||||
propAAC: {
|
||||
propAAAA: "this is even more nested 1",
|
||||
propAAAB: "this is even more nested 2",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedPropertiesObject: {
|
||||
propA: {
|
||||
propAA: "a",
|
||||
propAB: "B",
|
||||
propAC: {
|
||||
propAA: {
|
||||
propAAA: "this is quite nested 1",
|
||||
propAAC: {
|
||||
propAAAA: "this is even more nested 1",
|
||||
propAAAB: "this is even more nested 2",
|
||||
},
|
||||
propAAB: "this is quite nested 2",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
] satisfies {
|
||||
testName: string;
|
||||
sourcePropertiesObject: Record<string, unknown>;
|
||||
newPropertiesObject: Record<string, unknown>;
|
||||
expectedPropertiesObject: Record<string, unknown>;
|
||||
}[];
|
||||
|
||||
tests.forEach(({ testName, ...testObjects }) =>
|
||||
test(`${testName}`, ({ expect }) => {
|
||||
const {
|
||||
sourcePropertiesObject,
|
||||
newPropertiesObject,
|
||||
expectedPropertiesObject,
|
||||
} = testObjects;
|
||||
const sourceObj = createObjectExpression(sourcePropertiesObject);
|
||||
const newProperties = createObjectExpression(newPropertiesObject)
|
||||
.properties as recast.types.namedTypes.ObjectProperty[];
|
||||
const expectedObj = createObjectExpression(expectedPropertiesObject);
|
||||
|
||||
mergeObjectProperties(sourceObj, newProperties);
|
||||
|
||||
expect(recast.prettyPrint(sourceObj, { parser }).code).toEqual(
|
||||
recast.prettyPrint(expectedObj, { parser }).code
|
||||
);
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
const createObjectExpression = (
|
||||
sourceObj: Record<string, unknown>
|
||||
): recast.types.namedTypes.ObjectExpression => {
|
||||
return (
|
||||
(
|
||||
recast.parse(
|
||||
`const obj = {${Object.entries(sourceObj)
|
||||
.map(([key, value]) => `${key}: ${JSON.stringify(value)}`)
|
||||
.join(",\n")}}`,
|
||||
{ parser }
|
||||
).program.body[0] as recast.types.namedTypes.VariableDeclaration
|
||||
).declarations[0] as recast.types.namedTypes.VariableDeclarator
|
||||
).init as recast.types.namedTypes.ObjectExpression;
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"types": ["node"],
|
||||
"tsBuildInfoFile": "tsconfig.tsbuildinfo"
|
||||
},
|
||||
"include": ["**/*.ts"],
|
||||
"exclude": []
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"extends": "@cloudflare/workers-tsconfig/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"module": "esnext",
|
||||
"types": ["node"],
|
||||
"tsBuildInfoFile": ".tsbuildinfo"
|
||||
},
|
||||
"include": ["**/*.ts", "**/*.js"],
|
||||
"exclude": [
|
||||
"dist",
|
||||
"node_modules",
|
||||
"tests/**/*.test.ts",
|
||||
"dev-snippets",
|
||||
"dev-snippets-outputs"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { defineConfig } from "tsup";
|
||||
|
||||
export default defineConfig(() => [
|
||||
{
|
||||
treeshake: true,
|
||||
keepNames: true,
|
||||
entry: ["src/index.ts"],
|
||||
platform: "node",
|
||||
format: "esm",
|
||||
dts: true,
|
||||
outDir: "dist",
|
||||
tsconfig: "tsconfig.json",
|
||||
metafile: true,
|
||||
sourcemap: process.env.SOURCEMAPS !== "false",
|
||||
define: {
|
||||
"process.env.NODE_ENV": `'${"production"}'`,
|
||||
},
|
||||
external: [
|
||||
"@cloudflare/*",
|
||||
"vitest",
|
||||
// Note: recast is external and a peer dependency of the package because `recast` does generally need
|
||||
// to be used directly by this package's consumers, and if it were bundled in this package that
|
||||
// would mean that consumers of this package would get the `recast` code twice significantly
|
||||
"recast",
|
||||
"recast/parsers/esprima",
|
||||
"recast/parsers/typescript",
|
||||
],
|
||||
},
|
||||
]);
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"$schema": "http://turbo.build/schema.json",
|
||||
"extends": ["//"],
|
||||
"tasks": {
|
||||
"build": {
|
||||
"inputs": ["$TURBO_DEFAULT$", "!**/__tests__/**"],
|
||||
"outputs": ["dist/**"],
|
||||
"env": ["SOURCEMAPS"],
|
||||
"passThroughEnv": ["PWD"]
|
||||
},
|
||||
"test:ci": {
|
||||
"dependsOn": ["build"],
|
||||
"env": ["LC_ALL", "TZ"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
testTimeout: 15_000,
|
||||
pool: "forks",
|
||||
include: ["**/tests/**/*.test.ts"],
|
||||
reporters: ["default"],
|
||||
unstubEnvs: true,
|
||||
mockReset: true,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user