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/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 833db8a..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"; @@ -36,7 +38,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. @@ -114,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 @@ -149,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(); + }); +}); 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,