@vida-global/core 2.4.1 → 2.4.3

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.
@@ -12,6 +12,7 @@ class AppDependencies {
12
12
  setDependency(name, dependency) {
13
13
  this.#dependencies[name] = dependency;
14
14
  this.#defineAccessor(name);
15
+ this.#defineSingletonAccessor(name);
15
16
  }
16
17
 
17
18
 
@@ -26,6 +27,17 @@ class AppDependencies {
26
27
  }
27
28
 
28
29
 
30
+ #defineSingletonAccessor(name) {
31
+ if (Object.getOwnPropertyDescriptor(this, name)) return;
32
+ if (name in this) return;
33
+
34
+ Object.defineProperty(this, name, {
35
+ configurable: false,
36
+ get: () => this.get(name)
37
+ });
38
+ }
39
+
40
+
29
41
  // Registering after `installOn` still defines the accessor, so wiring order does not matter.
30
42
  // An existing property is left alone: a controller that defines the name itself wins.
31
43
  #defineAccessor(name) {
@@ -248,13 +248,53 @@ const InstanceMethods = {
248
248
  if (this.rendered) return;
249
249
  if (this.isStreaming) return;
250
250
 
251
+ this._response.setHeader('Accept-Ranges', 'bytes');
252
+ const range = Buffer.isBuffer(body) ? this._requestedByteRange(body.length) : null;
253
+ if (range?.unsatisfiable) {
254
+ this._response.status(416);
255
+ this._response.setHeader('Content-Range', `bytes */${body.length}`);
256
+ this._send('');
257
+ this.markRendered();
258
+ return;
259
+ }
260
+
251
261
  this._response.setHeader('Content-Type', contentType);
252
262
  if (filename) this._response.attachment(filename);
263
+ if (range) {
264
+ this._response.status(206);
265
+ this._response.setHeader('Content-Range', `bytes ${range.start}-${range.end}/${body.length}`);
266
+ body = body.subarray(range.start, range.end + 1);
267
+ }
253
268
  this._send(body);
254
269
  this.markRendered();
255
270
  },
256
271
 
257
272
 
