Skip to content

🚧 CDN pivot: stop building the webpack Pages bundle, serve eyereasoner from a CDN + redirect old Pages URLs - #1957

Draft
jeswr wants to merge 2 commits into
mainfrom
fix/pages-cdn-redirect
Draft

🚧 CDN pivot: stop building the webpack Pages bundle, serve eyereasoner from a CDN + redirect old Pages URLs#1957
jeswr wants to merge 2 commits into
mainfrom
fix/pages-cdn-redirect

Conversation

@jeswr

@jeswr jeswr commented Jul 4, 2026

Copy link
Copy Markdown
Member

🚧 Stop building the webpack Pages bundle; serve eyereasoner from a CDN and backfill the old Pages URLs

Note: This PR was generated by an agent (Claude Fable 5) on @jeswr's behalf, following up on the triage in #1845 and superseding the approach in #1955. It is a draft for review. Nothing has been pushed to the pages branch; this PR only changes the repo's build/docs/redirect sources.

Revised after review feedback: (1) the "add an IIFE artifact to the npm package" option is dropped, (2) the first-class CDN delivery now serves the WASM as a separate binary asset instead of inlined in JS, (3) the release workflow no longer generates redirect files at all — the stubs are a one-time historical backfill.

References #1845. Supersedes / re-frames #1955: rather than keep building the bundle and prune it every release, this stops building it at all.

The pivot

Today each release builds a ~4MB webpack bundle and publishes it to the pages branch, which has grown to ~7.5GB (~7× the 1GB Pages limit) and keeps breaking the Pages deployment. This PR removes the cause:

  1. Docs & example → CDN, WASM as a separate asset. The README and examples/prebuilt/index.html now document two deliveries, both zero-build and working for every already-published version:
    • Separate WebAssembly assets (recommended): an import map + ?external=swipl-wasm swaps eyereasoner's internal WASM-inlined SWIPL dependency for swipl-wasm's split web build, and a 5-line wrapper points locateFile at the CDN. The browser then fetches swipl-web.wasm as a real binary (application/wasmstreaming-compiled, cached independently of the eyereasoner release) instead of parsing it out of a multi-MB JavaScript string.
    • Zero-config single URL: import { n3reasoner } from 'https://esm.sh/eyereasoner' (self-contained, WASM inlined) stays documented as the no-setup variant.
  2. Old Pages URLs → one-time backfill of smart redirect stubs. Two tiny, self-deriving stubs (redirects/index.js classic global, redirects/dynamic-import.js ESM) read their own URL, work out the requested version, and load it from the CDN. scripts/generate-redirects --all generates them for every version ever published to npm (validated: 847 versions → 2,116 files that git dedups to 2 unique blobs). This is run once to replace the existing bundle tree — it is deliberately not wired into CI.
  3. Releases stop touching Pages entirely. The redirect-generation step is removed from the release workflow and the @qiwi/semantic-release-gh-pages-plugin is removed from the release config: since the docs point new consumers at the CDN, new versions need no redirect stubs and no Pages files at all.

The delivery investigation (what changed and why)

The previous draft recommended the esm.sh single URL, which ships SWI-Prolog's WASM inlined in JavaScript. Per review, the first-class delivery should serve the WASM as a separate, streamable, separately-cacheable binary. That reopened the "can the runtime locate its assets cross-origin?" question, so it was verified end-to-end:

How the split build locates assets (swipl-wasm/dist/swipl/swipl-web.js): the .wasm resolves relative to the script's own URL (document.currentScript/__dirname) — CDN-friendly for classic scripts — but the .data package resolves relative to the page, and module scripts have no currentScript. So a CDN delivery must pass Module.locateFile. eyereasoner doesn't forward emscripten options, but n3reasoner(..., { SWIPL }) accepts a factory, so a wrapper does it. One genuine boot-blocker was found and solved: eyereasoner passes preRun as a function, and the split build's data loader requires an array (Module.preRun.push) — the wrapper normalises it. Without that, boot throws TypeError: Module.preRun.push is not a function.

