From 5d000064880729546159d202e5f6f143e8a9766c Mon Sep 17 00:00:00 2001 From: bebrasmell Date: Sat, 15 Aug 2026 23:34:09 +0300 Subject: [PATCH 1/2] chore: make the compatibility floor real, ES2020 The ES2015 claim was never achievable: `globalThis` is read at module scope, and no build target can lower a runtime global. Rather than document that gap, close it and state a floor that holds everywhere. - pin the build to `target: 'es2020'`, and write the sources to the same level so the JSR package, which publishes `src/` rather than the bundle, asks no more of consumers than npm does - rewrite the last two `??=` operators (ES2021), which were the only reason JSR sat a level above the bundle - stop reaching for `performance` unguarded. That global only arrived in Node.js 16, so a diagnostic measurement had quietly made 16 the real floor; it now falls back to `Date.now()` where absent - README states ES2020 / Node.js 14+ and lists what sets it No engine loses support. With no explicit target esbuild had been emitting `esnext`, so every bundle released so far carried ES2022 class static blocks and this one is strictly more portable than any of them. Leaving ES2017 also brings async injection back to native emit rather than a generator. --- CHANGELOG.md | 21 ++++++--- README.md | 40 +++++++++------- src/lib/container/container.ts | 18 ++++---- src/lib/context/context.ts | 4 +- src/lib/global/global.ts | 4 +- .../middlewares/diagnostics.middleware.ts | 5 +- src/lib/utils/clock.spec.ts | 46 +++++++++++++++++++ src/lib/utils/clock.ts | 17 +++++++ tsup.config.ts | 9 ++-- 9 files changed, 124 insertions(+), 40 deletions(-) create mode 100644 src/lib/utils/clock.spec.ts create mode 100644 src/lib/utils/clock.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c9a83f..1b44414 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,13 +22,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- The build now pins `target: 'es2015'`, matching the syntax floor the README has always - advertised. Without an explicit target esbuild emitted `esnext`, so the published bundle - carried ES2022 class static blocks and ES2021 logical assignment despite documenting an - ES2015 floor. Costs ~0.7 KB gzipped. `README.md` now also states what the pin does *not* - cover — the module-scope `globalThis` reads (ES2020) and the JSR package, which ships - `src/` rather than the bundle — and the one option (`weakParentLink`) that deliberately - reaches past the floor. +- **The supported floor is now ES2020 / Node.js 14+, on every channel, and checked + rather than claimed.** The build pins `target: 'es2020'`, the sources are written + to the same level, and `README.md` lists what sets it. No engine loses support: + with no explicit target esbuild had been emitting `esnext`, so every bundle + released so far carried ES2022 class static blocks and this one is strictly more + portable than any of them. The previous claim of ES2015 was never achievable, + because `globalThis` is read at module scope and no build target can lower a + runtime global. +- Two things that used to push the real floor above the documented one are gone. + The last two `??=` operators (ES2021) are rewritten, so the JSR package — which + publishes `src/`, not the bundle — is no longer a level stricter than npm. And + bootstrap timing no longer reaches for `performance` unguarded: that global only + arrived in Node.js 16, which had quietly made 16 the true floor for a diagnostic + measurement. It now falls back to `Date.now()` where `performance` is absent. ## 2.4.0 - 2026-06-30 ### Added diff --git a/README.md b/README.md index c799aec..6cb6601 100644 --- a/README.md +++ b/README.md @@ -45,23 +45,29 @@ The skill lives at [skills/illuma-core/](skills/illuma-core/) and is installed i ## Compatibility -Compatible with virtually anything supporting ES2015+ (ES6+). -Practically the library is compatible with Node.js (v14+), Bun, Deno and all modern browsers. -For older environments, consider using a transpiler or provide polyfills as needed. - -The npm bundle pins its **syntax** to that floor by the build (`target: 'es2015'`). -Two things it does not cover: - -- The library reads `globalThis` at module scope, which is ES2020. Older engines - need a `globalThis` polyfill — a transpiler alone will not do. -- The JSR package publishes `src/`, not the bundle, so the syntax pin does not - apply there; JSR consumers compile the sources themselves. - -One container option reaches past the floor deliberately. `weakParentLink` needs -`WeakRef` and `FinalizationRegistry` (ES2021 — Node.js 14.6+, Chrome 84+, -Firefox 79+, Safari 14.1+). Where either is missing the option logs a warning -once and falls back to the default strong parent link; nothing else in the -library requires them. +**ES2020+.** In practice: Node.js 14+, Bun, Deno, and browsers from early 2020 +onwards (Chrome 80+, Firefox 74+, Safari 13.1+). + +That is a checked floor rather than an aspiration. The npm bundle is pinned to it +(`target: 'es2020'`), and the sources are written to it too, so the JSR package — +which publishes `src/` rather than the bundle — asks no more of you than npm does. + +| Requirement | Level | Why | +| --- | --- | --- | +| `globalThis` | ES2020 | Read at module scope, to key shared state by symbol so two copies of the library interoperate | +| Optional chaining, `??` | ES2020 | Throughout | +| `WeakRef`, `FinalizationRegistry` | ES2021 | Only for `weakParentLink`, and only when you opt in | + +Two notes: + +- **`globalThis` is a runtime global, not syntax.** It is the one thing a + transpiler cannot supply for you, so an engine older than ES2020 needs a + polyfill for it in addition to compiling the bundle down. That configuration is + not tested here. +- **`weakParentLink` is the only thing that reaches past the floor.** Where + `WeakRef` or `FinalizationRegistry` is missing it logs a warning once and falls + back to the default strong parent link, so it degrades instead of breaking. + Nothing else in the library touches either. ## Quick start diff --git a/src/lib/container/container.ts b/src/lib/container/container.ts index c79b2aa..8f586ae 100644 --- a/src/lib/container/container.ts +++ b/src/lib/container/container.ts @@ -21,6 +21,7 @@ import type { ProtoNode } from "../provider/proto"; import type { UpstreamGetter } from "../provider/resolver"; import type { TreeNode } from "../provider/tree-node"; import type { Ctor, iNodeProvider, Provider, Token } from "../provider/types"; +import { now } from "../utils/clock"; import { Injector, InjectorImpl } from "../utils/injector"; import { LifecycleRef, LifecycleRefImpl } from "./lifecycle"; import type { iContainerOptions, iDIContainer } from "./types"; @@ -42,12 +43,13 @@ const PARENT_LINK_REGISTRY: FinalizationRegistry void>> | null = }); /** - * `WeakRef` and `FinalizationRegistry` are ES2021, while the rest of the library - * targets ES2015. Both are required: a `WeakRef` without the registry would stop - * retaining children but would never prune their registrations, quietly trading - * one accumulation for another. An engine missing either keeps the strong link - * rather than failing to build a container at all — the option is an - * optimisation, not a correctness requirement. + * `WeakRef` and `FinalizationRegistry` are ES2021, one level above the library's + * ES2020 floor, and the only thing in it that reaches past that floor. Both are + * required: a `WeakRef` without the registry would stop retaining children but + * would never prune their registrations, quietly trading one accumulation for + * another. An engine missing either keeps the strong link rather than failing to + * build a container at all — the option is an optimisation, not a correctness + * requirement. */ const WEAK_PARENT_LINK_SUPPORTED = PARENT_LINK_REGISTRY !== null; @@ -320,7 +322,7 @@ export class NodeContainer extends Illuma implements iDIContainer { if (!this._parent.bootstrapped) throw InjectionError.parentNotBootstrapped(); } - const start = performance.now(); + const start = now(); // Snapshot providers and lifecycle hooks so a build that throws rolls back // to the pre-bootstrap state instead of leaving cleared maps and stray @@ -352,7 +354,7 @@ export class NodeContainer extends Illuma implements iDIContainer { this._unsubParentBootstrap?.(); this._unsubParentBootstrap = undefined; - const end = performance.now(); + const end = now(); const duration = end - start; if (this._opts?.measurePerformance) { Illuma.logger.log(`[Illuma] 🚀 Bootstrapped in ${duration.toFixed(2)} ms`); diff --git a/src/lib/context/context.ts b/src/lib/context/context.ts index 833db8a..1250858 100644 --- a/src/lib/context/context.ts +++ b/src/lib/context/context.ts @@ -36,7 +36,9 @@ if (!contextGlobal[INJECTION_CONTEXT_STATE_KEY]) { const injectionContextState = contextGlobal[INJECTION_CONTEXT_STATE_KEY]; // Backfill if state was created by an older version of @illuma/core sharing // the same globalThis (npm + jsr, dual-installs, etc.) -injectionContextState.stack ??= []; +if (injectionContextState.stack === undefined || injectionContextState.stack === null) { + injectionContextState.stack = []; +} /** * Internal context manager for tracking dependency injections during factory execution. diff --git a/src/lib/global/global.ts b/src/lib/global/global.ts index 75664d5..64d2508 100644 --- a/src/lib/global/global.ts +++ b/src/lib/global/global.ts @@ -55,7 +55,9 @@ if (!illumaGlobal[ILLUMA_STATE_KEY]) { const illumaState = illumaGlobal[ILLUMA_STATE_KEY]; // Backfill if state was created by an older version of @illuma/core sharing // the same globalThis (npm + jsr, dual-installs, etc.) -illumaState.logger ??= defaultLogger; +if (illumaState.logger === undefined || illumaState.logger === null) { + illumaState.logger = defaultLogger; +} /** * Global plugin container for managing core plugins such as diagnostics and context scanners. diff --git a/src/lib/plugins/middlewares/diagnostics.middleware.ts b/src/lib/plugins/middlewares/diagnostics.middleware.ts index b094b21..cdeb289 100644 --- a/src/lib/plugins/middlewares/diagnostics.middleware.ts +++ b/src/lib/plugins/middlewares/diagnostics.middleware.ts @@ -1,4 +1,5 @@ import { Illuma } from "../../global"; +import { now } from "../../utils/clock"; import type { iMiddleware } from "./types"; export const performanceDiagnostics: iMiddleware = (params, next) => { @@ -6,9 +7,9 @@ export const performanceDiagnostics: iMiddleware = (params, next) => { return next(params); } - const start = performance.now(); + const start = now(); const instance = next(params); - const end = performance.now(); + const end = now(); const duration = end - start; Illuma.logger.log(`Instantiated ${params.token.name} in ${duration.toFixed(2)} ms`); diff --git a/src/lib/utils/clock.spec.ts b/src/lib/utils/clock.spec.ts new file mode 100644 index 0000000..c2a96d0 --- /dev/null +++ b/src/lib/utils/clock.spec.ts @@ -0,0 +1,46 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { NodeContainer } from "../container/container"; +import { now } from "./clock"; + +describe("now()", () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.resetModules(); + }); + + it("reports a time", () => { + expect(typeof now()).toBe("number"); + expect(now()).toBeGreaterThan(0); + }); + + it("falls back to Date.now() on an engine without `performance`", async () => { + vi.stubGlobal("performance", undefined); + vi.resetModules(); + + const { now: fresh } = await import("./clock"); + + expect(typeof fresh()).toBe("number"); + expect(fresh()).toBeGreaterThan(0); + }); + + it("lets a container bootstrap without the `performance` global", async () => { + vi.stubGlobal("performance", undefined); + vi.resetModules(); + + const { NodeContainer: Fresh } = await import("../container/container"); + + const container = new Fresh({ measurePerformance: true }); + expect(() => container.bootstrap()).not.toThrow(); + expect(container.bootstrapped).toBe(true); + }); + + it("still uses `performance` when the engine provides it", () => { + const spy = vi.fn(() => 123.5); + vi.stubGlobal("performance", { now: spy }); + + const container = new NodeContainer(); + container.bootstrap(); + + expect(spy).toHaveBeenCalled(); + }); +}); diff --git a/src/lib/utils/clock.ts b/src/lib/utils/clock.ts new file mode 100644 index 0000000..a021eeb --- /dev/null +++ b/src/lib/utils/clock.ts @@ -0,0 +1,17 @@ +/** + * @internal + * Monotonic-ish timestamp in milliseconds, used only to measure durations. + * + * `performance` is a host global rather than an ECMAScript one: it did not + * become global in Node.js until v16. Reaching for it unguarded would put the + * library's floor above its own ES2020 syntax for no reason other than + * diagnostics timing, so fall back to `Date.now()` where it is absent — coarser, + * but only ever fed into a reported duration. + * + * Deliberately not re-exported from `./index`: this is not public API. + */ +export function now(): number { + return typeof performance !== "undefined" && typeof performance.now === "function" + ? performance.now() + : Date.now(); +} diff --git a/tsup.config.ts b/tsup.config.ts index 2b73818..26e1c91 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -3,10 +3,11 @@ import { defineConfig } from 'tsup'; export default defineConfig({ entry: ['src/index.ts', 'src/testkit.ts', "src/plugins.ts"], format: ['cjs', 'esm'], - // Pinned to the floor the README advertises. Without it esbuild emits - // `esnext`, which shipped ES2022 class static blocks and ES2021 logical - // assignment into a bundle documented as ES2015-compatible. - target: 'es2015', + // Pinned to the floor the library actually has: it reads `globalThis` at + // module scope, so no lower target can make it run anywhere lower. Without an + // explicit target esbuild emits `esnext`, which shipped ES2022 class static + // blocks into a bundle documented as far more portable than that. + target: 'es2020', dts: true, splitting: false, sourcemap: true, From 6822da0743d68c0c05d4c9691c4b921a85a0e366 Mon Sep 17 00:00:00 2001 From: bebrasmell Date: Sun, 16 Aug 2026 02:39:20 +0300 Subject: [PATCH 2/2] fix: honour teardown registered while a factory is being scanned MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Discovering a factory's dependencies means running it, and that dry run executes the constructor body for real. Anything it sets up outside the container survives the throwaway instance, but `LifecycleRef` resolved to the shape-shifter during the scan, so `beforeDestroy` silently registered nothing and even correct code could not undo it. Measured: a constructor that registers one teardown hook is called twice and the hook runs once. A service that subscribes to a module-scoped source therefore leaks exactly one subscription per provider, for good, whatever the author writes. The scan now hands out a scratch lifecycle instead — every other token stays a shape-shifter — and tears it down as the scan ends, because the instance that registered those hooks is discarded at that same moment and nothing will ever be able to run them later. End to end against @illuma/signals: a service subscribing to a module-scoped signal used to leave 2 listeners while alive and 1 after destroy; it is now 1 and 0. The trade-off, stated plainly: teardown registered during a scan now runs at `provide()` time. That is right for a constructor tearing down what it itself set up, and early for one registering the teardown of a shared resource it did not create. The asymmetry it removes — setup running twice, teardown once — is the defect itself. Also resolves `LifecycleRef.destroyed` to `false` during a scan rather than to a truthy shape-shifter, which is the truthful answer: the container being scanned is not destroyed. --- src/lib/container/tests/audit-fixes.spec.ts | 5 + src/lib/context/context.ts | 27 ++++- src/lib/context/scan-teardown.spec.ts | 127 ++++++++++++++++++++ 3 files changed, 158 insertions(+), 1 deletion(-) create mode 100644 src/lib/context/scan-teardown.spec.ts diff --git a/src/lib/container/tests/audit-fixes.spec.ts b/src/lib/container/tests/audit-fixes.spec.ts index 161b837..ed8ca2a 100644 --- a/src/lib/container/tests/audit-fixes.spec.ts +++ b/src/lib/container/tests/audit-fixes.spec.ts @@ -691,6 +691,11 @@ describe("bootstrap() is atomic (rollback on a failed build)", () => { }), ); + // `provide` scans the factory by running it, and the throwaway instance that + // scan builds tears itself down when the scan ends. That is not what this + // test is about; only what happens from here on is. + destroyRuns = 0; + expect(() => c.bootstrap()).toThrow("boom"); boom = false; c.bootstrap(); diff --git a/src/lib/context/context.ts b/src/lib/context/context.ts index 1250858..434c87f 100644 --- a/src/lib/context/context.ts +++ b/src/lib/context/context.ts @@ -1,4 +1,6 @@ +import { SHAPE_SHIFTER } from "../api/proxy"; import type { InjectorFn } from "../api/types"; +import { LifecycleRef, LifecycleRefImpl } from "../container/lifecycle"; import { InjectionError } from "../errors"; import { Illuma } from "../global/global"; import type { iContextScanner } from "../plugins/context/types"; @@ -116,7 +118,18 @@ abstract class InjectionContextBase { */ public static scanInto(factory: any, target: Set>): void { if (typeof factory !== "function") return; - InjectionContextBase.open(); + + // The dry run executes the factory body for real, so a constructor that + // reaches outside the container — subscribing to something module-scoped, + // say — leaves that subscription behind. Resolving `LifecycleRef` to the + // shape-shifter meant the documented `beforeDestroy` escape hatch silently + // registered nothing, so even correct code could not undo it. Hand out a + // scratch lifecycle instead; every other token stays a shape-shifter. + const scratch = new LifecycleRefImpl(); + + InjectionContextBase.open((token) => + token === LifecycleRef ? scratch : SHAPE_SHIFTER, + ); const baseDepth = injectionContextState.stack.length; // close() must run on every path: a throwing context scanner would @@ -151,6 +164,18 @@ abstract class InjectionContextBase { InjectionContextBase.close(); } InjectionContextBase.close(); + + // The instance this scan built is discarded the moment it ends, so its + // teardown has to run now: nothing will ever be able to run it later. + // Closed first, so a hook that injects sees the outer context restored. + try { + scratch.destroy(); + } catch (err) { + Illuma.logger.error( + "[Illuma] A teardown hook registered during a dependency scan threw:", + err, + ); + } } } diff --git a/src/lib/context/scan-teardown.spec.ts b/src/lib/context/scan-teardown.spec.ts new file mode 100644 index 0000000..9624772 --- /dev/null +++ b/src/lib/context/scan-teardown.spec.ts @@ -0,0 +1,127 @@ +import { describe, expect, it, vi } from "vitest"; +import { makeInjectable } from "../api/decorator"; +import { nodeInject } from "../api/injection"; +import { NodeToken } from "../api/token"; +import { NodeContainer } from "../container/container"; +import { LifecycleRef } from "../container/lifecycle"; +import { Illuma } from "../global/global"; + +/** + * Discovering a factory's dependencies means running it, and a dry run executes + * the body for real. Whatever it sets up outside the container — a subscription + * to a module-scoped source, a timer, a listener — outlives the throwaway + * instance, so `beforeDestroy` has to be honoured during the scan as well. + * It used to resolve to the shape-shifter, which swallowed the registration and + * left correct code leaking one setup per provider, permanently. + */ +describe("teardown registered while a factory is being scanned", () => { + it("runs for every construction, including the scan's own", () => { + let constructed = 0; + let torn = 0; + + class _Subscriber { + private readonly _lifecycle = nodeInject(LifecycleRef); + + constructor() { + constructed++; + this._lifecycle.beforeDestroy(() => { + torn++; + }); + } + } + const Subscriber = makeInjectable(_Subscriber); + + const container = new NodeContainer({ instant: false }); + container.provide([Subscriber]); + container.bootstrap(); + container.get(Subscriber); + + expect(constructed).toBeGreaterThan(1); + expect(torn).toBe(constructed - 1); + + container.destroy(); + expect(torn).toBe(constructed); + }); + + it("does not wait for the container to be built, let alone destroyed", () => { + let torn = 0; + + class _Eager { + private readonly _lifecycle = nodeInject(LifecycleRef); + + constructor() { + this._lifecycle.beforeDestroy(() => { + torn++; + }); + } + } + const Eager = makeInjectable(_Eager); + + const container = new NodeContainer({ instant: false }); + container.provide([Eager]); + + // `provide` is what scans, so the scan's own setup is already undone here. + expect(torn).toBe(1); + + container.destroy(); + }); + + it("survives a teardown hook that throws, and says so", () => { + const error = vi.fn(); + Illuma.setLogger({ log: vi.fn(), warn: vi.fn(), error }); + + const TOKEN = new NodeToken("scan-teardown-throws"); + + const container = new NodeContainer({ instant: false }); + expect(() => + container.provide([ + { + provide: TOKEN, + factory: () => { + nodeInject(LifecycleRef).beforeDestroy(() => { + throw new Error("teardown boom"); + }); + return "value"; + }, + }, + ]), + ).not.toThrow(); + + container.bootstrap(); + expect(container.get(TOKEN)).toBe("value"); + + expect(error).toHaveBeenCalledWith( + expect.stringContaining("teardown hook registered during a dependency scan"), + expect.any(Error), + ); + + // The real instance registered the same throwing hook, and a container's + // destroy surfaces it rather than swallowing it. Only the scan's copy is + // logged, because a scan must never break `provide`. + expect(() => container.destroy()).toThrow(/teardown boom/); + Illuma.setLogger(null); + }); + + it("leaves every other token a shape-shifter during the scan", () => { + const OTHER = new NodeToken<{ ping: () => string }>("scan-other"); + let sawLifecycle: unknown = null; + + class _Reader { + constructor() { + sawLifecycle = nodeInject(LifecycleRef); + // A shape-shifter answers anything without throwing, which is what lets + // the dry run reach the end of a constructor it cannot really satisfy. + nodeInject(OTHER).ping(); + } + } + const Reader = makeInjectable(_Reader); + + const container = new NodeContainer({ instant: false }); + expect(() => container.provide([Reader])).not.toThrow(); + expect(typeof (sawLifecycle as { beforeDestroy: unknown }).beforeDestroy).toBe( + "function", + ); + + container.destroy(); + }); +});