Skip to content

feat(library): add a fully-external build entry and externalize Yjs#782

Merged
FelixTJDietrich merged 9 commits into
mainfrom
build/externalize-yjs-y-protocols
Jun 26, 2026
Merged

feat(library): add a fully-external build entry and externalize Yjs#782
FelixTJDietrich merged 9 commits into
mainfrom
build/externalize-yjs-y-protocols

Conversation

@krusche

@krusche krusche commented Jun 25, 2026

Copy link
Copy Markdown
Member

Summary

Apollon's default build is a single self-contained file — perfect for a <script> tag or CDN, but it inlines React, MUI, xyflow, and Apollon's own runtime deps. For a host that embeds Apollon with a bundler (Angular, Vue, Svelte, React), that means shipping — and being unable to de-duplicate or audit — a second private copy of each. And because Yjs is a CRDT whose runtime relies on module-level singletons, a host that already uses Yjs ends up running two Yjs copies on the same page.

This PR fixes both, without changing the existing entries:

  1. New @tumaet/apollon/external entry — the same imperative ApollonEditor API as the default entry, but with every runtime dependency external (the React family plus @dnd-kit / zustand / uuid / @chenglou/pretext; yjs / y-protocols were already external). The host's own bundler then resolves, de-duplicates, and gets full supply-chain / SBOM attribution for each one from its node_modules, instead of a copy buried invisibly in Apollon's bundle.
  2. yjs and y-protocols are now required peer dependencies of every build. They are Apollon's document model and undo/redo engine (used by every editor, collaboration or not), and they rely on module-level singletons — so one shared instance is the correct contract for any host that already uses Yjs.

The default @tumaet/apollon (self-contained) and @tumaet/apollon/react entries are behaviourally unchanged.

Subpath Dependencies Use when
@tumaet/apollon (default) all bundled (except Yjs) No bundler — vanilla JS, <script>, CDN.
@tumaet/apollon/react React family external A React host that wants the <Apollon> component and shares its own React/MUI.
@tumaet/apollon/external (new) everything external A bundler host of any framework that wants one shared, fully auditable copy of every dependency.

Release note

Two packaging improvements for embedding hosts. First, yjs and y-protocols are now required peer dependencies instead of being bundled, so your app and Apollon share a single Yjs instance — no duplicate payload and no cross-instance document errors. Second, a new @tumaet/apollon/external entry exposes the same imperative ApollonEditor API as the default entry but leaves every dependency external (React, MUI, emotion, xyflow, @dnd-kit, zustand, uuid, @chenglou/pretext, …) — so a bundler host of any framework resolves and de-duplicates each one from its own node_modules and gets full supply-chain / SBOM visibility, instead of a copy inlined invisibly into the bundle. The default @tumaet/apollon (self-contained) and @tumaet/apollon/react entries are unchanged. Action required only if you adopt the new entry or the Yjs peer: install the corresponding peers (most package managers do this automatically).

Implementation notes

  • vite.config.ts — a third build pass (LIB_EXTERNAL=true) emits dist/external/index.js, externalizing the React family and Apollon's own runtime deps. It reuses the root dist/index.d.ts (same public surface as the default entry), so no second declaration file is emitted. yjs / y-protocols are external in all three passes.
  • library/package.json — adds the ./external export; moves yjs / y-protocols from dependencies to required peerDependencies (the React-family peers remain optional), and keeps them in devDependencies so the workspace still builds and tests. Adds a size-limit budget for the new entry (≈38 KB brotli — everything is external).
  • Source maps were intentionally dropped. An earlier revision enabled sourcemap: true, but that does not deliver the SBOM goal: a consumer that disables vendor source maps ignores them, and a dependency inlined-but-undeclared has no version to match against an advisory. It also added ~12 MB to the published tarball. The /external entry is the structural fix instead.
  • Compatibility / action required (semver minor). The default entry's old "zero peers" promise becomes "install yjs + y-protocols." npm 7+ and pnpm 8+ add missing peers automatically; Yarn (all versions) and pnpm < 8 do not, and CDN / import-map users must declare them explicitly. A missing peer surfaces as a clear module-not-found error, not silent breakage.
  • Adopting the new entry (host side, separate change): a bundler host switches its import to @tumaet/apollon/external and provides the now-external peers it doesn't already have. Its existing SBOM tooling (cdxgen, Syft, …) then attributes every Apollon dependency natively from its own node_modules — no Apollon-side SBOM artifact needed.

Steps for testing

  1. pnpm --filter @tumaet/apollon build → emits dist/index.js, dist/react/react.js, and the new dist/external/index.js (with dist/index.d.ts shared by . and /external). (verified locally — green)
  2. pnpm --filter @tumaet/apollon size → all five budgets pass; the /external entry is ≈38 KB brotli. (verified locally — green)
  3. Confirm dist/external/index.js imports react, @mui/material, yjs, @dnd-kit/*, zustand, … as bare specifiers (no inlined copy).
  4. pnpm --filter @tumaet/apollon lint and … test — green in CI (lint-and-format-check, library-node22-compat, visual-regression-tests).
  5. In a real bundler host: import { ApollonEditor } from "@tumaet/apollon/external", then confirm exactly one resolved copy of each dependency in the lockfile.

Screenshots / screencasts

n/a — packaging / build + docs change, no UI.

Checklist

  • Linked to a related issue (if applicable) — n/a
  • Added a changeset whose summary is written in the user's voice
  • PR title's Conventional Commit type matches the kind of change (feat — a new public entry)
  • Tests added or updated — n/a (packaging change; covered by existing build / size / lint / test gates)
  • Documentation updated — README + docs (overview "Three builds, one API", install "Fully external build", quickstart, Angular)
  • Screenshots or screencasts attached (if a UI change) — n/a

Copilot AI review requested due to automatic review settings June 25, 2026 10:45

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@Claudia-Anthropica Claudia-Anthropica left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@krusche The build change itself checks out locally: library build and size passed, and the generated artifacts keep Yjs imports external. I am requesting changes because the public install docs still promise a zero-peer standalone setup, which is no longer true with these required Yjs peers.

Comment thread library/package.json
@github-project-automation github-project-automation Bot moved this from Backlog to In progress in Apollon Development Jun 25, 2026
The standalone `.` build inlined yjs and y-protocols, so hosts that
already use Yjs (e.g. Artemis, which runs a Yjs-backed collaborative
code editor) shipped two Yjs copies at different versions — wasted
payload plus a cross-instance correctness hazard, since Yjs relies on
module-level singletons (instanceof checks, shared Doc/transaction
state).

Externalize yjs and y-protocols in both the default and peer build
passes (mirroring how React/MUI are already externalized) and promote
them to required peerDependencies, so the host provides exactly one Yjs
instance. Also enable build source maps so the dependencies Apollon
still inlines (React, MUI, @dnd-kit, zustand, …) stay attributable in
consumers' bundle analyzers and SBOM tooling.

Update the README and docs (install pages, overview, quickstart, Angular
guide, landing page) to document yjs/y-protocols as required peers of
both builds — the standalone build is no longer "zero peer deps".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@krusche
krusche force-pushed the build/externalize-yjs-y-protocols branch from 5c13f18 to 66427fd Compare June 25, 2026 11:06
krusche and others added 2 commits June 25, 2026 14:43
The README and docs documented the React-family peers as React 18.3
(`react`/`react-dom` `^18.3.0`, `@emotion/*` `^11.11.0`, `@xyflow/react`
`^12.3.0`), but the library is React-19-only: `library/package.json`
declares `react`/`react-dom` `^19.0.0`, `@emotion/*` `^11.12.0`,
`@xyflow/react` `^12.9.0`, and the React Compiler targets 19. Align the
peer tables and prose in the README and the docs install/overview pages
to the actual `peerDependencies` ranges.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds a third build pass (LIB_EXTERNAL) exposing `@tumaet/apollon/external`:
the same imperative ApollonEditor API as the default entry, but with EVERY
runtime dependency external (the React family plus @dnd-kit/zustand/uuid/
@chenglou/pretext; yjs/y-protocols are already external). A bundler host of
any framework then resolves, de-duplicates, and gets full SBOM attribution
for each dependency from its own node_modules, instead of a copy inlined
invisibly into Apollon's bundle.

This is what actually delivers SBOM visibility and single-copy guarantees to
bundler hosts like Artemis; source maps could not — a consumer with
`sourceMap.vendor: false` ignores vendor maps, and inlined deps that aren't
in the consumer's dependency tree would break its SBOM filter. So the earlier
`sourcemap: true` change is dropped (it added ~12 MB to the tarball for no
benefit here).

The default `@tumaet/apollon` (self-contained) and `/react` entries are
unchanged. README + docs updated; changeset covers the new entry.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@Claudia-Anthropica Claudia-Anthropica left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@krusche The npm install docs are mostly updated now, but the CDN/vanilla path is still missing the peer-resolution guidance from the original thread. Leaving this as changes requested until those no-bundler snippets explain or make explicit how yjs and y-protocols resolve.

@krusche krusche changed the title build(library): externalize yjs/y-protocols and emit source maps feat(library): externalize Yjs and add a fully-external bundler entry Jun 25, 2026
@FelixTJDietrich FelixTJDietrich changed the title feat(library): externalize Yjs and add a fully-external bundler entry feat(library): add a fully-external build entry and externalize Yjs Jun 26, 2026
@FelixTJDietrich

FelixTJDietrich commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

Refreshed the title and description to match where this landed.

TL;DR — two packaging changes for hosts that embed Apollon with a bundler: yjs / y-protocols become required peers (one shared Yjs instance instead of two), and a new @tumaet/apollon/external entry leaves every dependency external so a host of any framework resolves, de-duplicates, and fully audits each one from its own node_modules. The default (.) and /react entries are unchanged.

Source maps were dropped on purpose — they don't deliver the SBOM goal (a consumer with sourceMap.vendor: false, like Artemis, ignores vendor maps; a dep that's inlined-but-undeclared has no version to match against an advisory) and added ~12 MB to the tarball. The /external entry is the structural fix.

Action required for consumers: the default entry is no longer zero-peer — install yjs + y-protocols. npm 7+ and pnpm 8+ add them automatically; Yarn and pnpm < 8 don't. A missing peer is a clear module-not-found, not silent breakage. Shipped as a minor.

CI status (transparent): everything is green except e2e-tests (and the PR Health Gate that depends on it). The single failing test — activity-swimlane.spec.ts › lanes can be reordered by dragging in the popover — is a dnd-kit drag interaction that is failing on most current PRs (#774, #777, #778, #780, #781), not just this one, and this PR touches neither the webapp nor that test (git diff origin/main -- standalone/webapp/ is empty). So it's a pre-existing flaky test on main, unrelated to this change; a rerun doesn't fix it. It's worth quarantining/stabilising separately so it stops blocking unrelated PRs.

For Artemis (separate, host-side): import @tumaet/apollon/external and add the peers it doesn't already have; its existing cdxgen SBOM pipeline then sees every Apollon dependency natively — no Apollon-side SBOM artifact required.

The root README's "Use the library" snippet still showed a bare
`npm install @tumaet/apollon`, and the CDN/no-bundler examples (the docs
Vanilla page and the published library README) imported Apollon from esm.sh
without resolving its now-required Yjs peers. Add `yjs`/`y-protocols` to the
root install command, and give the CDN path an import map plus
`?external=yjs,y-protocols` so the page shares a single Yjs instance instead
of silently loading a second copy.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@FelixTJDietrich

Copy link
Copy Markdown
Contributor

Pushed f29a2ebf — finishes the install docs for the new Yjs peer:

  • root README.md install command now includes yjs y-protocols;
  • the CDN/no-bundler examples (docs Vanilla page and the published library/README.md) now use an import map + ?external=yjs,y-protocols so the page shares a single Yjs instance instead of silently loading a second copy.

Docs-only, so no changeset. The three other entries' install instructions and the peer tables were already correct.

The previous commit's CDN example tried to externalize the Yjs peers with an
import map plus `?external=yjs,y-protocols`. Verified in a headless browser
against the built bundle: that produces TWO Yjs copies (the "Yjs was already
imported" error — the very problem this PR prevents), and a correct import map
would additionally need `*y-protocols/` and `lib0/`, version-aligned — too
fragile to document. esm.sh already resolves Apollon's `yjs`/`y-protocols`
peers from the bare import URL and shims `process`, so revert the CDN examples
(docs Vanilla page + published library README) to the plain import.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@Claudia-Anthropica Claudia-Anthropica left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@krusche Most of the docs are fixed now, including the dedicated vanilla guide and library README. I am keeping changes requested because the homepage Vanilla JS CDN snippet still lacks the peer-resolution note from the open thread.

The peer-dependency notes called yjs/y-protocols "the collaboration engine,"
which implies they're only needed when collaboration is on. They are actually
Apollon's document model and undo/redo engine, instantiated for every editor
(new Y.Doc() in the constructor), so they are required whether or not you
collaborate. Reword across the README, library README, and docs (overview,
quickstart, install) to say so, and fix install.md's stale "both builds"
(there are now three entries).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@Claudia-Anthropica Claudia-Anthropica left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@krusche The homepage npm install line is updated, but the open CDN/Vanilla JS point is still not addressed in the tab that shows the snippet. Keeping this as changes requested until that snippet explains the esm.sh peer resolution or imports the peers explicitly.

… tab

The homepage Vanilla JS snippet imported Apollon from esm.sh without noting how
its required yjs/y-protocols peers resolve on the no-bundler path. Add a one-line
comment next to the import (esm.sh serves them automatically), matching the
vanilla embedding guide and the library README.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@Claudia-Anthropica Claudia-Anthropica left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@krusche All requested docs feedback is addressed now. The thread is resolved and the latest clarification looks good to me.

@FelixTJDietrich FelixTJDietrich left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changes look good and work as expected, I ensured that the examples in the docs are working. I will do one more minor adjustment to the docs before I merge

The published README and the overview table tied the new `/external` entry to
Artemis, but Artemis imports the default `@tumaet/apollon` entry everywhere and
does not use `/external` at all. Remove the claim (and the matching "e.g.
Artemis" in the build table) — build-selection guidance shouldn't hinge on one
consumer, and in this case the claim was simply untrue.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@Claudia-Anthropica Claudia-Anthropica left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@krusche The docs follow-up is resolved, but the new /external build still bundles Zustand subpath helpers because the external list only matches the package root. I left the concrete fix inline.

Comment thread library/vite.config.ts Outdated
RUNTIME_DEPS externalized only the exact id "zustand", but the source imports
zustand/middleware (7x) and zustand/shallow (120x). Rollup treats subpaths as
distinct ids, so the /external build still inlined that code. Match zustand and
its subpaths with a regex (mirroring the @dnd-kit entry). Verified: after the
change dist/external/index.js imports "zustand", "zustand/middleware", and
"zustand/shallow" as bare externals. The other exact-string externals (uuid,
@chenglou/pretext, @emotion/*, @xyflow/react) import no subpaths and stay clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@Claudia-Anthropica Claudia-Anthropica left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@krusche All requested feedback is addressed now. I verified the library build, the generated external bundle imports the Zustand subpaths as bare externals, and the size budget still passes.

@FelixTJDietrich
FelixTJDietrich merged commit 515777b into main Jun 26, 2026
18 of 19 checks passed
@FelixTJDietrich
FelixTJDietrich deleted the build/externalize-yjs-y-protocols branch June 26, 2026 09:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

4 participants