DEV Community

Cover image for Developing your own Chrome Extension - Fixing Errors with ts-morph and using Buns API (Part 4)
JoLo
JoLo

Posted on

Developing your own Chrome Extension - Fixing Errors with ts-morph and using Buns API (Part 4)

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

Idea

A little recap: here is what we want to build

Building a Chrome Extension which summarizes an external link

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. 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. Because ts-morph only understands TypeScript files, we bundled our files accordingly.

// 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",
});
Enter fullscreen mode Exit fullscreen mode

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.

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
Enter fullscreen mode Exit fullscreen mode

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 findReferences1

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
Enter fullscreen mode Exit fullscreen mode

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.

firstReferenceClass.replaceWithText(
  `${basePromptTemplateDeclaration}\n${firstReferenceClassDeclaration}`,
);
basePromptTemplateClass.remove();
file.saveSync();
Enter fullscreen mode Exit fullscreen mode

And now the same for the BaseChain

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

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");
Enter fullscreen mode Exit fullscreen mode

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 (no, it’s not jQuery 🤭).

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

await $`rm -rf out`;
Enter fullscreen mode Exit fullscreen mode

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. I used it without recursive- option and should watch the src- folder

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`;
});
Enter fullscreen mode Exit fullscreen mode

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.

{
"scripts": {
    "dev": "bun run --watch build.ts",
    "build": "bun run build.ts"
  }
}
Enter fullscreen mode Exit fullscreen mode

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

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();
});
Enter fullscreen mode Exit fullscreen mode

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.

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

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

  await build();
});
Enter fullscreen mode Exit fullscreen mode

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

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.

Stay tuned!


  1. https://ts-morph.com/navigation/finding-references 

Top comments (0)