UNPKG

23.6 kBJavaScriptView Raw
1/* eslint-disable consistent-return */
2/* eslint-disable no-return-assign */
3/* eslint-disable no-else-return */
4/* eslint-disable default-case */
5/* eslint-disable no-param-reassign */
6
7'use strict';
8
9const url = require('url');
10const JSONStream = require('JSONStream');
11const request = require('request');
12const JsWriter = require('@asset-pipe/js-writer');
13const CssWriter = require('@asset-pipe/css-writer');
14const assert = require('assert');
15const { extname } = require('path');
16const isStream = require('is-stream');
17const Joi = require('joi');
18const Boom = require('boom');
19const { hashArray } = require('@asset-pipe/common');
20const Metrics = require('@metrics/client');
21const abslog = require('abslog');
22const AssetDevMiddleware = require('@asset-pipe/dev-middleware');
23const ow = require('ow');
24const schemas = require('./schemas');
25const { buildURL } = require("./utils");
26
27function getStream(stream) {
28 const data = [];
29 return new Promise((resolve, reject) => {
30 stream.once('error', reject);
31 stream.on('data', chunk => data.push(chunk));
32 stream.on('end', () => resolve(data));
33 });
34}
35
36const extensions = Object.freeze({
37 JS: '.js',
38 CSS: '.css'
39});
40
41const endpoints = Object.freeze({
42 UPLOAD: 'feed',
43 BUNDLE: 'bundle',
44 PUBLISH_ASSETS: 'publish-assets',
45 PUBLISH_INSTRUCTIONS: 'publish-instructions'
46});
47
48const assetTypes = ['js', 'css'];
49
50function post(options) {
51 const opts = {
52 url: options.url,
53 headers: {
54 'content-type': 'application/json',
55 accept: 'application/json'
56 }
57 };
58
59 if (options.serverId) {
60 opts.headers['origin-server-id'] = options.serverId;
61 }
62
63 if (!isStream(options.body)) {
64 opts.body = options.body;
65 }
66
67 return new Promise((resolve, reject) => {
68 const req = request.post(opts, (error, response, body) => {
69 if (error) return reject(error);
70 try {
71 body = JSON.parse(body);
72 } catch (err) {} // eslint-disable-line no-empty
73 resolve({ response, body });
74 });
75
76 if (isStream(options.body)) {
77 options.body.pipe(req);
78 }
79 });
80}
81
82function get(uri) {
83 return new Promise((resolve, reject) => {
84 request(uri, (error, response, body) => {
85 if (error) return reject(error);
86 resolve({ response, body });
87 });
88 });
89}
90
91module.exports = class AssetPipeClient {
92 constructor({
93 serverId,
94 server,
95 buildServerUri,
96 minify,
97 sourceMaps,
98 logger,
99 tag,
100 development,
101 rebundle = true
102 } = {}) {
103 this.buildServerUri = server || buildServerUri;
104 assert(
105 this.buildServerUri,
106 `Expected "server" to be a uri, got ${this.buildServerUri}`
107 );
108 this.publicFeedUrl = null;
109 this.publicBundleUrl = null;
110 this.serverId = serverId;
111
112 this.transforms = [];
113 this.plugins = [];
114 this.minify = minify;
115 this.sourceMaps = sourceMaps;
116 this.metrics = new Metrics();
117 this.log = abslog(logger);
118 this.tag = tag;
119 this.development = Boolean(development);
120 this.instructions = { js: [], css: [] };
121 this.assets = { js: null, css: null };
122 this.hashes = { js: null, css: null };
123 this.bundleURLs = { js: '', css: '' };
124 this.publishPromises = [];
125 this.bundlePromises = [];
126 this.verifyingBundle = { js: false, css: false };
127 this.rebundle = rebundle;
128
129 this.publishAllMetric = this.metrics.histogram({
130 name: 'publish_all_assets_timer',
131 description: 'Time spent on publishing all assets to asset server',
132 buckets: [1, 5, 10, 15, 20, 30, 60, 120]
133 });
134
135 this.publishMetric = this.metrics.histogram({
136 name: 'publish_assets_timer',
137 description: 'Time spent publishing assets',
138 buckets: [1, 5, 10, 15, 20, 30, 60, 120]
139 });
140
141 this.publishInstructionsMetric = this.metrics.histogram({
142 name: 'publish_instructions_timer',
143 description: 'Time spent publishing instructions',
144 buckets: [1, 5, 10, 15, 20, 30, 60, 120]
145 });
146 }
147
148 transform(transform, options) {
149 this.transforms.push({
150 transform,
151 options
152 });
153 }
154
155 plugin(plugin, options) {
156 this.plugins.push({
157 plugin,
158 options
159 });
160 }
161
162 uploadJsFeed(files, options) {
163 const writer = new JsWriter(files, options);
164
165 this.plugins.forEach(entry => {
166 writer.plugin(entry.plugin, entry.options);
167 });
168
169 this.transforms.forEach(entry => {
170 writer.transform(entry.transform, entry.options);
171 });
172
173 return post({
174 url: url.resolve(this.buildServerUri, `${endpoints.UPLOAD}/js`),
175 body: writer.bundle().pipe(JSONStream.stringify()),
176 serverId: this.serverId
177 });
178 }
179
180 uploadCssFeed(files) {
181 const writer = new CssWriter(files);
182 return post({
183 url: url.resolve(this.buildServerUri, `${endpoints.UPLOAD}/css`),
184 body: writer.bundle().pipe(JSONStream.stringify()),
185 serverId: this.serverId
186 });
187 }
188
189 /**
190 * Upload asset feed to asset server
191 */
192
193 async uploadFeed(files, options = {}) {
194 assert(
195 Array.isArray(files),
196 `Expected 'files' to be an array, instead got ${files}`
197 );
198 assert(
199 files.length,
200 `Expected 'files' array to contain at least 1 item`
201 );
202 assert(
203 files.every(file => typeof file === 'string'),
204 `Expected each item in array 'files' to be a string, got ${files}`
205 );
206 assert(
207 files.every(file => file.includes('.js')) ||
208 files.every(file => file.includes('.css')),
209 `Expected ALL items in array 'files' to end with .js or ALL items in array 'files' to end with .css, got ${files.join(
210 ', '
211 )}`
212 );
213
214 let upload;
215
216 switch (extname(files[0])) {
217 case extensions.CSS:
218 upload = await this.uploadCssFeed(files);
219 break;
220 case extensions.JS:
221 upload = await this.uploadJsFeed(files, options);
222 break;
223 }
224
225 const { response, body } = upload;
226 if (response.statusCode === 200) {
227 return Promise.resolve(body);
228 }
229 if (response.statusCode === 400) {
230 throw new Error(body.message);
231 }
232 throw new Error(
233 `Asset build server responded with unknown error. Http status ${response.statusCode}`
234 );
235 }
236
237 /**
238 * Make a bundle out of asset feeds on the asset server
239 */
240
241 async createRemoteBundle(sources, type) {
242 assert(
243 Array.isArray(sources),
244 `Expected argument 'sources' to be an array. Instead got ${typeof sources}`
245 );
246 assert(
247 sources.every(source => typeof source === 'string'),
248 `Expected all entries in array 'sources' to be strings. Instead got ${sources}`
249 );
250 assert(
251 sources.every(source => source.includes('.json')),
252 `Expected ALL items in array 'sources' to end with .json. Instead got ${sources}`
253 );
254 assert(
255 assetTypes.includes(type),
256 `Expected argument 'type' to be one of ${assetTypes.join(
257 '|'
258 )}. Instead got '${type}'`
259 );
260 const { response, body } = await post({
261 url: url.resolve(
262 this.buildServerUri,
263 `${endpoints.BUNDLE}/${type}`
264 ),
265 body: JSON.stringify(sources),
266 serverId: this.serverId
267 });
268
269 const { statusCode } = response;
270
271 if ([200, 202].includes(statusCode)) {
272 return Promise.resolve(body);
273 }
274
275 const { message } = body;
276
277 if (statusCode === 400) {
278 throw new Error(message);
279 }
280
281 throw new Error(
282 `Asset build server responded with unknown error. Http status ${statusCode}. Original message: "${message}".`
283 );
284 }
285
286 writer(type, files, options) {
287 Joi.assert(
288 type,
289 schemas.type,
290 `Invalid 'type' argument given when attempting to determine writer.`
291 );
292
293 if (type === 'css') {
294 return new CssWriter(files);
295 } else {
296 const writer = new JsWriter(files, options);
297
298 this.plugins.forEach(entry => {
299 writer.plugin(entry.plugin, entry.options);
300 });
301
302 this.transforms.forEach(entry => {
303 writer.transform(entry.transform, entry.options);
304 });
305
306 return writer;
307 }
308 }
309
310 determineType(values) {
311 return extname((values && values[0]) || '').replace('.', '');
312 }
313
314 async publishAssets(tag, entrypoints, options = {}) {
315 Joi.assert(
316 tag,
317 schemas.tag,
318 `Invalid 'tag' argument given when attempting to publish assets.`
319 );
320
321 if (entrypoints && !Array.isArray(entrypoints)) {
322 entrypoints = [entrypoints];
323 }
324
325 Joi.assert(
326 entrypoints,
327 schemas.files,
328 `Invalid 'entrypoints' argument given when attempting to publish assets.`
329 );
330 Joi.assert(
331 options,
332 schemas.options,
333 `Invalid 'options' argument given when attempting to publish assets.`
334 );
335
336 const opts = {
337 minify: this.minify,
338 sourceMaps: this.sourceMaps,
339 rebundle: this.rebundle,
340 ...options
341 };
342
343 const metricEnd = this.publishMetric.timer({
344 labels: { assetType: null, statusCode: null }
345 });
346
347 const type = options.type || this.determineType(entrypoints);
348
349 try {
350 const writer = this.writer(type, entrypoints, options);
351
352 const data = await getStream(writer.bundle());
353
354 this.log.trace(
355 `${type} asset feed produced for tag "${tag}", entrypoints "${JSON.stringify(
356 entrypoints
357 )}" and options "${JSON.stringify(options)}"`
358 );
359
360 const u = buildURL(
361 url.resolve(
362 this.buildServerUri,
363 `${endpoints.PUBLISH_ASSETS}`
364 ),
365 {
366 minify: opts.minify,
367 sourceMaps: opts.sourceMaps,
368 rebundle: opts.rebundle
369 }
370 );
371
372 const {
373 response: { statusCode },
374 body
375 } = await post({
376 url: u,
377 body: JSON.stringify({
378 tag,
379 type,
380 data
381 }),
382 serverId: this.serverId
383 });
384
385 const { message } = body;
386
387 if (statusCode === 200) {
388 metricEnd({ labels: { assetType: type, statusCode } });
389 this.log.debug(
390 `${type} asset feed successfully published to asset server "${this.buildServerUri}" as files "${body.id}.json" and "${body.id}.${type}"`
391 );
392 return body;
393 }
394
395 if (statusCode === 400) {
396 throw Boom.badRequest(message);
397 }
398
399 throw new Boom(
400 `Asset build server responded with an error. Original message: ${message}. Status code: ${statusCode}. URL: ${u}`,
401 {
402 url: u,
403 statusCode
404 }
405 );
406 } catch (err) {
407 metricEnd({
408 labels: { assetType: type, statusCode: err.statusCode || 500 }
409 });
410
411 throw err;
412 }
413 }
414
415 async publishInstructions(tag, type, data, options = {}) {
416 Joi.assert(
417 tag,
418 schemas.tag,
419 `Invalid 'tag' argument given when attempting to publish instructions.`
420 );
421 Joi.assert(
422 type,
423 schemas.type,
424 `Invalid 'type' argument given when attempting to publish instructions.`
425 );
426 Joi.assert(
427 data,
428 schemas.bundleInstruction,
429 `Invalid 'data' argument given when attempting to publish instructions.`
430 );
431
432 const opts = {
433 minify: this.minify,
434 sourceMaps: this.sourceMaps,
435 ...options
436 };
437
438 const metricEnd = this.publishInstructionsMetric.timer({
439 labels: { assetType: null, statusCode: null }
440 });
441
442 try {
443 const u = buildURL(
444 url.resolve(
445 this.buildServerUri,
446 `${endpoints.PUBLISH_INSTRUCTIONS}`
447 ),
448 { minify: opts.minify, sourceMaps: opts.sourceMaps }
449 );
450 const {
451 response: { statusCode },
452 body
453 } = await post({
454 url: u,
455 body: JSON.stringify({ tag, type, data }),
456 serverId: this.serverId
457 });
458
459 const { message } = body;
460
461 if ([200, 204].includes(statusCode)) {
462 metricEnd({ labels: { assetType: type, statusCode } });
463 this.log.debug(
464 `${type} asset bundling instructions successfully published to asset server "${
465 this.buildServerUri
466 }" using tag "${tag}", data "${JSON.stringify(
467 data
468 )}" and options "${JSON.stringify(options)}"`
469 );
470 return Promise.resolve(body);
471 }
472
473 if (statusCode === 400) {
474 throw Boom.badRequest(message);
475 }
476
477 throw new Boom(
478 `Asset build server responded with an error. Original message: ${message}.`,
479 {
480 url: u,
481 statusCode
482 }
483 );
484 } catch (err) {
485 metricEnd({
486 labels: { assetType: type, statusCode: err.statusCode || 500 }
487 });
488
489 throw err;
490 }
491 }
492
493 async sync() {
494 if (!this.publicFeedUrl || !this.publicBundleUrl) {
495 const { body } = await get(
496 url.resolve(this.buildServerUri, '/sync/')
497 );
498 try {
499 const parsedBody = JSON.parse(body);
500 this.publicFeedUrl = parsedBody.publicFeedUrl;
501 this.publicBundleUrl = parsedBody.publicBundleUrl;
502 this.log.debug(
503 `asset server sync successfully performed against asset server "${this.buildServerUri}", publicFeedUrl set to "${this.publicFeedUrl}" and publicBundleUrl set to "${this.publicBundleUrl}"`
504 );
505 } catch (err) {
506 throw Boom.boomify(err, {
507 message:
508 'Unable to perform client/server sync as server returned an unparsable response'
509 });
510 }
511 }
512 }
513
514 bundleHash(feedHashes) {
515 return hashArray(feedHashes);
516 }
517
518 bundleFilename(hash, type) {
519 return `${hash}.${type}`;
520 }
521
522 bundleURL(feedHashes, options = {}) {
523 assert(
524 Array.isArray(feedHashes),
525 `Expected argument 'feedHashes' to be an array when calling 'bundleURL(feedHashes)'. Instead 'feedHashes' was ${typeof feedHashes}`
526 );
527 assert(
528 feedHashes.every(source => typeof source === 'string'),
529 `Expected all entries in array 'feedHashes' to be strings when calling 'bundleURL(feedHashes)'. Instead 'feedHashes' was ${feedHashes}`
530 );
531
532 if (feedHashes.length === 0) return null;
533
534 const { type, prefix } = {
535 prefix:
536 this.publicBundleUrl ||
537 url.resolve(this.buildServerUri, '/bundle/'),
538 type: 'js',
539 ...options
540 };
541 const hash = this.bundleHash(feedHashes);
542 const filename = this.bundleFilename(hash, type);
543 return url.resolve(prefix, filename);
544 }
545
546 async bundlingComplete(feedHashes, options = {}) {
547 assert(
548 Array.isArray(feedHashes),
549 `Expected argument 'feedHashes' to be an array when calling 'bundlingComplete(feedHashes)'. Instead 'feedHashes' was ${typeof feedHashes}`
550 );
551 assert(
552 feedHashes.every(source => typeof source === 'string'),
553 `Expected all entries in array 'feedHashes' to be strings when calling 'bundlingComplete(feedHashes)'. Instead 'feedHashes' was ${feedHashes}`
554 );
555
556 const uri = this.bundleURL(feedHashes, options);
557 if (!uri) return true;
558 const { response } = await get(uri);
559 return response.statusCode >= 200 && response.statusCode < 300;
560 }
561
562 middleware() {
563 const middlewares = [
564 (req, res, next) =>
565 this.ready()
566 .then(() => next())
567 .catch(next)
568 ];
569
570 if (this.development) {
571 const devMiddleware = new AssetDevMiddleware(
572 [this.assets.js].filter(Boolean),
573 [this.assets.css].filter(Boolean)
574 );
575
576 this.plugins.forEach(entry => {
577 devMiddleware.plugin(entry.plugin, entry.options);
578 });
579
580 this.transforms.forEach(entry => {
581 devMiddleware.transform(entry.transform, entry.options);
582 });
583
584 // middleware to serve up development assets at /js and /css
585 middlewares.push(devMiddleware.router());
586 }
587
588 // middleware to ensure that assets are uploaded
589 return middlewares;
590 }
591
592 js() {
593 return this.hashes.js;
594 }
595
596 css() {
597 return this.hashes.css;
598 }
599
600 publish(options = { js: null, css: null }) {
601 ow(options, ow.object.hasAnyKeys('js', 'css'));
602 const { js = null, css = null } = options;
603 ow(js, ow.any(ow.null, ow.string));
604 ow(css, ow.any(ow.null, ow.string));
605
606 const metricEnd = this.publishAllMetric.timer();
607
608 this.publishPromises = [];
609 this.assets = { js, css };
610
611 if (js) {
612 options.type = 'js';
613 const promise = this.publishAssets(this.tag, js, options).then(
614 ({ id }) => (this.hashes.js = id)
615 );
616
617 this.publishPromises.push(promise);
618 }
619
620 if (css) {
621 options.type = 'css';
622 const promise = this.publishAssets(this.tag, css, options).then(
623 ({ id }) => (this.hashes.css = id)
624 );
625
626 this.publishPromises.push(promise);
627 }
628
629 return Promise.all(this.publishPromises).then(() => {
630 metricEnd();
631 return this.hashes;
632 });
633 }
634
635 bundle(options = { js: [], css: [] }) {
636 ow(options, ow.object.hasAnyKeys('js', 'css'));
637 const { js = [], css = [] } = options;
638 ow(js, ow.array.ofType(ow.string));
639 ow(css, ow.array.ofType(ow.string));
640
641 this.bundlePromises = [];
642 this.instructions = { js, css };
643 if (this.development) return Promise.resolve();
644
645 if (js.length) {
646 const promise = this.publishInstructions(
647 this.tag,
648 'js',
649 js,
650 options
651 );
652 this.bundlePromises.push(promise);
653 }
654
655 if (css.length) {
656 const promise = this.publishInstructions(this.tag, 'css', css);
657 this.bundlePromises.push(promise);
658 }
659
660 return Promise.all(this.bundlePromises);
661 }
662
663 assetUrlByType(hashes = [], type) {
664 ow(hashes, ow.array.ofType(ow.string));
665 ow(type, ow.string.oneOf(['js', 'css']));
666
667 const tags = this.instructions[type];
668 if (!hashes.length) {
669 this.log.trace(
670 `${type} "hashes" argument is an empty array, did you pass forget to pass this in when calling .scripts(hashes) or .styles(hashes)?`
671 );
672 return [];
673 }
674
675 if (tags.length === hashes.length) {
676 const cachedBundleURL = this.bundleURLs[type];
677 const calculatedBundleURL = this.bundleURL(hashes, { type });
678
679 // success condition
680 if (cachedBundleURL === calculatedBundleURL) {
681 this.log.trace(
682 `returning optimal bundle "[${calculatedBundleURL}]" as already verified and cached`
683 );
684 return [cachedBundleURL];
685 }
686
687 if (!this.verifyingBundle[type]) {
688 this.log.trace(
689 `attempting to verify existence of optimal bundle "${calculatedBundleURL}"`
690 );
691 this.verifyingBundle[type] = true;
692 this.bundleURLs[type] = null;
693
694 // kicks off asynchronous bundle verification but does not wait.
695 // first call to .assetUrlByType() after verification will succeed.
696 this.bundlingComplete(hashes, { type })
697 .then(success => {
698 if (!success) {
699 return Promise.reject(
700 new Error('bundle verification incomplete')
701 );
702 }
703 this.log.trace(
704 `optimal bundle "${calculatedBundleURL}" successfully verified, inserting into cache`
705 );
706 this.bundleURLs[type] = calculatedBundleURL;
707 this.verifyingBundle[type] = false;
708 })
709 .catch(err => {
710 if (err.message !== 'bundle verification incomplete') {
711 this.log.error(err.message);
712 }
713 this.verifyingBundle[type] = false;
714 });
715 }
716 } else {
717 this.log.trace(
718 `length of arrays "tags" and "hashes" do not match, cannot produce optimal bundle`
719 );
720 }
721
722 const fallbacks = hashes.map(
723 hash => `${this.publicBundleUrl}${hash}.${type}`
724 );
725
726 this.log.trace(
727 `optimal requested bundle not currently available, falling back to individual bundles "${JSON.stringify(
728 fallbacks
729 )}"`
730 );
731
732 return fallbacks;
733 }
734
735 scripts(hashes = []) {
736 ow(hashes, ow.array.ofType(ow.string));
737
738 return this.assetUrlByType(hashes, 'js');
739 }
740
741 styles(hashes = []) {
742 ow(hashes, ow.array.ofType(ow.string));
743
744 return this.assetUrlByType(hashes, 'css');
745 }
746
747 async ready() {
748 await Promise.all([...this.publishPromises, ...this.bundlePromises]);
749
750 this.log.trace(
751 `client.ready(): all publishing and bundling operations have now successfully completed`
752 );
753
754 return true;
755 }
756};