Repository navigation
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
Conversation
- 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.
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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/@unbindfor 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.mdlists each with what to check. The ones most likely to be noticed:countProvidershad 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
npm ci,build,test:run)main, no mergeReleasing
publish.ymltriggers on a published GitHub release and takes the version from the tag name.package.jsonsays0.1.0, so a tag0.1.0is consistent — and because the version carries no hyphen, the workflow publishes it aslatest, which also corrects thelatestdist-tag that currently points at0.0.1-next.1while0.0.1-next.2exists.Two things worth knowing before merging: the workflow runs
buildandtest:runbut neither lint nortypecheck:examples/typecheck:types, so examples and type tests could regress unnoticed. And six dependabot branches will meet amainthat is 64 commits newer, wherepackage-lock.jsonis the likely conflict.