UNPKG

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