Google Apps Script projects shouldn’t be tied to one bundler.
So I rebuilt my Vite-only plugin into a universal plugin for Vite, Rollup, webpack, esbuild, and Bun — and added a scaffolding CLI on top.
The problem with modern bundlers in Google Apps Script
If you’ve ever deployed a GAS project only to find that doGet was silently removed by tree-shaking — or that export keywords broke the Apps Script runtime — you know the pain.
Google Apps Script has a slightly awkward relationship with modern JavaScript tooling.
Bundlers like Vite, Rollup, webpack, and esbuild are great at optimizing modules. But GAS expects certain functions — doGet, doPost, onOpen, installable triggers, menu handlers — to exist in the global scope with stable names.
That creates a few recurring problems:
-
exportis convenient in source code, but GAS doesn’t want it in the final bundle - tree-shaking can remove entry points that are only referenced by the Apps Script runtime
-
appsscript.jsonhas to be copied into the output alongside the bundle
Here’s what the plugin actually does to your output:
// Your source:
export function doGet(e) {
return HtmlService.createHtmlOutput("Hello");
}
// Without plugin — bundler may wrap, rename, or tree-shake:
// doGet is gone or unreachable
// With @gas-plugin/unplugin — export stripped, global preserved:
function doGet(e) {
return HtmlService.createHtmlOutput("Hello");
}
In my previous post, I introduced gas-vite-plugin, a small Vite plugin that handled exactly that.
It worked well — but only for Vite.
If your team used Rollup, webpack, or esbuild, you had to solve the same problem a different way.
So I rebuilt it as a universal plugin.
What changed
The project is now @gas-plugin — a monorepo with two packages:
| Package | What it does |
|---|---|
@gas-plugin/unplugin |
Universal bundler plugin for Vite, Rollup, webpack, esbuild, and Bun |
@gas-plugin/cli |
Scaffolding CLI for new GAS projects |
The original gas-vite-plugin still exists as a deprecated compatibility package, but the main path going forward is @gas-plugin/unplugin.
Why I rebuilt it with unplugin
I could have maintained separate plugins for each bundler.
That would have meant duplicating the same logic across multiple packages: transform exported entry points, preserve GAS globals, and copy the manifest into the output directory.
Instead, I used unplugin, which lets you implement the plugin once and expose adapters for multiple bundlers. For this kind of narrowly focused build-time behavior, that’s a much better fit than maintaining five independent implementations.
The result is simple:
- same plugin behavior
- same options
- different import path per bundler
That means the GAS-specific parts stay consistent even if your build tool changes.
@gas-plugin/unplugin: one plugin, any bundler
Here’s the same plugin working across different bundlers.
Vite
import gasPlugin from "@gas-plugin/unplugin/vite";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [gasPlugin()],
build: {
lib: {
entry: "src/main.ts",
formats: ["es"],
fileName: () => "Code.js",
},
},
});
Rollup
import gasPlugin from "@gas-plugin/unplugin/rollup";
export default {
input: "src/main.ts",
output: { dir: "dist", format: "es" },
plugins: [gasPlugin()],
};
esbuild
import gasPlugin from "@gas-plugin/unplugin/esbuild";
import { build } from "esbuild";
await build({
entryPoints: ["src/main.ts"],
outdir: "dist",
bundle: true,
format: "esm",
plugins: [gasPlugin()],
});
webpack
import gasPlugin from "@gas-plugin/unplugin/webpack";
export default {
entry: "./src/main.ts",
output: { path: new URL("./dist", import.meta.url).pathname },
plugins: [gasPlugin()],
};
Bun
import gasPlugin from "@gas-plugin/unplugin/bun";
await Bun.build({
entrypoints: ["src/main.ts"],
outdir: "dist",
plugins: [gasPlugin()],
});
The options are the same everywhere:
gasPlugin({
manifest: "appsscript.json",
include: ["src/**/*.html"],
globals: ["processData"],
autoGlobals: true,
});
How autoGlobals and globals work
By default, bundlers may tree-shake or rename your GAS entry points like doGet or onOpen — because nothing in your code imports them.
-
autoGlobals: truescans your source for exported functions and automatically exposes them as global declarations in the output. This is the simplest way to make sure your GAS entry points survive the build. -
globals: ["processData"]lets you manually specify additional functions that should be preserved as globals — useful for installable triggers or menu callbacks that aren’t exported from the entry point.
You can use both together: autoGlobals handles the common case, and globals covers edge cases.
That consistency is the main goal: you shouldn’t have to rethink your GAS build setup just because you prefer one bundler over another.
@gas-plugin/cli: scaffold a project in seconds
I also added a CLI so you can start a new project without wiring everything manually.
npx @gas-plugin/cli create
You’ll get interactive prompts for:
- project name
- template
- bundler
- optional clasp configuration
- optional Script ID
Example:
◆ Project name
│ my-gas-app
│
◆ Template
│ ● basic — Spreadsheet automation (onOpen, triggers)
│ ○ webapp — Web app (doGet/doPost + HTML client)
│
◆ Bundler
│ ● vite
│ ○ rollup
│ ○ esbuild
│ ○ webpack
│ ○ bun
│
◆ Include clasp configuration?
│ Yes
│
◆ Script ID (optional)
│ 1BxTjDm...
And if you prefer automation-friendly setup, you can skip prompts entirely:
npx @gas-plugin/cli create my-gas-app \
--template webapp \
--bundler vite \
--clasp \
--script-id YOUR_SCRIPT_ID
The generated project is intentionally minimal:
my-gas-app/
├── src/
│ ├── index.ts
│ ├── utils.ts
│ └── client.html # webapp template only
├── package.json
├── appsscript.json
├── tsconfig.json
├── biome.json
├── vite.config.ts # or rollup/esbuild/webpack config
├── .clasp.json # when --clasp
├── .claspignore
└── .gitignore
Then:
npm run build
npx clasp push
That gives you the basics you actually need, without forcing a larger opinionated toolchain on every project.
Why this exists when google/aside already exists
Google already provides @google/aside, and it’s a legitimate option.
So this project is not trying to replace it outright.
The difference is scope.
google/aside is a batteries-included starter
It gives you a more opinionated setup out of the box.
@gas-plugin is a smaller, composable alternative
It focuses on two things:
- making bundled output work cleanly for GAS
- letting you choose your own bundler and surrounding toolchain
Here’s the practical difference:
google/aside |
@gas-plugin |
|
|---|---|---|
| Bundler choice | Primarily Rollup-based setup | Vite, Rollup, esbuild, webpack, Bun |
export handling |
You work within the template’s conventions |
export is stripped at build time for GAS output |
| Tree-shaking protection | Side-effect import pattern |
autoGlobals can preserve exported entry points automatically |
| Scaffolding style | More batteries included | Minimal scaffold, bring your own tools |
| Templates | Opinionated starter |
basic and webapp
|
So the real distinction is:
- choose
google/asideif you want a fuller default setup - choose
@gas-pluginif you want a lighter, bundler-agnostic foundation
Migration from gas-vite-plugin
If you’re already using the old package, migration is intentionally small:
- import gasPlugin from "gas-vite-plugin";
+ import gasPlugin from "@gas-plugin/unplugin/vite";
npm uninstall gas-vite-plugin
npm install -D @gas-plugin/unplugin
Same idea, same behavior — just no longer tied to Vite alone.
Why I think this matters
GAS is still incredibly useful for internal tools, automations, lightweight web apps, and spreadsheet-driven workflows.
But the tooling ecosystem has often pushed developers into one of two extremes:
- stay close to vanilla Apps Script and give up modern build ergonomics
- adopt a very specific template and toolchain
I wanted something in between:
- modern TypeScript workflow
- support for the bundler you already use
- minimal GAS-specific friction
- no unnecessary lock-in
That’s what @gas-plugin is trying to be.
If you’ve tried it, I’d love to hear which bundler you’re using and whether anything felt off. Issues and PRs are welcome on GitHub.
Links
- GitHub — source, examples, issues
- @gas-plugin/unplugin
- @gas-plugin/cli
Top comments (0)