@joist/di 3.9.0 → 4.0.1-next.0

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.
Files changed (48) hide show
  1. package/README.md +153 -135
  2. package/package.json +13 -2
  3. package/src/lib/dom-injector.test.ts +30 -0
  4. package/src/lib/dom-injector.ts +12 -0
  5. package/src/lib/injectable-map.ts +1 -11
  6. package/src/lib/injectable.test.ts +0 -16
  7. package/src/lib/injectable.ts +16 -21
  8. package/src/lib/injector.test-node.ts +187 -0
  9. package/src/lib/injector.test.ts +4 -4
  10. package/src/lib/injector.ts +0 -3
  11. package/src/lib/lifecycle.test.ts +1 -0
  12. package/src/lib/provider.ts +0 -4
  13. package/src/lib.ts +3 -3
  14. package/target/lib/dom-injector.d.ts +5 -0
  15. package/target/lib/dom-injector.js +11 -0
  16. package/target/lib/dom-injector.js.map +1 -0
  17. package/target/lib/dom-injector.test.js +21 -0
  18. package/target/lib/dom-injector.test.js.map +1 -0
  19. package/target/lib/injectable-map.d.ts +1 -4
  20. package/target/lib/injectable-map.js +1 -8
  21. package/target/lib/injectable-map.js.map +1 -1
  22. package/target/lib/injectable.js +11 -21
  23. package/target/lib/injectable.js.map +1 -1
  24. package/target/lib/injectable.test.js +0 -27
  25. package/target/lib/injectable.test.js.map +1 -1
  26. package/target/lib/injector.d.ts +0 -1
  27. package/target/lib/injector.js +0 -3
  28. package/target/lib/injector.js.map +1 -1
  29. package/target/lib/injector.test-node.d.ts +1 -0
  30. package/target/lib/injector.test-node.js +231 -0
  31. package/target/lib/injector.test-node.js.map +1 -0
  32. package/target/lib/injector.test.js +4 -4
  33. package/target/lib/injector.test.js.map +1 -1
  34. package/target/lib/lifecycle.test.js.map +1 -1
  35. package/target/lib/provider.d.ts +0 -1
  36. package/target/lib/provider.js +0 -3
  37. package/target/lib/provider.js.map +1 -1
  38. package/target/lib.d.ts +3 -3
  39. package/target/lib.js +3 -3
  40. package/target/lib.js.map +1 -1
  41. package/src/lib/environment.test.ts +0 -30
  42. package/src/lib/environment.ts +0 -21
  43. package/target/lib/environment.d.ts +0 -5
  44. package/target/lib/environment.js +0 -16
  45. package/target/lib/environment.js.map +0 -1
  46. package/target/lib/environment.test.js +0 -54
  47. package/target/lib/environment.test.js.map +0 -1
  48. /package/target/lib/{environment.test.d.ts → dom-injector.test.d.ts} +0 -0
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()).
33
32
 
34
- class Engine {
35
- type: 'gas' | 'electric' = 'gas';
33
+ ```ts
34
+ const app = new Injector();
36
35
 
37
- accelerate() {
38
- return 'vroom';
36
+ class Counter {
37
+ value = 0;
38
+
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
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.
45
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);
84
107
 
85
- //hmmmmmmmm, 20
86
- console.log(car2.accelerate(), car2.tires().size);
108
+ const res = await api.getData();
109
+
110
+ assert.equals(res.fname, 'Danny');
111
+ assert.equals(res.lname, 'Blue');
112
+ });
87
113
  ```
88
114
 
89
- ## Factories
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.
90
118
 
91
- In addition to defining providers with classes you can also use factory functions.
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
+ }
131
+
132
+ @injectable
133
+ class MyService {
134
+ static providers = [{ provide: Logger, use: ConsoleLogger }];
135
+ }
136
+ ```
137
+
138
+ ### Factories
139
+
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
 
@@ -259,8 +274,40 @@ This behavior allows for services to be "scoped" within a certain branch of the
259
274
 
260
275
  Joist is built to work with custom elements. Since the document is a tree we can search up that tree for providers.
261
276
 
277
+ Setting your web page to work is very similar to any other JavaScript environment. There is a special `DOMInjector` class that will allow you to attach an injector to any location in the dom, in most cases this will be document.body.
278
+
262
279
  ```TS
