Skip to content

feat: the module and service layer — OSGi Core 3.3/4/5 and Compendium 104/105/112/159, released as 0.1.0 - #25

Merged
vwfox merged 66 commits into
mainfrom
feat/service-ownership-and-alias-cleanup
Aug 25, 2026
Merged

vwfox merged 66 commits into
mainfrom
feat/service-ownership-and-alias-cleanup

Conversation

@vwfox

@vwfox vwfox commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Sixty-four commits that take tsm from a module loader to the module and service layer it was aiming at, released as 0.1.0.

Measured against OSGi Release 8 section by section in docs/CONFORMANCE.md: of 142 compared points, 73 conform, 49 differ, 20 are absent — and every departure carries a reason (language, platform, runtime model, module cut, or a named decision). Nothing is left standing as "not done yet". Every issue filed against the package is closed, #17 through #24.

What arrived, in the order it builds on itself

Configuration Admin and Metatype (Compendium 104, 105) — configuration belongs to the component, not the bundle. A PID decides whether a component runs, how many instances exist, and what their services publish. @modified() decides whether a change is absorbed or rebuilds.

Requirements and capabilities (Core 3.3) with a system bundle — what can run is answerable before anything loads, and a module that waits can be told apart from one that waits in vain.

The component level — satisfaction per component (112.5.2), @bind/@unbind for dynamic references, factory components, collection references with the field option, conditions, and enable/disable per component. Declarative Services covered but for what rests on a class loader.

Features (Compendium 159) — the document that answers "which modules, in which versions, with which configuration", plus the launcher the specification leaves open.

Two seams that make separately built bundles hold together: typed service ids, so a contract names itself once in both namespaces; and a bundle boundary the build enforces, because ES modules have no class loader to stop a relative path reaching into another bundle.

Breaking changes

Fourteen, and the split matters more than the count: seven change behaviour, so a build that still compiles can behave differently. CHANGELOG.md lists each with what to check. The ones most likely to be noticed:

  • A tie in ranking now goes to the earlier registration (Core 5.2.5). With the later one winning, merely loading another module displaced a running provider.
  • An outranked registration now raises events. countProviders had changed while nothing was reported, so cardinality 0..n went stale in silence.
  • tsmPlugin({ boundary }) fails builds that used to pass. In a monorepo this typically surfaces framework or contract code that was being copied into every bundle — it found 25 such bundles in the Daanse board app.

State

  • 1281 tests, lint and three typechecks clean
  • Coverage measured for the first time: 94.4% of statements, 92.9% of branches
  • Verified from a fresh clone with exactly what the publish workflow runs (npm ci, build, test:run)
  • Fast-forward onto main, no merge

Releasing

publish.yml triggers on a published GitHub release and takes the version from the tag name. package.json says 0.1.0, so a tag 0.1.0 is consistent — and because the version carries no hyphen, the workflow publishes it as latest, which also corrects the latest dist-tag that currently points at 0.0.1-next.1 while 0.0.1-next.2 exists.

Two things worth knowing before merging: the workflow runs build and test:run but neither lint nor typecheck:examples/typecheck:types, so examples and type tests could regress unnoticed. And six dependabot branches will meet a main that is 64 commits newer, where package-lock.json is the likely conflict.

vwfox added 30 commits April 17, 2026 14:59
- Set version from release tag instead of package.json
- Determine npm tag (next for pre-releases, latest for stable)
- Upgrade to Node.js 24
Groundwork for #18. Services now carry an owner and withdrawals are
observable; acting on a withdrawal (the unsatisfied state, static/dynamic
policies) is deliberately left out.

Fixes two bugs in the same area:

- unregister() left alias bindings from bindClass(..., { implements })
  behind. has(alias) reported the service while get(alias) returned
  undefined, so checkRequirements() — which goes through has() — treated
  a requirement as satisfied for an unreachable service, and the module
  activated only to fail on first use. Aliases are tracked in a reverse
  index now and removed with their primary, each with its own event;
  has() also resolves alias chains.
- clear() only iterated instantiated services, leaving lazily bound ones
  in the registry.

Adds:

- register(id, service, { providedBy }) — an owner on every registration,
  which a teardown needs to know what belonged to a module. Optional, so
  existing calls are unaffected.
- ModuleLoader tracks which active modules require which services and
  emits a service-withdrawn event when one disappears. Plus dispose(),
  since the registry listener would otherwise outlive the loader.
- ObservableServiceRegistry, kept separate from ServiceRegistry so custom
  registries without addListener stay valid; support is detected at
  runtime.

Rebinding a primary ID now discards the aliases of the registration it
replaces, rather than pointing them at a provider that never declared
those interfaces.
Implements #18 for the static case. A module with an unmet requiresService
is parked in the new 'unsatisfied' state and activated when the service
appears; when a required service is withdrawn, the consumer is deactivated
and parked, so it returns when the service does. Load order follows from
the manifests, which is what removes the need for a hand-ordered startup
list.

- ScopedServiceRegistry binds context.services to one module: registrations
  are attributed to it and withdrawn on deactivation, which is what carries
  a cascade to indirect consumers. BundleContext's role in OSGi.
- DependencyResolver treats a non-optional requiresService as an edge to the
  provider of that service, in the topological sort and in cycle detection.
- Dependencies count towards satisfaction: a module whose dependency is
  parked waits too, rather than activating against code that is not running.
- Reactions to registry events are queued and serialized, since the events
  are synchronous and activation is not. settle() awaits the whole cascade
  and loadAll() calls it before returning.
- getUnsatisfiedModules() answers why a module is not running; a module that
  flips more than ten times in one cascade is set to error instead of
  looping.

policy: 'dynamic' is accepted but behaves as 'static'. Cardinality stays at
one provider per ID.

BREAKING CHANGE: an unmet service requirement parks the module instead of
throwing; strictRequirements: true restores the previous behaviour.
context.services is now a module-scoped facade, so services registered
through it are withdrawn when the module is deactivated.
Three gaps found while writing tests for the satisfaction lifecycle:

- Circular module dependencies overflowed the stack: ensureDependencies()
  called loadModule() for a module that was already being loaded, which
  restarted it. loadModule() now returns the entry for a module in flight,
  so the modules park on each other and show up in
  getUnsatisfiedModules() instead of crashing.
- A dependent could activate after its dependency was unloaded, because
  only a present-but-inactive dependency counted as a reason to wait. Any
  dependency that is not active now blocks activation.
- unloadModule() and reloadModule() await the cascade they cause, so the
  caller sees a settled state.
The lint script called eslint, which was not among the devDependencies,
so `npm run lint` failed with "eslint: not found". Adds eslint 9 and
typescript-eslint 8 with a flat config, and drops the --ext flag eslint 9
no longer accepts.

Fixes the nine findings in existing code:

- decorators.ts: the metadata readers took `Function`; they only ever pass
  the target to Reflect.getOwnMetadata, so an object type says what is
  actually required.
- types.ts: the no-explicit-any directive sat above the interface while
  the any is inside it, so it silenced nothing.
- The unused catch binding, import and variable in the tests. The lifecycle
  placeholder test built a ModuleLifecycle and asserted nothing; with the
  window stub it can assert what it was written for.