273
+ // Single-range only: a multi-range request gets the whole body, which media players handle
274
+ // better than a multipart/byteranges reply.
275
+ _requestedByteRange(totalBytes) {
276
+ const header = this._request.headers?.range;
277
+ if (typeof header !== 'string') return null;
278
+
279
+ const match = /^bytes=(\d*)-(\d*)$/.exec(header.trim());
280
+ if (!match || (!match[1] && !match[2])) return null;
281
+
282
+ let start, end;
283
+ if (!match[1]) {
284
+ const suffix = Number(match[2]);
285
+ if (suffix === 0) return { unsatisfiable: true };
286
+ start = Math.max(0, totalBytes - suffix);
287
+ end = totalBytes - 1;
288
+ } else {
289
+ start = Number(match[1]);
290
+ end = match[2] ? Math.min(Number(match[2]), totalBytes - 1) : totalBytes - 1;
291
+ }
292
+
293
+ if (start >= totalBytes || start > end) return { unsatisfiable: true };
294
+ return { start, end };
295
+ },
296
+
297
+
258
298
  renderFile(filePath, { contentType, cacheControl, maxAge } = {}) {
259
299
  if (!filePath) throw new Error('renderFile requires a filePath');
260
300
  const resolvedType = contentType || guessContentType(filePath);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vida-global/core",
3
- "version": "2.4.1",
3
+ "version": "2.4.3",
4
4
  "description": "Core libraries for supporting Vida development",
5
5
  "author": "",
6
6
  "license": "ISC",
@@ -46,6 +46,44 @@ describe('AppDependencies', () => {
46
46
  });
47
47
 
48
48
 
49
+ describe('singleton accessors', () => {
50
+ it ('exposes a registered name as a property on the singleton', () => {
51
+ const helper = { name: 'helper' };
52
+
53
+ AppDependencies.setDependency('singletonHelper', helper);
54
+
55
+ expect(AppDependencies.singletonHelper).toBe(helper);
56
+ });
57
+
58
+
59
+ it ('reads through, so a later replacement is picked up', () => {
60
+ AppDependencies.setDependency('replacedHelper', 'first');
61
+
62
+ AppDependencies.setDependency('replacedHelper', 'second');
63
+
64
+ expect(AppDependencies.replacedHelper).toBe('second');
65
+ });
66
+
67
+
68
+ it ('reads null after a reset', () => {
69
+ AppDependencies.setDependency('resetHelper', 'something');
70
+
71
+ AppDependencies.reset();
72
+
73
+ expect(AppDependencies.resetHelper).toBeNull();
74
+ });
75
+
76
+
77
+ // `get`, `reset`, and friends must survive a hostile dependency name.
78
+ it ('does not overwrite the singleton API', () => {
79
+ AppDependencies.setDependency('get', 'not a function');
80
+
81
+ expect(typeof AppDependencies.get).toBe('function');
82
+ expect(AppDependencies.get('get')).toBe('not a function');
83
+ });
84
+ });
85
+
86
+
49
87
  describe('#installOn', () => {
50
88
  it ('exposes what was registered before the install as a getter', () => {
51
89
  AppDependencies.setDependency('myHelper', 'early');
@@ -758,11 +758,12 @@ describe('VidaServerController', () => {
758
758
 
759
759
 
760
760
  beforeEach(() => {
761
- request = {};
761
+ request = { headers: {} };
762
762
  response = {
763
763
  setHeader: jest.fn(),
764
764
  attachment: jest.fn(),
765
765
  send: jest.fn(),
766
+ status: jest.fn(),
766
767
  };
767
768
  controller = new FooController(request, response);
768
769
  });
@@ -774,6 +775,52 @@ describe('VidaServerController', () => {
774
775
  controller.renderBinary(body, { contentType: 'audio/wav' });
775
776
 
776
777
  expect(response.setHeader).toHaveBeenCalledWith('Content-Type', 'audio/wav');
778
+ expect(response.setHeader).toHaveBeenCalledWith('Accept-Ranges', 'bytes');
779
+ expect(response.status).not.toHaveBeenCalled();
780
+ expect(response.send).toHaveBeenCalledWith(body);
781
+ });
782
+
783
+
784
+ it.each([
785
+ ['a bounded range', 'bytes=2-5', 'bytes 2-5/10', '2345'],
786
+ ['an open-ended range', 'bytes=7-', 'bytes 7-9/10', '789'],
787
+ ['a suffix range', 'bytes=-3', 'bytes 7-9/10', '789'],
788
+ ['an overshooting end', 'bytes=8-99', 'bytes 8-9/10', '89'],
789
+ ])('serves %s as 206 Partial Content', (_label, range, contentRange, slice) => {
790
+ request.headers.range = range;
791
+
792
+ controller.renderBinary(Buffer.from('0123456789'), { contentType: 'audio/wav' });
793
+
794
+ expect(response.status).toHaveBeenCalledWith(206);
795
+ expect(response.setHeader).toHaveBeenCalledWith('Content-Range', contentRange);
796
+ expect(response.send).toHaveBeenCalledWith(Buffer.from(slice));
797
+ });
798
+
799
+
800
+ it ('answers an unsatisfiable range with 416 and the total size', () => {
801
+ request.headers.range = 'bytes=10-';
802
+
803
+ controller.renderBinary(Buffer.from('0123456789'), { contentType: 'audio/wav' });
804
+
805
+ expect(response.status).toHaveBeenCalledWith(416);
806
+ expect(response.setHeader).toHaveBeenCalledWith('Content-Range', 'bytes */10');
807
+ expect(response.send).toHaveBeenCalledWith('');
808
+ expect(controller.rendered).toBe(true);
809
+ });
810
+
811
+
812
+ it.each([
813
+ ['a multi-range request', 'bytes=0-1,5-9'],
814
+ ['an empty range', 'bytes=-'],
815
+ ['a non-byte unit', 'items=0-5'],
816
+ ['garbage', 'garbage'],
817
+ ])('serves the whole body for %s', (_label, range) => {
818
+ request.headers.range = range;
819
+ const body = Buffer.from('0123456789');
820
+
821
+ controller.renderBinary(body, { contentType: 'audio/wav' });
822
+
823
+ expect(response.status).not.toHaveBeenCalled();
777
824
  expect(response.send).toHaveBeenCalledWith(body);
778
825
  });
779
826