263
- import { injectable, inject } from '@joist/di';
280
+ const app = new DOMInjector();
281
+
282
+ app.attach(document.body); // anything rendered in the body will have access to this injector.
283
+
284
+ class Colors {
285
+ primary = 'red';
286
+ secodnary = 'green';
287
+ }
288
+
289
+ @injectable
290
+ class MyElement extends HTMLElement {
291
+ #colors = inject(Colors);
292
+
293
+ connectedCallback() {
294
+ const { primary } = this.#colors();
295
+
296
+ this.style.background = primary;
297
+ }
298
+ }
299
+
300
+ customElements.define('my-element', MyElement);
301
+ ```
302
+
303
+ ### Context Elements:
304
+
305
+ Context elements are where Hierarchical Injectors can really shine as they allow you to defined React/Preact esq "context" elements. Since custom elements are treated the same as any other class they can define providers for their local scope.
306
+
307
+ ```TS
308
+ const app = new DOMInjector();
309
+
310
+ app.attach(document.body);
264
311
 
265
312
  class Colors {
266
313
  primary = 'red';
@@ -307,7 +354,7 @@ customElements.define('my-element', MyElement);
307
354
  </color-ctx>
308
355
  ```
309
356
 
310
- ## Environment
357
+ ### Environment
311
358
 
312
359
  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
360
  If you need to define something in this environment you can do so with the `defineEnvironment` method.
@@ -317,32 +364,3 @@ import { defineEnvironment } from '@joist/di';
317
364
 
318
365
  defineEnvironment([{ provide: MyService, use: SomeOtherService }]);
319
366
  ```
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": "4.0.1-next.0",
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
  ]
@@ -0,0 +1,30 @@
1
+ import { expect } from '@open-wc/testing';
2
+
3
+ import { DOMInjector } from './dom-injector.js';
4
+ import { INJECTABLE_MAP } from './injectable.js';
5
+
6
+ describe('DOMInjector', () => {
7
+ it('should attach an injector to a dom element', () => {
8
+ const root = document.createElement('div');
9
+ const app = new DOMInjector();
10
+
11
+ app.attach(root);
12
+
13
+ const injector = INJECTABLE_MAP.get(root);
14
+
15
+ expect(injector).to.equal(app);
16
+ });
17
+
18
+ it('should remove an injector associated with a dom element', () => {
19
+ const root = document.createElement('div');
20
+ const app = new DOMInjector();
21
+
22
+ app.attach(root);
23
+
24
+ expect(INJECTABLE_MAP.get(root)).to.equal(app);
25
+
26
+ app.detach(root);
27
+
28
+ expect(INJECTABLE_MAP.get(root)).to.equal(undefined);
29
+ });
30
+ });
@@ -0,0 +1,12 @@
1
+ import { INJECTABLE_MAP } from './injectable.js';
2
+ import { Injector } from './injector.js';
3
+
4
+ export class DOMInjector extends Injector {
5
+ attach(root: HTMLElement) {
6
+ INJECTABLE_MAP.set(root, this);
7
+ }
8
+
9
+ detach(root: HTMLElement) {
10
+ INJECTABLE_MAP.delete(root);
11
+ }
12
+ }
@@ -1,13 +1,3 @@
1
1
  import { Injector } from './injector.js';
2
2
 
3
- export class InjectableMap {
4
- #injectables = new WeakMap<object, Injector>();
5
-
6
- get(key: object) {
7
- return this.#injectables.get(key);
8
- }
9
-
10
- set(key: object, injector: Injector) {
11
- return this.#injectables.set(key, injector);
12
- }
13
- }
3
+ export class InjectableMap extends WeakMap<object, Injector> {}
@@ -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,6 +1,5 @@
1
1
  import { ConstructableToken } from './provider.js';
2
2
  import { Injector } from './injector.js';
3
- import { environment } from './environment.js';
4
3
  import { InjectableMap } from './injectable-map.js';
5
4
 
6
5
  export const INJECTABLE_MAP = new InjectableMap();
@@ -10,35 +9,31 @@ export function injectable<T extends ConstructableToken<any>>(Base: T, _?: unkno
10
9
  constructor(..._: any[]) {
11
10
  super();
12
11
 
12
+ // Define a new Injector and assiciate it with this instance of the service
13
13
  const injector = new Injector(Base.providers);
14
-
15
14
  INJECTABLE_MAP.set(this, injector);
16
15
 
17
- try {
18
- if (this instanceof HTMLElement) {
19
- this.addEventListener('finddiroot', (e) => {
20
- const parentInjector = findInjectorRoot(e);
16
+ // If the current injectable instance is a HTMLElement preform additional startup logic
17
+ // this will find and attach parent injectors
18
+ if ('HTMLElement' in globalThis && this instanceof HTMLElement) {
19
+ this.addEventListener('finddiroot', (e) => {
20
+ const parentInjector = findInjectorRoot(e);
21
21
 
22
- if (parentInjector !== null) {
23
- injector.setParent(parentInjector);
24
- } else {
25
- injector.setParent(environment());
26
- }
27
- });
28
- }
29
- } catch {}
22
+ if (parentInjector !== null) {
23
+ injector.setParent(parentInjector);
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() {