The hook only ran the tests, so a lint error reached the branch. Lint runs
first: it is the faster of the two and fails on things the tests do not see.
The consuming side of #18. Two ways for a module to learn about service
changes, for the two shapes that need it:

- policy: 'dynamic' on a requirement: the module stays active when the
  service is withdrawn and gets onServiceUnbound / onServiceBound instead
  of being torn down. Cardinality still decides activation, policy only
  decides the reaction to a change, as in DS. A throwing hook is logged
  and the module keeps running.
- context.services is an ObservableServiceRegistry, so a registry-style
  service inside a module can react to services it never declared. Its
  listeners are removed with the module, so a collection cannot outlive
  what owns it.

The second one closes a gap left by the module-scoped facade: it
implemented ServiceRegistry, which has no addListener, so a module had no
way to observe the registry at all.

BREAKING CHANGE: ModuleContext.services is typed ObservableServiceRegistry.
Modules consuming the context are unaffected; a hand-written ModuleContext
needs addListener/removeListener.
Completes the dynamic side, driven by what two consumers actually do
today.

- whenAvailable(id, { timeoutMs }) resolves once a service is there,
  immediately if it already is. It replaces polling has()/get() in an
  interval, which is what a consumer starting before its service has to do
  now. On the registry and on the module scope.
- Singletons bound through bindClass() are discarded when a service they
  received at construction is replaced or withdrawn, transitively, so the
  next get() rebuilds them. Without it a dynamic requirement is reported
  as changed while the injected instance still serves the old service.

The limit is unchanged and now documented as such: a hand-written bind()
factory is opaque to the registry, so its instance is left alone.
Stage 1, shaped by what two consumers reported: neither collects several
providers through the registry today, but both need a change to be
noticed, and one needs default/override.

- Registrations are ranked and the losers are kept. Until now a second
  register() under one ID displaced the first for good; now the best
  remaining registration takes over when the visible one goes, and
  consumers see 'updated' instead of the ID falling silent. Equal ranking
  keeps last-wins, so nothing changes until someone sets a ranking.
- register/bind/bindClass return a ServiceRegistration handle, since
  unregister(id) cannot say which of several registrations is meant. In
  the module scope it means "all of mine", so a teardown no longer takes
  another module's provider along.
- Cardinality 0..n / 1..n, consumed via getServiceReferences(), which
  lists providers without instantiating them — a factory must not build
  what nobody asked for. Teardown happens when the last provider goes, not
  on every change to the set; policy 'dynamic' reports set changes, which
  is what lets a palette pick up a widget loaded at runtime.
- bindClass aliases are registrations too, so two classes may implement
  one interface and rank against each other.
- The resolver picks the highest-ranked declared provider and creates no
  edge for n-cardinality, whose set is filled at runtime.

BREAKING CHANGE: register/bind/bindClass return a handle instead of void,
which affects implementers of ServiceRegistry (consumers are unaffected).
unregister(id) now removes every registration for that ID.
Four follow-ups that only became worthwhile once ranking existed.

- policyOption reluctant/greedy: a running module stays with the provider
  it has unless it asked to switch. Static rebuilds, dynamic gets
  unbound/bound. Without ranking there was no "better provider", so the
  distinction had nothing to decide.
- Target filters in OSGi syntax, with service properties to match against
  (declared at registration or in the manifest; service.ranking and
  service.providedBy come from the registry). The filter narrows
  satisfaction, collection and counting without instantiating anything;
  getMatching() is the filtered counterpart to get(). An invalid filter
  throws with its position instead of matching nothing, and ~= is rejected
  rather than guessed at.
- getDeclarationMismatches() plus a declaration-mismatch event: a service
  declared in provides but never registered now surfaces, because the
  resolver builds load order from provides and drift there points modules
  at a provider that never delivers.
- getAll(pattern) is deprecated: it matches ID names, not registrations,
  and only sees what is already instantiated.

Two bugs found while testing the filter parser: (attr~=value) was read as
an attribute named 'attr~' so the rejection never triggered, and an
escaped \\* still counted as a wildcard because the escape was dropped
before the wildcard split.
Implements #17. The transform already saw every tsm: import; now it checks
them against the manifest, so a mismatch fails the build instead of the
activation.

- An import of an undeclared module reports file and line and fails the
  build (strict: false downgrades it to a warning); a dependencies entry
  nothing imports is warned about at buildEnd.
- Type-only imports are exempt, including brace clauses whose specifiers
  are all inline `type` — they leave no runtime trace.
- The check runs in transform even when useRenderChunk is on, since only a
  source file can name a line. An import counts as declared when the
  manifest names the module ID, the full specifier or its subpath, because
  tsm: serves module imports and host libraries alike.

Also aligns the target filter with org.osgi.framework.FilterImpl after
comparing against it: attribute names match case-insensitively, comparison
follows the type of the property value rather than the look of the filter
text, an array property matches when any element matches, booleans have no
ordering, and ~= is implemented (whitespace stripped, case-insensitive)
instead of rejected.
The filter is now checked against org.osgi.test.cases.framework
AbstractFilterTests rather than against my reading of the spec. Adopting
those cases surfaced three more deviations:

- '&', '|' and '!' are operators only when a nested filter follows. OSGi
  accepts (&=c) as an attribute literally named '&' and (!  ab=b) as one
  named '!  ab'; both were parse errors here.
- A wildcard is a string operation: (intvalue=100*) must not match 1000.
  Stringifying the property first made numbers match substrings.
- Number('') is 0, so (count=) matched a property of 0. A value the
  property's type cannot parse never matches.

Left out are property types with no equivalent here (Character,
BigInteger/BigDecimal as distinct types, Version, arbitrary Comparable) and
filter normalisation; the test file says so.

SPEC.md §11.3 documents the configuration recipe instead of implementing a
Configuration Admin: configuration registered as a service per PID, with a
mapping from DS's configurationPolicy, modified and factory configurations
to the declarations TSM already has. The static target filter is named as
the one remaining gap.
Cleanup after the feature work, no behaviour change.

- Cardinality was decided by string comparisons in three places — the
  registry when checking requirements, the loader when counting providers,
  the resolver when building edges. They now share requiresAtLeastOne()
  and collectsMany() in cardinality.ts; three copies of '..n' would drift.
- The requirement shape checkRequirements() accepts was spelled out inline
  three times; it is ServiceQuery now, which also states the boundary: the
  registry has no business with policy or policyOption.
- The property types are exported from types.ts alone rather than from
  types.ts and serviceFilter.ts both.
… modules

- reloadModule() took dependents from getDependents(), which stops at the
  first level, and filtered them by isLoaded(), which only means 'active'.
  In A <- B <- C, reloading A left C on replaced code, and a parked
  dependent kept the old container to activate with later. It now walks the
  chain via getTransitiveDependents() and includes parked modules; a
  dependent that refuses to unload fails the reload rather than leaving it
  half done.
- settle() spells out its contract, including that it must not be called
  from a lifecycle hook. An attempt to await it automatically inside
  loadModule() is deliberately not part of this: a flag for "a task is
  running" cannot tell "I am inside that task", so it made settle() return
  early under concurrency — worse than the documented rule.
- TsmRuntime and SharedModuleLoader had no tests at all (28 now), and
  cardinality.ts gets direct ones for the case where optional and
  cardinality disagree.
