Skip to content

Distribute the library in ESM and CJS forms in addition to the classic script #218

@maboa

Description

@maboa

Problem

js/hyperaudio-lite.js is currently a single classic script — it declares class HyperaudioLite at the top level, which becomes a global when loaded via <script src=...>. That works for the example HTML files but is awkward for everyone else:

  • Modern frontend projects (Vite, webpack, Rollup, etc.) want import { HyperaudioLite } from 'hyperaudio-lite', which requires an export.
  • Node-based tooling (tests, server-side rendering, build scripts) wants const { HyperaudioLite } = require('hyperaudio-lite'), which requires module.exports.
  • Trying to add export { HyperaudioLite }; to the current file makes it a syntactically-invalid classic script — browsers loading it with <script src=...> would throw a SyntaxError.

Today, downstream consumers vendor the file and patch in the missing export/require line by hand. This is annoying and easy to get out of sync when Hyperaudio Lite updates.

Proposed direction (zero-build, low-maintenance)

Ship three sibling files in js/:

  • hyperaudio-lite.js — current classic-script form, unchanged. Works in <script src=...>.
  • hyperaudio-lite.mjs — same code + export { HyperaudioLite }; at the bottom. Works with ESM import.
  • hyperaudio-lite.cjs — same code + module.exports = { HyperaudioLite }; at the bottom. Works with require.

Plus an exports map in package.json so consumers automatically get the right file:

{
  "exports": {
    ".": {
      "import": "./js/hyperaudio-lite.mjs",
      "require": "./js/hyperaudio-lite.cjs",
      "browser": "./js/hyperaudio-lite.js",
      "default": "./js/hyperaudio-lite.mjs"
    }
  },
  "main": "./js/hyperaudio-lite.cjs",
  "module": "./js/hyperaudio-lite.mjs"
}

Keeping the three files in sync

Two options:

a. Convention. Treat hyperaudio-lite.js as the source of truth. A small npm script (npm run sync-modules) appends the export/require line into .mjs / .cjs. Run it as a prepublishOnly and document in CONTRIBUTING.

b. Tooling. Author in ESM, use tsup / esbuild / unbuild to emit all three (plus an IIFE for the classic-script case) into dist/. ~10 lines of config, no source-level changes.

(a) preserves the project's "no build step" ethos. (b) is the modern standard for npm-published libraries. Either is fine — (a) probably fits this project better given its size.

Why not just one universal-shim file

It's possible to write a single file that supports classic-script and CJS via a runtime sniff:

class HyperaudioLite { /* ... */ }

if (typeof module !== 'undefined' && module.exports) {
  module.exports = { HyperaudioLite };
} else if (typeof globalThis !== 'undefined') {
  globalThis.HyperaudioLite = HyperaudioLite;
}

But ESM is not addable to that same file — top-level export makes the whole file an ES module, which is incompatible with classic-script loading. So one file can serve at most two of the three consumption modes, and the missing one is the one that arguably matters most going forward (ESM).

Migration impact

  • Existing <script src="js/hyperaudio-lite.js"> users: zero change.
  • Downstream npm/bundler users: stop vendoring + patching, just import { HyperaudioLite } from 'hyperaudio-lite'.
  • Jest tests: switch the require in __TEST__/hyperaudio-lite.test.js to point at the .cjs file (or leave alone and let the exports map resolve it).

Related

This pairs naturally with #217 (options-object constructor) — both are "make HLE pleasant to integrate" changes that could share a release window (2.5.0 or 3.0.0).

Metadata

Metadata

Assignees

No one assigned

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions