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
5 changes: 5 additions & 0 deletions src/lib/container/tests/audit-fixes.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
31 changes: 29 additions & 2 deletions src/lib/context/context.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -114,7 +118,18 @@ abstract class InjectionContextBase {
*/
public static scanInto(factory: any, target: Set<iInjectionNode<any>>): 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
Expand Down Expand Up @@ -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,
);
}
}
}

Expand Down
127 changes: 127 additions & 0 deletions src/lib/context/scan-teardown.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string>("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();
});
});
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();
});
});
Loading