From a6d05b09e9a7ae9ff8ae1b7ac1c83ad9cea58df9 Mon Sep 17 00:00:00 2001 From: Thomas Wang Date: Mon, 5 Apr 2021 22:07:51 -0700 Subject: [PATCH] Increment schema to latest 5 html-oriented manifest format ----------------------------- In the new schema format, which is defined in https://github.com/ember-fastboot/fastboot/pull/272/commits/3fd5bc90b63e2c497a8f519627556f9e94160065 the manifest is written into HTML and later extracted by fastboot on server side instead of previously reading from dist/package.json Note: The new schema in fastboot does not handle fastboot config https://github.com/ember-fastboot/ember-cli-fastboot/tree/e4d0b7c7bcdf82def0dc8726835b49d707673f41#providing-additional-config This commit changes to read Fastboot.config from dist/package.json instead of ignoring it Allow to require module path from whitelisted dependency ------------------------------------------------------- Incrementing schema to 5 also included the changes in schema 4 strictWhitelist See https://github.com/ember-fastboot/fastboot/pull/200 Revert back to put config in dist/package.json add data-fastboot-ignore to unexpected files properly ignore files that should not execute in fastboot --- packages/ember-cli-fastboot/index.js | 27 +++- .../lib/broccoli/fastboot-config.js | 75 ++++++----- .../lib/broccoli/html-writer.js | 123 ++++++++++++++++++ packages/ember-cli-fastboot/package.json | 1 + packages/fastboot/src/fastboot-schema.js | 3 +- packages/fastboot/src/html-entrypoint.js | 14 +- .../fixtures/html-entrypoint/package.json | 26 +++- .../assets/onerror-per-visit.js | 1 - .../fastboot/test/html-entrypoint-test.js | 30 +---- test-packages/integration-tests/package.json | 1 + 10 files changed, 231 insertions(+), 70 deletions(-) create mode 100644 packages/ember-cli-fastboot/lib/broccoli/html-writer.js diff --git a/packages/ember-cli-fastboot/index.js b/packages/ember-cli-fastboot/index.js index 41c20d52b..d7d4465bd 100644 --- a/packages/ember-cli-fastboot/index.js +++ b/packages/ember-cli-fastboot/index.js @@ -11,6 +11,7 @@ const chalk = require('chalk'); const fastbootAppBoot = require('./lib/utilities/fastboot-app-boot'); const FastBootConfig = require('./lib/broccoli/fastboot-config'); +const HTMLWriter = require('./lib/broccoli/html-writer'); const fastbootAppFactoryModule = require('./lib/utilities/fastboot-app-factory-module'); const migrateInitializers = require('./lib/build-utilities/migrate-initializers'); const SilentError = require('silent-error'); @@ -219,6 +220,7 @@ module.exports = { return finalFastbootTree; }, + // Note: this hook is ignored when built with embroider treeForPublic(tree) { let fastbootTree = this._getFastbootTree(); let trees = []; @@ -229,7 +231,7 @@ module.exports = { let newTree = new MergeTrees(trees); - let fastbootConfigTree = this._buildFastbootConfigTree(newTree); + let fastbootConfigTree = (this._fastbootConfigTree = this._buildFastbootConfigTree(newTree)); // Merge the package.json with the existing tree return new MergeTrees([newTree, fastbootConfigTree], { overwrite: true }); @@ -306,6 +308,29 @@ module.exports = { }); }, + /** + * Write fastboot-script tags to the html file + */ + postprocessTree(type, tree) { + this._super(...arguments); + if (type === 'all') { + let { fastbootConfig, appName, manifest } = this._fastbootConfigTree; + let fastbootHTMLTree = new HTMLWriter(tree, { + annotation: 'FastBoot HTML Writer', + fastbootConfig, + appName, + manifest, + appJsPath: this.app.options.outputPaths.app.js, + outputPaths: this.app.options.outputPaths, + }); + + // Merge the package.json with the existing tree + return new MergeTrees([tree, fastbootHTMLTree], { overwrite: true }); + } + + return tree; + }, + serverMiddleware(options) { let emberCliVersion = this._getEmberCliVersion(); let app = options.app; diff --git a/packages/ember-cli-fastboot/lib/broccoli/fastboot-config.js b/packages/ember-cli-fastboot/lib/broccoli/fastboot-config.js index d66c80a29..44e93167f 100644 --- a/packages/ember-cli-fastboot/lib/broccoli/fastboot-config.js +++ b/packages/ember-cli-fastboot/lib/broccoli/fastboot-config.js @@ -1,17 +1,17 @@ /* eslint-env node */ 'use strict'; -const fs = require('fs'); -const fmt = require('util').format; -const uniq = require('ember-cli-lodash-subset').uniq; -const merge = require('ember-cli-lodash-subset').merge; +const fs = require('fs'); +const fmt = require('util').format; +const uniq = require('ember-cli-lodash-subset').uniq; +const merge = require('ember-cli-lodash-subset').merge; const md5Hex = require('md5-hex'); -const path = require('path'); +const path = require('path'); const Plugin = require('broccoli-plugin'); const stringify = require('json-stable-stringify'); -const LATEST_SCHEMA_VERSION = 3; +const LATEST_SCHEMA_VERSION = 5; module.exports = class FastBootConfig extends Plugin { constructor(inputNode, options) { @@ -38,24 +38,28 @@ module.exports = class FastBootConfig extends Plugin { this.htmlFile = 'index.html'; } + this.prepareConfig(); + this.prepareDependencies(); } - /** * The main hook called by Broccoli Plugin. Used to build or * rebuild the tree. In this case, we generate the configuration * and write it to `package.json`. */ build() { - this.buildConfig(); - this.buildDependencies(); - this.buildManifest(); this.buildHostWhitelist(); - let outputPath = path.join(this.outputPath, 'package.json'); this.writeFileIfContentChanged(outputPath, this.toJSONString()); } + get manifest() { + if (!this._manifest) { + this._manifest = this.buildManifest(); + } + return this._manifest; + } + writeFileIfContentChanged(outputPath, content) { let previous = this._fileToChecksumMap[outputPath]; let next = md5Hex(content); @@ -66,11 +70,11 @@ module.exports = class FastBootConfig extends Plugin { } } - buildConfig() { + prepareConfig() { // we only walk the host app's addons to grab the config since ideally // addons that have dependency on other addons would never define // this advance hook. - this.project.addons.forEach((addon) => { + this.project.addons.forEach(addon => { if (addon.fastbootConfigTree) { let configFromAddon = addon.fastbootConfigTree(); @@ -83,7 +87,7 @@ module.exports = class FastBootConfig extends Plugin { }); } - buildDependencies() { + prepareDependencies() { let dependencies = {}; let moduleWhitelist = []; let ui = this.ui; @@ -97,7 +101,10 @@ module.exports = class FastBootConfig extends Plugin { if (dep in dependencies) { version = dependencies[dep]; - ui.writeLine(fmt("Duplicate FastBoot dependency %s. Versions may mismatch. Using range %s.", dep, version), ui.WARNING); + ui.writeLine( + fmt('Duplicate FastBoot dependency %s. Versions may mismatch. Using range %s.', dep, version), + ui.WARNING + ); return; } @@ -129,7 +136,7 @@ module.exports = class FastBootConfig extends Plugin { } updateFastBootManifest(manifest) { - this.project.addons.forEach(addon =>{ + this.project.addons.forEach(addon => { if (addon.updateFastBootManifest) { manifest = addon.updateFastBootManifest(manifest); @@ -157,7 +164,7 @@ module.exports = class FastBootConfig extends Plugin { htmlFile: this.htmlFile }; - this.manifest = this.updateFastBootManifest(manifest); + return this.updateFastBootManifest(manifest); } buildHostWhitelist() { @@ -167,17 +174,21 @@ module.exports = class FastBootConfig extends Plugin { } toJSONString() { - return stringify({ - dependencies: this.dependencies, - fastboot: { - moduleWhitelist: this.moduleWhitelist, - schemaVersion: LATEST_SCHEMA_VERSION, - manifest: this.manifest, - hostWhitelist: this.normalizeHostWhitelist(), - config: this.fastbootConfig, - appName: this.appName, - } - }, null, 2); + return stringify( + { + name: this.appName, + dependencies: this.dependencies, + fastboot: { + moduleWhitelist: this.moduleWhitelist, + schemaVersion: LATEST_SCHEMA_VERSION, + hostWhitelist: this.normalizeHostWhitelist(), + config: this.fastbootConfig, + htmlEntrypoint: this.manifest.htmlFile + } + }, + null, + 2 + ); } normalizeHostWhitelist() { @@ -194,7 +205,7 @@ module.exports = class FastBootConfig extends Plugin { } }); } -} +}; function eachAddonPackage(project, cb) { project.addons.map(addon => cb(addon.pkg)); @@ -207,7 +218,11 @@ function getFastBootDependencies(pkg) { } if (addon.fastBootDependencies) { - throw new SilentError('ember-addon.fastBootDependencies has been replaced with ember-addon.fastbootDependencies [addon: ' + pkg.name + ']') + throw new SilentError( + 'ember-addon.fastBootDependencies has been replaced with ember-addon.fastbootDependencies [addon: ' + + pkg.name + + ']' + ); } return addon.fastbootDependencies; diff --git a/packages/ember-cli-fastboot/lib/broccoli/html-writer.js b/packages/ember-cli-fastboot/lib/broccoli/html-writer.js new file mode 100644 index 000000000..07bdff388 --- /dev/null +++ b/packages/ember-cli-fastboot/lib/broccoli/html-writer.js @@ -0,0 +1,123 @@ +'use strict'; + +const Filter = require('broccoli-persistent-filter'); +const { JSDOM } = require('jsdom'); + +module.exports = class BasePageWriter extends Filter { + constructor(inputNodes, { annotation, fastbootConfig, appName, manifest, outputPaths }) { + super(inputNodes, { + annotation, + extensions: ['html'], + targetExtension: 'html', + }); + this._manifest = manifest; + this._rootURL = getRootURL(fastbootConfig, appName); + this._appJsPath = outputPaths.app.js; + this._expectedFiles = expectedFiles(outputPaths); + } + + getDestFilePath() { + let filteredRelativePath = super.getDestFilePath(...arguments); + + return filteredRelativePath === this._manifest.htmlFile ? filteredRelativePath : null; + } + + processString(content) { + let dom = new JSDOM(content); + let scriptTags = dom.window.document.querySelectorAll('script'); + + this._ignoreUnexpectedScripts(scriptTags); + + let fastbootScripts = this._findFastbootScriptToInsert(scriptTags); + let appJsTag = findAppJsTag(scriptTags, this._appJsPath, this._rootURL); + insertFastbootScriptsBeforeAppJsTags(fastbootScripts, appJsTag); + + return dom.serialize(); + } + + _findFastbootScriptToInsert(scriptTags) { + let rootURL = this._rootURL; + let scriptSrcs = []; + for (let element of scriptTags) { + scriptSrcs.push(urlWithin(element.getAttribute('src'), rootURL)); + } + + return this._manifest.vendorFiles + .concat(this._manifest.appFiles) + .map(src => urlWithin(src, rootURL)) + .filter(src => !scriptSrcs.includes(src)); + } + + _ignoreUnexpectedScripts(scriptTags) { + let expectedFiles = this._expectedFiles; + let rootURL = this._rootURL; + for (let element of scriptTags) { + if (!expectedFiles.includes(urlWithin(element.getAttribute('src'), rootURL))) { + element.setAttribute('data-fastboot-ignore', ''); + } + } + } +}; + +function expectedFiles(outputPaths) { + function stripLeadingSlash(filePath) { + return filePath.replace(/^\//, ''); + } + + let appFilePath = stripLeadingSlash(outputPaths.app.js); + let appFastbootFilePath = appFilePath.replace(/\.js$/, '') + '-fastboot.js'; + let vendorFilePath = stripLeadingSlash(outputPaths.vendor.js); + return [appFilePath, appFastbootFilePath, vendorFilePath]; +} + +function getRootURL(appName, config) { + let rootURL = (config[appName] && config[appName].rootURL) || '/'; + if (!rootURL.endsWith('/')) { + rootURL = rootURL + '/'; + } + return rootURL; +} + +function urlWithin(candidate, root) { + let candidateURL = new URL(candidate, 'http://_the_current_origin_'); + let rootURL = new URL(root, 'http://_the_current_origin_'); + if (candidateURL.href.startsWith(rootURL.href)) { + return candidateURL.href.slice(rootURL.href.length); + } +} + +function findAppJsTag(scriptTags, appJsPath, rootURL) { + appJsPath = urlWithin(appJsPath, rootURL); + for (let e of scriptTags) { + if (urlWithin(e.getAttribute('src'), rootURL) === appJsPath) { + return e; + } + } +} + +function insertFastbootScriptsBeforeAppJsTags(fastbootScripts, appJsTag) { + let range = new NodeRange(appJsTag); + + for (let src of fastbootScripts) { + range.insertAsScriptTag(src); + } +} + +class NodeRange { + constructor(initial) { + this.start = initial.ownerDocument.createTextNode(''); + initial.parentElement.insertBefore(this.start, initial); + this.end = initial; + } + + insertAsScriptTag(src) { + let newTag = this.end.ownerDocument.createElement('fastboot-script'); + newTag.setAttribute('src', src); + this.insertNode(newTag); + this.insertNode(this.end.ownerDocument.createTextNode('\n')); + } + + insertNode(node) { + this.end.parentElement.insertBefore(node, this.end); + } +} diff --git a/packages/ember-cli-fastboot/package.json b/packages/ember-cli-fastboot/package.json index c955c63f0..0b89ebf40 100644 --- a/packages/ember-cli-fastboot/package.json +++ b/packages/ember-cli-fastboot/package.json @@ -43,6 +43,7 @@ "fastboot-express-middleware": "3.2.0-beta.2", "fastboot-transform": "^0.1.3", "fs-extra": "^7.0.0", + "jsdom": "^16.2.2", "json-stable-stringify": "^1.0.1", "md5-hex": "^2.0.0", "recast": "^0.19.1", diff --git a/packages/fastboot/src/fastboot-schema.js b/packages/fastboot/src/fastboot-schema.js index 91fcede9c..c513f9c83 100644 --- a/packages/fastboot/src/fastboot-schema.js +++ b/packages/fastboot/src/fastboot-schema.js @@ -79,7 +79,8 @@ function loadConfig(distPath) { ({ appName, config, html, scripts } = loadManifest(distPath, pkg.fastboot, schemaVersion)); } else { appName = pkg.name; - ({ config, html, scripts } = htmlEntrypoint(appName, distPath, pkg.fastboot.htmlEntrypoint)); + config = pkg.fastboot.config; + ({ html, scripts } = htmlEntrypoint(appName, distPath, pkg.fastboot.htmlEntrypoint, config)); } let sandboxRequire = buildWhitelistedRequire( diff --git a/packages/fastboot/src/html-entrypoint.js b/packages/fastboot/src/html-entrypoint.js index 8e2a2213d..12d6191e4 100644 --- a/packages/fastboot/src/html-entrypoint.js +++ b/packages/fastboot/src/html-entrypoint.js @@ -4,21 +4,11 @@ const { JSDOM } = require('jsdom'); const fs = require('fs'); const path = require('path'); -function htmlEntrypoint(appName, distPath, htmlPath) { +function htmlEntrypoint(appName, distPath, htmlPath, config = {}) { let html = fs.readFileSync(path.join(distPath, htmlPath), 'utf8'); let dom = new JSDOM(html); let scripts = []; - let config = {}; - for (let element of dom.window.document.querySelectorAll('meta')) { - let name = element.getAttribute('name'); - if (name && name.endsWith('/config/environment')) { - let content = JSON.parse(decodeURIComponent(element.getAttribute('content'))); - content.APP = Object.assign({ autoboot: false }, content.APP); - config[name.slice(0, -1 * '/config/environment'.length)] = content; - } - } - let rootURL = getRootURL(appName, config); for (let element of dom.window.document.querySelectorAll('script,fastboot-script')) { @@ -34,7 +24,7 @@ function htmlEntrypoint(appName, distPath, htmlPath) { } } - return { config, html: dom.serialize(), scripts }; + return { html: dom.serialize(), scripts }; } function extractSrc(element) { diff --git a/packages/fastboot/test/fixtures/html-entrypoint/package.json b/packages/fastboot/test/fixtures/html-entrypoint/package.json index d7b07497e..ca8aed90f 100644 --- a/packages/fastboot/test/fixtures/html-entrypoint/package.json +++ b/packages/fastboot/test/fixtures/html-entrypoint/package.json @@ -4,6 +4,30 @@ "fastboot": { "schemaVersion": 5, "moduleWhitelist": [], - "htmlEntrypoint": "index.html" + "htmlEntrypoint": "index.html", + "config": { + "fastboot-test": { + "modulePrefix": "fastboot-test", + "environment": "development", + "rootURL": "/", + "locationType": "auto", + "EmberENV": { + "FEATURES": {}, + "EXTEND_PROTOTYPES": { + "Date": false + }, + "_APPLICATION_TEMPLATE_WRAPPER": false, + "_DEFAULT_ASYNC_OBSERVERS": true, + "_JQUERY_INTEGRATION": false, + "_TEMPLATE_ONLY_GLIMMER_COMPONENTS": true + }, + "APP": { + "name": "fastboot-test", + "version": "0.0.0+e168a770", + "autoboot": false + }, + "exportApplicationGlobal": true + } + } } } diff --git a/packages/fastboot/test/fixtures/onerror-per-visit/assets/onerror-per-visit.js b/packages/fastboot/test/fixtures/onerror-per-visit/assets/onerror-per-visit.js index 483115ae3..f42a58908 100644 --- a/packages/fastboot/test/fixtures/onerror-per-visit/assets/onerror-per-visit.js +++ b/packages/fastboot/test/fixtures/onerror-per-visit/assets/onerror-per-visit.js @@ -65,7 +65,6 @@ if (isFastBoot) { let fastbootService = owner.lookup('service:fastboot'); - debugger fastbootRequestPath = fastbootService.request.path; } // normally only done in prod builds, but this makes the demo easier diff --git a/packages/fastboot/test/html-entrypoint-test.js b/packages/fastboot/test/html-entrypoint-test.js index df8030f67..a9290486e 100644 --- a/packages/fastboot/test/html-entrypoint-test.js +++ b/packages/fastboot/test/html-entrypoint-test.js @@ -219,36 +219,18 @@ describe('htmlEntrypoint', function() { expect(scripts).to.deep.equal([]); }); - it('extracts configs from meta', function() { - let tmpobj = tmp.dirSync(); - let tmpLocation = tmpobj.name; - - let project = { - 'index.html': ` - - - - - - - `, - }; - - fixturify.writeSync(tmpLocation, project); - let { config } = htmlEntrypoint('my-app', tmpLocation, 'index.html'); - expect(config).to.deep.equal({ - 'my-app': { APP: { autoboot: false }, rootURL: '/custom-root-url/' }, - }); - }); - it('understands customized rootURL', function() { let tmpobj = tmp.dirSync(); let tmpLocation = tmpobj.name; + let config = { + 'my-app': { + rootURL: '/custom-root-url/', + }, + }; let project = { 'index.html': ` - @@ -258,7 +240,7 @@ describe('htmlEntrypoint', function() { fixturify.writeSync(tmpLocation, project); - let { scripts } = htmlEntrypoint('my-app', tmpLocation, 'index.html'); + let { scripts } = htmlEntrypoint('my-app', tmpLocation, 'index.html', config); expect(scripts).to.deep.equal([`${tmpLocation}/bar.js`]); }); }); diff --git a/test-packages/integration-tests/package.json b/test-packages/integration-tests/package.json index 2b5569673..51bb68b5b 100644 --- a/test-packages/integration-tests/package.json +++ b/test-packages/integration-tests/package.json @@ -4,6 +4,7 @@ "version": "3.2.0-beta.1", "repository": "", "scripts": { + "clean": "node helpers/clean-dists.js", "test": "node helpers/clean-dists.js && mocha" }, "devDependencies": {