From 5d000064880729546159d202e5f6f143e8a9766c Mon Sep 17 00:00:00 2001 From: bebrasmell Date: Sat, 15 Aug 2026 23:34:09 +0300 Subject: [PATCH] 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,