UNPKG

8.68 kBJavaScriptView Raw
1'use strict';
2
3const ContentLengthStream = require('./streams/content-length-stream');
4const EventEmitter = require('events').EventEmitter;
5const PassThrough = require('stream').PassThrough;
6const LinkHeader = require('http-link-header');
7const zlib = require('zlib');
8
9/**
10 * Merge the attributes based on the fragment tag attributes and context
11 *
12 * @param {object} tag - Fragment tag from the template
13 * @param {object=} context - Context object for the given fragment
14 * @returns {object}
15 */
16const getAttributes = (tag, context) => {
17 const attributes = Object.assign({}, tag.attributes);
18 const fragmentId = attributes.id;
19 if (context && fragmentId && context[fragmentId]) {
20 const fragmentCtxt = context[fragmentId];
21 Object.assign(attributes, fragmentCtxt);
22 }
23
24 return {
25 url: attributes.src,
26 id: fragmentId,
27 fallbackUrl: attributes['fallback-src'],
28 timeout: parseInt(attributes.timeout || 3000, 10),
29 async: attributes.async,
30 primary: attributes.primary,
31 public: attributes.public
32 };
33};
34
35/**
36 * Class representing a Fragment
37 * @extends EventEmitter
38 */
39module.exports = class Fragment extends EventEmitter {
40 /**
41 * Create a Fragment
42 * @param {Object} tag - Fragment tag from the template
43 * @param {object} context - Context object for the given fragment
44 * @param {number} index - Order of the fragment
45 * @param {function} requestFragment - Function to request the fragment
46 * @param {string} pipeInstanceName - Pipe instance name that is available in the browser window for consuming hooks
47 */
48 constructor(
49 {
50 tag,
51 context,
52 index,
53 requestFragment,
54 pipeInstanceName,
55 maxAssetLinks,
56 pipeAttributes = () => {}
57 } = {}
58 ) {
59 super();
60 this.attributes = getAttributes(tag, context, index);
61 ['async', 'primary', 'public'].forEach(key => {
62 let value = this.attributes[key];
63 if (value || value === '') {
64 value = true;
65 } else {
66 value = false;
67 }
68 this.attributes[key] = value;
69 });
70
71 this.index = index;
72 this.maxAssetLinks = maxAssetLinks;
73 this.getPipeAttributes = () =>
74 pipeAttributes(
75 Object.assign({}, { id: this.index }, tag.attributes)
76 );
77 this.requestFragment = requestFragment;
78 this.pipeInstanceName = pipeInstanceName;
79 this.stream = new PassThrough();
80 }
81
82 /**
83 * Handles fetching the fragment
84 * @param {object} request - HTTP request stream
85 * @param {boolean} isFallback - decides between fragment and fallback URL
86 * @returns {object} Fragment response streams in case of synchronous fragment or buffer incase of async fragment
87 */
88 fetch(request, isFallback) {
89 if (!isFallback) {
90 this.emit('start');
91 }
92 const url = isFallback
93 ? this.attributes.fallbackUrl
94 : this.attributes.url;
95
96 this.requestFragment(url, this.attributes, request).then(
97 res => this.onResponse(res, isFallback),
98 err => {
99 if (!isFallback) {
100 const { fallbackUrl } = this.attributes;
101 if (fallbackUrl) {
102 this.emit('fallback', err);
103 this.fetch(request, true);
104 } else {
105 this.emit('error', err);
106 this.stream.end();
107 }
108 } else {
109 this.stream.end();
110 }
111 }
112 );
113 // Async fragments are piped later on the page
114 if (this.attributes.async) {
115 return Buffer.from(
116 `<script data-pipe>${this.pipeInstanceName}.placeholder(${this
117 .index})</script>`
118 );
119 }
120 return this.stream;
121 }
122
123 /**
124 * Handle the fragment response
125 * @param {object} response - HTTP response stream from fragment
126 * @param {boolean} isFallback - decides between response from fragment or fallback URL
127 */
128 onResponse(response, isFallback) {
129 const { statusCode, headers } = response;
130
131 if (!isFallback) {
132 this.emit('response', statusCode, headers);
133 }
134 // Extract the assets from fragment link headers.
135 const { refs } = LinkHeader.parse(
136 [headers.link, headers['x-amz-meta-link']].join(',')
137 );
138
139 this.scriptRefs = refs
140 .filter(ref => ref.rel === 'fragment-script')
141 .slice(0, this.maxAssetLinks)
142 .map(ref => ref.uri);
143 this.styleRefs = refs
144 .filter(ref => ref.rel === 'stylesheet')
145 .slice(0, this.maxAssetLinks)
146 .map(ref => ref.uri);
147
148 this.insertStart();
149
150 const contentLengthStream = new ContentLengthStream(contentLength => {
151 if (!isFallback) {
152 this.emit('end', contentLength);
153 }
154 });
155
156 contentLengthStream.on('end', () => {
157 this.insertEnd();
158 this.stream.end();
159 });
160
161 const handleError = err => {
162 this.emit('warn', err);
163 contentLengthStream.end();
164 };
165
166 // Handle errors on all piped streams
167 response.on('error', handleError);
168 contentLengthStream.on('error', handleError);
169
170 // Unzip the fragment response if gzipped before piping it to the Client(Browser) - Composition will break otherwise
171 let responseStream = response;
172 const contentEncoding = headers['content-encoding'];
173 if (
174 contentEncoding &&
175 (contentEncoding === 'gzip' || contentEncoding === 'deflate')
176 ) {
177 let unzipStream = zlib.createUnzip();
178 unzipStream.on('error', handleError);
179 responseStream = response.pipe(unzipStream);
180 }
181
182 responseStream
183 .pipe(contentLengthStream)
184 .pipe(this.stream, { end: false });
185 }
186
187 /**
188 * Insert the placeholder for pipe assets and load the required JS and CSS assets at the start of fragment stream
189 *
190 * - JS assets are loading via AMD(requirejs) for both sync and async fragments
191 * - CSS for the async fragments are loaded using custom loadCSS(available in src/pipe.js)
192 */
193 insertStart() {
194 this.styleRefs.forEach(uri => {
195 this.stream.write(
196 this.attributes.async
197 ? `<script>${this
198 .pipeInstanceName}.loadCSS("${uri}")</script>`
199 : `<link rel="stylesheet" href="${uri}">`
200 );
201 });
202
203 if (this.scriptRefs.length === 0) {
204 this.stream.write(
205 `<script data-pipe>${this.pipeInstanceName}.start(${this
206 .index})</script>`
207 );
208 this.index++;
209 return;
210 }
211
212 const range = [this.index, this.index + this.scriptRefs.length - 1];
213 const fragmentId = this.attributes.id || range[0];
214 this.scriptRefs.forEach(uri => {
215 const attributes = Object.assign({}, this.getPipeAttributes(), {
216 id: fragmentId,
217 range
218 });
219 this.stream.write(
220 `<script data-pipe>${this.pipeInstanceName}.start(${this
221 .index}, "${uri}", ${JSON.stringify(attributes)})</script>`
222 );
223 this.index++;
224 });
225 }
226
227 /**
228 * Insert the placeholder for pipe assets at the end of fragment stream
229 */
230 insertEnd() {
231 if (this.scriptRefs.length > 0) {
232 const range = [this.index - this.scriptRefs.length, this.index - 1];
233 this.index--;
234 const fragmentId = this.attributes.id || range[0];
235 this.scriptRefs.reverse().forEach(uri => {
236 const attributes = Object.assign({}, this.getPipeAttributes(), {
237 id: fragmentId,
238 range
239 });
240 this.stream.write(
241 `<script data-pipe>${this.pipeInstanceName}.end(${this
242 .index--}, "${uri}", ${JSON.stringify(
243 attributes
244 )})</script>`
245 );
246 });
247 } else {
248 this.stream.write(
249 `<script data-pipe>${this.pipeInstanceName}.end(${this.index -
250 1})</script>`
251 );
252 }
253 }
254};