Opt-in, no timeout: loadModule() returns only once the cascade it caused
has run, so a caller does not have to remember settle(). This is what OSGi
has by default — service events are delivered synchronously, so
registerService() returns with the consequences applied — and what we
cannot have by default, because import() is asynchronous: a synchronous
registry listener cannot finish an activation that awaits a module load.

No timeout, deliberately. The loader knows how many reactions are
outstanding, so waiting is exact; OSGi test utilities wait with a deadline
because they cannot know.

Off by default and documented as unusable from a lifecycle hook, since a
hook runs inside the cascade it would wait for. The earlier attempt to make
this automatic failed on exactly that: a flag saying "a task is running"
cannot tell "I am inside that task".
Adapted from the tsm-devtools module in the gene application, exported as
@eclipse-daanse/tsm/devtools.

Rewritten rather than copied. The original talked to an application-side
'tsm.system' facade and, having no access to the real types, restated four
TSM interfaces structurally — which had already drifted: getBindingInfo was
typed `scope: string` there against 'singleton' | 'transient' here.
installDevtools() now takes the loader (required) plus registry, resolver
and runtime (optional), so the commands use the same types as the rest of
the package. A command whose collaborator is absent says so.

That coupling is the point: getUnsatisfiedModules(), getDeclarationMismatches(),
getServiceReferences() and countProviders() did not exist when the module was
written, so it could not use them. The new commands unsatisfied(),
mismatches(), providers() and shared() cover them, and load() passes
awaitCascade so the picture is complete when it returns.

Output goes through a sink: coloured console by default, collectingOutput()
for tests, which is what makes the 38 tests possible without a browser.

Adds ModuleLoader.getManifests(): a listing needs what is known, not only
what runs.
The two gaps a console needs, after devtools moved into the repository.

- disableModule()/enableModule(): deactivating alone does not last, because
  the module is satisfied and the next reconcile activates it again. Being
  disabled is a separate dimension from the state, as DS treats component
  'enabled', so the module stays stopped until enabled. Its services go with
  it, so consumers park through the usual cascade, and loadModule() will not
  activate a disabled module.
- getServiceConsumers(): who asked for a service, with state and how
  (cardinality, policy, target) — the counterpart to providedBy, derived
  from the manifests so modules that never loaded are included.

DevTools expose both as disable(), enable() and consumers(), and modules()
marks a disabled module.
The example is six modules that find each other through services: load order
from the manifests, a 0..n collection with properties, a set growing at
runtime, default and override by ranking, devtools in the console, plus one
module that stays parked so unsatisfied() has something to say. No framework,
no separate install — it runs against the sources, so it moves with the code.

Running it in a browser found a real bug. loadEntry() returned
window[moduleId] whenever it was truthy, and browsers expose every element id
as a global, so <ul id="palette"> made the loader take the DOM element as the
container for the module named palette: no hook ran and the module still
reported active. Built-ins like name or status collide the same way. The
global is now checked for being a plausible container and never a DOM node;
otherwise the module is imported, the global is left untouched, and the
collision is logged.

example-whiteboard.test.ts runs the example without a browser, so the
behaviour it demonstrates is asserted rather than described. lb() and ls()
are added as Gogo-style aliases. Examples are now type-checked and linted,
which they were not before.
A shell with three regions that modules contribute views to, and out of which
those views disappear again while the shell keeps running. It draws the
distinction a workbench needs: a region collects every contribution (0..n,
ordered by a property), a slot has several modules competing for one place and
shows the highest ranked — disable Outline Pro and the plain outline takes
over. The clock holds an interval, so unmount() has something real to do; the
test keeps the detached counter element and advances timers by five seconds to
prove nothing writes into it any more.

Building it surfaced two framework bugs, both found by clicking a button:

- loadModule() with a manifest that was never registered loaded the module but
  left manifests untouched, so the module scope found no declared properties
  for it and getManifests() denied the running module existed. A view loaded on
  demand activated, registered its component, and was never mounted because its
  declared region had gone missing. loadModule() now registers a new manifest.
- Manifest-declared properties and registration properties replaced each other
  instead of merging per key, so a module passing any property silently dropped
  what its manifest declared. They now merge, as DS overlays component
  properties with configuration.

eslint ignores nested dist/ and node_modules/ so the examples can be linted at
all; the older shared-libraries example stays out of scope, it carries built
bundles and Vite template declarations.
…r interface

The examples registered plain objects only, so nothing exercised
bindClass/@injectable/@Inject. modules/metrics.ts now registers two decorated
classes: one service that gets the host's root injected, and a view that gets
that service. It also settles a question the examples left open — @Inject
names the service id explicitly, so no type reflection is involved and
experimentalDecorators suffices; esbuild's missing emitDecoratorMetadata is
irrelevant, which is why decorators work under Vite without extra setup.

Adding it exposed a gap. The module scope applied manifest-declared properties
only to the id a registration was made under, so the alias registrations from
implements: [...] received none — and the interface is precisely what
consumers filter on. The view was registered under ui.component without its
declared region and therefore never mounted. BindClassOptions.propertiesById
now carries properties per id, filled by the scope from the manifest, and
available directly for an alias that needs its own.
The views were plain objects, so the decorators only showed up in one
module. All five are classes registered through bindClass() now, which also
makes the registration path uniform: a view goes under the interface id
directly, and only the module that offers a service of its own uses
implements.

The clock takes @Inject(METRICS_SERVICE, { optional: true }), which
demonstrates a distinction nothing showed before: the manifest decides
whether a module may activate, the injection point decides whether one
dependency may be absent. Verified in a browser — the log shows
"metrics: clock mounted" when metrics is present.

Writing the test for the absent case found a bug: the guard in loadModule()
only returned an existing entry, so a module disabled before it had ever
been loaded fell through and activated. loadAll() skips disabled modules,
loadModule() rejects one, and enableModule() loads a module that was
disabled before its first start instead of leaving a manifest without code.
Declarative components, so a class says what it offers and the loader does
the registering. This addresses what makes the activator style discouraged
in modern OSGi: the declaration lives on the class instead of beside it, and
provides in the manifest becomes optional — the drift that
getDeclarationMismatches() detects cannot arise where there is only one
place to state it.

Immediate and delayed follow DS: a component with an @activate method is
created when its module activates, because something has to run whether or
not anyone resolves its service; without one it waits for the first
resolution. An async activate is awaited, which is where the synchronous
get() draws the line — the distinction resolves that rather than hiding it.
@deactivate runs on the same instance, reverse order, also when a withdrawn
service parks the module. Both styles coexist; an imperative activate export
runs first so it can prepare what a component injects.

Two semantics had to be corrected on the way, both found by declaring
components rather than by reasoning:

- Two classes in one module could not offer the same service id: the
  replacement rule took the provider as the unit, so the second bindClass
  replaced the first. A registration is now identified by provider and
  origin, so a different class accumulates while the same class replaces.
- An alias resolved through its id rather than to its own class, so a later
  registration under that id would answer for an interface it never
  claimed. Aliases now resolve to the registration they were created with.

Adds ServiceRegistry.construct() for a component that only has a lifecycle,
and ServiceRegistration.resolve() for resolving one's own registration
instead of whatever is visible — OSGi's ServiceRegistration/ServiceReference
pair. examples/workbench/modules/clock.ts is converted and its manifest
declares no provides any more.
Stage B: the Vite plugin lifts the @component() declarations out of the
sources, either checking them against the manifest's provides ('validate')
or generating that provides and emitting the manifest ('derive').

The loader reads the declarations at runtime, so this is not for it. It is
for everything that must know what a module offers *before* importing it:
load order, satisfaction, and whether a set of modules is self-sufficient —
the question a feature would ask. bnd generates descriptors for the same
reason, since a resolver cannot load a bundle to find out what it provides.

The scan goes through the TypeScript AST, not patterns: the decorator
carries nested object literals. A service id may be a string literal, a
const in the same file, or a const in a relatively imported module; anything
else fails the build with file, line and reason rather than quietly deriving
nothing. Two components declaring one id differently are reported, because
provides holds one entry per id and cannot express both.
The modules were files in one directory with their manifests in the host,
which blurred exactly the line the example is about. Each is a deployment
unit now: its own manifest, its own build, discovered at runtime through
PluginRegistry from dist-bundles/index.json. The host knows no module by name
except the clock, which a button loads on demand.

provides is no longer written by hand. Each build runs
tsmPlugin({ components: 'derive' }), so the declaration lives on the class
and the manifest the resolver needs before importing is generated from it —
what bnd does for OSGi. The metrics bundle shows it with two components, one
service and one view, in one generated manifest.

Building them standalone exposed two bugs:

- The decorator metadata keys were created with Symbol(), so a bundle
  carrying its own copy of the package wrote under different keys and every
  decorator in it was ignored — without a word. Symbol.for() fixes it, and a
  test asserts the keys are globally shared.
- startComponents() registered and activated each component in one pass, so
  the metrics view injecting the metrics service of its own bundle found
  nothing. Registration for all, then activation — DS separates the phases
  for this reason.

Adds the @eclipse-daanse/tsm/decorators subpath so a bundle imports the
decorators without the loader, a build script for all bundles, and a static
middleware for serving them: Vite refuses dynamic imports from public/, since
a finished artefact must not go through its transform pipeline.
Answering "does the clock not need the shell?": no, and it must not — a view
registers a service without knowing who collects it, while the shell asks for
0..n. A dependency the other way would make a provider need its consumer.

But the question exposed a real mistake. The clock started its interval in
@activate, so it ticked whether or not anything showed it — and the comment
even claimed that had to happen "whether or not anyone resolves its service",
which is wrong for a view. The interval is in mount/unmount now, and the clock
declares no lifecycle methods at all: it is a delayed component, constructed
only when a shell resolves it.

The consequence is visible in the running example: the outline hidden by
Outline Pro reports instantiated: false, because nobody resolves it. metrics
keeps @activate — recording that it exists is worth doing unshown — and gains
@deactivate, so both halves stay demonstrated.
Answering "is export necessary?": yes, and forgetting it fails silently. The
loader finds components in the module's namespace, which holds exports only,
so an unexported class is never registered — and no runtime check can say
why, because the class is not there to be found.

Only the source shows it, so the scan now records whether a component is
exported and the plugin reports one that is not, with file and line. The
decorator's documentation and the README say so too.
… example

The loader could report bundles and services, but not the components between
them: a service reference names the module that provided it, never the class.
getComponents(moduleId?) closes that gap, listing the @component() classes of
the loaded modules with what each declared — services, immediate or delayed,
which lifecycle methods it has. DS offers the same view as scr:list.

examples/graph draws the three layers as SVG, and draws nothing it doesn't
read back from the loader, so the picture follows the buttons: a higher-ranked
provider mutes the previous one's edge instead of removing it, disabling a
provider makes its consumer wait, and a bundle whose service nobody offers
stays parked with no components at all — its classes are loaded but were
never registered, because the bundle never activated.
Configuration Admin manages values by PID and knows nothing about components;
the loader reads them and runs the component lifecycle from them. That is the
same split OSGi has between Compendium 104 and DS, and it is what makes
`configurationPolicy: 'require'` possible: the component waits, its bundle
stays active. Until now every reason to hold something back parked a whole
module.

A change is applied the way DS applies it. An instance that was never created
only needs its properties updated; one with `@modified()` is handed the new
values and keeps running, with its service properties updated in place through
the new `ServiceRegistration.setProperties()`; one without is rebuilt. And a
PID that names a factory PID instantiates the component once per configuration
— not a separate feature but the same mechanism, which is why the registration
identity had to learn to hold several registrations of one class.

Persistence is a `ConfigurationStore`, the seam the specification itself draws
by requiring that configuration survives a restart and leaving the medium open.

examples/config shows all of it and earned its place: it found the bug where a
component that gained configuration later was rebuilt instead of modified,
because instances were keyed by a PID they did not yet have.
…hema

Configuration now says what it is: names, types, defaults, ranges, options —
OSGi's Compendium 105. One thing falls out better than in Java, where a
configuration needs an annotated interface for the type and annotations for the
description and the two can drift: here the schema is a value, and
ConfigurationOf<typeof schema> derives the type from it. Type-level tests hold
that inference in place.

It earns its keep twice over. Declared defaults are applied underneath the
configuration, so a component reads a value instead of inventing one — the
example has no `?? 1000` left in it — and they become service properties like
any other value. And a registry handed to ConfigurationAdmin refuses an update
that does not fit, which is a deliberate departure: in OSGi, Config Admin does
not validate and Metatype only describes. Both are opt-in.

For the way out of tsm's own vocabulary there is no Ecore generator here, and
there should not be: @emfts/codec.jsonschema already converts in both
directions. So tsm emits JSON Schema — as a document schema for validators, and
as a $defs metamodel for an EPackage converter, which ignores a top-level object
schema. Checked against the real converter rather than assumed: three EClasses
with correct bounds and a named EEnum. From there @emfts/vue-registry and
@emfts/uimodel-composer render the interface, which is why this repository has
no form generator of its own.
The JSON Schema route loses defaults, value ranges, translations and the fact
that a validator exists. @emfts/tsm-metatype converts directly and keeps all of
it — on the EMFTs side, where @emfts/core already lives, so tsm stays free of
it and is imported for types alone.
vwfox added 29 commits August 19, 2026 17:09
Closes #19. A module now arrives through loadModule(manifest, { container }) for
one, or entryResolver for many, and otherwise from its entry URL. Nothing is
looked up by name in a global namespace any more.

The question that turned this from a nicer API into the right change was why
there was a window reference at all. It was Module Federation — and that support
turned out to be nominal: container.get(export) was called, container.init(
shareScope) never, so no real remote could have worked, no test ever built such a
container, no example used one, and SPEC.md excludes Module Federation from the
scope in the first place. What window[moduleId] actually served was handing over
an already-imported module, which is what #19 asked to make explicit.

Three costs went with it. Collisions with DOM ids, which the browser exposes as
globals — the isModuleContainer guard existed only because <ul id="palette"> had
been taken as the module palette. A namespace shared between two applications on
one page. And the loader being unusable in Node, where window does not exist:
loadModule threw ReferenceError before fetching anything. It runs there now, and
so does the URL path, since Node's ESM loader takes file: and data:.

