Skip to content
Open
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
16 changes: 16 additions & 0 deletions packages/tiny-di/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# tiny-di

Extremely simple dependency injection library.
See `./src/index.test.ts` for the basic usage.

The idea is similar to [tsyringe](https://github.com/microsoft/tsyringe)'s constructor injection
but this library has following benefits:

- extreme simplicity (~ 100 LOC)
- it doesn't rely on typescript decorator metadata and thus doesn't require special setup for vite/esbuild based proejcts.
- it provides a dependency graph to implement, for example, own per-module setup/teardown hooks very easily.

# ideas

- https://github.com/microsoft/tsyringe
- https://docs.nestjs.com/fundamentals/lifecycle-events
37 changes: 37 additions & 0 deletions packages/tiny-di/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
{
"name": "@hiogawa/tiny-di",
"version": "0.0.0",
"type": "module",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.js",
"require": "./dist/index.cjs",
"types": "./dist/index.d.ts"
}
},
"files": [
"dist"
],
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/hi-ogawa/js-utils",
"directory": "packages/tiny-di"
},
"typedoc": {
"displayName": "@hiogawa/tiny-di",
"readmeFile": "./README.md",
"entryPoint": "./src/index.ts"
},
"scripts": {
"build": "tsup",
"test": "vitest",
"release": "pnpm publish --no-git-checks --access public"
},
"devDependencies": {
"@hiogawa/utils": "workspace:*"
}
}
247 changes: 247 additions & 0 deletions packages/tiny-di/src/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,247 @@
import { wrapError } from "@hiogawa/utils";
import { beforeAll, describe, expect, it } from "vitest";
import { TinyDi } from ".";

beforeAll(() => {
// pretty print class
expect.addSnapshotSerializer({
test(val) {
return typeof val === "function" && val.toString().startsWith("class ");
},
serialize(val, _config, _indentation, _depth, _refs, _printer) {
return `[class ${val.name}]`;
},
});
});

describe(TinyDi, () => {
it("basic", async () => {
const tinyDi = new TinyDi();
const logs: any[] = [];

class App {
config = tinyDi.resolve(Config);
database = tinyDi.resolve(Database);

constructor() {
logs.push("App.constructor");
}

async init() {
logs.push("App.init");
}

async deinit() {
logs.push("App.deinit");
}

async doSomething() {
logs.push("App.doSomething");
}
}

class Database {
config = tinyDi.resolve(Config);

constructor() {
logs.push("Database.constructor");
}

async init() {
logs.push("Database.init");
}

async deinit() {
logs.push("Database.deinit");
}
}

class Config {
constructor() {
logs.push("Config.constructor");
}

async init() {
logs.push("Config.init");
}
}

// resolve top module
const app = tinyDi.resolve(App);

// check internal
expect(tinyDi.stack).toMatchInlineSnapshot("[]");
expect(tinyDi.deps).toMatchInlineSnapshot(`
[
[
[class App],
[class Config],
],
[
[class App],
[class Database],
],
[
[class Database],
[class Config],
],
]
`);
expect(tinyDi.instances).toMatchInlineSnapshot(`
Map {
[class Config] => Config {},
[class Database] => Database {
"config": Config {},
},
[class App] => App {
"config": Config {},
"database": Database {
"config": Config {},
},
},
}
`);
expect(tinyDi.sortDeps()).toMatchInlineSnapshot(`
[
Config {},
Database {
"config": Config {},
},
App {
"config": Config {},
"database": Database {
"config": Config {},
},
},
]
`);
expect(logs).toMatchInlineSnapshot(`
[
"Config.constructor",
"Database.constructor",
"App.constructor",
]
`);

// check init/deinit calls
for (const mod of tinyDi.sortDeps()) {
await (mod as any).init?.();
}
expect(logs).toMatchInlineSnapshot(`
[
"Config.constructor",
"Database.constructor",
"App.constructor",
"Config.init",
"Database.init",
"App.init",
]
`);

app.doSomething();
expect(logs).toMatchInlineSnapshot(`
[
"Config.constructor",
"Database.constructor",
"App.constructor",
"Config.init",
"Database.init",
"App.init",
"App.doSomething",
]
`);

for (const mod of tinyDi.sortDeps().reverse()) {
await (mod as any).deinit?.();
}
expect(logs).toMatchInlineSnapshot(`
[
"Config.constructor",
"Database.constructor",
"App.constructor",
"Config.init",
"Database.init",
"App.init",
"App.doSomething",
"App.deinit",
"Database.deinit",
]
`);
});

it("error-cyclic-deps", () => {
const tinyDi = new TinyDi();

class X {
y = tinyDi.resolve(Y);
}

class Y {
z = tinyDi.resolve(Z);
}

class Z {
x = tinyDi.resolve(X);
}

const result = wrapError(() => tinyDi.resolve(X));
expect(result).toMatchInlineSnapshot(`
{
"ok": false,
"value": [Error: 'TinyDi.resolve' detected cyclic dependency],
}
`);
expect((result.value as any).cause).toMatchInlineSnapshot("[class X]");
});

it("mocking", () => {
const tinyDi = new TinyDi();
const logs: unknown[] = [];

class X {
y = tinyDi.resolve(Y);
z = tinyDi.resolve(Z);

hey() {
logs.push("x.hoy");
this.y.hee();
this.z.hoy();
}
}

class Y {
z = tinyDi.resolve(Z);

hee() {
logs.push("y.hoy");
this.z.hoy();
}
}

class Z {
hoy() {
logs.push("z.hoy");
}
}

const zMock: Z = {
hoy() {
logs.push("zMock.hoy");
},
};

tinyDi.instances.set(Z, zMock);

const x = tinyDi.resolve(X);
x.hey();

expect(logs).toMatchInlineSnapshot(`
[
"x.hoy",
"y.hoy",
"zMock.hoy",
"zMock.hoy",
]
`);
});
});
94 changes: 94 additions & 0 deletions packages/tiny-di/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { range, tinyassert } from "@hiogawa/utils";

