Developing your own Chrome Extension - Fixing Errors with ts-morph and using Buns API (Part 4)
Tags: typescript, chrome, bunjs, tsmorph
Published: 2024-03-03 09:10:00

Oh geez… Developing a Chrome Extension is hard.  If you haven't seen my previous post, please take a look. 

In this update, we'll be addressing an error where injected JavaScript into a Chromium-based platform can't read a class declaration that comes after initialization.

![ReferenceError: Cannot access 'BasePromptTemplate' before initialization](https://assets.vrite.io/646ce5fc07ef9ba4530fd23b/PlIJm5av7n_QhOAzzFqOp.png)

## Idea

A little recap: here is what we want to build

![Building a Chrome Extension which summarizes an external link](https://assets.vrite.io/646ce5fc07ef9ba4530fd23b/Bi5TTP7RuWVs0kYu21KZi.png)

## What’s the Error?

It took me a while to figure out what happened until I played a round with the resulting package. I noticed that a class named `BasePromptTemplate` was being declared after it had already been used by another class called `BasePromptStringTemplate`. To fix this issue, the solution would be to move the declaration of `BasePromptTemplate` above the declaration of `BasePromptStringTemplate`.

The same happens with `BaseChain`.

### Using `ts-morph`

Whenever you execute `bun dev`, all the classes and functions from external packages are compiled into a single `index.js`.

Here is the idea:
1. Find the class declaration
2. Get the full declaration
3. Find its first reference
4. Get the full declaration of the reference
5. Replace the 4. with 2. + 4.
6. Remove 2.

I recently came across an incredibly powerful tool called [ts-morph](https://github.com/dsherret/ts-morph). It's an Abstract Syntax Tree (AST) code analyzer and manipulator that can easily locate class declarations and their references. Just remember, it only works on TypeScript files. To use it, we created a new "build.ts" file and harnessed the full potential of [Bun's API](https://bun.sh/docs/bundler#api). Because ts-morph only understands TypeScript files, we bundled our files accordingly.

```typescript
// in build.ts
import Bun from "bun";

// First step to bundle all the files
await Bun.build({
  entrypoints: ["./src/index.ts"],
  outdir: "./out",
  naming: "[name].ts",
});
```

Now, we start using ts-morph by adding a new project and reading the bundled `index.ts` inside `out`- folder. And then, we use their API to get the `BasePromptTemplate`- class and the `getText()` gets us the full declaration of this class.

```typescript
import { Project } from "ts-morph";

const project = new Project();
const file = project.addSourceFileAtPath("out/index.ts");
const basePromptTemplate = file.getClass("BasePromptTemplate") // 1. Find the class
const basePromptTemplateDeclaration = basePromptTemplate?.getText(); // 2. Get the Full Declaration
```

Now, we want to navigate to the first reference and get its full declaration of the reference. The `findReferencesAsNodes` gives us enough information, but the `findReferences`[^1]

```typescript
import { SyntaxKind } from 'ts-morph';

const references = basePromptTemplate.findReferencesAsNodes();
const firstUsage = references[0]
const firstReferenceUsage = firstUsage.getFirstAncestorByKind(
  SyntaxKind.ClassDeclaration,
); // This gives me the class declaration of the first usage
const firstReferenceUsageClassName = firstReferenceUsage?.getName(); // 3. Find its first reference
if(!firstReferenceUsageClassName) throw new Error('No First Reference');
const firstReferenceClass = file.getClass(firstReferenceUsageClassName);
const firstReferenceClassDeclaration = firstReferenceClass.getText(); // 4. Get full declaration
```

The next step is to bring the `basePromptTemplateDeclaration` before the `firstReferenceClassDeclaration`. For that, we use the `replaceWithText`- function, we remove the `basePromptTemplateClass`, and save it.

```typescript
firstReferenceClass.replaceWithText(
  `${basePromptTemplateDeclaration}\n${firstReferenceClassDeclaration}`,
);
basePromptTemplateClass.remove();
file.saveSync();
```

And now the same for the `BaseChain`…

Instead of doing that, we will put that into a function and refactor a bit

```typescript
function hoistClassUptoFirstUsage(targetClassName: string) {
  const targetClass = file.getClass(targetClassName);
  if (!targetClass) return;

  const targetClassText = targetClass.getText();
  const references = targetClass.findReferencesAsNodes();

  if (references.length === 0 || !targetClassText) return;

  const firstUsage = references[0];
  const firstReferenceUsage = firstUsage.getFirstAncestorByKind(
    SyntaxKind.ClassDeclaration,
  );

  if (!firstReferenceUsage) return;

  const firstReferenceUsageClassName = firstReferenceUsage.getName();
  if (!firstReferenceUsageClassName) return;

  const firstReferenceClass = file.getClass(firstReferenceUsageClassName);
  if (!firstReferenceClass) return;

  const firstReferenceClassDeclaration = firstReferenceClass.getText();
  firstReferenceClass.replaceWithText(
    `${targetClassText}\n${firstReferenceClassDeclaration}`,
  );

  targetClass.remove();
  file.saveSync();
}

hoistClassUptoFirstUsage("BasePromptTemplate");
hoistClassUptoFirstUsage("BaseChain");
```

### Final step- Bundling again

Since the bundled file is in TypeScript, we must bundle it back to `index.js`. At this point, we can also bundle the `popup.ts`.

Furthermore, we can also get rid of the `out`- folder. For that, we use Bun’s in-built `$`- [Shell ](https://bun.sh/docs/runtime/shell)(no, it’s not jQuery 🤭).

```typescript
await Bun.build({
  entrypoints: ['./out/index.ts', './src/popup.ts'],
  outdir: './dist',
  naming: '[name].[ext]',
});

await $`rm -rf out`;
```

### Add Watcher

Now, we need to bundle whenever there is a change in the directory. I took the example from Bun’s instruction on how to [watch a directory for changes in Bun](https://bun.sh/guides/read-file/watch). I used it without `recursive`- option and should watch the `src`- folder

```typescript
import { watch } from 'fs';

watch('src', async (event, filename) => {
  console.log(`Detected ${event} in ${filename}`);

  // First step to bundle all the files
  await Bun.build({
    entrypoints: ['./src/index.ts'],
    outdir: './out',
    naming: '[name].ts',
  });

  hoistClassUptoFirstUsage('BasePromptTemplate');
  hoistClassUptoFirstUsage('BaseChain');

  await Bun.build({
    entrypoints: ['./out/index.ts', './src/popup.ts'],
    outdir: './dist',
    naming: '[name].[ext]',
  });

  await $`rm -rf out`;
});
```

### Adjust the Script part

The `index.js` will now be bundled by the `build.ts` . Until now, we used the CLI, but now we need to run `bun run build.ts`. So, we need to adjust the `package.json` and add a new script.

```json
{
"scripts": {
    "dev": "bun run --watch build.ts",
    "build": "bun run build.ts"
  }
}
```

The `—watch` - flag executes the command whenever there are changes on the file. That’s really handy whenever we change something on the `build.ts` .

## The Full `build.ts`

Here is the full `build.ts`

```typescript
import Bun, { $ } from "bun";
import { Project, SyntaxKind } from "ts-morph";
import { watch } from "fs";

/**
 * The function hoistClassUptoFirstUsage moves a target class to the first usage within its parent
 * class.
 * @param {string} targetClassName - The `targetClassName` parameter is a string that represents the
 * name of the class that you want to hoist up to its first usage.
 * @returns nothing (undefined) if any of the following conditions are met:
 * - The target class with the specified name does not exist in the file.
 * - There are no references to the target class in the file.
 * - The target class text is empty or undefined.
 * - The first usage of the target class does not have a parent class declaration.
 * - The parent class declaration does not have a name
 */
function hoistClassUptoFirstUsage(targetClassName: string) {
  // Start Ts-morph
  const project = new Project();
  const file = project.addSourceFileAtPath("out/index.ts");
  const targetClass = file.getClass(targetClassName);
  if (!targetClass) return;

  const targetClassText = targetClass.getText();
  const references = targetClass.findReferencesAsNodes();

  if (references.length === 0 || !targetClassText) return;

  const firstUsage = references[0];
  const firstReferenceUsage = firstUsage.getFirstAncestorByKind(
    SyntaxKind.ClassDeclaration,
  );

  if (!firstReferenceUsage) return;

  const firstReferenceUsageClassName = firstReferenceUsage.getName();
  if (!firstReferenceUsageClassName) return;

  const firstReferenceClass = file.getClass(firstReferenceUsageClassName);
  if (!firstReferenceClass) return;

  const firstReferenceClassDeclaration = firstReferenceClass.getText();
  firstReferenceClass.replaceWithText(
    `${targetClassText}\n${firstReferenceClassDeclaration}`,
  );

  targetClass.remove();
  file.saveSync();
}

export async function build() {
  await Bun.build({
    entrypoints: ["./src/index.ts"],
    outdir: "./out",
    naming: "[name].ts",
  });

  hoistClassUptoFirstUsage("BasePromptTemplate");
  hoistClassUptoFirstUsage("BaseChain");

  const result = await Bun.file('out/index.ts').text();
  await Bun.write('dist/index.js', result);

  await Bun.build({
    entrypoints: ["./src/popup.ts"],
    outdir: "./dist"
  });

  const remove = await $`rm -rf out`;
  console.log(remove.exitCode === 0 ? "Bundled sucessfully" : remove.stderr);
}

watch("src", async (event, filename) => {
  console.log(`Detected ${event} in ${filename}`);

  await build();
});
```

Now, when you run `bun dev` it should wait until you change the file.

There is a slight issue if you only want to build the `index.ts`. Currently, the extension is built only when there are changes in the `src` folder. We need to add a new file `watch.ts` with the following to change that.

```typescript
import { watch } from "fs";
import { build } from "./build";

watch("src", async (event, filename) => {
  console.log(`Detected ${event} in ${filename}`);

  await build();
});
```

And remove that from the `build.ts` and use `build()` instead.

## Another issue

Are we done now? Unfortunately, not yet. Here is another problem

![Another issue when calling link outside the origin](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/681w6v4smrcz9qybp2gh.gif)


This most likely happened because the script cannot call an external page.

## Conclusion

Creating a Chrome Extension can be challenging, but it can become easier with the right tools and techniques. We addressed a class initialization error in a Chromium-based platform using the ts-morph tool and discussed automating the bundling process. However, calling an external page remains an issue. This project requires patience, perseverance, and a willingness to learn. But the end result is rewarding.

We will add a proxy to address this issue in the next post.

Find the repository [here](https://github.com/jolo-dev/worth-the-click)[.](https://github.com/jolo-dev/worth-the-click)

Stay tuned! 

[^1]: [https://ts-morph.com/navigation/finding-references](https://ts-morph.com/navigation/finding-references)