The test suite lost its globalThis.window fakes along the way — ten files, plus
a window proxy that existed only to survive the unload during a reload. A
handed-over container is kept across reloadModule for that instead, and the
cache buster is applied only where there is a URL to bust.
The conformance table had nothing to say about shared libraries, which the
question deserved: the intent is Import-Package, the mechanism is not. OSGi
wires an import to an exporter and the class loader enforces a single instance;
tsm checks that the host registered one and relies on there being a single
registry. That is why there is no second version at a time, and why the build
check exists — where OSGi has a verifier, a bundled copy here just behaves
subtly wrong.

Also noted why a shared library uses tsm.library rather than one of the
registered namespaces: none of them means 'the host supplies this instance', and
Core 3.3 provides for own namespaces.
A module declaring sharedDependencies could never resolve. The requirement is
derived from its manifest, but the library itself is registered with the runtime
— outside the model — so nothing offered the tsm.library capability, and
getUnresolvedModules() reported every such module as unable to ever run. I
introduced that with Core 3.3 and no test caught it: requirementsOf was tested
for shared dependencies, resolveWiring never was with them.

resolveWiring(manifests, { offered }) now takes capabilities that come from no
manifest, libraryCapabilities() builds them from what the runtime holds, and
getWiring() supplies them itself. The version range is then checked at resolve
time too — the same judgement validateSharedDependencies makes at load time,
only earlier.

The question behind this was whether a shared library should be a bundle. For
declaration and resolution it can be: a module that declares the capability
closes the same gap without a host-side detour, which is what a shared library
is in OSGi — a bundle exporting a package. For the import it changes nothing,
since `import { ref } from 'vue'` is resolved by the bundler or the browser, not
by the loader.
The conformance table claimed Core 3.3.5 was absent because there is no system
bundle. The mechanism is there — resolveWiring({ offered }) with libraries the
host registered, wired under the provider 'environment'. The specification has a
framework parse system.capabilities.extra and resolve against it 'as if
provided by the system bundle', and export the JRE packages as system packages
the same way. What tsm lacks is the synthetic bundle 0 to hang it on, not the
construction.
"In addition to normal bundles, the Framework itself is represented as a bundle"
(Core 4.6). That is the trick worth copying: instead of the resolver knowing
about an environment, the environment takes the form it already understands. So
getWiring() no longer threads capabilities through as a second argument — it
adds getSystemBundle() to the manifests and resolves them all the same way.

It carries the shared libraries the host registered plus whatever
systemCapabilities declares, which is org.osgi.framework.system.capabilities.extra
by another name; the screen example from the specification works verbatim. Id
`system.bundle`, as OSGi requires to be recognised as an alias, and entry the
fixed string `System Bundle`, as getLocation() returns there. It resolves like
any module, shows up in tsm.capabilities(), and refuses to be loaded — its
start() does nothing in OSGi for the same reason.

Deliberately not in getManifests(): that answers what was registered, and this
was not. What stays absent is the lifecycle half, where stop() shuts the
framework down; a loader is a library, not a runtime to shut down.
The doc comment on ServiceRequirement.policy still claimed only 'static' was
implemented and 'dynamic' behaved the same. It has been implemented since the
dynamics stage — four places in the loader, eight tests — and I noticed the
stale comment earlier without fixing it.

SPEC.md said nothing about it at all, so 3.5 now covers the distinction that
gets confused most often: cardinality decides whether a module may start, policy
only decides what a later withdrawal does. A dynamic requirement still has to be
satisfied to activate; for a service that need never exist, the answer is
cardinality '0..1'.
What a component injects is now its own requirement (DS 112.3, 112.5.2). A
missing service leaves it unsatisfied-reference, with waitingFor naming it,
while the module around it keeps running. The service arriving starts the
component; the service leaving stops it through @deactivate and withdraws the
services it registered, which cascades to whatever injected those.

Before this, a missing service threw — "Dependency 'demo.tiles' not found" —
and took the whole module's load down with it. That was the largest of the
listed gaps, and the one everything else on the list depended on.

Two things came out of building it. Components of one module are registered in
rounds, because one may inject the service another offers and the order inside a
module says nothing; that is the same fixpoint the module level already uses.
And activation now follows the registration order rather than the declaration
order — otherwise a consumer starts while its provider is constructed but not
yet activated. The round-based registration is what made that visible; the
defect was there before, hidden by the declaration order happening to agree.

requiresService stays as it was: the coarse tool for when a module has no
purpose without a service. Per-reference bind/unbind methods (112.3.2) remain
open, but the satisfaction they need is now in place.
…h, derived deps

Three points, all of them Declarative Services catching up with the satisfaction
that was put in place last.

@Bind()/@unbind() make a reference dynamic (112.3.2, 112.5.18). The component
stays and is handed the change instead of being rebuilt, and the decorator names
the method by sitting on it where DS needs XML for that. Bind runs before
@activate and in declaration order, as the specification orders it. A mandatory
reference going means the component goes — after being told; an optional one
only gets the call. Without an @unbind the loss is reported and the component
keeps what it stored, because stopping it would turn an optional reference into
a mandatory one. That last rule started out the other way round in my code and
the comment contradicted the behaviour; the test is what surfaced it.

disableComponent()/enableComponent() (112.5.1) switch one component off while
its module and siblings keep running. Off is not waiting, so no reconciliation
brings it back — and the switch survives a reload, since it belongs to the
deployment rather than the instance.

dependencies: 'derive' closes the last point of #17. Shared libraries and
type-only imports stay out, and when components are derived as well both end up
in one manifest — two emits under the same name would have overwritten each
other silently.
Two rows contradicted the count. Targeted PIDs were filed under
Plattform, as if they needed something the browser cannot give - but the
manifest already carries module identity and version, so `pid|id|version`
is buildable and only `location` has no counterpart. Logger support was
marked a gap in its row and then left out of the table.

So it is seven gaps, not six, and the platform count drops by one. Also
name the counting basis: the four closing rows summarise whole chapters
and were never part of the 124.
… PIDs

`module` service scope is OSGi's `bundle` scope under the name tsm uses
for a bundle: one instance per consuming module. The facade already knew
which module was asking, so the only thing missing was passing it on.

Two things fell out of building it. A service's own references resolve on
behalf of the module that *provides* it, not the one that asked - whose
code runs decides whose instance it gets, and otherwise the scope leaks
down the chain and one singleton ends up with two different dependencies
underneath it. And without a consumer the scope shares one instance
rather than building a new one per call: the test caught it behaving as
`transient`, a contract the registration never declared.

MODIFIED_ENDMATCH needs listeners that filter, so the registry now takes
a filter with the listener. That is the whole reason to filter there
rather than in the callback: a listener testing properties itself never
learns that a service it had accepted stopped qualifying, so whatever it
collected quietly goes stale.

Targeted PIDs were mis-filed as a platform limit. The manifest carries
module id and version, so `pid|id|version` is buildable; only `location`
has no counterpart here. The lookup walks most-specific-first, and an
empty entry does not end the search - `getConfiguration()` creates those,
and one of them must not shadow a configuration that has values.

`affects()` needed the same chain. Comparing plain PIDs alone, a
component would keep running on untargeted values while a configuration
written for it sat there unnoticed.
…ditions, logger

With these, docs/CONFORMANCE.md carries no row marked as a gap. What is
still absent from the specifications is absent for a reason of language,
platform, model or intent, which is a different claim than "not yet
done" - and the one that table exists to make.

