@joist/di 3.9.0 → 3.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,20 +1,20 @@
1
1
  # Di
2
2
 
3
- Dependency Injection in ~800 bytes.
3
+ Small and efficient dependency injection.
4
4
 
5
5
  Allows you to inject services into other class instances (including custom elements and node).
6
6
 
7
7
  ## Table of Contents
8
8
 
9
9
  - [Installation](#installation)
10
- - [Example Usage](#example)
11
- - [Factories](#factories)
12
- - [StaticToken](#statictoken)
13
- - [Testing](#testing)
14
- - [LifeCycle](#life-cycle)
15
- - [Parent/Child Relationship](#parentchild-relationship)
10
+ - [Injectors](#injectors)
11
+ - [Services](#services)
12
+ - [Injectable Services](#injectable-services)
13
+ - [Defining Providers](#defining-providers)
14
+ - [StaticTokens](#statictokens)
15
+ - [LifeCycle](#lifecycle)
16
+ - [Hierarchical Injectors](#hierarchical-injectors)
16
17
  - [Custom Elements](#custom-elements)
17
- - [Environment](#environment)
18
18
 
19
19
  ## Installation:
20
20
 
@@ -22,73 +22,122 @@ Allows you to inject services into other class instances (including custom eleme
22
22
  npm i @joist/di
23
23
  ```
24
24
 
25
- ## Example:
25
+ ## Injectors
26
26
 
27
- Classes that are decoratored with `@injectable` can use the `inject()` function to inject a class instance.
27
+ Injectors are what are used to construct new [services](#services). Injectors can manually [provide implementations](#defining-providers) of services. Injectors can also have [parents](#hierarchical-injectors), parent injectors can define services for all of it's children.
28
28
 
29
- Different implementations can be provided for services.
29
+ ## Services
30
30
 
31
- ```TS
32
- import { Injector, injectable, inject } from '@joist/di';
31
+ At their simplest, services are classses. Services can be constructed via an `Injector` and treated are singletons (The same instance is returned for each call to Injector.inject()).
32
+
33
+ ```ts
34
+ const app = new Injector();
33
35
 
34
- class Engine {
35
- type: 'gas' | 'electric' = 'gas';
36
+ class Counter {
37
+ value = 0;
36
38
 
37
- accelerate() {
38
- return 'vroom';
39
+ inc(val: number) {
40
+ this.value += val;
39
41
  }
40
42
  }
41
43
 
42
- class Tires {
43
- size = 16;
44
- }
44
+ // these two calls will return the same instance
45
+ const foo = app.inject(Counter);
46
+ const bar = app.inject(Counter);
47
+ ```
48
+
49
+ ## Injectable Services
45
50
 
51
+ Singleton services are great but the real benefit can be seen when passing instances of one service to another. Services are injected into other services using the `inject()` fuction. In order to use `inject()` classes must be decorated with `@injectable`.
52
+
53
+ `inject()` returns a function that will then return an instance of the requested service. This means that services are only created when they are needed and not when the class is constructed.
54
+
55
+ ```ts
46
56
  @injectable
47
- class Car {
48
- engine = inject(Engine);
49
- tires = inject(Tires);
50
-
51
- accelerate() {
52
- // the inject function returns a function
53
- // this means that services are not initalized until they are called
54
- return this.engine().accelerate();
57
+ class App {
58
+ #counter = inject(Counter);
59
+
60
+ update(val: number) {
61
+ const instance = this.#counter();
62
+
63
+ instance.inc(val);
55
64
  }
56
65
  }
66
+ ```
57
67
 
58
- const factory1 = new Injector();
59
- const car1 = factory1.get(Car);
68
+ ## Defining Providers
60
69
 
61
- // vroom, 16
62
- console.log(car1.accelerate(), car1.tires().size);
70
+ A big reason to use dependency injection is the ability to provide multiple implementations for a particular service. For example we probably want a different http client when running unit tests vs in our main application.
63
71
 
64
- const factory2 = new Injector([
65
- {
66
- provide: Engine,
67
- use: class extends Engine {
68
- type = 'electric';
72
+ In the below example we have a defined HttpService that wraps fetch. but for our unit test we will use a custom implementation that returns just the data we want. This also has the benefit of avoiding test framework specific mocks.
69
73
 
70
- accelerate() {
71
- return 'hmmmmmmmm';
72
- }
73
- }
74
- },
75
- {
76
- provide: Tires,
77
- use: class extends Tires {
78
- size = 20;
74
+ ```ts
75
+ // services.ts
76
+
77
+ class HttpService {
78
+ fetch(url: string, init?: RequestInit) {
79
+ return fetch(url, init);
80
+ }
81
+ }
82
+
83
+ @injectable
84
+ class ApiService {
85
+ #http = inject(HttpService);
86
+
87
+ getData() {
88
+ return this.#http()
89
+ .fetch('/api/v1/users')
90
+ .then((res) => res.json());
91
+ }
92
+ }
93
+ ```
94
+
95
+ ```ts
96
+ // services.test.ts
97
+
98
+ test('should return json', async () => {
99
+ class MockHttpService extends HttpService {
100
+ async fetch() {
101
+ return Response.json({ fname: 'Danny', lname: 'Blue' });
79
102
  }
80
103
  }
81
- ]);
82
104
 
83
- const car2 = factory2.get(Car);
105
+ const app = new Injector([{ provide: HttpService, use: MockHttpService }]);
106
+ const api = app.inject(ApiService);
107
+
108
+ const res = await api.getData();
109
+
110
+ assert.equals(res.fname, 'Danny');
111
+ assert.equals(res.lname, 'Blue');
112
+ });
113
+ ```
114
+
115
+ ### Service level providers
116
+
117
+ Under the hood, each service decorated with `@injectable` creates its own injector. This means that it is possible to defined providers from that level down.
118
+
119
+ The below example will use this particular instance of Logger as wall as any other services injected into this service.
120
+
121
+ ```ts
122
+ class Logger {
123
+ log(..._: any[]): void {}
124
+ }
125
+
126
+ class ConsoleLogger implements Logger {
127
+ log(...args: any[]) {
128
+ console.log(...args);
129
+ }
130
+ }
84
131
 
85
- //hmmmmmmmm, 20
86
- console.log(car2.accelerate(), car2.tires().size);
132
+ @injectable
133
+ class MyService {
134
+ static providers = [{ provide: Logger, use: ConsoleLogger }];
135
+ }
87
136
  ```
88
137
 
89
- ## Factories
138
+ ### Factories
90
139
 
91
- In addition to defining providers with classes you can also use factory functions.
140
+ In addition to defining providers with classes you can also use factory functions. Factories allow for more flexibility for deciding exactly how a service is created. This is helpful when which instance that is provided depends on some runtime value.
92
141
 
93
142
  ```ts
94
143
  class Logger {
@@ -99,15 +148,21 @@ const app = new Injector([
99
148
  {
100
149
  provide: Logger,
101
150
  factory() {
102
- return console;
151
+ const params = new URLSearchParams(window.location.search);
152
+
153
+ if (params.has('debug')) {
154
+ return console;
155
+ }
156
+
157
+ return new Logger(); // noop logger
103
158
  }
104
159
  }
105
160
  ]);
106
161
  ```
107
162
 
108
- ### Accessing the injector
163
+ ### Accessing the injector in factories
109
164
 
110
- Factories provide more flexibility but often times cannot use the `inject()` function. To get around this all factories are passed an instance of the current injector.
165
+ Factories provide more flexibility but sometimes will require access to the injector itself. For this reason the factory method is passed the injector that is being used to construct the requested service.
111
166
 
112
167
  ```ts
113
168
  class Logger {
@@ -128,13 +183,15 @@ const app = new Injector([
128
183
  {
129
184
  provide: Feature,
130
185
  factory(i) {
131
- return new Feature(i.get(Logger));
186
+ const logger = i.inject(Logger);
187
+
188
+ return new Feature(logger);
132
189
  }
133
190
  }
134
191
  ]);
135
192
  ```
136
193
 
137
- ## StaticToken
194
+ ## StaticTokens
138
195
 
139
196
  In most cases a token is any constructable class. There are cases where you might want to return other data types that aren't objects.
140
197
 
@@ -164,58 +221,16 @@ Static tokens can also leverage promises for cases when you need to async create
164
221
 
165
222
  ```ts
166
223
  // StaticToken<Promise<string>>
167
- const URL_TOKEN = new StaticToken('app_url', () => Promise.resolve('/default-url/'));
224
+ const URL_TOKEN = new StaticToken('app_url', async () => '/default-url/');
168
225
 
169
226
  const app = new Injector();
170
227
 
171
- const url = await app.get(URL_TOKEN);
228
+ const url: string = await app.inject(URL_TOKEN);
172
229
  ```
173
230
 
174
- ## Testing
231
+ ## LifeCycle
175
232
 
176
- Dependency injection can make testing easy without requiring test framework level mock.
177
-
178
- ```TS
179
- import { Injector, injectable, inject } from '@joist/di';
180
-
181
- @injectable
182
- class HttpService {
183
- fetch(url: string, init?: RequestInit) {
184
- return fetch(url, init);
185
- }
186
- }
187
-
188
- @injectable
189
- class ApiService {
190
- #http = inject(HttpService);
191
-
192
- getData() {
193
- return this.#http()
194
- .fetch('/api/v1/users')
195
- .then((res) => res.json());
196
- }
197
- }
198
-
199
- // unit test
200
- const testApp = new Injector([
201
- {
202
- provide: HttpService,
203
- use: class extends HttpService {
204
- async fetch() {
205
- // return whatever response we like
206
- return Response.json({ fname: 'Danny', lname: 'Blue' });
207
- }
208
- },
209
- },
210
- ]);
211
-
212
- // our test instance will be using our mock when making http requests
213
- const api = testApp.get(ApiService);
214
- ```
215
-
216
- ## Life Cycle
217
-
218
- To helo provide more information to services that are being created, joist will call several life cycle hooks as services are created. These hooks are defined using the provided symbols so there is no risk of naming colisions.
233
+ To help provide more information to services that are being created, joist will call several life cycle hooks as services are created. These hooks are defined using the provided symbols so there is no risk of naming colisions.
219
234
 
220
235
  ```ts
221
236
  class MyService {
@@ -229,7 +244,7 @@ class MyService {
229
244
  }
230
245
  ```
231
246
 
232
- ## Parent/Child relationship
247
+ ## Hierarchical Injectors
233
248
 
234
249
  Injectors can be defined with a parent element. The top most parent will (by default) be where services are constructed and cached. Only if manually defined providers are found earlier in the chain will services be constructed lower. The injector resolution algorithm behaves as following.
235
250
 
@@ -257,11 +272,9 @@ This behavior allows for services to be "scoped" within a certain branch of the
257
272
 
258
273
  ## Custom Elements:
259
274
 
260
- Joist is built to work with custom elements. Since the document is a tree we can search up that tree for providers.
275
+ Joist is built to work with custom elements. Since the document is a tree we can search up that tree for providers. This is where Hierarchical Injectors can really shine as they allow you to defined React/Preact esq "context" elements.
261
276
 
262
277
  ```TS
263
- import { injectable, inject } from '@joist/di';
264
-
265
278
  class Colors {
266
279
  primary = 'red';
267
280
  secodnary = 'green';
@@ -307,7 +320,7 @@ customElements.define('my-element', MyElement);
307
320
  </color-ctx>
308
321
  ```
309
322
 
310
- ## Environment
323
+ ### Environment
311
324
 
312
325
  When using @joist/di with custom elements a default root injector is created dubbed 'environment'. This is the injector that all other injectors will eventually stop at.
313
326
  If you need to define something in this environment you can do so with the `defineEnvironment` method.
@@ -317,32 +330,3 @@ import { defineEnvironment } from '@joist/di';
317
330
 
318
331
  defineEnvironment([{ provide: MyService, use: SomeOtherService }]);
319
332
  ```
320
-
321
- #### No decorators no problem:
322
-
323
- While this library is built with decorators in mind it is designed so that it can be used without them.
324
-
325
- ```TS
326
- import { Injector, injectable, inject } from '@joist/di';
327
-
328
- class Engine {
329
- type: 'gas' | 'electric' = 'gas';
330
- }
331
-
332
- class Tires {
333
- size = 16;
334
- }
335
-
336
- const Car = injectable(
337
- class {
338
- engine = inject(Engine);
339
- tires = inject(Tires);
340
- }
341
- );
342
-
343
- const app = new Injector();
344
- const car = app.get(Car);
345
-
346
- // gas, 16
347
- console.log(car.engine(), car.tires());
348
- ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@joist/di",
3
- "version": "3.9.0",
3
+ "version": "3.9.1",
4
4
  "type": "module",
5
5
  "main": "./target/lib.js",
6
6
  "module": "./target/lib.js",
@@ -9,7 +9,7 @@
9
9
  "import": "./target/lib.js"
10
10
  },
11
11
  "./*": {
12
- "import": "./target/lib/*.js"
12
+ "import": "./target/lib/*"
13
13
  }
14
14
  },
15
15
  "files": [
@@ -60,6 +60,17 @@
60
60
  "target/**"
61
61
  ],
62
62
  "output": [],
63
+ "dependencies": [
64
+ "build",
65
+ "test:node"
66
+ ]
67
+ },
68
+ "test:node": {
69
+ "command": "node --test target/**/*.test-node.js",
70
+ "files": [
71
+ "target/**"
72
+ ],
73
+ "output": [],
63
74
  "dependencies": [
64
75
  "build"
65
76
  ]
package/src/lib/inject.ts CHANGED
@@ -1,11 +1,9 @@
1
+ import { INJECTABLE_MAP } from './injector.js';
1
2
  import { InjectionToken } from './provider.js';
2
- import { INJECTABLE_MAP } from './injectable.js';
3
3
 
4
4
  export type Injected<T> = () => T;
5
5
 
6
- export function inject<This extends object, T>(
7
- token: InjectionToken<T>
8
- ): Injected<T> {
6
+ export function inject<This extends object, T>(token: InjectionToken<T>): Injected<T> {
9
7
  return function (this: This) {
10
8
  const injector = INJECTABLE_MAP.get(this);
11
9
 
@@ -2,7 +2,6 @@ import { expect, fixture, html } from '@open-wc/testing';
2
2
 
3
3
  import { injectable } from './injectable.js';
4
4
  import { inject } from './inject.js';
5
- import { Injector } from './injector.js';
6
5
 
7
6
  describe('@injectable()', () => {
8
7
  it('should allow a custom element to be injected with deps', () => {
@@ -40,21 +39,6 @@ describe('@injectable()', () => {
40
39
  expect(el.foo()).to.be.instanceOf(Bar);
41
40
  });
42
41
 
43
- it('should call the onInject lifecycle hook', () => {
44
- class A {}
45
-
46
- @injectable
47
- class B {
48
- a = inject(A);
49
-
50
- onInject() {
51
- expect(this.a()).to.be.instanceOf(A);
52
- }
53
- }
54
-
55
- new Injector().inject(B);
56
- });
57
-
58
42
  it('should handle parent HTML Injectors', async () => {
59
43
  @injectable
60
44
  class A {}
@@ -1,44 +1,39 @@
1
1
  import { ConstructableToken } from './provider.js';
2
- import { Injector } from './injector.js';
2
+ import { INJECTABLE_MAP, Injector } from './injector.js';
3
3
  import { environment } from './environment.js';
4
- import { InjectableMap } from './injectable-map.js';
5
-
6
- export const INJECTABLE_MAP = new InjectableMap();
7
4
 
8
5
  export function injectable<T extends ConstructableToken<any>>(Base: T, _?: unknown) {
9
6
  return class InjectableNode extends Base {
10
7
  constructor(..._: any[]) {
11
8
  super();
12
9
 
10
+ // Define a new Injector and assiciate it with this instance of the service
13
11
  const injector = new Injector(Base.providers);
14
-
15
12
  INJECTABLE_MAP.set(this, injector);
16
13
 
17
- try {
18
- if (this instanceof HTMLElement) {
19
- this.addEventListener('finddiroot', (e) => {
20
- const parentInjector = findInjectorRoot(e);
14
+ // If the current injectable instance is a HTMLElement preform additional startup logic
15
+ // this will find and attach parent injectors
16
+ if ('HTMLElement' in globalThis && this instanceof HTMLElement) {
17
+ this.addEventListener('finddiroot', (e) => {
18
+ const parentInjector = findInjectorRoot(e);
21
19
 
22
- if (parentInjector !== null) {
23
- injector.setParent(parentInjector);
24
- } else {
25
- injector.setParent(environment());
26
- }
27
- });
28
- }
29
- } catch {}
20
+ if (parentInjector !== null) {
21
+ injector.setParent(parentInjector);
22
+ } else {
23
+ injector.setParent(environment());
24
+ }
25
+ });
26
+ }
30
27
  }
31
28
 
32
29
  connectedCallback() {
33
- try {
34
- if (this instanceof HTMLElement) {
35
- this.dispatchEvent(new Event('finddiroot'));
30
+ if ('HTMLElement' in globalThis && this instanceof HTMLElement) {
31
+ this.dispatchEvent(new Event('finddiroot'));
36
32
 
37
- if (super.connectedCallback) {
38
- super.connectedCallback();
39
- }
33
+ if (super.connectedCallback) {
34
+ super.connectedCallback();
40
35
  }
41
- } catch {}
36
+ }
42
37
  }
43
38
 
44
39
  disconnectedCallback() {
@@ -0,0 +1,187 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert';
3
+
4
+ import { Injector } from './injector.js';
5
+ import { inject } from './inject.js';
6
+ import { injectable } from './injectable.js';
7
+ import { Provider, StaticToken } from './provider.js';
8
+
9
+ test('should create a new instance of a single provider', () => {
10
+ class A {}
11
+
12
+ const app = new Injector();
13
+
14
+ assert(app.inject(A) instanceof A);
15
+
16
+ assert.equal(app.inject(A), app.inject(A));
17
+ });
18
+
19
+ test('should inject providers in the correct order', () => {
20
+ class A {}
21
+ class B {}
22
+
23
+ @injectable
24
+ class MyService {
25
+ a = inject(A);
26
+ b = inject(B);
27
+ }
28
+
29
+ const app = new Injector();
30
+ const instance = app.inject(MyService);
31
+
32
+ assert(instance.a() instanceof A);
33
+ assert(instance.b() instanceof B);
34
+ });
35
+
36
+ test('should create a new instance of a provider that has a full dep tree', () => {
37
+ class A {}
38
+
39
+ @injectable
40
+ class B {
41
+ a = inject(A);
42
+ }
43
+
44
+ @injectable
45
+ class C {
46
+ b = inject(B);
47
+ }
48
+
49
+ @injectable
50
+ class D {
51
+ c = inject(C);
52
+ }
53
+
54
+ @injectable
55
+ class E {
56
+ d = inject(D);
57
+ }
58
+
59
+ const app = new Injector();
60
+ const instance = app.inject(E);
61
+
62
+ assert(instance.d().c().b().a() instanceof A);
63
+ });
64
+
65
+ test('should override a provider if explicitly instructed', () => {
66
+ class A {}
67
+
68
+ @injectable
69
+ class B {
70
+ a = inject(A);
71
+ }
72
+
73
+ class AltA extends A {}
74
+ const app = new Injector([{ provide: A, use: AltA }]);
75
+
76
+ assert(app.inject(B).a() instanceof AltA);
77
+ });
78
+
79
+ test('should return an existing instance from a parent injector', () => {
80
+ class A {}
81
+
82
+ const parent = new Injector();
83
+
84
+ const app = new Injector([], parent);
85
+
86
+ assert.equal(parent.inject(A), app.inject(A));
87
+ });
88
+
89
+ test('should use a factory if provided', () => {
90
+ class Service {
91
+ hello() {
92
+ return 'world';
93
+ }
94
+ }
95
+
96
+ const injector = new Injector([
97
+ {
98
+ provide: Service,
99
+ factory() {
100
+ return {
101
+ hello() {
102
+ return 'world';
103
+ }
104
+ };
105
+ }
106
+ }
107
+ ]);
108
+
109
+ assert.equal(injector.inject(Service).hello(), 'world');
110
+ });
111
+
112
+ test('should throw an error if provider is missing both factory and use', () => {
113
+ class Service {
114
+ hello() {
115
+ return 'world';
116
+ }
117
+ }
118
+
119
+ const injector = new Injector([
120
+ {
121
+ provide: Service
122
+ }
123
+ ]);
124
+
125
+ assert.throws(
126
+ () => injector.inject(Service),
127
+ new Error("Provider for Service found but is missing either 'use' or 'factory'")
128
+ );
129
+ });
130
+
131
+ test('should pass factories and instance of the injector', async () => {
132
+ class Service {
133
+ hello() {
134
+ return 'world';
135
+ }
136
+ }
137
+
138
+ let factoryInjector: Injector | null = null;
139
+
140
+ const injector = new Injector([
141
+ {
142
+ provide: Service,
143
+ factory(i) {
144
+ factoryInjector = i;
145
+ }
146
+ }
147
+ ]);
148
+
149
+ injector.inject(Service);
150
+
151
+ assert.equal(factoryInjector, injector);
152
+ });
153
+
154
+ test('should create an instance from a StaticToken factory', () => {
155
+ const TOKEN = new StaticToken('test', () => 'Hello World');
156
+ const injector = new Injector();
157
+
158
+ const res = injector.inject(TOKEN);
159
+
160
+ assert.equal(res, 'Hello World');
161
+ });
162
+
163
+ test('should create an instance from an async StaticToken factory', async () => {
164
+ const TOKEN = new StaticToken('test', () => Promise.resolve('Hello World'));
165
+ const injector = new Injector();
166
+
167
+ const res = await injector.inject(TOKEN);
168
+
169
+ assert.equal(res, 'Hello World');
170
+ });
171
+
172
+ test('should allow static token to be overridden', () => {
173
+ const TOKEN = new StaticToken<string>('test');
174
+
175
+ const provider: Provider<string> = {
176
+ provide: TOKEN,
177
+ factory() {
178
+ return 'Hello World';
179
+ }
180
+ };
181
+
182
+ const injector = new Injector([provider]);
183
+
184
+ const res = injector.inject(TOKEN);
185
+
186
+ assert.equal(res, 'Hello World');
187
+ });