Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 14 additions & 7 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
40 changes: 23 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
18 changes: 10 additions & 8 deletions src/lib/container/container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -42,12 +43,13 @@ const PARENT_LINK_REGISTRY: FinalizationRegistry<Array<() => 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;

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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`);
Expand Down
4 changes: 3 additions & 1 deletion src/lib/context/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 3 additions & 1 deletion src/lib/global/global.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 3 additions & 2 deletions src/lib/plugins/middlewares/diagnostics.middleware.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
import { Illuma } from "../../global";
import { now } from "../../utils/clock";
import type { iMiddleware } from "./types";

export const performanceDiagnostics: iMiddleware = (params, next) => {
if (!params.deps.size) {
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`);
Expand Down
46 changes: 46 additions & 0 deletions src/lib/utils/clock.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
17 changes: 17 additions & 0 deletions src/lib/utils/clock.ts
Original file line number Diff line number Diff line change
@@ -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();
}
9 changes: 5 additions & 4 deletions tsup.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down