diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..42061c0 --- /dev/null +++ b/.prettierignore @@ -0,0 +1 @@ +README.md \ No newline at end of file diff --git a/README.md b/README.md index 6409392..750d8f0 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,6 @@ import { Container } from "nwire" const context = Container - // .register("prisma", new PrismaClient()) .register("redis", new Redis()) .group("services", (container: Container) => @@ -36,7 +35,201 @@ class UsersService { ## API -(Coming soon) +`nwire` has two high-level concepts: the `Container` and the `Context`. The `Container` allows you to compose a strongly-typed `Context`, and the `Context` is the proxy that resolves dependencies for you lazily. The `Context` lives within the `Container` (as a closure) and interacts with the registration of your dependencies behind the scenes. + +When using the library you likely won't have to think about these semantics, but we figured it's important to understand how it works under the hood. + +### `Container` + +The `Container` class is the main entrypoint for `nwire`. It provides a fluent API for registering +dependencies and creating `Context`s from them. + +#### Creating a `Container` + +You can use `new Container()` to create a container: + +```tsx +const container = new Container() + +container.register("prisma", new PrismaClient()) +container.register("redis", new Redis()) + +const context = container.context() +``` + +In a majority of cases you'll be creating a single container, registering a bunch of dependencies, and then grabbing the generated `Context`. For this reason we've included static methods that return a new container and are chainable, so you can write your code like this instead: + +```tsx +const context = Container + .register("prisma", () => new PrismaClient()) + .register("redis", () => new Redis()) + .context() +``` + +The choice is yours: you can keep the `Container` around in case you want to register more dependencies later, or you can simply grab the `Context`. + +#### `Container.register` + +Registers a dependency with the container. + +```tsx +Container.register("prisma", () => new PrismaClient()) // => Container +``` + +The second argument is a function that returns the dependency. + +Optionally you have access to the fully resolved `Context` at reolution time, in case you wish to do something with it: + +```tsx +Container.register("users", (context) => new UsersService(context)) // => Container +``` + +> ⚠️ Due to `nwire`'s design, the `Context` that's sent to the dependency when it's resolved will always be the fully registered one. + +This may not match with what the compiler sees (as TypeScript is only able to see what's been registered up until the point you called `register`). For instance, the following results in a compiler error: + +```tsx +const context = Container + .register("tasksCreator", (context) => new TasksCreator(context)) + // Argument of type '{}' is not assignable to parameter of type 'AppContext'. + // Type '{}' is missing the following properties from type 'AppContext': tasks, tasksCreator + .register("tasks", (context) => new SQLiteTaskStore(context)) +``` + +One way to get around this is to explicitly let the `Container` know what kind of context you'll be building using the `build` method: + +```tsx +const context = Container.build + .register("tasksCreator", (context) => new TasksCreator(context)) + .register("tasks", (context) => new SQLiteTaskStore(context)) +``` + +However, we've included a method to avoid this boilerplate altogether: + +#### `Container.instance` + +Your goal will often be to simply pass in the fully resolved `Context` to classes. For this reason, `nwire` provides a function that will create a new instance of your class with a fully resolved `Context` whenever the dependency is resolved: + +```tsx +Container.instance("users", UsersService) // => Container +``` + +When the `users` dependency is used, `nwire` will create a new `UsersService` class with the resolved `Context` as the first parameter: + +```tsx +const user = await context.users.findOne("123") + +// Equivalent without nwire, sans singleton setup: +const users = new UsersService(container.context()) +const user = await users.findOne("123") +``` + +You can also pass in additional arguments to the constructor: + +```tsx +Container.instance("users", UsersService, { cookieSecret: process.env.COOKIE_SECRET }) +``` + +#### `Container.context` + +Creates a new `Context` class. This is the class you're meant to pass around to all of your dependencies. It's reponsible for resolving dependencies: + +```tsx +const context = Container + // ... lots of registrations here + .register("users", () => new UsersService()) + .context() + +const user = await context.users.findOne("123") +// `users` is resolved lazily. +``` + +`nwire` will only resolve dependencies when they're needed. This is an intentional design decision to avoid +having to instantiate the entire `Container`, which is especially useful for tests. + +#### `Container.group` + +Sometimes you'll want to group things together within the `Container`. You could technically do this: + +```tsx +const context = Container + // + .register("services", (context) => ({ + users: new UsersService(context), + tasks: new TasksService(context), + })) + .context() +``` + +And now all services will be nested under `services`: + +```tsx +context.services.users.findOne("123") +``` + +However, this has a big issue: once you access `service` for the first time you make an instance of every single class all at once. + +`nwire` provides a solution for this: `Container.group`. `Container.group` creates a nested `Container` that will only resolve when you access properties within it. The nested container will be passed as the first argument to the function you pass in: + +```tsx +const context = Container + // + .group("services", (services: Container) => + services + // + .singleton("users", UsersService) + .singleton("tasks", TasksService) + ) + .context() + +type AppContext = typeof context +``` + +```tsx +type AppContext = { + services: { + users: UsersService + tasks: TasksService + } +} +``` + +```tsx +// Two contexts are used for resolution here: `context` and `services` +context.services.users.findOne("123") +``` + +### Lifetime of a dependency + +`nwire` will resolve dependencies for you lazily and keep an instance of the dependency as a singleton by default. + +```tsx +container.register("randomizer", () => new RandomizerClass()) +container.resolve("randomizer").id // => 353 +container.resolve("randomizer").id // => 353 +``` + +Unless unregistered, the dependency will be kept in memory for the lifetime of the `Container`. + +However, you can create transient dependencies by specifying the `{ transient: true }` option: + +```tsx +container.register("randomizer", () => new RandomizerClass(), { + transient: true, +}) +container.resolve("randomizer").id // => 964 +container.resolve("randomizer").id // => 248 +``` + +`nwire` will invoke this function when the `prisma` dependency is either resolved through the `Container` using `Container.resolve` or through the `Context` using `context.prisma`. + +There is currently no API for transient `instance` registrations, so if you do want to create a unique instance on every call you'll need to provide an initial context: + +```tsx +const context = Container.build + .register("users", (context) => new UsersService(context), { transient: true })) + .context() +``` ## What is dependency injection? diff --git a/packages/example-fastify/src/AppContext.ts b/packages/example-fastify/src/AppContext.ts new file mode 100644 index 0000000..144c3e3 --- /dev/null +++ b/packages/example-fastify/src/AppContext.ts @@ -0,0 +1,25 @@ +import { Container } from "nwire" +import { TasksCreator } from "./TasksCreator" +import { createDatabase } from "./createDatabase" +import { SQLiteTaskStore } from "./SQLiteTaskStore" +import { Injected } from "nwire" + +export type AppContext = { + db: Awaited> + tasksCreator: TasksCreator + tasks: SQLiteTaskStore +} + +export async function createContext() { + const database = await createDatabase() + + const context = Container.build() + .register("db", () => database) + .instance("tasksCreator", TasksCreator) + .instance("tasks", SQLiteTaskStore) + .context() + + return context +} + +export class Service extends Injected {} diff --git a/packages/example-fastify/src/SQLiteTaskStore.ts b/packages/example-fastify/src/SQLiteTaskStore.ts index 5f49580..01c2c9c 100644 --- a/packages/example-fastify/src/SQLiteTaskStore.ts +++ b/packages/example-fastify/src/SQLiteTaskStore.ts @@ -1,54 +1,20 @@ -import { Container } from "nwire" import { TaskStore } from "./TaskStore" -import * as sqlite from "sqlite" import { Task } from "./Task" -import * as sqlite3 from "sqlite3" - -export class SQLiteTaskStore implements TaskStore { - db!: sqlite.Database - - constructor() { - ;(async () => { - this.db = await sqlite.open({ - filename: ":memory:", - driver: sqlite3.Database, - }) - - this.db.run( - `CREATE TABLE IF NOT EXISTS tasks ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - title TEXT NOT NULL, - completed INTEGER NOT NULL DEFAULT 0 - )` - ) - - this.db.run( - `CREATE TABLE IF NOT EXISTS users ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL - )` - ) - - this.db.run( - `CREATE TABLE IF NOT EXISTS users_tasks ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL, - task_id INTEGER NOT NULL, - FOREIGN KEY(user_id) REFERENCES users(id), - FOREIGN KEY(task_id) REFERENCES tasks(id) - )` - ) - })() - } +import { Service } from "./AppContext" +export class SQLiteTaskStore extends Service implements TaskStore { async get(id: number): Promise { - return (await this.db.get(`SELECT * FROM tasks WHERE id = ?`, [id])) ?? null + return ( + (await this.context.db.get(`SELECT * FROM tasks WHERE id = ?`, [id])) ?? + null + ) } async save(title: string): Promise { - const insert = await this.db.run(`INSERT INTO tasks (title) VALUES (?);`, [ - title, - ]) + const insert = await this.context.db.run( + `INSERT INTO tasks (title) VALUES (?);`, + [title] + ) if (!insert.lastID) throw new Error("unable to save task") @@ -56,6 +22,6 @@ export class SQLiteTaskStore implements TaskStore { } async delete(id: number): Promise { - await this.db.run(`DELETE FROM tasks WHERE id = ?`, [id]) + await this.context.db.run(`DELETE FROM tasks WHERE id = ?`, [id]) } } diff --git a/packages/example-fastify/src/TasksCreator.ts b/packages/example-fastify/src/TasksCreator.ts index 8acc7e3..a441ef0 100644 --- a/packages/example-fastify/src/TasksCreator.ts +++ b/packages/example-fastify/src/TasksCreator.ts @@ -1,8 +1,6 @@ -import { AppContext } from "./createServer" - -export class TasksCreator { - constructor(private context: AppContext) {} +import { AppContext, Service } from "./AppContext" +export class TasksCreator extends Service { async createBasicTasks() { await this.context.tasks.save("My first test") await this.context.tasks.save("My second test") diff --git a/packages/example-fastify/src/createDatabase.ts b/packages/example-fastify/src/createDatabase.ts new file mode 100644 index 0000000..0260f73 --- /dev/null +++ b/packages/example-fastify/src/createDatabase.ts @@ -0,0 +1,36 @@ +import * as sqlite from "sqlite" +import * as sqlite3 from "sqlite3" + +export async function createDatabase() { + const db = await sqlite.open({ + filename: ":memory:", + driver: sqlite3.Database, + }) + + await db.run( + `CREATE TABLE IF NOT EXISTS tasks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + completed INTEGER NOT NULL DEFAULT 0 + )` + ) + + await db.run( + `CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL + )` + ) + + await db.run( + `CREATE TABLE IF NOT EXISTS users_tasks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + task_id INTEGER NOT NULL, + FOREIGN KEY(user_id) REFERENCES users(id), + FOREIGN KEY(task_id) REFERENCES tasks(id) + )` + ) + + return db +} diff --git a/packages/example-fastify/src/createServer.test.ts b/packages/example-fastify/src/createServer.test.ts index 3fed646..d7c1ccf 100644 --- a/packages/example-fastify/src/createServer.test.ts +++ b/packages/example-fastify/src/createServer.test.ts @@ -4,6 +4,8 @@ import { SQLiteTaskStore } from "./SQLiteTaskStore" import { Container } from "nwire" import { SuperAgentTest, agent } from "supertest" import { TasksCreator } from "./TasksCreator" +import { AppContext } from "./AppContext" +import { createDatabase } from "./createDatabase" declare module "vitest" { export interface TestContext { @@ -13,13 +15,17 @@ declare module "vitest" { describe("server", function () { beforeEach(async (context) => { - const container = Container + const database = await createDatabase() + + const container = Container.build() // - .singleton("tasks", SQLiteTaskStore) - .singleton("tasksCreator", TasksCreator) + .register("db", () => database) + .instance("tasks", SQLiteTaskStore) + .instance("tasksCreator", TasksCreator) const server = createServer(container.context()) await server.ready() + context.request = agent(server.server) }) diff --git a/packages/example-fastify/src/createServer.ts b/packages/example-fastify/src/createServer.ts index a4a7a1f..935c1f7 100644 --- a/packages/example-fastify/src/createServer.ts +++ b/packages/example-fastify/src/createServer.ts @@ -1,12 +1,6 @@ import Fastify from "fastify" -import { TaskStore } from "./TaskStore" import { Task } from "./Task" -import { TasksCreator } from "./TasksCreator" - -export type AppContext = { - tasks: TaskStore - tasksCreator: TasksCreator -} +import { AppContext } from "./AppContext" export function createServer(context: AppContext) { const server = Fastify() diff --git a/packages/example-fastify/src/index.ts b/packages/example-fastify/src/index.ts index 490e545..10bbe6e 100644 --- a/packages/example-fastify/src/index.ts +++ b/packages/example-fastify/src/index.ts @@ -1,19 +1,25 @@ -import { Container } from "nwire" import { createServer } from "./createServer" -import { SQLiteTaskStore } from "./SQLiteTaskStore" -import { TasksCreator } from "./TasksCreator" +import { createContext } from "./AppContext" -const context = Container.build() - .singleton("tasks", SQLiteTaskStore) - .singleton("tasksCreator", TasksCreator) - .context() +start() -const server = createServer(context) +// Can use top-level `await` in ESM. +async function start() { + try { + const server = createServer(await createContext()) -server.listen({ port: 3000 }, (err, address) => { - if (err) { + server.listen({ port: 3000 }, (err, address) => { + if (err) { + console.error(err) + process.exit(1) + } + + console.info(`Server listening at ${address}`) + }) + + return server + } catch (err) { console.error(err) process.exit(1) } - console.info(`Server listening at ${address}`) -}) +} diff --git a/packages/nwire/dist/Container.d.ts b/packages/nwire/dist/Container.d.ts index 3639385..3dbb8bb 100644 --- a/packages/nwire/dist/Container.d.ts +++ b/packages/nwire/dist/Container.d.ts @@ -1,7 +1,7 @@ -type Context = { +export type Context = { [key: string]: unknown; }; -export type Singleton = { +export type Instance = { new (context: any, ...args: any[]): TValue; }; type Flatten = {} & { @@ -10,17 +10,22 @@ type Flatten = {} & { type MergeContext = Flatten; +type RegistrationOptions = { + transient?: boolean; +}; export declare class Container { + private _registry; private _map; + private _transient; constructor(); static build(): Container; - context(rootContext?: {}): TContext; + context(rootContext?: Context): TContext; group(key: TNewKey, decorator: (container: Container) => Container): Container>; static group(key: TNewKey, decorator: (container: Container) => Container): Container>; - singleton(key: TNewKey, ValueClass: Singleton, ...args: any[]): Container>; - static singleton(key: TNewKey, ValueClass: Singleton, ...args: any[]): Container>; - register(key: TNewKey, value: TValue): Container>; - static register(key: TNewKey, value: TValue): Container>; + instance(key: TNewKey, ValueClass: Instance, ...args: any[]): Container>; + static instance(key: TNewKey, ValueClass: Instance, ...args: any[]): Container>; + register(key: TNewKey, value: (context: TContext) => TValue, { transient }?: RegistrationOptions): Container>; + static register(key: TNewKey, value: (context: Context) => TValue, options?: RegistrationOptions): Container>; unregister(key: TNewKey): Container>; static unregister(key: TNewKey): Container>; resolve(key: keyof TContext): T; diff --git a/packages/nwire/dist/Injected.d.ts b/packages/nwire/dist/Injected.d.ts new file mode 100644 index 0000000..4a52f90 --- /dev/null +++ b/packages/nwire/dist/Injected.d.ts @@ -0,0 +1,5 @@ +import { Context } from "./Container"; +export declare class Injected { + protected context: TContext; + constructor(context: TContext); +} diff --git a/packages/nwire/dist/index.d.ts b/packages/nwire/dist/index.d.ts index 815c894..2151314 100644 --- a/packages/nwire/dist/index.d.ts +++ b/packages/nwire/dist/index.d.ts @@ -1 +1,2 @@ -export { Container, Singleton } from "./Container"; +export { Container, Instance } from "./Container"; +export { Injected } from "./Injected"; diff --git a/packages/nwire/dist/index.esm.js b/packages/nwire/dist/index.esm.js index 71045b0..7f07b14 100644 --- a/packages/nwire/dist/index.esm.js +++ b/packages/nwire/dist/index.esm.js @@ -1,7 +1,11 @@ // src/Container.ts var Container = class _Container { + _registry; _map; + _transient; constructor() { + this._transient = /* @__PURE__ */ new Set(); + this._registry = /* @__PURE__ */ new Map(); this._map = /* @__PURE__ */ new Map(); } static build() { @@ -9,51 +13,75 @@ var Container = class _Container { } context(rootContext = {}) { const handler = { - get: (target, prop) => { - if (this._map.has(prop)) - return this._map.get(prop); - return target[prop]; + get: (target, key) => { + if (rootContext.hasOwnProperty(key)) { + return rootContext[key]; + } else if (this._registry.has(key)) { + return this._registry.get(key); + } else if (this._map.has(key)) { + const value = this._map.get(key); + const instance = value(this.context()); + if (!this._transient.has(key)) + this._registry.set(key, instance); + return instance; + } + return target[key]; } }; const proxy = new Proxy( - { ...rootContext, ...Object.fromEntries(this._map) }, + { ...Object.fromEntries(this._map) }, handler ); return proxy; } // Add a subcontext to a property of this context group(key, decorator) { - this._map.set(key, this.context(decorator(this).context())); + this._map.set(key, () => this.context(decorator(this).context())); return this; } static group(key, decorator) { return _Container.build().group(key, decorator); } - singleton(key, ValueClass, ...args) { - this._map.set(key, new ValueClass(this.context(), ...args)); + instance(key, ValueClass, ...args) { + this._map.set(key, () => new ValueClass(this.context(), ...args)); return this; } - static singleton(key, ValueClass, ...args) { - return _Container.build().singleton(key, ValueClass, ...args); + static instance(key, ValueClass, ...args) { + return _Container.build().instance(key, ValueClass, ...args); } - register(key, value) { - this._map.set(key, value); + register(key, value, { transient } = { transient: false }) { + this._map.set( + key, + () => value(this.context()) + ); + if (transient) + this._transient.add(key); return this; } - static register(key, value) { - return _Container.build().register(key, value); + static register(key, value, options) { + return _Container.build().register(key, value, options); } unregister(key) { this._map.delete(key); + this._registry.delete(key); + this._transient.delete(key); return this; } static unregister(key) { return _Container.build().unregister(key); } resolve(key) { - return this._map.get(key); + return this._map.get(key)?.(this.context()); + } +}; + +// src/Injected.ts +var Injected = class { + constructor(context) { + this.context = context; } }; export { - Container + Container, + Injected }; diff --git a/packages/nwire/dist/index.js b/packages/nwire/dist/index.js index 60f5aee..1f35717 100644 --- a/packages/nwire/dist/index.js +++ b/packages/nwire/dist/index.js @@ -2,8 +2,12 @@ (() => { // src/Container.ts var Container = class _Container { + _registry; _map; + _transient; constructor() { + this._transient = /* @__PURE__ */ new Set(); + this._registry = /* @__PURE__ */ new Map(); this._map = /* @__PURE__ */ new Map(); } static build() { @@ -11,49 +15,72 @@ } context(rootContext = {}) { const handler = { - get: (target, prop) => { - if (this._map.has(prop)) - return this._map.get(prop); - return target[prop]; + get: (target, key) => { + if (rootContext.hasOwnProperty(key)) { + return rootContext[key]; + } else if (this._registry.has(key)) { + return this._registry.get(key); + } else if (this._map.has(key)) { + const value = this._map.get(key); + const instance = value(this.context()); + if (!this._transient.has(key)) + this._registry.set(key, instance); + return instance; + } + return target[key]; } }; const proxy = new Proxy( - { ...rootContext, ...Object.fromEntries(this._map) }, + { ...Object.fromEntries(this._map) }, handler ); return proxy; } // Add a subcontext to a property of this context group(key, decorator) { - this._map.set(key, this.context(decorator(this).context())); + this._map.set(key, () => this.context(decorator(this).context())); return this; } static group(key, decorator) { return _Container.build().group(key, decorator); } - singleton(key, ValueClass, ...args) { - this._map.set(key, new ValueClass(this.context(), ...args)); + instance(key, ValueClass, ...args) { + this._map.set(key, () => new ValueClass(this.context(), ...args)); return this; } - static singleton(key, ValueClass, ...args) { - return _Container.build().singleton(key, ValueClass, ...args); + static instance(key, ValueClass, ...args) { + return _Container.build().instance(key, ValueClass, ...args); } - register(key, value) { - this._map.set(key, value); + register(key, value, { transient } = { transient: false }) { + this._map.set( + key, + () => value(this.context()) + ); + if (transient) + this._transient.add(key); return this; } - static register(key, value) { - return _Container.build().register(key, value); + static register(key, value, options) { + return _Container.build().register(key, value, options); } unregister(key) { this._map.delete(key); + this._registry.delete(key); + this._transient.delete(key); return this; } static unregister(key) { return _Container.build().unregister(key); } resolve(key) { - return this._map.get(key); + return this._map.get(key)?.(this.context()); + } + }; + + // src/Injected.ts + var Injected = class { + constructor(context) { + this.context = context; } }; })(); diff --git a/packages/nwire/package.json b/packages/nwire/package.json index ba8f1b7..d147662 100644 --- a/packages/nwire/package.json +++ b/packages/nwire/package.json @@ -22,7 +22,7 @@ ], "license": "MIT", "scripts": { - "build": "concurrently \"pnpm run build:cjs\" \"pnpm run build:esm\" \"pnpm run build:types\"", + "build": "rimraf dist && concurrently \"pnpm run build:cjs\" \"pnpm run build:esm\" \"pnpm run build:types\"", "build:types": "tsc", "build:cjs": "esbuild src/index.ts --bundle --outfile=dist/index.js", "build:esm": "esbuild src/index.ts --bundle --outfile=dist/index.esm.js --format=esm", @@ -30,10 +30,11 @@ "testing": "vitest" }, "devDependencies": { - "@types/node": "^20.8.9", - "concurrently": "^8.2.2", - "typescript": "^5.2.2", - "vitest": "^0.34.6" + "@types/node": "20.8.9", + "concurrently": "8.2.2", + "rimraf": "5.0.5", + "typescript": "5.2.2", + "vitest": "0.34.6" }, "exports": { ".": { diff --git a/packages/nwire/src/Container.test.ts b/packages/nwire/src/Container.test.ts index 83c5342..a35183b 100644 --- a/packages/nwire/src/Container.test.ts +++ b/packages/nwire/src/Container.test.ts @@ -1,10 +1,16 @@ import { describe, expect, it, test } from "vitest" import { Container } from "./Container" -type TestContext = { - container: Container -} +class RandomizerDependency { + constructor() { + this._id = Math.floor(Math.random() * 1000) + } + private _id: number + get id() { + return this._id + } +} class TestDependency { test() { return 1 @@ -27,14 +33,14 @@ describe("nwire", () => { it("registers a dependency", () => { const dependency = { test: () => console.info("testing!") } - Container.register("dependency", dependency) + Container.register("dependency", () => dependency) }) it("unregisters a dependency", () => { const dependency = { test: () => console.info("testing!") } const container = Container.build() - .register("dependency", dependency) + .register("dependency", () => dependency) .unregister("dependency") const resolved = container.resolve("dependency" as never) @@ -43,21 +49,24 @@ describe("nwire", () => { it("resolves registered dependency", () => { const dependency = { test: () => console.info("testing!") } - const container = Container.register("dependency", dependency) + const container = Container.register("dependency", () => dependency) const resolved = container.resolve("dependency") expect(resolved).toBe(dependency) }) it("resolves classes", () => { - const container = Container.register("dependency", TestDependency) + const container = Container.register("dependency", () => TestDependency) const resolved = container.resolve("dependency") expect(resolved).toEqual(TestDependency) }) it("creates a context", () => { - const context = Container.register("dependency", TestDependency).context() + const context = Container.register( + "dependency", + () => TestDependency + ).context() const DependencyClass = context.dependency const dependency = new DependencyClass() @@ -68,14 +77,14 @@ describe("nwire", () => { }) it("understands singletons", () => { - const context = Container.singleton("dependency", TestDependency).context() + const context = Container.instance("dependency", TestDependency).context() expect(context.dependency.test).toBeTruthy() }) it("creates singletons lazily", () => { - const context = Container.singleton("dependent", DependentDependency) - .singleton("dependency", TestDependency) + const context = Container.instance("dependent", DependentDependency) + .instance("dependency", TestDependency) .context() expect(context.dependent.test()).toEqual(1) @@ -83,16 +92,16 @@ describe("nwire", () => { it("allows groupings of containers", () => { const context = Container.group("dependencies", (container) => - container.singleton("dependency", TestDependency) + container.instance("dependency", TestDependency) ).context() expect(context.dependencies.dependency.test).toBeTruthy() }) it("groupings have access to lazy dependencies in parent", () => { - const context = Container.singleton("dependency", TestDependency) + const context = Container.instance("dependency", TestDependency) .group("dependencies", (container) => - container.singleton("dependent", DependentDependency) + container.instance("dependent", DependentDependency) ) .context() @@ -100,12 +109,31 @@ describe("nwire", () => { }) it("can pass in additional parameters to the constructor of a singleton", () => { - const context = Container.singleton("dependency", TestDependency) + const context = Container.instance("dependency", TestDependency) .group("dependencies", (container) => - container.singleton("dependent", DependentDependency, 3) + container.instance("dependent", DependentDependency, 3) ) .context() expect(context.dependencies.dependent.test()).toEqual(3) }) + + it("returns a singleton", () => { + const context = Container.instance( + "randomizer", + RandomizerDependency + ).context() + + expect(context.randomizer.id).toEqual(context.randomizer.id) + }) + + it("does not return a singleton when transient", () => { + const context = Container.register( + "randomizer", + () => new RandomizerDependency(), + { transient: true } + ).context() + + expect(context.randomizer.id).not.toEqual(context.randomizer.id) + }) }) diff --git a/packages/nwire/src/Container.ts b/packages/nwire/src/Container.ts index 81a09c7..1e46a12 100644 --- a/packages/nwire/src/Container.ts +++ b/packages/nwire/src/Container.ts @@ -1,8 +1,8 @@ -type Context = { +export type Context = { [key: string]: unknown } -export type Singleton = { +export type Instance = { new (context: any, ...args: any[]): TValue } @@ -14,27 +14,46 @@ type MergeContext = Flatten< } > +type RegistrationOptions = { + transient?: boolean +} + export class Container { - private _map: Map + private _registry: Map + private _map: Map unknown> + private _transient: Set constructor() { - this._map = new Map() + this._transient = new Set() + this._registry = new Map() + this._map = new Map unknown>() } static build() { return new Container() } - context(rootContext = {}): TContext { + context(rootContext: Context = {}): TContext { const handler = { - get: (target: Context, prop: string) => { - if (this._map.has(prop)) return this._map.get(prop) - return target[prop] + get: (target: Context, key: string) => { + if (rootContext.hasOwnProperty(key)) { + return rootContext[key] + } else if (this._registry.has(key)) { + return this._registry.get(key) + } else if (this._map.has(key)) { + const value = this._map.get(key)! + const instance = value(this.context()) + + if (!this._transient.has(key)) this._registry.set(key, instance) + + return instance + } + return target[key] }, } const proxy = new Proxy( - { ...rootContext, ...Object.fromEntries(this._map) } as TContext, + { ...Object.fromEntries(this._map) } as TContext, handler ) @@ -46,7 +65,7 @@ export class Container { key: TNewKey, decorator: (container: Container) => Container ): Container> { - this._map.set(key, this.context(decorator(this).context())) + this._map.set(key, () => this.context(decorator(this).context())) return this as any } @@ -57,42 +76,52 @@ export class Container { return Container.build().group(key, decorator) as any } - singleton( + instance( key: TNewKey, - ValueClass: Singleton, + ValueClass: Instance, ...args: any[] ): Container> { - this._map.set(key, new ValueClass(this.context(), ...args)) + this._map.set(key, () => new ValueClass(this.context(), ...args)) return this as any } - static singleton( + static instance( key: TNewKey, - ValueClass: Singleton, + ValueClass: Instance, ...args: any[] ): Container> { - return Container.build().singleton(key, ValueClass, ...args) as any + return Container.build().instance(key, ValueClass, ...args) as any } register( key: TNewKey, - value: TValue + value: (context: TContext) => TValue, + { transient }: RegistrationOptions = { transient: false } ): Container> { - this._map.set(key, value) + this._map.set(key, () => + value(this.context() as MergeContext) + ) + + if (transient) this._transient.add(key) + return this as any } static register( key: TNewKey, - value: TValue + value: (context: Context) => TValue, + options?: RegistrationOptions ): Container> { - return Container.build().register(key, value) as any + return Container.build().register(key, value, options) as any } unregister( key: TNewKey ): Container> { this._map.delete(key) + this._registry.delete(key) + this._transient.delete(key) + return this as any } @@ -103,6 +132,6 @@ export class Container { } resolve(key: keyof TContext): T { - return this._map.get(key as string) as unknown as T + return this._map.get(key as string)?.(this.context()) as unknown as T } } diff --git a/packages/nwire/src/Injected.ts b/packages/nwire/src/Injected.ts new file mode 100644 index 0000000..991c438 --- /dev/null +++ b/packages/nwire/src/Injected.ts @@ -0,0 +1,5 @@ +import { Context } from "./Container" + +export class Injected { + constructor(protected context: TContext) {} +} diff --git a/packages/nwire/src/index.ts b/packages/nwire/src/index.ts index 3fba54a..fcde006 100644 --- a/packages/nwire/src/index.ts +++ b/packages/nwire/src/index.ts @@ -1 +1,2 @@ -export { Container, Singleton } from "./Container" +export { Container, Instance } from "./Container" +export { Injected } from "./Injected" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a08f8f7..e0d5f21 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -45,16 +45,19 @@ importers: packages/nwire: devDependencies: '@types/node': - specifier: ^20.8.9 + specifier: 20.8.9 version: 20.8.9 concurrently: - specifier: ^8.2.2 + specifier: 8.2.2 version: 8.2.2 + rimraf: + specifier: 5.0.5 + version: 5.0.5 typescript: - specifier: ^5.2.2 + specifier: 5.2.2 version: 5.2.2 vitest: - specifier: ^0.34.6 + specifier: 0.34.6 version: 0.34.6 packages: @@ -270,6 +273,18 @@ packages: dev: false optional: true + /@isaacs/cliui@8.0.2: + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + dependencies: + string-width: 5.1.2 + string-width-cjs: /string-width@4.2.3 + strip-ansi: 7.1.0 + strip-ansi-cjs: /strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: /wrap-ansi@7.0.0 + dev: true + /@jest/schemas@29.6.3: resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -319,6 +334,13 @@ packages: dev: false optional: true + /@pkgjs/parseargs@0.11.0: + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + requiresBuild: true + dev: true + optional: true + /@sinclair/typebox@0.27.8: resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==} dev: true @@ -487,6 +509,11 @@ packages: engines: {node: '>=8'} requiresBuild: true + /ansi-regex@6.0.1: + resolution: {integrity: sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==} + engines: {node: '>=12'} + dev: true + /ansi-styles@4.3.0: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} @@ -499,6 +526,11 @@ packages: engines: {node: '>=10'} dev: true + /ansi-styles@6.2.1: + resolution: {integrity: sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==} + engines: {node: '>=12'} + dev: true + /aproba@2.0.0: resolution: {integrity: sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ==} dev: false @@ -555,7 +587,6 @@ packages: /balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} requiresBuild: true - dev: false /base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} @@ -569,6 +600,12 @@ packages: concat-map: 0.0.1 dev: false + /brace-expansion@2.0.1: + resolution: {integrity: sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==} + dependencies: + balanced-match: 1.0.2 + dev: true + /buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} dev: false @@ -730,6 +767,15 @@ packages: resolution: {integrity: sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==} dev: true + /cross-spawn@7.0.3: + resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==} + engines: {node: '>= 8'} + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + dev: true + /date-fns@2.30.0: resolution: {integrity: sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==} engines: {node: '>=0.11'} @@ -790,10 +836,18 @@ packages: engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} dev: true + /eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + dev: true + /emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} requiresBuild: true + /emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + dev: true + /encoding@0.1.13: resolution: {integrity: sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==} requiresBuild: true @@ -939,6 +993,14 @@ packages: safe-regex2: 2.0.0 dev: false + /foreground-child@3.1.1: + resolution: {integrity: sha512-TMKDUnIte6bfb5nWv7V/caI169OHgvwjb7V4WkeUvbQQdjr5rWKqHFiKWb/fcOwB+CzBT+qbWjvj+DVwRskpIg==} + engines: {node: '>=14'} + dependencies: + cross-spawn: 7.0.3 + signal-exit: 4.1.0 + dev: true + /form-data@4.0.0: resolution: {integrity: sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==} engines: {node: '>= 6'} @@ -1040,6 +1102,18 @@ packages: resolve-pkg-maps: 1.0.0 dev: false + /glob@10.3.10: + resolution: {integrity: sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==} + engines: {node: '>=16 || 14 >=14.17'} + hasBin: true + dependencies: + foreground-child: 3.1.1 + jackspeak: 2.3.6 + minimatch: 9.0.3 + minipass: 5.0.0 + path-scurry: 1.10.1 + dev: true + /glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} requiresBuild: true @@ -1209,8 +1283,15 @@ packages: /isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} requiresBuild: true - dev: false - optional: true + + /jackspeak@2.3.6: + resolution: {integrity: sha512-N3yCS/NegsOBokc8GAdM8UcmfsKiSS8cipheD/nivzr700H+nsMOxJjQnvwOcRYVuFkdH0wGUvW2WbXGmrZGbQ==} + engines: {node: '>=14'} + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + dev: true /json-schema-traverse@1.0.0: resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} @@ -1243,6 +1324,11 @@ packages: get-func-name: 2.0.2 dev: true + /lru-cache@10.0.1: + resolution: {integrity: sha512-IJ4uwUTi2qCccrioU6g9g/5rvvVl13bsdczUUcqbciD9iLr095yj8DQKdObriEvuNSx325N1rV1O0sJFszx75g==} + engines: {node: 14 || >=16.14} + dev: true + /lru-cache@6.0.0: resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} engines: {node: '>=10'} @@ -1320,6 +1406,13 @@ packages: brace-expansion: 1.1.11 dev: false + /minimatch@9.0.3: + resolution: {integrity: sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==} + engines: {node: '>=16 || 14 >=14.17'} + dependencies: + brace-expansion: 2.0.1 + dev: true + /minipass-collect@1.0.2: resolution: {integrity: sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA==} engines: {node: '>= 8'} @@ -1379,7 +1472,6 @@ packages: /minipass@5.0.0: resolution: {integrity: sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==} engines: {node: '>=8'} - dev: false /minizlib@2.1.2: resolution: {integrity: sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==} @@ -1535,6 +1627,19 @@ packages: requiresBuild: true dev: false + /path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + dev: true + + /path-scurry@1.10.1: + resolution: {integrity: sha512-MkhCqzzBEpPvxxQ71Md0b1Kk51W01lrYvlMzSUaIzNsODdd7mqhiimSZlr+VegAz5Z6Vzt9Xg2ttE//XBhH3EQ==} + engines: {node: '>=16 || 14 >=14.17'} + dependencies: + lru-cache: 10.0.1 + minipass: 5.0.0 + dev: true + /pathe@1.1.1: resolution: {integrity: sha512-d+RQGp0MAYTIaDBIMmOfMwz3E+LOZnxx1HZd5R18mmCZY0QBlK0LDZfPc8FW8Ed2DlvsuE6PRjroDY+wg4+j/Q==} dev: true @@ -1730,6 +1835,14 @@ packages: glob: 7.2.3 dev: false + /rimraf@5.0.5: + resolution: {integrity: sha512-CqDakW+hMe/Bz202FPEymy68P+G50RfMQK+Qo5YUqc9SPipvbGjCGKd0RSKEelbsfQuw3g5NZDSrlZZAJurH1A==} + engines: {node: '>=14'} + hasBin: true + dependencies: + glob: 10.3.10 + dev: true + /rollup@3.29.4: resolution: {integrity: sha512-oWzmBZwvYrU0iJHtDmhsm662rC15FRXmcjCk1xD771dFDx5jJ02ufAQQTn0etB2emNk4J9EZg/yWKpsn9BWGRw==} engines: {node: '>=14.18.0', npm: '>=8.0.0'} @@ -1800,6 +1913,18 @@ packages: has-property-descriptors: 1.0.1 dev: true + /shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + dependencies: + shebang-regex: 3.0.0 + dev: true + + /shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + dev: true + /shell-quote@1.8.1: resolution: {integrity: sha512-6j1W9l1iAs/4xYBI1SYOVZyFcCis9b4KCLQ8fgAGG07QvzaRLVVRQvAy85yNmmZSjYjg4MWh4gNvlPujU/5LpA==} dev: true @@ -1820,6 +1945,11 @@ packages: resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} dev: false + /signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + dev: true + /smart-buffer@4.2.0: resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} @@ -1929,6 +2059,15 @@ packages: is-fullwidth-code-point: 3.0.0 strip-ansi: 6.0.1 + /string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.1.0 + dev: true + /string_decoder@1.3.0: resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} dependencies: @@ -1941,6 +2080,13 @@ packages: dependencies: ansi-regex: 5.0.1 + /strip-ansi@7.1.0: + resolution: {integrity: sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==} + engines: {node: '>=12'} + dependencies: + ansi-regex: 6.0.1 + dev: true + /strip-literal@1.3.0: resolution: {integrity: sha512-PugKzOsyXpArk0yWmUwqOZecSO0GH0bPoctLcqNDH9J04pVW3lflYE0ujElBGTloevcxF5MofAOZ7C5l2b+wLg==} dependencies: @@ -2237,8 +2383,6 @@ packages: requiresBuild: true dependencies: isexe: 2.0.0 - dev: false - optional: true /why-is-node-running@2.2.2: resolution: {integrity: sha512-6tSwToZxTOcotxHeA+qGCq1mVzKR3CwcJGmVcY+QE8SHy6TnpFnh8PAvPNHYr7EcuVeG0QSMxtYCuO1ta/G/oA==} @@ -2264,6 +2408,15 @@ packages: strip-ansi: 6.0.1 dev: true + /wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + dependencies: + ansi-styles: 6.2.1 + string-width: 5.1.2 + strip-ansi: 7.1.0 + dev: true + /wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} requiresBuild: true