UNPKG

tailorx

Version:

Tailor assembles a web page from multiple fragments

306 lines (268 loc) 9.71 kB
'use strict'; const EventEmitter = require('events').EventEmitter; const PassThrough = require('stream').PassThrough; const zlib = require('zlib'); const ContentLengthStream = require('./streams/content-length-stream'); const parseLinkHeader = require('./parse-link-header'); const { globalTracer, Tags } = require('opentracing'); const tracer = globalTracer(); const hasValue = value => { if (value || value === '') { return true; } return false; }; const getFragmentAssetUris = (refs, assetSize) => { const scriptUris = []; const styleUris = []; for (const ref of refs) { if (ref.rel === 'fragment-script') { scriptUris.push(ref.uri); } else if (ref.rel === 'stylesheet') { styleUris.push(ref.uri); } } return [scriptUris.slice(0, assetSize), styleUris.slice(0, assetSize)]; }; /** * Merge the attributes based on the fragment tag attributes and context * * @param {object} tag - Fragment tag from the template * @param {object=} context - Context object for the given fragment * @returns {object} */ const getAttributes = (tag, context) => { const attributes = Object.assign({}, tag.attributes); const fragmentId = attributes.id; if (context && fragmentId && context[fragmentId]) { const fragmentCtxt = context[fragmentId]; Object.assign(attributes, fragmentCtxt); } const { src, async: isAsync, primary, public: isPublic, timeout, 'return-headers': returnHeaders, 'forward-querystring': forwardQuerystring, 'ignore-invalid-ssl': ignoreInvalidSsl, ...rest } = attributes; return { ...rest, url: src, id: fragmentId, async: hasValue(isAsync), primary: hasValue(primary), public: hasValue(isPublic), timeout: parseInt(timeout || 3000, 10), returnHeaders: hasValue(returnHeaders), forwardQuerystring: hasValue(forwardQuerystring), ignoreInvalidSsl: hasValue(ignoreInvalidSsl) }; }; /** * Class representing a Fragment * @extends EventEmitter */ module.exports = class Fragment extends EventEmitter { /** * Create a Fragment * @param {Object} tag - Fragment tag from the template * @param {object} context - Context object for the given fragment * @param {number} index - Order of the fragment * @param {function} requestFragment - Function to request the fragment * @param {number} maxAssetLinks - Number of `Link` Header directives for CSS and JS respected per fragment * @param {object} fragmentHooks * @param {function} fragmentHooks.insertStart * @param {function} fragmentHooks.insertEnd */ constructor({ tag, context, index, requestFragment, maxAssetLinks, fragmentHooks = {} } = {}) { super(); this.attributes = getAttributes(tag, context); this.index = index; this.maxAssetLinks = maxAssetLinks; this.requestFragment = requestFragment; this.fragmentHooks = fragmentHooks; this.stream = new PassThrough(); this.scriptRefs = []; this.styleRefs = []; } /** * Handles fetching the fragment * @param {object} request - HTTP request stream * @param {object} parentSpan - opentracing Span that will be the parent of the current operation * @returns {object} Fragment response streams in case of synchronous fragment or buffer in case of async fragment */ fetch(request, parentSpan = null) { this.emit('start'); let url = this.attributes.url; const spanOptions = parentSpan ? { childOf: parentSpan } : {}; const span = tracer.startSpan('fetch_fragment', spanOptions); const { id, primary, async: isAsync, public: isPublic, timeout } = this.attributes; span.addTags({ [Tags.SPAN_KIND]: Tags.SPAN_KIND_RPC_CLIENT, [Tags.HTTP_URL]: url, public: isPublic, async: isAsync, id: id || 'unnamed', primary, timeout }); if (this.attributes.forwardQuerystring) { const origUrl = new URL('http://fake' + request.url); const fragmentUrl = new URL(url); origUrl.searchParams.forEach((v, k) => { if (fragmentUrl.searchParams.has(k)) { return; } fragmentUrl.searchParams.append(k, v); }); url = fragmentUrl.toString(); } this.requestFragment(url, this.attributes, request, span).then( res => this.onResponse(res, span), err => { span.setTag(Tags.ERROR, true); span.log({ message: err.message }); this.emit('error', err); this.stream.end(); span.finish(); } ); // Async fragments are piped later on the page if (isAsync) { //TODO: to be implemented return Buffer.from( '<!-- Async fragments are not fully implemented yet -->' ); } return this.stream; } /** * Handle the fragment response * @param {object} response - HTTP response stream from fragment * @param {object} span - fetch-fragment opentracing span */ onResponse(response, span) { const { statusCode, headers } = response; // Extract the assets from fragment link headers. const refs = parseLinkHeader( [headers.link, headers['x-amz-meta-link']].join(',') ); if (refs.length > 0) { [this.scriptRefs, this.styleRefs] = getFragmentAssetUris( refs, this.maxAssetLinks ); } this.emit('response', statusCode, headers); this.insertStart(headers); //TODO: add error handling for this hook, now it causes UnhandledPromiseRejection const contentLengthStream = new ContentLengthStream(contentLength => { this.emit('end', contentLength); }); contentLengthStream.on('end', () => { this.insertEnd(headers); //TODO: add error handling for this hook, now it causes UnhandledPromiseRejection this.stream.end(); span.finish(); }); const handleError = err => { this.emit('warn', err); span.setTag(Tags.ERROR, true); span.log({ message: err.message }); contentLengthStream.end(); }; // Handle errors on all piped streams response.on('error', handleError); contentLengthStream.on('error', handleError); // Unzip the fragment response if gzipped before piping it to the Client(Browser) - Composition will break otherwise let responseStream = response; const contentEncoding = headers['content-encoding']; if ( contentEncoding && (contentEncoding === 'gzip' || contentEncoding === 'deflate') ) { let unzipStream = zlib.createUnzip(); unzipStream.on('error', handleError); responseStream = response.pipe(unzipStream); } responseStream .pipe(contentLengthStream) .pipe(this.stream, { end: false }); } /** * Insert the placeholder for pipe assets and load the required JS and CSS assets at the start of fragment stream * * - JS assets are loading via AMD(requirejs) for both sync and async fragments * - CSS for the async fragments are loaded using custom loadCSS(available in src/pipe.js) */ insertStart(headers) { const { async: isAsync, id } = this.attributes; this.stream.write( `<!-- Fragment #${this.index}${ this.attributes.id ? ` "${this.attributes.id}"` : '' } START -->` ); if (this.fragmentHooks.insertStart) { this.fragmentHooks.insertStart( this.stream, this.attributes, headers, this.index ); } else { this.styleRefs.forEach(uri => { this.stream.write( isAsync ? `<!-- Async fragments are not fully implemented yet: ${uri} -->` : `<link rel="stylesheet" href="${uri}"${ id !== undefined ? ` data-fragment-id="${id}"` : '' }>` ); }); } } /** * Insert the placeholder for pipe assets at the end of fragment stream */ insertEnd(headers) { const { id } = this.attributes; if (this.fragmentHooks.insertEnd) { this.fragmentHooks.insertEnd( this.stream, this.attributes, headers, this.index ); } else { this.scriptRefs.reverse().forEach(uri => { this.stream.write( `<script type="text/javascript" src="${uri}"${ id !== undefined ? ` data-fragment-id="${id}"` : '' }></script>` ); }); } this.stream.write( `<!-- Fragment #${this.index}${ this.attributes.id ? ` "${this.attributes.id}"` : '' } END -->` ); } };