type InstanceKey = new () => unknown;
type Instance = unknown;

export class TinyDi {
instances = new Map<InstanceKey, Instance>();

// manage `resolve` call stack to create module dependency graph
// which is necessary for init/deinit ordering
stack: InstanceKey[] = [];
deps: [InstanceKey, InstanceKey][] = [];

resolve<T>(ctor: new () => T): T {
// detect cycle
if (this.stack.includes(ctor)) {
throw new Error("'TinyDi.resolve' detected cyclic dependency", {
cause: ctor,
});
}

// add dependency
const parent = this.stack.at(-1);
if (parent) {
this.deps.push([parent, ctor]);
}

// skip already instantiated
if (this.instances.has(ctor)) {
return this.instances.get(ctor) as T;
}

// instantiate within new stack
this.stack.push(ctor);
const instance = new ctor();
this.instances.set(ctor, instance);
this.stack.pop();

return instance as T;
}

sortDeps(): Instance[] {
const keys = [...this.instances.keys()];
const sortedKeys = topologicalSort(keys, this.deps);
return sortedKeys.map((k) => this.instances.get(k)!);
}
}

//
// topological sort
//

function topologicalSort<T>(verts: T[], edges: [T, T][]): T[] {
const indexMap = new Map(verts.map((k, i) => [k, i]));

const n = verts.length;
const adj: number[][] = range(n).map(() => []);
for (const [k1, k2] of edges) {
const i1 = indexMap.get(k1);
const i2 = indexMap.get(k2);
tinyassert(typeof i1 === "number");
tinyassert(typeof i2 === "number");
adj[i1].push(i2);
}

const sortedIndices = topologicalSortInner(adj);
return sortedIndices.map((i) => verts[i]);
}

function topologicalSortInner(adj: number[][]): number[] {
// dfs + sort by "exit" time

const n = adj.length;
const visited = range(n).map(() => false);
const result: number[] = [];

function dfs(v: number) {
visited[v] = true;
for (const u of adj[v]) {
if (!visited[u]) {
dfs(u);
}
}
result.push(v);
}

for (const v of range(n)) {
if (!visited[v]) {
dfs(v);
}
}

return result;
}
Loading