Factory components (112.2.4) are the counterpart of factory
configurations, and the difference is who decides there should be
another instance. A factory configuration is data, so a UI or a stored
file creates them; a factory component is a call, so code does. "One
editor per open tab" is something only the code that opens tabs can
know. The factory follows satisfaction: with a mandatory reference
missing it is withdrawn, because nobody should be able to ask for an
instance of something that cannot run. When it comes back the instances
do not - those belonged to whoever asked for them.

Collection references bring cardinality 0..n to a field, and with them
the field option. `update` mutates the array the component holds instead
of assigning a new one, which is what a reactive view bound to it needs:
with `replace` a template holding the old array never sees the change.
It also skips the assignment when nothing actually changed, or every
unrelated registry event would re-render.

A condition is a service with no behaviour, only the statement that
something is the case - how to say "not before" without inventing a
service to depend on. It is treated as one more mandatory reference,
which is how DS models it, so everything that already waits for a
reference waits for this too.

The component logger is the one departure of the four: DS injects a
Logger service, tsm names the one on ComponentContext after the
component. Filed as Sprache, not as conformance.
Closing the seven gaps left five rows explaining themselves by pointing
at something that no longer exists. Three said "follows from 5.3" or
"the missing prototype scope" - 5.3 is implemented, and `transient` is
the prototype scope. One said tsm has no requirements/capabilities model,
which Core 3.3 has been since it was built.

5.9 was the substantial one: `module` scope *is* ServiceFactory - one
instance per consuming module, built on its first resolution. What is
actually absent is the callback shape, since a factory here needs no
bundle argument to be handed a consumer.

Six more departures carried an explanation but no kind, so the counts in
the prose did not add up to the rows. Every one of the 64 now has both.
124 rows: 60 conform, 46 different, 18 absent - Sprache 21, Plattform 17,
Laufzeit 2, Modell 8, Absicht 16, and no gaps.

112.3.6 (reference scope) became Absicht rather than a gap, so SPEC.md
§11.4b now argues it: the provider decides whether its service is
shareable, because whoever writes it knows if it carries state - and a
consumer who can demand its own instance bypasses that judgement without
being able to contradict it.
…ses them

`tsm.factories()` lists the component factories and what each has built -
a withdrawn one being the interesting entry, since it means no instance
can be had. `tsm.conditions()` shows which conditions hold and which are
waited for in vain; the second half is the point, because a condition
nobody registered is invisible in the service list and a component
waiting for it looks like one waiting for nothing.

`components()` now shows a factory as a factory rather than naming
services it does not register, plus the condition and the collections
with their field option. ComponentInfo carries all three.

examples/editors puts the four newer features on one thing - an editor -
and found two defects that reasoning had not:

A component built through `construct()`, one with no service of its own,
lost the consumer, so every module was handed the *same* `module`-scoped
instance. There the scope was wrong rather than merely absent.

Withdrawing an *outranked* registration raised no event. `get(id)`
answers with the same object as before, so it looked like no change - but
countProviders had changed, and a collection reference consumes every
provider, so cardinality 0..n went stale in silence. Both directions now
notify, as OSGi raises REGISTERED and UNREGISTERING per registration. One
test asserted the old silence; it now asserts the event and says why the
old behaviour was wrong.

A third came out as a small addition: two `register()` calls under one id
from one bundle replaced each other, so a bundle could not offer two
tools under one id - the whiteboard pattern. `instanceKey` is now
reachable from register()/bind(), while registering the same thing twice
by accident still replaces.

The example bundles also clear their own module-level state on activate
and deactivate. The framework withdraws their registrations; what it
cannot know about is the state they keep beside them, and a stale handle
made restore() believe it had already registered.
… OSGi

In OSGi, SCR is an ordinary bundle: an extender that reads other
bundles' component descriptions and manages them from outside, while the
framework knows nothing about Declarative Services. Two consequences of
that are worth keeping even where the extender is not a separate bundle.

Introspection is a service. `ServiceComponentRuntime` under
`tsm.component.runtime` carries the descriptions, the state, and
enable/disable. That is the difference between a tool that can ship as a
module and one that must live in the host: `loader.getComponents()` was
the only way in, and ModuleContext does not hand out the loader - so a
component view, a diagnostics panel or devtools were the host's business.
The devtools in this package now go through the service, the way
`scr:list` is a shell bundle talking to SCR.

And what the runtime offers, a module can require. The system bundle
carries the capabilities that in OSGi come from each implementation
bundle: `osgi.extender=osgi.component` always, plus
`osgi.extender=osgi.metatype` and `osgi.implementation=osgi.cm` only when
the application actually supplied a registry or an admin. Conditional on
purpose - a capability nobody can rely on is worse than none, and a
module with a configurationSchema would otherwise resolve and then find
its schema dropped.

What stays a model departure is what actually follows from the loader
being framework and SCR in one object: SCR cannot be stopped or swapped.
The rest did not follow from it, and 112.9, 105.12 and 135.4 say so now.

112.3.7 was stale from earlier work: reference policy is per component
reference since @bind/@unbind, expressed by the methods being there
rather than by an attribute that could contradict the code.

124 rows: 61 conform, 46 different, 17 absent. Modell drops from 8 to 6.
Compendium 159, the last chapter with no counterpart here. What a
feature turned out to be is the document that answers "which modules, in
which versions, with which configuration" — the question that otherwise
lives in host code, where it cannot be versioned, reviewed or shipped.

The specification defines the document and the API to read it, and says
explicitly that installing one is a launcher's business. Both halves are
here, and the second is marked as an addition rather than conformance.

installFeature() makes two decisions the specification leaves open.
Configuration is written *before* loading, because a component with
configurationPolicy 'require' does not run without it and one with
@Modified would be reconfigured a moment after starting; writing first
means every component sees its values on the first activation. And it is
all or nothing: a missing variable, an unavailable module or an unhandled
mandatory extension refuses the install before anything happens, because
a half-installed feature cannot be told from a system meant to look that
way.

Two departures, both from the identifier model. An id is name@version or
@scope/name@version, not groupId:artifactId:type:classifier:version —
tsm modules are npm packages, and a group id would be a field nobody
could fill in truthfully. And `configurations` follows Configuration
Admin directly rather than the Configurator (150), which tsm does not
have; the typed key syntax is supported because a ${...} placeholder
always yields a string.

`complete: true` is a claim by the author, so isComplete() checks it
against the live wiring — counting what the runtime itself offers, since
the same feature is complete where a capability exists and not where it
does not.

142 rows now: 72 conform, 50 different, 20 absent, and still no gaps.
Two things that both come down to the same question: what identifies a
thing across a bundle boundary.

A library bundle is a module whose contribution is a package rather than
a service — the OSGi bundle that exports a package and is never started.
The `tsm.library` capability could already say so, but generateImportMap
only ever read the host's offers, so the edge counted as missing.
offeredByModules() now reads it from the manifests, with the module's own
entry as the URL, because a library bundle *is* the package. The consumer
cannot tell who supplies it, which is the point.

The host wins where both offer, and the unused offer is reported as
`shadowed` rather than dropped in silence: usually it means a library
bundle was deployed that nobody needed.

