@vida-global/core 2.4.0 → 2.4.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/index.js CHANGED
@@ -1,11 +1,12 @@
1
- const ActiveRecord = require('./lib/activeRecord');
2
- const httpLibs = require('./lib/http/client');
3
- const APM = require('./lib/apm');
4
- const JobQueue = require('./lib/jobQueue');
5
- const { logger } = require('./lib/logger');
6
- const redisLibs = require('./lib/redis');
7
- const cacheLibs = require('./lib/cache');
8
- const serverLibs = require('./lib/server');
1
+ const ActiveRecord = require('./lib/activeRecord');
2
+ const httpLibs = require('./lib/http/client');
3
+ const APM = require('./lib/apm');
4
+ const { AppDependencies } = require('./lib/appDependencies');
5
+ const JobQueue = require('./lib/jobQueue');
6
+ const { logger } = require('./lib/logger');
7
+ const redisLibs = require('./lib/redis');
8
+ const cacheLibs = require('./lib/cache');
9
+ const serverLibs = require('./lib/server');
9
10
 
10
11
 
11
12
  const {
@@ -27,6 +28,7 @@ module.exports = {
27
28
  ...redisLibs,
28
29
  ...serverErrorsToExport,
29
30
  ApiDocsGenerator,
31
+ AppDependencies,
30
32
  VidaServer,
31
33
  VidaServerController,
32
34
  };
@@ -0,0 +1,48 @@
1
+ class AppDependencies {
2
+ #dependencies = {};
3
+ #controllerClass = null;
4
+
5
+
6
+ installOn(controllerClass) {
7
+ this.#controllerClass = controllerClass;
8
+ for (const name of Object.keys(this.#dependencies)) this.#defineAccessor(name);
9
+ }
10
+
11
+
12
+ setDependency(name, dependency) {
13
+ this.#dependencies[name] = dependency;
14
+ this.#defineAccessor(name);
15
+ }
16
+
17
+
18
+ get(name) {
19
+ return this.#dependencies[name] ?? null;
20
+ }
21
+
22
+
23
+ // Mainly for tests, which register their own doubles and must not leak them into the next one.
24
+ reset() {
25
+ this.#dependencies = {};
26
+ }
27
+
28
+
29
+ // Registering after `installOn` still defines the accessor, so wiring order does not matter.
30
+ // An existing property is left alone: a controller that defines the name itself wins.
31
+ #defineAccessor(name) {
32
+ if (!this.#controllerClass) return;
33
+
34
+ const prototype = this.#controllerClass.prototype;
35
+ if (Object.getOwnPropertyDescriptor(prototype, name)) return;
36
+
37
+ const dependencies = this;
38
+ Object.defineProperty(prototype, name, {
39
+ configurable: false,
40
+ get() { return dependencies.get(name); }
41
+ });
42
+ }
43
+ }
44
+
45
+
46
+ module.exports = {
47
+ AppDependencies: new AppDependencies()
48
+ };
@@ -130,7 +130,38 @@ See [Callbacks](./doc/callbacks.md) — before/after action callbacks and how su
130
130
 
131
131
  ## Rendering responses
132
132
 
133
- See [Rendering responses](./doc/renderer.md) — the render envelope, status-code helpers, error handling, and Server-Sent Events / streaming.
133
+ See [Rendering responses](./doc/renderer.md) — the render envelope, status-code helpers, binary and file responses, error handling, and Server-Sent Events / streaming.
134
+
135
+
136
+ ## App dependencies
137
+
138
+ `AppDependencies` is a registry for the long-lived collaborators an application builds at boot — helper libraries, service objects, queues. Code that runs far from that wiring reaches them by name instead of having them threaded through every call.
139
+
140
+ ```js
141
+ // at boot
142
+ const { AppDependencies } = require('@vida-global/core');
143
+
144
+ AppDependencies.setDependency('billingHelper', billingHelper);
145
+ AppDependencies.installOn(MyBaseController);
146
+ ```
147
+
148
+ `installOn` exposes every registered name as a getter on that controller class, so controllers read the dependency directly:
149
+
150
+ ```js
151
+ class InvoicesController extends MyBaseController {
152
+ async getIndex() {
153
+ return await this.billingHelper.invoicesFor(this.currentUser);
154
+ }
155
+ }
156
+ ```
157
+
158
+ Anything else reads them off the registry:
159
+
160
+ ```js
161
+ const helper = AppDependencies.get('billingHelper'); // null when nothing is registered
162
+ ```
163
+
164
+ Registration order does not matter — a dependency registered after `installOn` still gets its accessor. A controller that already defines the name keeps its own property. `reset()` clears the registry, which tests need so their doubles do not leak into the next one.
134
165
 
135
166
 
136
167
  ## Documentation
@@ -244,6 +244,17 @@ const InstanceMethods = {
244
244
  /***********************************************************************************************
245
245
  * FILE RENDERING
246
246
  ***********************************************************************************************/
247
+ renderBinary(body, { contentType, filename } = {}) {
248
+ if (this.rendered) return;
249
+ if (this.isStreaming) return;
250
+
251
+ this._response.setHeader('Content-Type', contentType);
252
+ if (filename) this._response.attachment(filename);
253
+ this._send(body);
254
+ this.markRendered();
255
+ },
256
+
257
+
247
258
  renderFile(filePath, { contentType, cacheControl, maxAge } = {}) {
248
259
  if (!filePath) throw new Error('renderFile requires a filePath');
249
260
  const resolvedType = contentType || guessContentType(filePath);
@@ -70,6 +70,29 @@ Don't set `this.statusCode` directly — use the helper that pairs the status wi
70
70
  After a render helper runs, the action does not need to return — the response has already been sent.
71
71
 
72
72
 
73
+ ## Binary responses
74
+
75
+ `render` JSON encodes its body, which corrupts anything that is not text. Use `renderBinary` for a
76
+ body you already hold in memory — a buffer proxied from another service, a generated document.
77
+
78
+ ```js
79
+ async getRecordRecording() {
80
+ const { body, contentType } = await fetchRecording(sid);
81
+ this.renderBinary(body, { contentType });
82
+ }
83
+ ```
84
+
85
+ Naming a `filename` sets `Content-Disposition`, so the browser downloads the body instead of
86
+ displaying it:
87
+
88
+ ```js
89
+ this.renderBinary(csv, { contentType: 'text/csv', filename: 'report.csv' });
90
+ ```
91
+
92
+ To send a file from disk rather than from memory, use `renderFile(path, { contentType,
93
+ cacheControl, maxAge })` instead — it streams and guesses the content type from the extension.
94
+
95
+
73
96
  ## Error handling
74
97
 
75
98
  ```js
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vida-global/core",
3
- "version": "2.4.0",
3
+ "version": "2.4.1",
4
4
  "description": "Core libraries for supporting Vida development",
5
5
  "author": "",
6
6
  "license": "ISC",
@@ -0,0 +1,93 @@
1
+ const { AppDependencies } = require('../../lib/appDependencies');
2
+
3
+
4
+ class FooController {}
5
+ class BarController {}
6
+
7
+
8
+ afterEach(() => AppDependencies.reset());
9
+
10
+
11
+ describe('AppDependencies', () => {
12
+ describe('#get', () => {
13
+ it ('returns what was registered', () => {
14
+ const helper = { name: 'helper' };
15
+
16
+ AppDependencies.setDependency('myHelper', helper);
17
+
18
+ expect(AppDependencies.get('myHelper')).toBe(helper);
19
+ });
20
+
21
+
22
+ // Null rather than undefined, so a missing dependency reads the same as one registered as
23
+ // nothing and callers can test it with a single check.
24
+ it ('returns null for a name nothing registered', () => {
25
+ expect(AppDependencies.get('nothingHere')).toBeNull();
26
+ });
27
+
28
+
29
+ it ('replaces a name that is registered twice', () => {
30
+ AppDependencies.setDependency('myHelper', 'first');
31
+ AppDependencies.setDependency('myHelper', 'second');
32
+
33
+ expect(AppDependencies.get('myHelper')).toBe('second');
34
+ });
35
+ });
36
+
37
+
38
+ describe('#reset', () => {
39
+ it ('forgets everything registered', () => {
40
+ AppDependencies.setDependency('myHelper', 'something');
41
+
42
+ AppDependencies.reset();
43
+
44
+ expect(AppDependencies.get('myHelper')).toBeNull();
45
+ });
46
+ });
47
+
48
+
49
+ describe('#installOn', () => {
50
+ it ('exposes what was registered before the install as a getter', () => {
51
+ AppDependencies.setDependency('myHelper', 'early');
52
+
53
+ AppDependencies.installOn(FooController);
54
+
55
+ expect(new FooController().myHelper).toBe('early');
56
+ });
57
+
58
+
59
+ // Wiring order must not matter: a dependency registered after the install still appears.
60
+ it ('exposes what is registered after the install', () => {
61
+ AppDependencies.installOn(FooController);
62
+
63
+ AppDependencies.setDependency('lateHelper', 'late');
64
+
65
+ expect(new FooController().lateHelper).toBe('late');
66
+ });
67
+
68
+
69
+ it ('reads through, so a later replacement is picked up', () => {
70
+ AppDependencies.installOn(FooController);
71
+ AppDependencies.setDependency('myHelper', 'first');
72
+ const controller = new FooController();
73
+
74
+ AppDependencies.setDependency('myHelper', 'second');
75
+
76
+ expect(controller.myHelper).toBe('second');
77
+ });
78
+
79
+
80
+ // A controller that defines the name itself keeps its own.
81
+ it ('does not overwrite a property the controller already has', () => {
82
+ Object.defineProperty(BarController.prototype, 'ownProperty', {
83
+ configurable: true,
84
+ get() { return 'mine' },
85
+ });
86
+
87
+ AppDependencies.setDependency('ownProperty', 'theirs');
88
+ AppDependencies.installOn(BarController);
89
+
90
+ expect(new BarController().ownProperty).toBe('mine');
91
+ });
92
+ });
93
+ });
@@ -751,6 +751,65 @@ describe('VidaServerController', () => {
751
751
  });
752
752
 
753
753
 
754
+ describe('#renderBinary', () => {
755
+ let request;
756
+ let response;
757
+ let controller;
758
+
759
+
760
+ beforeEach(() => {
761
+ request = {};
762
+ response = {
763
+ setHeader: jest.fn(),
764
+ attachment: jest.fn(),
765
+ send: jest.fn(),
766
+ };
767
+ controller = new FooController(request, response);
768
+ });
769
+
770
+
771
+ it ('sends the body untouched with the given content type', () => {
772
+ const body = Buffer.from([0x00, 0x01, 0x02]);
773
+
774
+ controller.renderBinary(body, { contentType: 'audio/wav' });
775
+
776
+ expect(response.setHeader).toHaveBeenCalledWith('Content-Type', 'audio/wav');
777
+ expect(response.send).toHaveBeenCalledWith(body);
778
+ });
779
+
780
+
781
+ it ('marks the response rendered', () => {
782
+ controller.renderBinary(Buffer.from('x'), { contentType: 'audio/wav' });
783
+
784
+ expect(controller.rendered).toBe(true);
785
+ });
786
+
787
+
788
+ it ('sets a download filename when one is given', () => {
789
+ controller.renderBinary(Buffer.from('x'), { contentType: 'audio/wav', filename: 'call.wav' });
790
+
791
+ expect(response.attachment).toHaveBeenCalledWith('call.wav');
792
+ });
793
+
794
+
795
+ // Without a filename the body is displayed rather than downloaded.
796
+ it ('sets no filename when none is given', () => {
797
+ controller.renderBinary(Buffer.from('x'), { contentType: 'audio/wav' });
798
+
799
+ expect(response.attachment).not.toHaveBeenCalled();
800
+ });
801
+
802
+
803
+ it ('is a no-op once something has already been rendered', () => {
804
+ controller.markRendered();
805
+
806
+ controller.renderBinary(Buffer.from('x'), { contentType: 'audio/wav' });
807
+
808
+ expect(response.send).not.toHaveBeenCalled();
809
+ });
810
+ });
811
+
812
+
754
813
  describe('#renderFile', () => {
755
814
  let request;
756
815
  let response;