@adobe/aem-cli 15.0.0

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.
@@ -0,0 +1,457 @@
1
+ /*
2
+ * Copyright 2018 Adobe. All rights reserved.
3
+ * This file is licensed to you under the Apache License, Version 2.0 (the "License");
4
+ * you may not use this file except in compliance with the License. You may obtain a copy
5
+ * of the License at http://www.apache.org/licenses/LICENSE-2.0
6
+ *
7
+ * Unless required by applicable law or agreed to in writing, software distributed under
8
+ * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9
+ * OF ANY KIND, either express or implied. See the License for the specific language
10
+ * governing permissions and limitations under the License.
11
+ */
12
+ import fs from 'fs-extra';
13
+ import crypto from 'crypto';
14
+ import path from 'path';
15
+ import { Socket } from 'net';
16
+ import { PassThrough } from 'stream';
17
+ import cookie from 'cookie';
18
+ import { getFetch } from '../fetch-utils.js';
19
+
20
+ const utils = {
21
+ status2level(status, debug3xx) {
22
+ if (status < 300) {
23
+ return 'debug';
24
+ }
25
+ if (status < 400) {
26
+ return debug3xx ? 'debug' : 'info';
27
+ }
28
+ if (status < 500) {
29
+ return 'warn';
30
+ }
31
+ return 'error';
32
+ },
33
+
34
+ /**
35
+ * Checks if the file addressed by the given filename exists and is a regular file.
36
+ * @param {String} filename Path to file
37
+ * @returns {Promise} Returns promise that resolves with the filename or rejects if is not a file.
38
+ */
39
+ async isFile(filename) {
40
+ const stats = await fs.stat(filename);
41
+ if (!stats.isFile()) {
42
+ throw Error(`no regular file: ${filename}`);
43
+ }
44
+ return filename;
45
+ },
46
+
47
+ /**
48
+ * Fetches content from the given uri
49
+ * @param {String} uri URL to fetch
50
+ * @param {RequestContext} ctx the context
51
+ * @param {object} auth authentication object ({@see https://github.com/request/request#http-authentication})
52
+ * @returns {Buffer} The requested content or NULL if not exists.
53
+ */
54
+ async fetch(ctx, uri, auth) {
55
+ const headers = {
56
+ 'X-Request-Id': ctx.requestId,
57
+ };
58
+ if (auth) {
59
+ headers.authorization = `Bearer ${auth}`;
60
+ }
61
+ const res = await getFetch()(uri, {
62
+ cache: 'no-store',
63
+ headers,
64
+ });
65
+ const body = await res.buffer();
66
+ if (!res.ok) {
67
+ const level = utils.status2level(res.status);
68
+ ctx.log[level](`resource at ${uri} does not exist. got ${res.status} from server`);
69
+ return null;
70
+ }
71
+ return body;
72
+ },
73
+
74
+ /**
75
+ * Injects the live-reload script
76
+ * @param {string} body the html body
77
+ * @param {HelixServer} server the proxy server
78
+ * @returns {string} the modified body
79
+ */
80
+ injectLiveReloadScript(body, server) {
81
+ let match = body.match(/<\/head>/i);
82
+ if (!match) {
83
+ match = body.match(/<\/body>/i);
84
+ }
85
+ if (!match) {
86
+ match = body.match(/<\/html>/i);
87
+ }
88
+ // don't inject if no html found at all.
89
+ if (match) {
90
+ const { index } = match;
91
+ // eslint-disable-next-line no-param-reassign
92
+ let newbody = body.substring(0, index);
93
+ if (process.env.CODESPACES === 'true') {
94
+ newbody += `<script>
95
+ window.LiveReloadOptions = {
96
+ host: new URL(location.href).hostname.replace(/-[0-9]+\\.preview\\.app\\.github\\.dev/, '-35729.preview.app.github.dev'),
97
+ port: 443,
98
+ https: true,
99
+ };
100
+ </script>`;
101
+ } else {
102
+ newbody += `<script>window.LiveReloadOptions={port:${server.port},host:location.hostname,https:${server.scheme === 'https'}};</script>`;
103
+ }
104
+ newbody += '<script src="/__internal__/livereload.js"></script>';
105
+ newbody += body.substring(index);
106
+ return newbody;
107
+ }
108
+ return body;
109
+ },
110
+
111
+ /**
112
+ * Injects meta tags
113
+ * @param {string} body the html body
114
+ * @param {object} props meta properties
115
+ * @returns {string} the modified body
116
+ */
117
+ injectMeta(body, props) {
118
+ const match = body.match(/<\/head>/i);
119
+ if (!match) {
120
+ return body;
121
+ }
122
+ const text = Object.entries(props).map(([property, content]) => {
123
+ const c = content
124
+ .replace(/&/g, '&amp;')
125
+ .replace(/"/g, '&quot;');
126
+ return `<meta property="${property}" content="${c}">`;
127
+ }).join('\n');
128
+
129
+ const { index } = match;
130
+ return `${body.substring(0, index)}${text}${body.substring(index)}`;
131
+ },
132
+
133
+ /**
134
+ * Computes a path to store a cache objet from a url
135
+ * @param {string} url the url
136
+ * @param {string} directory a local directory path
137
+ * @returns {string} the computed path
138
+ */
139
+ computePathForCache(url, directory) {
140
+ const u = new URL(url);
141
+ const { pathname, search } = u;
142
+ let fileName = pathname.substring(1) || 'index.html';
143
+ if (search) {
144
+ let qs = search.substring(1); // remove leading '?'
145
+ if (fileName.length + qs.length > 255) {
146
+ // try with query string as md5
147
+ qs = crypto.createHash('md5').update(qs).digest('hex');
148
+ }
149
+ if (fileName.length + qs.length <= 255) {
150
+ const index = fileName.lastIndexOf('.');
151
+ if (index > -1) {
152
+ // inject qs before extension
153
+ fileName = `${fileName.substring(0, index)}!${qs}${fileName.substring(index)}`;
154
+ } else {
155
+ fileName = `${fileName}!${qs}`;
156
+ }
157
+ } else {
158
+ // still too long, use md5 as filename
159
+ fileName = crypto.createHash('md5').update(`${fileName}${search.substring(1)}`).digest('hex');
160
+ }
161
+ }
162
+ return path.resolve(directory, fileName);
163
+ },
164
+
165
+ /**
166
+ * Writes a partial request object to a local file
167
+ * @param {string} url the request url
168
+ * @param {string} directory a local directory path
169
+ * @param {Object} ret the partial request object ({ body, headers, status })
170
+ * @param {Logger} logger a logger
171
+ */
172
+ async writeToCache(url, directory, { body, headers, status }, logger) {
173
+ try {
174
+ const filePath = utils.computePathForCache(url, directory);
175
+ const parent = path.dirname(filePath);
176
+ logger.debug(`Not in cache, saving: ${filePath}`);
177
+ await fs.ensureDir(parent);
178
+ await fs.writeFile(filePath, body);
179
+ await fs.writeJSON(`${filePath}.json`, { headers, status });
180
+ } catch (error) {
181
+ logger.error(error);
182
+ }
183
+ },
184
+
185
+ /**
186
+ * Returns a partial request object ({ body, headers, status }) from a local file
187
+ * @param {string} url the request url
188
+ * @param {string} directory a local directory path
189
+ * @param {Logger} logger a logger
190
+ * @returns {Object} the partial request object ({ body, headers, status }). Null if not found.
191
+ */
192
+ async getFromCache(url, directory, logger) {
193
+ try {
194
+ const filePath = utils.computePathForCache(url, directory);
195
+ logger.debug(`Trying from cache first: ${filePath}`);
196
+
197
+ if (await fs.pathExists(filePath)) {
198
+ const body = await fs.readFile(filePath);
199
+ const { headers, status } = await fs.readJSON(`${filePath}.json`);
200
+ return { body, headers, status };
201
+ }
202
+ } catch (error) {
203
+ logger.error(error);
204
+ }
205
+ return null;
206
+ },
207
+
208
+ /**
209
+ * Fetches the content from the url and streams it back to the response.
210
+ * @param {RequestContext} ctx Context
211
+ * @param {string} url The url to fetch from
212
+ * @param {Request} req The original express request
213
+ * @param {Response} res The express response
214
+ * @param {object} opts additional request options
215
+ * @return {Promise} A promise that resolves when the stream is done.
216
+ */
217
+ async proxyRequest(ctx, url, req, res, opts = {}) {
218
+ ctx.log.debug(`Proxy ${req.method} request to ${url}`);
219
+
220
+ if (opts.cacheDirectory) {
221
+ const cached = await utils.getFromCache(url, opts.cacheDirectory, ctx.log);
222
+ if (cached) {
223
+ res
224
+ .status(cached.status)
225
+ .set(cached.headers)
226
+ .send(cached.body);
227
+ return;
228
+ }
229
+ }
230
+
231
+ let body;
232
+ // GET and HEAD requests can't have a body
233
+ if (!['GET', 'HEAD'].includes(req.method)) {
234
+ body = new PassThrough();
235
+ req.pipe(body);
236
+ }
237
+ const stream = new PassThrough();
238
+ req.pipe(stream);
239
+ const headers = {
240
+ 'x-forwarded-host': `localhost:${ctx.config.server.port}`,
241
+ 'x-forwarded-scheme': 'http',
242
+ ...req.headers,
243
+ ...(opts.headers || {}),
244
+ };
245
+ // preserve hlx-auth-token cookie
246
+ const cookies = cookie.parse(headers.cookie || '');
247
+ delete headers.cookie;
248
+ const hlxAuthToken = cookies['hlx-auth-token'];
249
+ if (hlxAuthToken) {
250
+ headers.cookie = new URLSearchParams({
251
+ 'hlx-auth-token': hlxAuthToken,
252
+ }).toString();
253
+ }
254
+ delete headers.connection;
255
+ delete headers['proxy-connection'];
256
+ delete headers.host;
257
+ const ret = await getFetch()(url, {
258
+ method: req.method,
259
+ headers,
260
+ cache: 'no-store',
261
+ body,
262
+ redirect: 'manual',
263
+ });
264
+ const contentType = ret.headers.get('content-type') || 'text/plain';
265
+ const level = utils.status2level(ret.status, true);
266
+ ctx.log[level](`Proxy ${req.method} request to ${url}: ${ret.status} (${contentType})`);
267
+
268
+ // because fetch decodes the response, we need to reset content encoding and length
269
+ const respHeaders = Object.fromEntries(ret.headers.entries());
270
+ delete respHeaders['content-encoding'];
271
+ delete respHeaders['content-length'];
272
+ delete respHeaders['x-frame-options'];
273
+ delete respHeaders['content-security-policy'];
274
+ respHeaders['access-control-allow-origin'] = '*';
275
+ respHeaders.via = `${ret.httpVersion ?? '1.0'} ${new URL(url).hostname}`;
276
+
277
+ if (ret.status === 404 && contentType.indexOf('text/html') === 0 && opts.file404html) {
278
+ ctx.log.debug('serve local 404.html', opts.file404html);
279
+ let textBody = await fs.readFile(opts.file404html, 'utf-8');
280
+ if (opts.injectLiveReload) {
281
+ textBody = utils.injectLiveReloadScript(textBody, ctx.config.server);
282
+ }
283
+ res
284
+ .status(404)
285
+ .set(respHeaders)
286
+ .send(textBody);
287
+ return;
288
+ }
289
+
290
+ const isHTML = ret.status === 200 && contentType.indexOf('text/html') === 0;
291
+ const livereload = isHTML && opts.injectLiveReload;
292
+ const replaceHead = isHTML && opts.headHtml && opts.headHtml.isModified;
293
+ const doIndex = isHTML && opts.indexer && url.indexOf('.plain.html') < 0;
294
+
295
+ if (isHTML) {
296
+ let respBody;
297
+ let textBody;
298
+ if (contentType.startsWith('text/')) {
299
+ textBody = await ret.text();
300
+ } else {
301
+ respBody = await ret.buffer();
302
+ }
303
+ const lines = ['----------------------------->'];
304
+ if (ctx.log.level === 'silly') {
305
+ lines.push(`${req.method} ${url}`);
306
+ Object.entries(headers).forEach(([name, value]) => {
307
+ lines.push(`${name}: ${value}`);
308
+ });
309
+ lines.push('');
310
+ lines.push('<-----------------------------');
311
+ lines.push('');
312
+ lines.push(`http/${ret.httpVersion} ${ret.status} ${ret.statusText}`);
313
+ ret.headers.forEach((name, value) => {
314
+ lines.push(`${name}: ${value}`);
315
+ });
316
+ lines.push('');
317
+ if (respBody) {
318
+ lines.push(`<binary ${respBody.length} bytes>`);
319
+ } else {
320
+ lines.push(textBody);
321
+ }
322
+ ctx.log.trace(lines.join('\n'));
323
+ }
324
+ if (replaceHead) {
325
+ textBody = await opts.headHtml.replace(textBody);
326
+ }
327
+ if (livereload) {
328
+ textBody = utils.injectLiveReloadScript(textBody, ctx.config.server);
329
+ }
330
+ textBody = utils.injectMeta(textBody, {
331
+ 'hlx:proxyUrl': url,
332
+ });
333
+
334
+ if (doIndex) {
335
+ opts.indexer.onData(url, {
336
+ body: textBody,
337
+ headers: respHeaders,
338
+ });
339
+ }
340
+
341
+ if (opts.cacheDirectory) {
342
+ await utils.writeToCache(
343
+ url,
344
+ opts.cacheDirectory,
345
+ {
346
+ body: respBody || textBody,
347
+ headers: respHeaders,
348
+ status: ret.status,
349
+ },
350
+ ctx.log,
351
+ );
352
+ }
353
+
354
+ res
355
+ .status(ret.status)
356
+ .set(respHeaders)
357
+ .send(respBody || textBody);
358
+ return;
359
+ }
360
+
361
+ if (opts.cacheDirectory) {
362
+ const buffer = await ret.buffer();
363
+ await utils.writeToCache(
364
+ url,
365
+ opts.cacheDirectory,
366
+ {
367
+ body: buffer,
368
+ headers: respHeaders,
369
+ status: ret.status,
370
+ },
371
+ ctx.log,
372
+ );
373
+ res
374
+ .status(ret.status)
375
+ .set(respHeaders)
376
+ .send(buffer);
377
+ return;
378
+ }
379
+
380
+ res
381
+ .status(ret.status)
382
+ .set(respHeaders);
383
+ ret.body.pipe(res);
384
+ },
385
+
386
+ /**
387
+ * Generates a random string of the given `length` consisting of alpha numerical characters.
388
+ * if `hex` is {@code true}, the string will only consist of hexadecimal digits.
389
+ * @param {number}length length of the string.
390
+ * @param {boolean} hex returns a hex string if {@code true}
391
+ * @returns {String} a random string.
392
+ */
393
+ randomChars(length, hex = false) {
394
+ if (length === 0) {
395
+ return '';
396
+ }
397
+ if (hex) {
398
+ return crypto.randomBytes(Math.round(length / 2)).toString('hex').substring(0, length);
399
+ }
400
+ const str = crypto.randomBytes(length).toString('base64');
401
+ return str.substring(0, length);
402
+ },
403
+
404
+ /**
405
+ * Checks if the given port is already in use on any addr. This is used to prevent starting a
406
+ * server on the same port with an existing socket bound to 0.0.0.0 and SO_REUSEADDR.
407
+ * @param port
408
+ @param addr * @return {Promise} that resolves `true` if the port is in use.
409
+ */
410
+ checkPortInUse(port, addr = '0.0.0.0') {
411
+ return new Promise((resolve, reject) => {
412
+ let socket;
413
+
414
+ const cleanUp = () => {
415
+ if (socket) {
416
+ socket.removeAllListeners('connect');
417
+ socket.removeAllListeners('error');
418
+ socket.end();
419
+ socket.destroy();
420
+ socket.unref();
421
+ socket = null;
422
+ }
423
+ };
424
+
425
+ socket = new Socket();
426
+ socket.once('error', (err) => {
427
+ if (err.code !== 'ECONNREFUSED') {
428
+ reject(err);
429
+ } else {
430
+ resolve(false);
431
+ }
432
+ cleanUp();
433
+ });
434
+
435
+ socket.connect(port, addr, () => {
436
+ resolve(true);
437
+ cleanUp();
438
+ });
439
+ });
440
+ },
441
+
442
+ /**
443
+ * Rewrites all absolute urls to the proxy host with relative ones.
444
+ * @param {Buffer} html
445
+ * @param {string} host
446
+ * @returns {Buffer}
447
+ */
448
+ rewriteUrl(html, host) {
449
+ const hostPattern = host.replaceAll('.', '\\.');
450
+ let text = html.toString('utf-8');
451
+ const re = new RegExp(`(src|href)\\s*=\\s*(["'])${hostPattern}(/.*?)?(['"])`, 'gm');
452
+ text = text.replaceAll(re, (match, arg, q1, value, q2) => (`${arg}=${q1}${value || '/'}${q2}`));
453
+ return Buffer.from(text, 'utf-8');
454
+ },
455
+ };
456
+
457
+ export default Object.freeze(utils);
package/src/up.cmd.js ADDED
@@ -0,0 +1,171 @@
1
+ /*
2
+ * Copyright 2018 Adobe. All rights reserved.
3
+ * This file is licensed to you under the Apache License, Version 2.0 (the "License");
4
+ * you may not use this file except in compliance with the License. You may obtain a copy
5
+ * of the License at http://www.apache.org/licenses/LICENSE-2.0
6
+ *
7
+ * Unless required by applicable law or agreed to in writing, software distributed under
8
+ * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9
+ * OF ANY KIND, either express or implied. See the License for the specific language
10
+ * governing permissions and limitations under the License.
11
+ */
12
+ import path from 'path';
13
+ import fse from 'fs-extra';
14
+ import chalk from 'chalk-template';
15
+ import chokidar from 'chokidar';
16
+ import { HelixProject } from './server/HelixProject.js';
17
+ import GitUtils from './git-utils.js';
18
+ import pkgJson from './package.cjs';
19
+ import { getFetch } from './fetch-utils.js';
20
+ import { AbstractServerCommand } from './abstract-server.cmd.js';
21
+
22
+ export default class UpCommand extends AbstractServerCommand {
23
+ withLiveReload(value) {
24
+ this._liveReload = value;
25
+ return this;
26
+ }
27
+
28
+ withUrl(value) {
29
+ this._url = value;
30
+ return this;
31
+ }
32
+
33
+ withPrintIndex(value) {
34
+ this._printIndex = value;
35
+ return this;
36
+ }
37
+
38
+ async doStop() {
39
+ await super.doStop();
40
+ if (this._watcher) {
41
+ const watcher = this._watcher;
42
+ delete this._watcher;
43
+ await watcher.close();
44
+ }
45
+ }
46
+
47
+ async init() {
48
+ await super.init();
49
+ // check for git repository
50
+ try {
51
+ const stat = await fse.lstat(path.resolve(this.directory, '.git'));
52
+ if (stat.isFile()) {
53
+ throw Error('git submodules are not supported.');
54
+ }
55
+ } catch (e) {
56
+ if (e.code === 'ENOENT') {
57
+ throw Error('hlx up needs local git repository.');
58
+ }
59
+ throw e;
60
+ }
61
+
62
+ // init dev default file params
63
+ this._project = new HelixProject()
64
+ .withCwd(this.directory)
65
+ .withLiveReload(this._liveReload)
66
+ .withLogger(this._logger)
67
+ .withKill(this._kill)
68
+ .withPrintIndex(this._printIndex);
69
+ this.log.info(chalk`{yellow ___ ________ ___ __ __ v${pkgJson.version}}`);
70
+ this.log.info(chalk`{yellow / | / ____/ |/ / _____(_)___ ___ __ __/ /___ _/ /_____ _____}`);
71
+ this.log.info(chalk`{yellow / /| | / __/ / /|_/ / / ___/ / __ \`__ \\/ / / / / __ \`/ __/ __ \\/ ___/}`);
72
+ this.log.info(chalk`{yellow / ___ |/ /___/ / / / (__ ) / / / / / / /_/ / / /_/ / /_/ /_/ / /}`);
73
+ this.log.info(chalk`{yellow /_/ |_/_____/_/ /_/ /____/_/_/ /_/ /_/\\__,_/_/\\__,_/\\__/\\____/_/}`);
74
+ this.log.info('');
75
+
76
+ const ref = await GitUtils.getBranch(this.directory);
77
+ const gitUrl = await GitUtils.getOriginURL(this.directory, { ref });
78
+ let explicitURL = true;
79
+ if (!this._url) {
80
+ explicitURL = false;
81
+ // check if remote already has the `ref`
82
+ await this.verifyUrl(gitUrl, ref);
83
+ }
84
+ this._project.withProxyUrl(this._url);
85
+ await this.initSeverOptions();
86
+
87
+ try {
88
+ await this._project.init();
89
+
90
+ if (!explicitURL) {
91
+ this.watchGit();
92
+ }
93
+ } catch (e) {
94
+ throw Error(`Unable to start AEM: ${e.message}`);
95
+ }
96
+
97
+ this._project.on('stopped', async () => {
98
+ await this.stop();
99
+ });
100
+ }
101
+
102
+ async verifyUrl(gitUrl, inref) {
103
+ let ref = inref;
104
+
105
+ // replace `/` by `-` in ref.
106
+ ref = ref.replace(/\//g, '-');
107
+ this._url = `https://${ref}--${gitUrl.repo}--${gitUrl.owner}.hlx.page`;
108
+ // check length limit
109
+ if (this._url.split('.')
110
+ .map((part) => part.replace(/^https:\/\//, ''))
111
+ .some((part) => part.length > 63)) {
112
+ this.log.error(chalk`URL {yellow ${this._url}} exceeds the 63 character limit for DNS labels.`);
113
+ this.log.error(chalk`Please use a shorter branch name or a shorter repository name.`);
114
+ await this.stop();
115
+ throw Error('branch name too long');
116
+ }
117
+
118
+ const fstabUrl = `${this._url}/fstab.yaml`;
119
+ const resp = await getFetch()(fstabUrl);
120
+ await resp.buffer();
121
+ if (!resp.ok) {
122
+ if (ref === 'main') {
123
+ this.log.warn(chalk`Unable to verify {yellow main} branch via {blue ${fstabUrl}} (${resp.status}). Maybe not pushed yet?`);
124
+ } else {
125
+ this.log.warn(chalk`Unable to verify {yellow ${ref}} branch on {blue ${fstabUrl}} (${resp.status}). Fallback to {yellow main} branch.`);
126
+ this._url = `https://main--${gitUrl.repo}--${gitUrl.owner}.hlx.page`;
127
+ }
128
+ }
129
+ }
130
+
131
+ /**
132
+ * Watches the git repository for changes and restarts the server if necessary.
133
+ */
134
+ watchGit() {
135
+ let timer = null;
136
+
137
+ this._watcher = chokidar.watch(path.resolve(this._project.directory, '.git'), {
138
+ persistent: true,
139
+ ignoreInitial: true,
140
+ });
141
+
142
+ this._watcher.on('all', (eventType, file) => {
143
+ if (file.endsWith('.git/HEAD') || file.endsWith('.git\\HEAD') || file.match(/\.git[/\\]refs[/\\]heads[/\\].+/)) {
144
+ if (timer) {
145
+ clearTimeout(timer);
146
+ }
147
+ // debounce a bit in case several files are changed at once
148
+ timer = setTimeout(async () => {
149
+ timer = null;
150
+ if (!this._watcher) {
151
+ // watcher was closed in the meantime
152
+ return;
153
+ }
154
+ try {
155
+ // restart if any of the files is not ignored
156
+ this.log.info('git HEAD or remotes changed, reconfiguring server...');
157
+ const ref = await GitUtils.getBranch(this.directory);
158
+ const gitUrl = await GitUtils.getOriginURL(this.directory, { ref });
159
+ await this.verifyUrl(gitUrl, ref);
160
+ this._project.withProxyUrl(this._url);
161
+ await this._project.initHeadHtml();
162
+ this.log.info(`Updated proxy to ${this._url}`);
163
+ this.emit('changed', this);
164
+ } catch {
165
+ // ignore
166
+ }
167
+ }, 100);
168
+ }
169
+ });
170
+ }
171
+ }