Proof it boots and reasons (headless node, gated/niced):

  • Published eyereasoner@21.1.10 + the split driver/.wasm/.data byte-for-byte as served by jsDelivr (md5-verified against npm) via options.SWIPL injection → boots, infers Socrates a Mortal in 354ms.
  • The live browser graph: esm.sh ?external=swipl-wasm module + esm.sh-transformed swipl-web.js, with a node loader hook emulating the exact import map → boots, reasons, 319ms.
  • Headers on the assets: application/wasm (streaming-compile eligible), access-control-allow-origin: *, immutable cache on both CDNs.
  • The inlined swipl-bundle-no-data build embeds exactly swipl-web.wasm (md5 1cee4be…, 2,190,887B), so the EYE .pvm image is compatible with the split build by construction — provided the swipl-wasm version matches the one the eyereasoner release pins (documented as a lockstep note in the README).

Measured wire sizes (uncompressed / brotli):

Delivery JS WASM data Total (raw) Total (brotli)
Single URL (inlined) 4.37MB (0.92 eyereasoner + 3.45 inlined SWIPL) 4.37MB ≈1.86MB
Separate assets 1.08MB (0.92 + 0.16 driver) 2.19MB (application/wasm) 1.64MB 4.91MB ≈2.74MB

Honest caveat: the split delivery is ~0.9MB more over the wire, not less. The inlined build never shipped the 1.6MB SWI-Prolog .data (that's what -no-data + the EYE .pvm image achieves), and the split swipl-web build downloads it unconditionally even though boot uses the .pvm. The split .wasm itself transfers ~0.4MB smaller than its JS-inlined form; what you buy for the extra .data bytes is streaming compilation, no multi-MB JS parse + string→ArrayBuffer decode on the main thread, and swipl-wasm assets that stay cached across eyereasoner upgrades. See open question 2 for the upstream fix that would make the split delivery strictly smaller.

The classic <script> global (Option B dropped)

The previous draft floated adding an esbuild --format=iife artifact to the npm package for a true synchronous global. Dropped: a new npm artifact can never apply to versions already published, so it cannot serve the historical Pages URLs — the very thing the redirects are for. The classic global therefore comes from the CDN's own transform of any published version, via dynamic import():

  • Docs: a module script that imports from the CDN and assigns window.n3reasoner = n3reasoner (explicitly documented as the only way to get a global from an already-published CJS build — and it is async).
  • Backfill stubs: dynamic-import() + a Proxy global so await eyereasoner.n3reasoner(...) keeps working on legacy pages. A consumer reading a non-callable property synchronously would see a change — documented. The stubs deliberately import the self-contained inlined build: legacy pages can't be assumed to carry the import map the split delivery needs.

⚠️ The browser check @jeswr should run (~5 min — this box has no browser)

Everything above was verified headless in node; the browser-only bits (import-map resolution, instantiateStreaming, the esm.sh module running in a real module context) were not. Open examples/prebuilt/index.html from this branch (it now uses the recommended split delivery), click Execute, and confirm:

  1. it prints the :Socrates a :Mortal derivation;
  2. DevTools → Network shows swipl-web.wasm (~2.19MB, application/wasm) and swipl-web.data (~1.64MB) fetched from cdn.jsdelivr.net with no 404s, and no swipl-bundle-no-data download;
  3. optionally, the zero-config snippet from the README as a second data point (no separate asset fetches at all).

What's in this PR

File Change
README.md Browser section rewritten: split-WASM delivery (recommended, with lockstep-version note), zero-config single URL, async classic global, migration note
examples/prebuilt/index.html Uses the recommended split-WASM delivery (import map + ?external + locateFile/preRun wrapper)
redirects/index.js Classic-global backfill stub (self-deriving, async Proxy global) — marked one-time backfill
redirects/dynamic-import.js ESM backfill stub (self-deriving, re-exports eyereasoner) — marked one-time backfill
scripts/generate-redirects.ts Now a one-time backfill tool: --all generates stubs for every npm-published version (registry-driven); not wired into CI
scripts/post-webpack.ts Deleted (Pages bundle emission)
.github/workflows/nodejs.yml Release job no longer creates/publishes any Pages files
package.json backfill:redirects script; @qiwi/semantic-release-gh-pages-plugin removed from release config and devDependencies

webpack.config.js / bundle:webpack are kept — still used by the browser E2E/memory tests. See open question 1.

Validation (headless, gated flock+nice)

  • Boot+reason proofs above (published package + CDN bytes: 354ms; live esm.sh graph: 319ms — both infer Socrates a Mortal).
  • npm run lint: 0 errors; tsc --noEmit on scripts/generate-redirects.ts: clean.
  • generate-redirects --name=v2.3.14: exact tree (2/3/14/, 2/3/latest/, 2/latest/, latest/, root index.js, .nojekyll), stubs md5-identical to redirects/*.js.
  • generate-redirects --all: 847 npm versions → 1,058 index.js + 1,057 dynamic-import.js + .nojekyll, 2 unique blobs, 210 latest dirs.
  • Both stubs node --check-parse; URL→CDN derivation re-checked for root/latest/@M/@M.m/@M.m.p/query-string shapes.
  • CDN headers: application/wasm + access-control-allow-origin: * on the split assets; all URLs in the docs return 200.

Follow-ups (need @jeswr sign-off — NOT in this PR)

  • Run the backfill + the one-off 7.5GB prune. Recommended as one operation: generate-redirects --all, commit the result as a fresh orphan commit on pages (plus example/, dev/bench/), force-push. That installs every historical redirect and shrinks the branch to a few MB atomically. Force-push needs explicit approval — deliberately excluded here.
  • Downscope 🚧 fix: skip Jekyll pages build and prune superseded patch bundles on release #1955 to just its .nojekyll marker (the prune logic is moot once nothing is published per release).

Open questions

  1. Keep webpack for the browser E2E/memory tests, or migrate them to the CDN delivery and delete webpack entirely?
  2. Upstream swipl-wasm improvement (you maintain it): the split delivery would be strictly better than the inlined one (~0.7MB brotli total, smallest of all options) if swipl-wasm shipped a split no-data web build (swipl-web-no-data.js + standalone .wasm) — external WASM without the 1.6MB .data that eyereasoner's .pvm boot doesn't need. Worth an issue on SWI-Prolog/npm-swipl-wasm? Existing releases would still use the recipe in this PR.
  3. esm.sh vs jsDelivr as primary, and is a third-party CDN in downstream consumers' runtime load path acceptable? (Both are needed today: esm.sh for the CJS→ESM transform, jsDelivr for the raw binary assets — esm.sh serves raw files too, so it could be single-sourced if preferred.)
  4. Pin redirect targets or track latest? Stubs resolve latest/@M/@M.m live, so a bad future npm publish would propagate to old URLs. Acceptable, or pin?
  5. Sign-off on the 7.5GB orphan force-push (backfill covers every npm-published version; Pages-only strays that never shipped to npm would drop).

🤖 Generated with Claude Code

…d of building the webpack bundle

Stop building and publishing the ~4MB webpack bundle to the `pages` branch on
every release (which grew the branch to ~7.5GB). Instead:

- README and the prebuilt example now use a public ESM CDN (esm.sh, with
  jsDelivr as a drop-in alternative) for both ESM `import` and classic
  `<script>` usage. The package is self-contained (WASM + EYE image inlined),
  so there are no separate .wasm/.data assets to host.
- Two tiny, self-deriving redirect stubs (redirects/index.js classic global,
  redirects/dynamic-import.js ESM) derive the requested version from their own
  URL and load it from the CDN. Broadcast byte-identically to every path they
  collapse to 2 unique blobs, so old Pages URLs keep working with ~0 growth.
- scripts/generate-redirects.ts replaces the webpack + post-webpack Pages
  emission in the release workflow and also writes the `.nojekyll` marker;
  the gh-pages publish now ships the stub tree (dotfiles enabled).

Refs #1845. Supersedes the build-and-prune approach of #1955.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ackfill

Revisions to the CDN pivot per review:

- docs/example: the recommended browser delivery now serves the SWI-Prolog
  WebAssembly as a separate binary asset (import map + ?external=swipl-wasm
  swaps the package's inlined SWIPL build for swipl-wasm's split web build;
  a small wrapper points locateFile at the CDN and normalises preRun to an
  array). The single-URL inlined delivery stays documented as the zero-config
  alternative, with measured wire sizes for both. The idea of adding a new
  IIFE artifact to the npm package is dropped: it could never serve versions
  that are already published, so the classic global is the async import() +
  Proxy shim in every case.
- redirects: the stubs are now explicitly a ONE-TIME historical backfill for
  already-published version URLs. generate-redirects gained --all (generates
  the stub tree for every version published to npm, from the registry) and is
  deliberately not wired into CI.
- release: stop publishing anything to the pages branch per release (drop the
  stub-generation step and the @qiwi/semantic-release-gh-pages-plugin); new
  versions need no Pages files because the docs point at the CDN directly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant