General-purpose Effect + Bun test tooling, published as a built dist. Every package's
exports map points at ./dist/*.js with a ./dist/*.d.ts beside it; under bun install a
consumer names the tarball and declares nothing else.
| Package | Purpose |
|---|---|
@packages/effect-bun-test |
Effect-native test harness over bun:test: it.effect / it.scoped / it.live / layer(), virtual time via Effect's TestClock, scripted subprocesses, in-repo fixture roots, tagged-error assertions. |
@packages/bun-svelte-test |
Bun loader that compiles .svelte under bun test, plus a component-mount helper. |
@packages/effect-test-kit |
Assertions over Effect exits and tagged errors. |
@packages/uuid-effect |
Effect-native UUID service: crypto-entropy UUIDs injected through Effect context, with a deterministic test layer. |
Requires Bun at runtime — bun:test is imported by the harness itself — and a TypeScript
moduleResolution that honours exports (bundler, node16, or nodenext).
Packages are named under @packages/*, which is not an ownable npm scope. Distribution is
therefore by tarball, not by registry: a GitHub Release asset URL, needing no registry
account and no npm login.
Both forms resolve under bun install, and neither consults a registry for the @packages
scope. Publishing these names to npmjs is not possible; adopting the registry path would require
first owning a real scope.
That manifest entry is the whole integration under bun install, and no package asks a consumer
to write an overrides entry.
@types/bun, happy-dom and @happy-dom/global-registrator are ordinary dependencies, so a
package manager installs them unprompted.
effect and svelte are peer dependencies, and a consumer still declares neither: bun
auto-installs a missing peer. Verified — a consumer naming only the harness, with no effect of
its own, ends up with effect installed at the top level.
The ranges are open — effect at >=4.0.0-rc.109 <5, svelte at ^5.56.8 — never an exact pin.
The exact pin 4.0.0-rc.109 is what made the earlier peer declaration painful: it admitted one
release and forced every consumer onto it. An open range lets a consumer already on a later v4 RC,
or on v4 stable once it ships, dedupe onto the copy they have.
These packages require Effect v4; they do not resolve against 3.x. A consumer still on v3
stays on the v0.2.1 tarballs, which keep working because they are pinned by URL.
@effect/platform is not a dependency at all. v4 folded it into core: Command and
CommandExecutor became effect/unstable/process, and @effect/platform/Error became
effect/PlatformError. There is no v4 release of @effect/platform, and none is needed.
Both must exist exactly once in a consumer's tree, and a peer declaration is what refuses to
duplicate them. Measured on bun 1.3.14, with a consumer pinned to effect@3.19.0 while these
packages require v4:
| declared as | consumer declares nothing | consumer declares a conflicting version |
|---|---|---|
dependencies |
installs it top-level | silent nested second copy, exit 0, no warning |
peerDependencies |
auto-installs it top-level | warn: incorrect peer dependency, no second copy |
| both | installs it top-level | silent nested second copy, and no warning |
bun does not fail the install in any of these cases, so the guarantee is not "refuses to install" —
it is that a version conflict never silently becomes two copies. __e2e__ proves it.
For svelte the duplicate is the more dangerous one: bun-svelte-test compiles a consumer's
components, and compiler output binds svelte/internal/client at runtime. A consumer on svelte 4
would silently receive a nested svelte 5 and compile against one copy while mounting on another.
This is why the published JavaScript never inlines either one, though it could otherwise depend
on nothing at all. There is a second, type-level reason it could not: the exported signatures are
written in Effect's own types — it.effect takes an Effect.Effect<A, E, R>, it.layer takes a
Layer.Layer<R> — and those must be the SAME types the consumer's effect provides. Vendoring
would mean rolling Effect's entire declaration surface into each package and typing a consumer's
test callbacks against that copy rather than their own install.
One thing a peer does cost: pnpm and yarn do not auto-install peers, they warn. Consumers on
those managers must declare effect (and svelte) themselves. Only bun install is verified here
either way.
A note on Effect v4 specifically: duplication is no longer instantly fatal the way it was under v3.
v4 identifies values by string type IDs — "~effect/Effect", "~effect/Context" — and keys a
Context by the service key's string, so two v4 copies do interoperate; a Context.Service key, a
Layer and a runtime drawn from separate copies resolve correctly. What breaks is a v3 consumer
meeting a v4 harness, which is exactly the case a dependency declaration turns into a silent nested
copy.
Nothing. No package imports a workspace sibling from its shipped source, so every published tarball
has an EMPTY @packages closure and a consumer naming one package resolves exactly that package.
@packages/fixture-residue used to be folded into @packages/effect-bun-test's dist; it was
deleted rather than unbundled. The bundling machinery in scripts/build-packages.ts survives and is
now unexercised — publishable-contract.test.ts pins the sibling set EMPTY, so re-introducing one
turns that test red and is the signal to re-arm the carve-out coverage alongside it.
Only bun install is verified. The __e2e__ suite installs the packed tarballs into a throwaway
consumer that declares nothing but the tarballs themselves, then proves that consumer
- installs with no
overridesand no lockfile present, declaring noeffectorsvelte, - runs a consumer-built
Effectthrough a service the packages define, - resolves exactly one copy of
effect, and - typechecks against the shipped
.d.tsunder bothbundlerandnodenext, withskipLibCheckoff.
A second consumer, pinned to effect@3.19.0, proves the peer guarantee: the install warns and
nests no second copy of effect under the package.
No other package manager is exercised. A file: path to the same tarball carries the same
CONTENTS, which is what the proof uses, so the suite covers contents and resolution but not the
Release-URL fetch itself.
Each package's exports is an explicit subpath map rather than a ./* wildcard, so a module
the map does not name is not reachable from a consumer.
bun run build writes each package's dist:
bun buildemits the JavaScript, bundling workspace siblings and keeping every installed dependency — peers above all — external. It also restores thenode:prefix the bundler drops, so a shippedimportcannot bind to a consumer's userlandfsorpath. The build fails if a declared peer stops appearing among the dist's imports, because that means the bundler inlined a copy rather than leaving it external.tsc --emitDeclarationOnly, throughbun runso the pinned TypeScript 7 is used and no node process starts, emits the.d.ts.
Entry points are derived from each manifest's exports map, so a subpath nobody exports is never
built and a subpath that is exported cannot be forgotten. Relative specifiers in shipped source
carry a .ts extension — TypeScript resolves ./x.ts to ./x.d.ts, and an extensionless
specifier inside a published declaration is a hard error for any consumer on node16 or
nodenext. It is the same form effect itself ships.
All packages share one version and ship on one tag. bun scripts/set-version.ts <version>
rewrites the root and every workspace package; git tag v<version> triggers the release
workflow, which builds and packs each package, proves a consumer installs the packed tarballs
with nothing else declared, and attaches them to a GitHub Release. Nothing is published to a
registry.
bun install
bun run build # every package's dist, which is what a consumer installs
bun run dod # build + tsc + effect-tsgo + bun test + biome
bun run test:e2e # build, pack, then install the tarballs into a throwaway consumerThe build comes first, and dod runs it first for that reason. In-repo code imports workspace
siblings by package name — scripts/fixture-root.ts reaches for @packages/effect-bun-test/fixture-root,
bun-svelte-test's suite for @packages/effect-bun-test — and those names now resolve through an
exports map that points at dist. On a fresh checkout bun run tsc therefore fails until a build
has run, with a plain Cannot find module. bun run dod and bun run test:e2e each build first so
neither can be run out of order; a bare bun run tsc or bun test cannot, so run bun run build
after bun install.
The build bootstraps itself: buildOrder puts a bundled sibling ahead of its dependent. No package
bundles a sibling today, so that ordering currently has nothing to order.
bun is the only runtime executed here — node is never spawned, and CI installs no node
toolchain. Two settings in bunfig.toml hold that, both closing paths that shell out silently
rather than expressing a preference:
[run] bun = true— package bins carry a#!/usr/bin/env nodeshebang,tscamong them, andbun runhonours it by spawning node unless told otherwise.[install] ignoreScripts = true— bun runs the postinstall of packages on its default-trusted list, andmsgpackr-extract(transitive througheffect) is one; its postinstall spawns node vianode-gyp-build-optional-packages. It is an optional accelerator with a pure-JS fallback, so nothing here needs it built. Note an emptytrustedDependenciesarray does not achieve this — bun reads[]as unset and keeps its defaults.
Source code still imports node: builtins and still typechecks against @types/node; both are
satisfied by bun and neither runs the node binary.
One carve-out to know about: bunx <bin> does not honour [run] bun = true and will spawn
node. Use bun run <bin> (or bunx --bun <bin>) for any package binary.
Typechecking runs on TypeScript 7 (tsc is the native compiler, no node in the loop). The
Effect language service ships as @effect/tsgo, which is the TypeScript 7 compatible
successor to @effect/language-service — it embeds a pinned, patched tsgo with the Effect
language service built in, so @effect/language-service is no longer a dependency. The
tsconfig.base.json plugin entry still carries the name @effect/language-service; that is
the plugin identifier the embedded service answers to, not a package reference.
bun run effect-tsgo diagnostics --project tsconfig.json # Effect type-aware lint
bun run effect-tsgo get-exe-path # LSP binary, for editor configEditors must be pointed at the executable get-exe-path prints; a stock tsserver cannot load the
plugin by name now that it is embedded rather than installed.
Effect diagnostics are not wired into bun run tsc. Doing so requires effect-tsgo patch,
which rewrites the installed compiler and so must be re-run after every install. It is also not a
drop-in here: the current rule severities produce 28 message-level diagnostics, and because
tsconfig.base.json sets no ignoreEffectSuggestionsInTscExitCode, a patched tsc exits 1 on
them. Closing that gap means either resolving those diagnostics or opting suggestions out of the
exit code — a deliberate change, not a side effect of the upgrade. Until then the neighbouring
ignoreEffectWarningsInTscExitCode / ignoreEffectErrorsInTscExitCode options only take effect
under diagnostics and in the editor.
{ "dependencies": { "@packages/uuid-effect": "https://github.com/Angel-Studios/effect-bun-tooling/releases/download/v0.4.1/packages-uuid-effect-0.4.1.tgz" } }