Bundling npm Packages

Many npm packages ship no CDN build. This guide explains why Bascik leaves client module bundling to dedicated tools, how to use any bundler (such as esbuild or Rolldown) with Bascik's lifecycle pipeline, and how to reference the resulting bundle from your pages.

Why Bascik delegates client module bundling

Bascik eliminates network waterfalls for your authored components by extracting, deduplicating, scoping, and inlining component CSS and companion scripts directly into the compiled HTML page. This delivers zero-runtime, single-roundtrip components without any framework overhead.

However, client-side npm libraries present a distinct set of problems:

  • Resolving deep node_modules dependency trees.
  • Scope hoisting, dead code elimination, and tree-shaking unused exports.
  • Emitting code-split chunks and managing bundle cache boundaries.

Rather than embedding a heavy JavaScript bundler into its core compiler, Bascik delegates client module bundling to specialized tools like esbuild, Rolldown, or Rollup. You run your preferred bundler through Bascik's pipeline.exec hook. Bascik manages the server and page compilation, while your bundler optimizes your client libraries.

What Bascik does with bare specifiers

Bascik does not rewrite bare specifiers in client-side scripts. A line like import x from 'some-package' in a <script type="module"> passes through to the browser verbatim, and the browser fails with Failed to resolve module specifier. Bare specifiers work in data-bascik-build and data-bascik-server scripts because those run in Node.js, which resolves them against node_modules. Client scripts run in the browser, where no such resolution exists.

html
<script type="module">
  // DOES NOT WORK: Bascik does not rewrite bare specifiers in client
  // scripts. The browser receives this line verbatim and fails with
  // "Failed to resolve module specifier".
  import { confetti } from 'canvas-confetti';
  confetti();
</script>

The fix is to bundle the package at build time and import the bundle with a root-relative URL the browser can resolve.

The recipe: bundler as an exec step

You can use any bundler you prefer. Below is an example with esbuild, followed by an equivalent setup using Rolldown.

Example with esbuild

Install esbuild and the package you want to use as development dependencies:

sh
npm install --save-dev esbuild canvas-confetti

Write a small bundler script at the project root. It bundles a browser entry point, resolves the bare specifier, and writes the output into dist/assets/js/:

js
// build-bundle.mjs: bundles client dependencies into dist/assets/js/.
// Run as a pipeline.exec step with phase: 'pre' so the bundle exists
// before pages are transpiled and referenced.
import { build } from 'esbuild';

await build({
  entryPoints: ['src/client/confetti-entry.mjs'],
  bundle: true,
  format: 'esm',
  minify: true,
  outfile: 'dist/assets/js/confetti-bundle.mjs',
});

The entry point is where the bare specifier lives. esbuild resolves it against node_modules and inlines the package into the bundle:

js
// src/client/confetti-entry.mjs: the browser entry point.
// Bare specifiers are fine here because esbuild resolves and bundles them.
import confetti from 'canvas-confetti';

export const celebrate = () => confetti();

Register the bundler as a pipeline.exec step in bascik.config.ts:

ts
// bascik.config.ts: run the bundler as a pre-transpile exec step.
import { defineConfig } from '@bascik/bascik';

export default defineConfig({
  pipeline: {
    exec: [
      {
        script: 'build-bundle.mjs',
        phase: 'pre',
        watch: ['src/client/'],
      },
    ],
  },
});

phase: 'pre' guarantees the bundle exists before page transpilation begins, so pages can reference it on the very first build. The watch entry re-runs the bundler in dev mode when the entry point changes, then triggers a coordinated browser reload.

Finally, reference the bundle from the page with a root-relative URL:

html
<script type="module">
  // Root-relative URL to the bundle esbuild wrote into dist/assets/js/.
  import { celebrate } from '/assets/js/confetti-bundle.mjs';

  document.getElementById('celebrate-btn').addEventListener('click', celebrate);
</script>

Alternative: Example with Rolldown

You can use Rolldown identically. Install Rolldown:

sh
npm install --save-dev rolldown canvas-confetti

Write build-bundle.mjs using the Rolldown API:

js
// build-bundle.mjs
import { rolldown } from 'rolldown';

const bundle = await rolldown({
  input: 'src/client/confetti-entry.mjs',
});

await bundle.write({
  dir: 'dist/assets/js',
  format: 'esm',
  minify: true,
});

Because Bascik's pipeline.exec runs standalone Node scripts, the rest of your setup (the bascik.config.ts configuration, dev-mode watcher, and root-relative <script type="module"> tag) remains identical.

Why direct output is the escape hatch

Bascik copies page assets from the pages tree, but generated files that live outside the pages tree, like bundles, need a deliberate destination. Writing directly into directory.out (dist/ by default) is the explicit escape hatch for generated files: the bundler owns dist/assets/js/, Bascik owns the rest, and neither fights the other. Because the exec step runs on every build, the bundle in dist/ is never stale.

What happens on watch

In dev mode, editing a source listed in pipeline.exec[].watch reruns the bundler in its configured phase and rebuilds associated pages in the same cycle. Use pre when pages need the bundle ready. Do not add generated bundles to pipeline.watchPaths: completion and output writes never start another compile. Editing a page recompiles it without rerunning the bundler unless its watch patterns also match that page.