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,