From f81bab15efb9cb66115845e6abf4f01b7ae24459 Mon Sep 17 00:00:00 2001 From: Exoridus Date: Fri, 11 Sep 2026 23:58:14 +0200 Subject: [PATCH] build: ship the full IIFE bundle and vendor every lockstep package in the Full ZIP The all-in-one IIFE (core plus every extension except react) was built only behind EXOJS_FULL_BUNDLE=1 and then went nowhere: not in the npm files list, not a release asset, not in the Full ZIP. A script-tag consumer had the core and no path to any extension. The production build now always emits exo.full.iife.js and its minified twin, the tarball ships them, size-limit budgets the minified one at 550 KB gzipped (measured 524 KB), and the deployment guide points at the jsDelivr URL. The Full ZIP vendored six of the twelve extensions, copied out of the built site's vendor directory through a hand-kept list. It now reads every lockstep package's own dist/esm, so the set is derived from the release matrix, and puts Core's single-file bundles beside the ESM tree under vendor/exojs. A test pins that bundle list to the tarball's files entries. --- .github/workflows/ci.yml | 1 - README.md | 2 +- package.json | 9 +++ scripts/build.ts | 24 ++++---- scripts/release/full-zip.ts | 56 ++++++++++++++----- scripts/release/lockstep-packages.ts | 4 +- .../src/content/guide/shipping/deployment.mdx | 9 +++ test/release/full-zip.test.ts | 22 +++++++- 8 files changed, 93 insertions(+), 34 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fc1377d7a..e8b0069a5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -196,7 +196,6 @@ jobs: - name: Build core env: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} - EXOJS_FULL_BUNDLE: '1' run: pnpm build - name: Build extension packages diff --git a/README.md b/README.md index ecdb00281..ceec38644 100644 --- a/README.md +++ b/README.md @@ -132,7 +132,7 @@ npm run dev npm install @codexo/exojs ``` -ExoJS ships as ESM — use `import` syntax with a modern bundler or runtime. A prebuilt IIFE bundle (`dist/exo.iife.js`, global `Exo`) is included for CDN and script-tag usage. +ExoJS ships as ESM — use `import` syntax with a modern bundler or runtime. Prebuilt IIFE bundles are included for CDN and script-tag usage: `dist/exo.iife.js` (the core) and `dist/exo.full.iife.js` (core plus every extension except React), both on the global `Exo`. Optional packages install independently — add only what your project needs: ```bash diff --git a/package.json b/package.json index caf32fc8c..2e1ac96f5 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,10 @@ "dist/exo.iife.js.map", "dist/exo.iife.min.js", "dist/exo.iife.min.js.map", + "dist/exo.full.iife.js", + "dist/exo.full.iife.js.map", + "dist/exo.full.iife.min.js", + "dist/exo.full.iife.min.js.map", "README.md", "CHANGELOG.md", "LICENSE" @@ -208,6 +212,11 @@ "limit": "340 KB", "gzip": true }, + { + "path": "dist/exo.full.iife.min.js", + "limit": "550 KB", + "gzip": true + }, { "path": "dist/exo.debug.esm.js", "limit": "5 KB", diff --git a/scripts/build.ts b/scripts/build.ts index 11f2792e6..116ef01fd 100644 --- a/scripts/build.ts +++ b/scripts/build.ts @@ -1,7 +1,7 @@ /** * Builds the core package: `exo.esm.js`, `exo.debug.esm.js`, the preserveModules * `dist/esm` tree (with declarations), `exo.iife.js`, and (production only) - * `exo.iife.min.js`. + * `exo.iife.min.js`, `exo.full.iife.js` and `exo.full.iife.min.js`. * * Bundling runs on Rolldown; declarations are a separate `tsc * --emitDeclarationOnly` pass over `dist/esm`, since Rolldown has no @@ -11,11 +11,14 @@ * inner dev loop this mode serves, and `pnpm typecheck` already covers type * correctness. * - * `EXOJS_FULL_BUNDLE=1` additionally builds the opt-in all-in-one IIFE bundle - * (core + every extension package), transpiling TypeScript source across - * multiple rootDirs (src/ and each extension package's src/) - Rolldown's - * built-in transpiler has no single-Program rootDir constraint, so this needs - * no separate esbuild-based path the way the previous Rollup pipeline did. + * The production build also emits the all-in-one IIFE bundle (`exo.full.iife.js` + * and its minified twin: core + every extension package except react, whose + * peer cannot ship in a script-tag bundle), transpiling TypeScript source + * across multiple rootDirs (src/ and each extension package's src/) - + * Rolldown's built-in transpiler has no single-Program rootDir constraint, so + * this needs no separate esbuild-based path the way the previous Rollup + * pipeline did. The dev build and watch mode skip it: it is the slowest job of + * the set and nothing in the inner loop reads it. */ import { spawnSync } from 'node:child_process'; import { dirname, relative as relativePath, resolve as resolvePath } from 'node:path'; @@ -227,7 +230,7 @@ if (watchMode) { } else { const jobs = buildMode === 'production' - ? [bundled(true), debugBundled(true), modules(), iife(false), iife(true)] + ? [bundled(true), debugBundled(true), modules(), iife(false), iife(true), fullBundle(false), fullBundle(true)] : [bundled(true), debugBundled(true), modules(), iife(false)]; for (const job of jobs) { @@ -235,11 +238,4 @@ if (watchMode) { } await emitDeclarations(); writeSourceStamp(resolvePath(rootDir, 'src'), resolvePath(rootDir, 'dist')); - - if (process.env.EXOJS_FULL_BUNDLE === '1') { - const fullBundleJobs = buildMode === 'production' ? [fullBundle(false), fullBundle(true)] : [fullBundle(false)]; - for (const job of fullBundleJobs) { - await runJob(job); - } - } } diff --git a/scripts/release/full-zip.ts b/scripts/release/full-zip.ts index a59310484..6ad889070 100644 --- a/scripts/release/full-zip.ts +++ b/scripts/release/full-zip.ts @@ -3,8 +3,9 @@ * * A self-contained, offline-servable snapshot of a coordinated release: * - * npm/ the four official tarballs (Core, Particles, Tilemap, Tiled) - * vendor/ each package's ESM tree (exojs, exojs-particles, exojs-tilemap, exojs-tiled) + * npm/ every lockstep tarball (Core + extensions) + * vendor/ every lockstep package's ESM tree, plus Core's single-file + * bundles (exo.esm.js, the IIFE pair, the full IIFE pair) * examples/ src/** (TS), js/** (transpiled), assets/**, examples.json * site/ the built static site (itself servable; references ./vendor + ./examples) * README.md CHANGELOG.md LICENSE release-manifest.json checksums.sha256 @@ -18,6 +19,7 @@ import { basename, dirname, join, relative, resolve } from 'node:path'; import { ModuleKind, ScriptTarget, transpileModule } from 'typescript'; import type { CommandRunner } from './command-runner.ts'; +import { LOCKSTEP_PACKAGES } from './lockstep-packages.ts'; import { type ReleaseManifest, renderChecksums, serializeManifest, sha256File } from './manifest.ts'; export interface AssembleOptions { @@ -32,15 +34,30 @@ export interface AssembleOptions { manifest: ReleaseManifest; } -const VENDOR_PACKAGES = [ - { name: 'exojs', vendorDir: 'exojs' }, - { name: 'exojs-particles', vendorDir: 'exojs-particles' }, - { name: 'exojs-tilemap', vendorDir: 'exojs-tilemap' }, - { name: 'exojs-tiled', vendorDir: 'exojs-tiled' }, - { name: 'exojs-physics', vendorDir: 'exojs-physics' }, - { name: 'exojs-audio-fx', vendorDir: 'exojs-audio-fx' }, +/** + * Core's single-file bundles, shipped under `vendor/exojs/` beside the ESM + * tree so the archive serves a script tag as well as an import map. The list + * mirrors the bundle entries of the root `package.json#files`; the test suite + * pins the two together. + */ +export const CORE_BUNDLE_FILES = [ + 'exo.esm.js', + 'exo.esm.js.map', + 'exo.debug.esm.js', + 'exo.debug.esm.js.map', + 'exo.iife.js', + 'exo.iife.js.map', + 'exo.iife.min.js', + 'exo.iife.min.js.map', + 'exo.full.iife.js', + 'exo.full.iife.js.map', + 'exo.full.iife.min.js', + 'exo.full.iife.min.js.map', ] as const; +/** `vendor/` for a lockstep package: the npm name without its scope. */ +export const vendorDirFor = (packageName: string): string => packageName.replace(/^@codexo\//, ''); + const FORBIDDEN_PATTERNS: { label: string; test: (text: string) => boolean }[] = [ { label: 'workspace: specifier', test: t => t.includes('workspace:') }, { label: '@assets alias', test: t => /['"`]@assets['"`/]/.test(t) }, @@ -204,13 +221,24 @@ export const assembleFullReleaseTree = (options: AssembleOptions): AssembleResul copyFile(resolve(options.stagingDir, record.file), npmOut); } - // vendor/ - each package's ESM tree, taken from the built site's vendor dir. - for (const { name, vendorDir } of VENDOR_PACKAGES) { - const from = resolve(options.siteDistDir, 'vendor', vendorDir, 'esm'); + // vendor/ - every lockstep package's ESM tree, read from the package's own + // dist so the set cannot drift from the release matrix, plus Core's bundles. + for (const pkg of LOCKSTEP_PACKAGES) { + const distDir = resolve(options.rootDir, pkg.dir, 'dist'); + const from = join(distDir, 'esm'); if (!existsSync(from)) { - throw new Error(`[full-zip] Missing vendored ESM for ${name} at ${from}. Run "pnpm site:build" first.`); + throw new Error(`[full-zip] Missing built ESM for ${pkg.name} at ${from}. Build every lockstep package first.`); + } + const vendorDir = join(treeDir, 'vendor', vendorDirFor(pkg.name)); + cpSync(from, join(vendorDir, 'esm'), { recursive: true }); + if (pkg.isExtension) continue; + for (const file of CORE_BUNDLE_FILES) { + const bundle = join(distDir, file); + if (!existsSync(bundle)) { + throw new Error(`[full-zip] Missing Core bundle ${file} at ${bundle}. Run a production "pnpm build" first.`); + } + copyFile(bundle, vendorDir); } - cpSync(from, join(treeDir, 'vendor', vendorDir, 'esm'), { recursive: true }); } // examples/ - src/** (TS), js/** (transpiled), assets/**, examples.json. diff --git a/scripts/release/lockstep-packages.ts b/scripts/release/lockstep-packages.ts index 06058ebcd..7f0c8c8aa 100644 --- a/scripts/release/lockstep-packages.ts +++ b/scripts/release/lockstep-packages.ts @@ -15,8 +15,8 @@ * directory instead and need no edit. * - `scripts/ci/select-lanes.ts` RUNTIME_PACKAGES (dependency-free ESM that * runs before any install, so it cannot import this TS module). - * - `site/scripts/sync-exo-vendor.ts` / `full-zip.ts` vendor tree - a smaller, - * site-owned set (the offline examples site only embeds packages it uses). + * - `site/scripts/sync-exo-vendor.ts` vendor tree - a smaller, site-owned + * set (the offline examples site only embeds packages it uses). */ /** Order is canonical PUBLISH_ORDER: Core first (peer of every extension), then extensions. */ diff --git a/site/src/content/guide/shipping/deployment.mdx b/site/src/content/guide/shipping/deployment.mdx index 29e4d38dd..ce0e23e6e 100644 --- a/site/src/content/guide/shipping/deployment.mdx +++ b/site/src/content/guide/shipping/deployment.mdx @@ -180,6 +180,15 @@ If you don't have any module-aware setup — no bundler, no import maps, just a There is no `import` statement anywhere in this path — `Exo` is already in scope by the time the second ` + +``` + Prefer the ES module bundle when your host supports import maps and you want the browser to load only what you use. Prefer the IIFE bundle when you need a true zero-build setup — for example a single static HTML file, a CMS or forum post that only allows pasting a `