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.
Idea
A little recap: here is what we want to build
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:
- Find the class declaration
- Get the full declaration
- Find its first reference
- Get the full declaration of the reference
- Replace the 4. with 2. + 4.
- 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",
});
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
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
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.
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
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 (no, it’s not jQuery 🤭).
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. 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`;
});
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"
}
}
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();
});
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();
});
And remove that from the build.ts
and use build()
instead.
Another issue
Are we done now? Unfortunately, not yet. Here is another problem
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.
Stay tuned!
Top comments (0)