Typed service ids close the gap that string ids left. A consumer knew the
interface but had to import the string as well, and nothing checked that
the two belonged together. `serviceId<T>()` ties them: the contract names
itself once, in both namespaces, and the consumer writes one import and
one name — the shape OSGi gets from naming services by their interface.

Two things came out of building it.

Signatures take `ServiceId<T>` alone, not `ServiceId<T> | string`. The
union looks more permissive and is strictly worse: inference matches the
argument against `string`, leaves T unresolved, and every call returns
`unknown`. It accepts the same arguments while destroying the only thing
the type is for. A plain string stays assignable because the brand is
optional.

register/bind/bindClass take the value as `NoInfer<T>`. With two
inference positions the argument won against the token, so registering
the wrong service under an id compiled — the type test asserted a
rejection that was not happening.

examples/whiteboard is converted, so the form is used and not merely
available: `const chart: Widget` next to `register(Widget, chart)`, with
the compiler checking that they agree.
ES modules have no counterpart to a class loader, so a relative path
into another bundle's sources compiles, bundles and runs — with that
bundle's code copied in, nothing in the manifest, and the service layer
bypassed. I demonstrated it before building this: the workbench build
went through, outline's code sat six times in notes/index.js, and the
only sign was the bundle growing by a kilobyte.

The boundary is where the manifest is. A bundle is defined by its
manifest, so dirname(manifest) is the root; anything under it is fine,
and so is anything under node_modules, because an npm dependency is a
declared dependency. What is left is a file from somewhere else in the
tree, which in a monorepo is usually another bundle.

It fails the build, unlike every other check here. An undeclared
dependency costs a needless module load; a file copied across a boundary
is structurally wrong, and there is no version of it that is merely
untidy. `boundary.allow` is where an exception goes, written down rather
than tolerated — and the contract module is what belongs there, since it
sits outside every bundle on purpose and that is exactly why it has to be
named instead of guessed.

Without a manifest path and without an explicit root the check stays off:
guessing a boundary from the entry or the working directory would draw
one nobody declared.

The message names the file relative to the root and who imported it,
which is the part an author can act on. All seven workbench bundles now
declare their allow list and still build.
The smaller of the two holes from the bundle-boundary discussion. An
import without `type` is a value import and the bundler follows it: the
file is pulled in, with everything it imports. For a bare interface
nothing happens today, but a contract module grows — an enum, a constant,
a helper — and then the same line starts copying code across a boundary.

verbatimModuleSyntax makes the distinction meaningful, and
consistent-type-imports makes it visible. src was already clean; the rule
turned up eleven places in the tests, all importing ModuleLoader as a
value where only the type was used.

The fix style turned out not to be cosmetic, which I only found by
checking what tsc emits:

  import { type Thing } from './dep.js'   →  import {} from './dep.js'
  import type { Thing } from './dep.js'   →  (nothing)

The inline form leaves a side-effect import behind, so the bundler still
pulls the file in — the very thing the boundary check exists to stop. My
first attempt configured `inline-type-imports` and would have quietly
undermined the check it was meant to complement. Only the separate form
disappears.

A genuinely mixed import is unaffected either way: the module is needed
at runtime for its value, so `import { ID, type Contract }` costs nothing
and keeps a contract on one line.

Worth noting for the boundary: a bundle importing a contract as types
only needs no `allow` entry for it, because nothing is copied. The entry
becomes necessary the moment a value is involved — an id constant, say.
…ing the lifecycle

Two module-identity defects the import-map consumers exposed:

The entry import went through whatever transform pipeline the loader's
own code was served by - a dev server rewrites the call and appends its
marker (vite: '?import'), and a different URL is a different module
instance. The loader then activates one instance while every bare import
through the import map reads another: state written in activate is
invisible to consumers. The fetch now goes through a Function-wrapped
native import no bundler can see (with a direct-import fallback for VM
hosts without a dynamic-import callback), so module identity stays URL
identity.

Entry resolution preferred 'default' over the namespace. A library
bundle may carry a genuine default export for its consumers; taking it
as the container hides the activate/deactivate exported next to it, and
the module counts as active without its lifecycle ever running. The
namespace wins whenever it exports a lifecycle; 'default' stands in only
for default-export-style modules.
A component with bind methods already counted as immediate - it wants to
hear about services, which it cannot do without existing. A component
whose only reference is an @injectAll() collection was left delayed: the
loader kept an instance record with no object, applyCollections() had
nothing to write into, and the collection stayed empty forever, however
many providers registered. The whiteboard consumer in its purest form -
a palette that is nothing but a collection - never saw a single
provider.

Collections now join @activate and @Bind in the immediacy rule, and the
regression test registers a provider before and one after activation.
7355a66 stopped a genuine default export from shadowing the lifecycle
exported next to it, by taking the namespace whenever it exports activate
or deactivate. That covered half the cause: components are the other way
a module contributes, and looking only for a lifecycle handed back the
default and left every @component() in the namespace unseen. The module
counted as active while contributing nothing — the same symptom the fix
was written for, one export kind further along.

Found by building the case the fix implies: a library-ish bundle with a
default for its consumers and a component beside it. Four tests now cover
the namespace decision, and they import a real file rather than handing
a container over, because the decision only happens on the import path.
…eId()

tsm#21: the build-time scan followed only relative imports, so a service
id declared in an API bundle could not appear in
`@component({ service: [...] })`. The board's twenty-four widget bundles
had to keep a repeated literal because of it.

Two halves, and either alone is useless.

Package specifiers now resolve. node_modules is walked by hand rather
than through a resolver: the scan is synchronous, the bundler's resolver
is not, and `import.meta.resolve` — the synchronous one — turns out not
to exist under Vite, which is where this runs. I had built on it first
and the tests caught it. The `types` condition is tried first, because a
workspace API package usually points at its TypeScript source, and
because built output is no use before it is built; where the resolution
lands in a build directory the matching source is tried too.

And a wrapped literal counts as a constant. `serviceId<T>('demo.tiles')`
is a call, not a literal, so `collectStringConstants` saw nothing —
meaning adopting a typed id silently cost the build-time declaration and
the component's `provides` entry with it. The scan now reads through an
identity call it knows by name (serviceId, Symbol.for) and through
`as const` / `satisfies`.

Verified against the real case rather than a fixture: the board's video
widget, rewritten to the form the commit wanted, now derives
`provides: ["daanse.widget"]` from
`serviceId<WidgetProvider>('daanse.widget')` in
lib.api.widget. My own fixtures had used bare literals and passed while
the real thing still failed.
Raised as an aside in #23 while writing a test there. The specification
is explicit — "ties give a preference to the earlier registrant"
(Core 5.2.5), because service ids are handed out in registration order
and the lowest ranks highest — and tsm did the opposite in two places:
`outranks` let the later seq win, and `registrationsOf` sorted the bench
newest first.

CONFORMANCE had it as Absicht, with the reason "before ranking existed, a
repeated registration replaced its predecessor, and that was kept". That
is a description of how it happened, not an argument, and the practical
argument runs the other way: with the later one winning, merely loading
another module displaces a running provider, so which service answers
depends on load order. A provider that means to win says so with a
ranking.

Five tests held the old behaviour and now hold the new one with the
reason attached; five more cover what the ordering buys, including that a
collection sees the same order.

142 rows: 73 conform, 49 different, 20 absent.
tsm#20. `import 'my.shared.module'` survived the rewrite, and because a
shared module is external, Rollup emitted it into the chunk as a bare
specifier — so the browser failed the whole bundle, not the part that
used it.

Dropped rather than rewritten, which was the question the issue asked.
The effect has already happened: a shared module is loaded by whoever
provides it and its top-level code ran then, while `__tsm__.require()`
only fetches what is already registered. Rewriting would emit a call
whose result is discarded, once per statement — and most of these
statements were never written by anyone, since Rollup synthesizes them
for externals while chunking. That is also why the source pass alone
cannot be enough, and renderChunk runs the same transform.

The missing replacement was not the whole story. The early-out asked
whether the code contains `from '<module>'`, and a side-effect import has
no `from` at all — so the file was never touched, whatever patterns came
after. Looking for the specifier itself covers both forms.

A hand-written one now warns: the author is asking for something to
happen and nothing will, so a silent drop would be worse than the error
it replaces. The synthesized ones stay quiet — nobody wrote them, so
there is nobody to tell.
tsm#24. The issue offered two options, and checking first showed that
option 2 already exists: `@activate(context)` has been handing components
their context all along, and `ComponentContext extends ModuleContext`, so
`context.services` is there. That is also the closer analogue of DS
112.5.8 and stays the normal case.

What it cannot reach is the case the issue actually blocks on: a class
whose dependency on the registry is constructional. A repository that
resolves identifiers when it is called has to *hold* the registry, and
`@activate` is too late for that — so such a class had to be built and
registered by hand in the module's activate, the imperative form of
exactly what @component declares.

`MODULE_CONTEXT_SERVICE_ID` closes it, and the mechanism is the `module`
scope: one service id answering differently per module. Which turned up a
gap in that scope — the factory was never told who it was building for,
so a per-consumer service could only ever be one-per-consumer, never
*about* the consumer. A `bind()` factory now receives the consumer id.

The boundary is deliberate: the module's context, not the component's.
Configuration and service properties differ per instance, and at
construction time the instance does not exist yet.

Two refusals worth naming. Resolved from outside a module it throws
rather than answering — handing over another module's context would make
a teardown release the wrong registrations. And a registry that cannot
bind factories simply has no context: that is a legitimate minimal
implementation, found by two existing tests that pass such a registry,
and it must not take the loader down.
The changelog's [Unreleased] head still claimed `policy: 'dynamic'` is not
implemented, which stopped being true seventeen commits ago. Four
features were missing from it entirely: factory components, collection
references with the field option, conditions, targeted PIDs, and
filtering listeners with modified-endmatch.

Fourteen breaking changes, and the split matters more than the count:
seven change *behaviour*, so a build that still compiles can behave
differently. They are listed first, each with what to check — the ranking
tie-break reversing, outranked registrations now raising events, a
`default` export no longer winning over a contributing namespace, the
entry import no longer passing through a bundler transform, and the
bundle boundary failing builds that used to pass. The other seven are
type-level.

Version 0.1.0, not 0.0.2: at major 0 a minor bump is what carries
breaking changes, and calling this a patch would hide seven behavioural
ones. It also has to clear what is already published — the registry has
0.0.1-next.1 and a next.2 that GitHub Actions built four months ago,
while the repo never left next.1, so `npm publish` would have been
refused.

`prepublishOnly` now builds, because reviewing this release found
dist/moduleContext.js missing from a stale dist — the tarball's contents
should not depend on whether someone remembered.

Coverage measured for the first time (@vitest/coverage-v8 was configured
but not installed): 94.4% of statements, 92.8% of branches. Tests added
where the gaps were real rather than everywhere — negative rankings and
property values, a computed property key, reads that pass through the
scoped registry facade, and refusing to wait on a registry that cannot.

The computed-key test failed, which turned up an inconsistency:
`propertiesById` resolved its keys through constants while `properties`
did not. A property name held in the contract module is the same practice
as the service id being there, so both do now.
Found while going through the roadmap in SPEC §9, which listed "Error
Boundaries: a plugin's error should not crash the host" as a critical open
item. It was open: an `@activate` that threw propagated, the module went
to `error`, and its other components were never activated at all — one
broken component took everything with it.

DS is explicit: "the component configuration is not activated and will be
discarded" (112.5.8). That one, and nothing else. Its services are
withdrawn with it, because a registration whose object never finished
starting would hand consumers a half-initialised thing, and
ComponentConfigurationInfo gained `'failed-activation'` — neither active
nor waiting, which is what whoever has to find out why needs to see.

Cleanup in the same pass, all of it documentation that had drifted behind
the code:

- 4.4.4 said "no wiring, see Core 3 below". Core 3.3 has been
  implemented since it was built; what is resolved is wired and reported
  as such.
- 5.9 said the factory has no way to be given a consumer. Since a1608df
  it does — `bind(id, consumer => …)` *is* `getService(bundle)`. What
  actually differs is `ungetService`: `dispose()` sits on the instance, so
  a factory keeping central account of its consumers is not told.
- 5.5 said there is nothing to release. A `module`-scoped instance is
  released with its module and told through `dispose()`; only `transient`
  is never told.
- SPEC §9's roadmap claimed five things as open that are done, three of
  them under a different name: Extension Points *are* the whiteboard
  pattern, DevTools are console commands rather than a browser extension,
  and lazy loading exists per component. The rest now says why it is open
  — for permissions and sandboxing, that declaring is easy and enforcing
  has no boundary to enforce at — or why it will not happen.

142 rows unchanged at 73 conform, 49 different, 20 absent; five reasons
now describe the code that is there.
The README's quick start did not work. `new ServiceRegistry()` calls a
constructor on a type that has none — the class is
DefaultServiceRegistry — and `new ModuleLoader({ services })` names an
option the loader never had. Its feature list predated configuration,
capabilities, the component level and features, and it led with
`initTsmRuntime` and `__tsm__`, which SPEC §6.3 replaced with import maps.

The new one opens with the shortest thing that works — a contract, a
component, a host, none importing another — and that example is
**executed by a test**. Documentation nobody runs is documentation nobody
can trust, and this one had been wrong long enough to prove it. Every
script, subpath, devtools command and example name in it was checked
against the code rather than remembered.

Writing that example found something: the shortest manifest anyone would
write did not typecheck, because `name` and `exports` were required.
`exports` is the Module Federation remnant — a module contributes through
@component() declarations and services and needs none of it, and the
loader already read it as optional. `name` is for a listing; a repository
index still asks for one, since a catalogue entry nobody can read is not
much of an entry, but the loader identifies a module by its id. Both are
optional now, so the smallest useful manifest is an id, a version and an
entry.
A prerelease first. The publish workflow reads the tag name and picks the
npm tag from it: a hyphen means `next`, so this lands beside `latest`
instead of on it, and `npm install @eclipse-daanse/tsm` keeps resolving
to whatever `latest` currently points at.

Which is worth saying plainly: `latest` still points at 0.0.1-next.1,
older than the 0.0.1-next.2 that exists. Publishing under `next` leaves
that as it is — correcting it needs a release without a hyphen, or moving
the tag by hand.

Content unchanged; the fourteen breaking changes below are the reason to
try this under a tag before it becomes what everyone gets by default.
@vwfox
vwfox merged commit bd6b91a into main Aug 25, 2026
3 checks passed
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