├── app └── .gitkeep ├── addon └── .gitkeep ├── vendor └── .gitkeep ├── tests ├── helpers │ └── .gitkeep ├── unit │ └── .gitkeep ├── integration │ └── .gitkeep ├── dummy │ ├── app │ │ ├── helpers │ │ │ └── .gitkeep │ │ ├── models │ │ │ └── .gitkeep │ │ ├── routes │ │ │ ├── .gitkeep │ │ │ ├── redirects.js │ │ │ ├── index.js │ │ │ └── use-static-asset.js │ │ ├── styles │ │ │ └── app.css │ │ ├── components │ │ │ └── .gitkeep │ │ ├── controllers │ │ │ └── .gitkeep │ │ ├── templates │ │ │ ├── discovered.hbs │ │ │ ├── from-sample-data.hbs │ │ │ ├── use-static-asset.hbs │ │ │ ├── application.hbs │ │ │ ├── head.hbs │ │ │ └── index.hbs │ │ ├── app.js │ │ ├── router.js │ │ └── index.html │ ├── public │ │ ├── static.json │ │ ├── robots.txt │ │ └── sample-data.json │ └── config │ │ ├── optional-features.json │ │ ├── ember-cli-update.json │ │ ├── targets.js │ │ └── environment.js ├── test-helper.js └── index.html ├── .watchmanconfig ├── .prettierrc.js ├── lib ├── .eslintrc.js ├── config.js └── prerender.js ├── config ├── environment.js └── ember-try.js ├── .ember-cli ├── .prettierignore ├── .eslintignore ├── .editorconfig ├── .gitignore ├── .npmignore ├── testem.js ├── CONTRIBUTING.md ├── node-tests ├── url-tester.js └── basic-test.js ├── ember-cli-build.js ├── LICENSE.md ├── .release-plan.json ├── .eslintrc.js ├── RELEASE.md ├── .github └── workflows │ ├── publish.yml │ ├── ci.yml │ └── plan-release.yml ├── CHANGELOG.md ├── package.json ├── index.js └── README.md /app/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /addon/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /vendor/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/helpers/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/unit/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/integration/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/dummy/app/helpers/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/dummy/app/models/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/dummy/app/routes/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/dummy/app/styles/app.css: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/dummy/app/components/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/dummy/app/controllers/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/dummy/app/templates/discovered.hbs: -------------------------------------------------------------------------------- 1 |

Discovered

-------------------------------------------------------------------------------- /.watchmanconfig: -------------------------------------------------------------------------------- 1 | { 2 | "ignore_dirs": ["tmp", "dist"] 3 | } 4 | -------------------------------------------------------------------------------- /tests/dummy/app/templates/from-sample-data.hbs: -------------------------------------------------------------------------------- 1 |

From Sample Data

-------------------------------------------------------------------------------- /tests/dummy/public/static.json: -------------------------------------------------------------------------------- 1 | { 2 | "message": "This is from static json" 3 | } 4 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | singleQuote: true, 5 | }; 6 | -------------------------------------------------------------------------------- /tests/dummy/public/robots.txt: -------------------------------------------------------------------------------- 1 | # http://www.robotstxt.org 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /tests/dummy/app/templates/use-static-asset.hbs: -------------------------------------------------------------------------------- 1 |
{{this.model.message}}
-------------------------------------------------------------------------------- /tests/dummy/public/sample-data.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "url": "/from-sample-data" 4 | } 5 | ] 6 | -------------------------------------------------------------------------------- /lib/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | node: true, 4 | browser: false, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /tests/dummy/app/templates/application.hbs: -------------------------------------------------------------------------------- 1 | {{head-layout}} 2 | 3 |

Welcome to Ember

4 | 5 | {{outlet}} -------------------------------------------------------------------------------- /config/environment.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = function (/* environment, appConfig */) { 4 | return {}; 5 | }; 6 | -------------------------------------------------------------------------------- /tests/dummy/app/templates/head.hbs: -------------------------------------------------------------------------------- 1 | {{this.model.title}} 2 | -------------------------------------------------------------------------------- /tests/dummy/app/templates/index.hbs: -------------------------------------------------------------------------------- 1 | {{page-title "Document Title from page-title"}} 2 | 3 |
This is some content
4 | discovered 5 | -------------------------------------------------------------------------------- /tests/dummy/config/optional-features.json: -------------------------------------------------------------------------------- 1 | { 2 | "application-template-wrapper": false, 3 | "default-async-observers": true, 4 | "jquery-integration": false, 5 | "template-only-glimmer-components": true 6 | } 7 | -------------------------------------------------------------------------------- /tests/dummy/app/routes/redirects.js: -------------------------------------------------------------------------------- 1 | import Route from '@ember/routing/route'; 2 | import { inject as service } from '@ember/service'; 3 | 4 | export default class RedirectsRoute extends Route { 5 | @service router; 6 | beforeModel() { 7 | return this.router.transitionTo('from-sample-data'); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /.ember-cli: -------------------------------------------------------------------------------- 1 | { 2 | /** 3 | Ember CLI sends analytics information by default. The data is completely 4 | anonymous, but there are times when you might want to disable this behavior. 5 | 6 | Setting `disableAnalytics` to true will prevent any data from being sent. 7 | */ 8 | "disableAnalytics": false 9 | } 10 | -------------------------------------------------------------------------------- /tests/dummy/app/routes/index.js: -------------------------------------------------------------------------------- 1 | import Route from '@ember/routing/route'; 2 | import { inject as service } from '@ember/service'; 3 | 4 | export default class IndexRoute extends Route { 5 | @service 6 | headData; 7 | 8 | afterModel() { 9 | this.headData.description = 'OG Description from Index Route'; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # unconventional js 2 | /blueprints/*/files/ 3 | /vendor/ 4 | 5 | # compiled output 6 | /dist/ 7 | /tmp/ 8 | 9 | # dependencies 10 | /bower_components/ 11 | /node_modules/ 12 | 13 | # misc 14 | /coverage/ 15 | !.* 16 | .eslintcache 17 | 18 | # ember-try 19 | /.node_modules.ember-try/ 20 | /bower.json.ember-try 21 | /package.json.ember-try 22 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | # unconventional js 2 | /blueprints/*/files/ 3 | /vendor/ 4 | 5 | # compiled output 6 | /dist/ 7 | /tmp/ 8 | 9 | # dependencies 10 | /bower_components/ 11 | /node_modules/ 12 | 13 | # misc 14 | /coverage/ 15 | !.* 16 | .*/ 17 | .eslintcache 18 | 19 | # ember-try 20 | /.node_modules.ember-try/ 21 | /bower.json.ember-try 22 | /package.json.ember-try 23 | -------------------------------------------------------------------------------- /tests/test-helper.js: -------------------------------------------------------------------------------- 1 | import Application from 'dummy/app'; 2 | import config from 'dummy/config/environment'; 3 | import * as QUnit from 'qunit'; 4 | import { setApplication } from '@ember/test-helpers'; 5 | import { setup } from 'qunit-dom'; 6 | import { start } from 'ember-qunit'; 7 | 8 | setApplication(Application.create(config.APP)); 9 | 10 | setup(QUnit.assert); 11 | 12 | start(); 13 | -------------------------------------------------------------------------------- /tests/dummy/app/app.js: -------------------------------------------------------------------------------- 1 | import Application from '@ember/application'; 2 | import Resolver from 'ember-resolver'; 3 | import loadInitializers from 'ember-load-initializers'; 4 | import config from 'dummy/config/environment'; 5 | 6 | export default class App extends Application { 7 | modulePrefix = config.modulePrefix; 8 | podModulePrefix = config.podModulePrefix; 9 | Resolver = Resolver; 10 | } 11 | 12 | loadInitializers(App, config.modulePrefix); 13 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig helps developers define and maintain consistent 2 | # coding styles between different editors and IDEs 3 | # editorconfig.org 4 | 5 | root = true 6 | 7 | [*] 8 | end_of_line = lf 9 | charset = utf-8 10 | trim_trailing_whitespace = true 11 | insert_final_newline = true 12 | indent_style = space 13 | indent_size = 2 14 | 15 | [*.hbs] 16 | insert_final_newline = false 17 | 18 | [*.{diff,md}] 19 | trim_trailing_whitespace = false 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # compiled output 4 | /dist/ 5 | /tmp/ 6 | 7 | # dependencies 8 | /bower_components/ 9 | /node_modules/ 10 | 11 | # misc 12 | /.env* 13 | /.pnp* 14 | /.sass-cache 15 | /.eslintcache 16 | /connect.lock 17 | /coverage/ 18 | /libpeerconnection.log 19 | /npm-debug.log* 20 | /testem.log 21 | /yarn-error.log 22 | 23 | # ember-try 24 | /.node_modules.ember-try/ 25 | /bower.json.ember-try 26 | /package.json.ember-try 27 | -------------------------------------------------------------------------------- /tests/dummy/config/ember-cli-update.json: -------------------------------------------------------------------------------- 1 | { 2 | "schemaVersion": "1.0.0", 3 | "packages": [ 4 | { 5 | "name": "ember-cli", 6 | "version": "3.28.3", 7 | "blueprints": [ 8 | { 9 | "name": "addon", 10 | "outputRepo": "https://github.com/ember-cli/ember-addon-output", 11 | "codemodsSource": "ember-addon-codemods-manifest@1", 12 | "isBaseBlueprint": true, 13 | "options": [ 14 | "--yarn", 15 | "--no-welcome" 16 | ] 17 | } 18 | ] 19 | } 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /tests/dummy/app/routes/use-static-asset.js: -------------------------------------------------------------------------------- 1 | import Route from '@ember/routing/route'; 2 | import fetch from 'fetch'; 3 | import { inject as service } from '@ember/service'; 4 | 5 | export default class UseStaticAssetRoute extends Route { 6 | @service 7 | fastboot; 8 | 9 | async model() { 10 | let url; 11 | if (this.fastboot.isFastBoot) { 12 | url = `http://${this.fastboot.request.host}/static.json`; 13 | } else { 14 | url = '/static.json'; 15 | } 16 | let response = await fetch(url); 17 | let json = await response.json(); 18 | return json; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /dist/ 3 | /tmp/ 4 | 5 | # dependencies 6 | /bower_components/ 7 | 8 | # misc 9 | /.bowerrc 10 | /.editorconfig 11 | /.ember-cli 12 | /.env* 13 | /.eslintcache 14 | /.eslintignore 15 | /.eslintrc.js 16 | /.git/ 17 | /.gitignore 18 | /.prettierignore 19 | /.prettierrc.js 20 | /.template-lintrc.js 21 | /.travis.yml 22 | /.watchmanconfig 23 | /bower.json 24 | /config/ember-try.js 25 | /CONTRIBUTING.md 26 | /ember-cli-build.js 27 | /testem.js 28 | /tests/ 29 | /node-tests 30 | /yarn-error.log 31 | /yarn.lock 32 | .gitkeep 33 | 34 | # ember-try 35 | /.node_modules.ember-try/ 36 | /bower.json.ember-try 37 | /package.json.ember-try 38 | -------------------------------------------------------------------------------- /testem.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | test_page: 'tests/index.html?hidepassed', 5 | disable_watching: true, 6 | launch_in_ci: ['Chrome'], 7 | launch_in_dev: ['Chrome'], 8 | browser_start_timeout: 120, 9 | browser_args: { 10 | Chrome: { 11 | ci: [ 12 | // --no-sandbox is needed when running Chrome inside a container 13 | process.env.CI ? '--no-sandbox' : null, 14 | '--headless', 15 | '--disable-dev-shm-usage', 16 | '--disable-software-rasterizer', 17 | '--mute-audio', 18 | '--remote-debugging-port=0', 19 | '--window-size=1440,900', 20 | ].filter(Boolean), 21 | }, 22 | }, 23 | }; 24 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # How To Contribute 2 | 3 | ## Installation 4 | 5 | * `git clone ` 6 | * `cd prember` 7 | * `yarn install` 8 | 9 | ## Linting 10 | 11 | * `yarn lint` 12 | * `yarn lint:fix` 13 | 14 | ## Running tests 15 | 16 | * `ember test` – Runs the test suite on the current Ember version 17 | * `ember test --server` – Runs the test suite in "watch mode" 18 | * `ember try:each` – Runs the test suite against multiple Ember versions 19 | 20 | ## Running the dummy application 21 | 22 | * `ember serve` 23 | * Visit the dummy application at [http://localhost:4200](http://localhost:4200). 24 | 25 | For more information on using ember-cli, visit [https://ember-cli.com/](https://ember-cli.com/). 26 | -------------------------------------------------------------------------------- /tests/dummy/app/router.js: -------------------------------------------------------------------------------- 1 | import EmberRouter from '@ember/routing/router'; 2 | import config from 'dummy/config/environment'; 3 | import { inject } from '@ember/service'; 4 | 5 | export default class Router extends EmberRouter { 6 | location = config.locationType; 7 | rootURL = config.rootURL; 8 | 9 | // This implements is the standard instructions from ember-cli-document-title 10 | // for making it play nicely with ember-cli-head 11 | @inject() 12 | headData; 13 | 14 | setTitle(title) { 15 | this.headData.set('title', title); 16 | } 17 | } 18 | 19 | Router.map(function () { 20 | this.route('discovered'); 21 | this.route('from-sample-data'); 22 | this.route('use-static-asset'); 23 | this.route('redirects'); 24 | }); 25 | -------------------------------------------------------------------------------- /tests/dummy/app/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | {{content-for "head"}} 10 | 11 | 12 | 13 | 14 | {{content-for "head-footer"}} 15 | 16 | 17 | {{content-for "body"}} 18 | 19 | 20 | 21 | 22 | {{content-for "body-footer"}} 23 | 24 | 25 | -------------------------------------------------------------------------------- /tests/dummy/config/targets.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const browsers = [ 4 | 'last 1 Chrome versions', 5 | 'last 1 Firefox versions', 6 | 'last 1 Safari versions', 7 | ]; 8 | 9 | // Ember's browser support policy is changing, and IE11 support will end in 10 | // v4.0 onwards. 11 | // 12 | // See https://deprecations.emberjs.com/v3.x#toc_3-0-browser-support-policy 13 | // 14 | // If you need IE11 support on a version of Ember that still offers support 15 | // for it, uncomment the code block below. 16 | // 17 | // const isCI = Boolean(process.env.CI); 18 | // const isProduction = process.env.EMBER_ENV === 'production'; 19 | // 20 | // if (isCI || isProduction) { 21 | // browsers.push('ie 11'); 22 | // } 23 | 24 | module.exports = { 25 | browsers, 26 | node: true, 27 | }; 28 | -------------------------------------------------------------------------------- /lib/config.js: -------------------------------------------------------------------------------- 1 | function findHost(context) { 2 | var current = context; 3 | var app; 4 | 5 | // Keep iterating upward until we don't have a grandparent. 6 | // Has to do this grandparent check because at some point we hit the project. 7 | do { 8 | app = current.app || app; 9 | } while ( 10 | current.parent && 11 | current.parent.parent && 12 | (current = current.parent) 13 | ); 14 | 15 | return app; 16 | } 17 | 18 | function loadConfig(context) { 19 | let app = findHost(context); 20 | let config = app.options.prember || {}; 21 | if (config.enabled == null) { 22 | config.enabled = app.env === 'production'; 23 | } 24 | if (process.env.PREMBER) { 25 | config.enabled = true; 26 | } 27 | return config; 28 | } 29 | 30 | module.exports = function () { 31 | if (!this._premberConfig) { 32 | this._premberConfig = loadConfig(this); 33 | } 34 | return this._premberConfig; 35 | }; 36 | -------------------------------------------------------------------------------- /node-tests/url-tester.js: -------------------------------------------------------------------------------- 1 | const jsdom = require('jsdom'); 2 | const { JSDOM } = jsdom; 3 | 4 | module.exports = async function ({ distDir, visit }) { 5 | let urls = ['/', '/redirects', '/use-static-asset']; 6 | 7 | // Here we exercise the ability to make requests against the 8 | // fastboot app in order to discover more urls 9 | let page = await visit('/'); 10 | if (page.statusCode === 200) { 11 | let html = await page.html(); 12 | let dom = new JSDOM(html); 13 | for (let aTag of [...dom.window.document.querySelectorAll('a')]) { 14 | if (aTag.href) { 15 | urls.push(aTag.href); 16 | } 17 | } 18 | } 19 | 20 | // Here we exercise the ability to inspect the build output of the 21 | // app to discover more urls 22 | let sampleData = require(distDir + '/sample-data.json'); 23 | for (let entry of sampleData) { 24 | urls.push(entry.url); 25 | } 26 | 27 | return urls; 28 | }; 29 | -------------------------------------------------------------------------------- /ember-cli-build.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const EmberAddon = require('ember-cli/lib/broccoli/ember-addon'); 4 | const urls = require('./node-tests/url-tester'); 5 | 6 | module.exports = function (defaults) { 7 | let app = new EmberAddon(defaults, { 8 | // This is the configuration for Prember's dummy app that we use 9 | // to test prember. You would do something similar to this in your 10 | // own app's ember-cli-build.js to configure prember, see the 11 | // README. 12 | prember: { 13 | enabled: true, 14 | urls, 15 | }, 16 | }); 17 | 18 | const { maybeEmbroider } = require('@embroider/test-setup'); 19 | const appTree = maybeEmbroider(app, { 20 | skipBabel: [ 21 | { 22 | package: 'qunit', 23 | }, 24 | ], 25 | }); 26 | 27 | if ('@embroider/core' in app.dependencies()) { 28 | return require('./index').prerender(app, appTree); 29 | } else { 30 | return appTree; 31 | } 32 | }; 33 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2018 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /.release-plan.json: -------------------------------------------------------------------------------- 1 | { 2 | "solution": { 3 | "prember": { 4 | "impact": "minor", 5 | "oldVersion": "2.0.0", 6 | "newVersion": "2.1.0", 7 | "constraints": [ 8 | { 9 | "impact": "minor", 10 | "reason": "Appears in changelog section :rocket: Enhancement" 11 | }, 12 | { 13 | "impact": "patch", 14 | "reason": "Appears in changelog section :house: Internal" 15 | } 16 | ], 17 | "pkgJSONPath": "./package.json" 18 | } 19 | }, 20 | "description": "## Release (2024-07-05)\n\nprember 2.1.0 (minor)\n\n#### :rocket: Enhancement\n* `prember`\n * [#82](https://github.com/ef4/prember/pull/82) recycle the fastboot instance after 1k requests ([@mansona](https://github.com/mansona))\n\n#### :house: Internal\n* `prember`\n * [#84](https://github.com/ef4/prember/pull/84) add release-plan ([@mansona](https://github.com/mansona))\n * [#83](https://github.com/ef4/prember/pull/83) switch to pnpm and fix tests ([@mansona](https://github.com/mansona))\n\n#### Committers: 1\n- Chris Manson ([@mansona](https://github.com/mansona))\n" 21 | } 22 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | root: true, 5 | parser: 'babel-eslint', 6 | parserOptions: { 7 | ecmaVersion: 2018, 8 | sourceType: 'module', 9 | ecmaFeatures: { 10 | legacyDecorators: true, 11 | }, 12 | }, 13 | plugins: ['ember'], 14 | extends: [ 15 | 'eslint:recommended', 16 | 'plugin:ember/recommended', 17 | 'plugin:prettier/recommended', 18 | ], 19 | env: { 20 | browser: true, 21 | }, 22 | rules: {}, 23 | overrides: [ 24 | // node files 25 | { 26 | files: [ 27 | './.eslintrc.js', 28 | './.prettierrc.js', 29 | './.template-lintrc.js', 30 | './ember-cli-build.js', 31 | './index.js', 32 | './testem.js', 33 | './blueprints/*/index.js', 34 | './config/**/*.js', 35 | './tests/dummy/config/**/*.js', 36 | './node-tests/**/*.js', 37 | ], 38 | parserOptions: { 39 | sourceType: 'script', 40 | }, 41 | env: { 42 | browser: false, 43 | node: true, 44 | }, 45 | plugins: ['node'], 46 | extends: ['plugin:node/recommended'], 47 | }, 48 | { 49 | // Test files: 50 | files: ['tests/**/*-test.{js,ts}'], 51 | extends: ['plugin:qunit/recommended'], 52 | }, 53 | ], 54 | }; 55 | -------------------------------------------------------------------------------- /tests/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Dummy Tests 7 | 8 | 9 | 10 | {{content-for "head"}} 11 | {{content-for "test-head"}} 12 | 13 | 14 | 15 | 16 | 17 | {{content-for "head-footer"}} 18 | {{content-for "test-head-footer"}} 19 | 20 | 21 | {{content-for "body"}} 22 | {{content-for "test-body"}} 23 | 24 |
25 |
26 |
27 |
28 |
29 |
30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | {{content-for "body-footer"}} 38 | {{content-for "test-body-footer"}} 39 | 40 | 41 | -------------------------------------------------------------------------------- /tests/dummy/config/environment.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = function (environment) { 4 | let ENV = { 5 | modulePrefix: 'dummy', 6 | environment, 7 | rootURL: '/', 8 | locationType: 'auto', 9 | EmberENV: { 10 | FEATURES: { 11 | // Here you can enable experimental features on an ember canary build 12 | // e.g. EMBER_NATIVE_DECORATOR_SUPPORT: true 13 | }, 14 | EXTEND_PROTOTYPES: { 15 | // Prevent Ember Data from overriding Date.parse. 16 | Date: false, 17 | }, 18 | }, 19 | 20 | APP: { 21 | // Here you can pass flags/options to your application instance 22 | // when it is created 23 | }, 24 | 25 | fastboot: { 26 | hostWhitelist: [/^localhost:\d+$/], 27 | }, 28 | }; 29 | 30 | if (environment === 'development') { 31 | // ENV.APP.LOG_RESOLVER = true; 32 | // ENV.APP.LOG_ACTIVE_GENERATION = true; 33 | // ENV.APP.LOG_TRANSITIONS = true; 34 | // ENV.APP.LOG_TRANSITIONS_INTERNAL = true; 35 | // ENV.APP.LOG_VIEW_LOOKUPS = true; 36 | } 37 | 38 | if (environment === 'test') { 39 | // Testem prefers this... 40 | ENV.locationType = 'none'; 41 | 42 | // keep test console output quieter 43 | ENV.APP.LOG_ACTIVE_GENERATION = false; 44 | ENV.APP.LOG_VIEW_LOOKUPS = false; 45 | 46 | ENV.APP.rootElement = '#ember-testing'; 47 | ENV.APP.autoboot = false; 48 | } 49 | 50 | if (environment === 'production') { 51 | // here you can enable a production-specific feature 52 | } 53 | 54 | return ENV; 55 | }; 56 | -------------------------------------------------------------------------------- /RELEASE.md: -------------------------------------------------------------------------------- 1 | # Release Process 2 | 3 | Releases in this repo are mostly automated using [release-plan](https://github.com/embroider-build/release-plan/). Once you label all your PRs correctly (see below) you will have an automatically generated PR that updates your CHANGELOG.md file and a `.release-plan.json` that is used to prepare the release once the PR is merged. 4 | 5 | ## Preparation 6 | 7 | Since the majority of the actual release process is automated, the remaining tasks before releasing are: 8 | 9 | - correctly labeling **all** pull requests that have been merged since the last release 10 | - updating pull request titles so they make sense to our users 11 | 12 | Some great information on why this is important can be found at [keepachangelog.com](https://keepachangelog.com/en/1.1.0/), but the overall 13 | guiding principle here is that changelogs are for humans, not machines. 14 | 15 | When reviewing merged PR's the labels to be used are: 16 | 17 | * breaking - Used when the PR is considered a breaking change. 18 | * enhancement - Used when the PR adds a new feature or enhancement. 19 | * bug - Used when the PR fixes a bug included in a previous release. 20 | * documentation - Used when the PR adds or updates documentation. 21 | * internal - Internal changes or things that don't fit in any other category. 22 | 23 | **Note:** `release-plan` requires that **all** PRs are labeled. If a PR doesn't fit in a category it's fine to label it as `internal` 24 | 25 | ## Release 26 | 27 | Once the prep work is completed, the actual release is straight forward: you just need to merge the open [Plan Release](https://github.com/ef4/prember/pulls?q=is%3Apr+is%3Aopen+%22Prepare+Release%22+in%3Atitle) PR 28 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | # For every push to the master branch, this checks if the release-plan was 2 | # updated and if it was it will publish stable npm packages based on the 3 | # release plan 4 | 5 | name: Publish Stable 6 | 7 | on: 8 | workflow_dispatch: 9 | push: 10 | branches: 11 | - main 12 | - master 13 | 14 | concurrency: 15 | group: publish-${{ github.head_ref || github.ref }} 16 | cancel-in-progress: true 17 | 18 | jobs: 19 | check-plan: 20 | name: "Check Release Plan" 21 | runs-on: ubuntu-latest 22 | outputs: 23 | command: ${{ steps.check-release.outputs.command }} 24 | 25 | steps: 26 | - uses: actions/checkout@v4 27 | with: 28 | fetch-depth: 0 29 | ref: 'master' 30 | # This will only cause the `check-plan` job to have a result of `success` 31 | # when the .release-plan.json file was changed on the last commit. This 32 | # plus the fact that this action only runs on main will be enough of a guard 33 | - id: check-release 34 | run: if git diff --name-only HEAD HEAD~1 | grep -w -q ".release-plan.json"; then echo "command=release"; fi >> $GITHUB_OUTPUT 35 | 36 | publish: 37 | name: "NPM Publish" 38 | runs-on: ubuntu-latest 39 | needs: check-plan 40 | if: needs.check-plan.outputs.command == 'release' 41 | permissions: 42 | contents: write 43 | pull-requests: write 44 | 45 | steps: 46 | - uses: actions/checkout@v4 47 | - uses: actions/setup-node@v4 48 | with: 49 | node-version: 18 50 | # This creates an .npmrc that reads the NODE_AUTH_TOKEN environment variable 51 | registry-url: 'https://registry.npmjs.org' 52 | 53 | - uses: pnpm/action-setup@v3 54 | with: 55 | version: 8 56 | - run: pnpm install --frozen-lockfile 57 | - name: npm publish 58 | run: pnpm release-plan publish 59 | 60 | env: 61 | GITHUB_AUTH: ${{ secrets.GITHUB_TOKEN }} 62 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 63 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## Release (2024-07-05) 4 | 5 | prember 2.1.0 (minor) 6 | 7 | #### :rocket: Enhancement 8 | * `prember` 9 | * [#82](https://github.com/ef4/prember/pull/82) recycle the fastboot instance after 1k requests ([@mansona](https://github.com/mansona)) 10 | 11 | #### :house: Internal 12 | * `prember` 13 | * [#84](https://github.com/ef4/prember/pull/84) add release-plan ([@mansona](https://github.com/mansona)) 14 | * [#83](https://github.com/ef4/prember/pull/83) switch to pnpm and fix tests ([@mansona](https://github.com/mansona)) 15 | 16 | #### Committers: 1 17 | - Chris Manson ([@mansona](https://github.com/mansona)) 18 | 19 | # 1.1.1 - 2022-08-10 20 | 21 | BUGFIX: ensure we always run after ember-auto-import. As of ember-auto-import 2.0, things can break if we run before. 22 | 23 | ## 1.1.0 - 2021-12-23 24 | 25 | ENHANCEMENT: Add embroider support by @simonihmig 26 | 27 | ## 1.0.5 - 2020-07-01 28 | 29 | BUGFIX: Use rootUrl for static files #57 from @mansona 30 | 31 | ## 1.0.4 - 2020-05-03 32 | 33 | ENHANCEMENT: Allow passing urls from prember 34 | 35 | ## 1.0.3 - 2019-05-29 36 | 37 | ENHANCEMENT: Support to ember-engines out of the box 38 | 39 | ## 1.0.2 - 2019-01-06 40 | 41 | BUGFIX: The protocol bugfix in 1.0.1 was not quite right and caused a regresion. 42 | 43 | ## 1.0.1 - 2018-12-20 44 | 45 | BUGFIX: Shutdown express server after build (thanks @astronomersiva) 46 | BUGFIX: Add protocol to fastboot requests for improved compatibility (thanks @xg-wang) 47 | 48 | ## 1.0.0 - 2018-10-28 49 | 50 | BREAKING: We now require ember-cli-fastboot >= 2.0.0, and if you're using broccoli-asset-rev it should be >= 2.7.0. This is to fix the order in which these run relative to prember, so that all asset links will get correct handling. 51 | 52 | ## 0.4.0 - 2018-04-25 53 | 54 | BREAKING: the signature for custom url discovery functions has changed from 55 | 56 | async function(distDir, visit) { return [...someURLs] } 57 | 58 | to 59 | 60 | async function({ distDir, visit }) { return [...someURLs] } 61 | 62 | This makes it nicer to compose multiple URL discovery strategies. 63 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | - main 8 | pull_request: 9 | 10 | jobs: 11 | test: 12 | name: Tests 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - name: Checkout code 17 | uses: actions/checkout@v4 18 | - uses: pnpm/action-setup@v4 19 | with: 20 | version: 8 21 | - name: Setup node.js 22 | uses: actions/setup-node@v4 23 | with: 24 | node-version: 16 25 | cache: pnpm 26 | - name: Install dependencies 27 | run: pnpm i --frozen-lockfile 28 | - name: Lint 29 | run: pnpm lint 30 | - name: Test 31 | run: pnpm test 32 | 33 | test-no-lock: 34 | name: Floating Dependencies 35 | runs-on: ubuntu-latest 36 | needs: 37 | - test 38 | steps: 39 | - name: Checkout code 40 | uses: actions/checkout@v4 41 | - uses: pnpm/action-setup@v4 42 | with: 43 | version: 8 44 | - name: Setup node.js 45 | uses: actions/setup-node@v4 46 | with: 47 | node-version: 16 48 | cache: pnpm 49 | - name: Install dependencies 50 | run: pnpm i --no-lockfile 51 | - name: Test 52 | run: pnpm test 53 | 54 | test-try: 55 | name: Additional Tests 56 | runs-on: ubuntu-latest 57 | needs: 58 | - test 59 | strategy: 60 | fail-fast: false 61 | matrix: 62 | scenario: 63 | - ember-lts-3.20 64 | - ember-lts-3.24 65 | - ember-release 66 | - ember-beta 67 | - ember-canary 68 | - embroider-safe 69 | - embroider-optimized 70 | steps: 71 | - name: Checkout code 72 | uses: actions/checkout@v4 73 | - uses: pnpm/action-setup@v4 74 | with: 75 | version: 8 76 | - name: Setup node.js 77 | uses: actions/setup-node@v4 78 | with: 79 | node-version: 16 80 | cache: pnpm 81 | - name: Install dependencies 82 | run: pnpm i --frozen-lockfile 83 | - name: Test 84 | run: pnpm ember try:one ${{ matrix.scenario }} 85 | -------------------------------------------------------------------------------- /config/ember-try.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const getChannelURL = require('ember-source-channel-url'); 4 | const { embroiderSafe, embroiderOptimized } = require('@embroider/test-setup'); 5 | 6 | module.exports = async function () { 7 | return { 8 | usePnpm: true, 9 | command: 'pnpm test', 10 | scenarios: [ 11 | { 12 | name: 'ember-lts-3.20', 13 | npm: { 14 | devDependencies: { 15 | 'ember-source': '~3.20.5', 16 | }, 17 | }, 18 | }, 19 | { 20 | name: 'ember-lts-3.24', 21 | npm: { 22 | devDependencies: { 23 | 'ember-source': '~3.24.3', 24 | }, 25 | }, 26 | }, 27 | { 28 | name: 'ember-release', 29 | npm: { 30 | devDependencies: { 31 | 'ember-source': await getChannelURL('release'), 32 | }, 33 | }, 34 | }, 35 | { 36 | name: 'ember-beta', 37 | npm: { 38 | devDependencies: { 39 | 'ember-source': await getChannelURL('beta'), 40 | }, 41 | }, 42 | }, 43 | { 44 | name: 'ember-canary', 45 | npm: { 46 | devDependencies: { 47 | 'ember-source': await getChannelURL('canary'), 48 | }, 49 | }, 50 | }, 51 | { 52 | name: 'ember-default-with-jquery', 53 | env: { 54 | EMBER_OPTIONAL_FEATURES: JSON.stringify({ 55 | 'jquery-integration': true, 56 | }), 57 | }, 58 | npm: { 59 | devDependencies: { 60 | '@ember/jquery': '^1.1.0', 61 | }, 62 | }, 63 | }, 64 | { 65 | name: 'ember-classic', 66 | env: { 67 | EMBER_OPTIONAL_FEATURES: JSON.stringify({ 68 | 'application-template-wrapper': true, 69 | 'default-async-observers': false, 70 | 'template-only-glimmer-components': false, 71 | }), 72 | }, 73 | npm: { 74 | devDependencies: { 75 | 'ember-source': '~3.28.0', 76 | }, 77 | ember: { 78 | edition: 'classic', 79 | }, 80 | }, 81 | }, 82 | embroiderSafe(), 83 | embroiderOptimized(), 84 | ], 85 | }; 86 | }; 87 | -------------------------------------------------------------------------------- /node-tests/basic-test.js: -------------------------------------------------------------------------------- 1 | const { execFileSync } = require('child_process'); 2 | const { module: Qmodule, test } = require('qunit'); 3 | const jsdom = require('jsdom'); 4 | const { JSDOM } = jsdom; 5 | const fs = require('fs'); 6 | 7 | function findDocument(filename) { 8 | let dom = new JSDOM(fs.readFileSync(`dist/${filename}`)); 9 | return dom.window.document; 10 | } 11 | 12 | Qmodule('Prember', function (hooks) { 13 | hooks.before(async function () { 14 | if (!process.env.REUSE_FASTBOOT_BUILD) { 15 | execFileSync('pnpm', ['ember', 'build']); 16 | } 17 | process.env.REUSE_FASTBOOT_BUILD = true; 18 | }); 19 | 20 | test('it renders /', function (assert) { 21 | let doc = findDocument('index.html'); 22 | assert.equal( 23 | doc.querySelector('[data-test-id="index-content"]').textContent, 24 | 'This is some content' 25 | ); 26 | }); 27 | 28 | test('it works with ember-cli-head', function (assert) { 29 | let doc = findDocument('index.html'); 30 | assert.equal( 31 | doc.querySelector('meta[property="og:description"]').content, 32 | 'OG Description from Index Route' 33 | ); 34 | }); 35 | 36 | test('it works with ember-page-title', function (assert) { 37 | let doc = findDocument('index.html'); 38 | assert.equal( 39 | doc.querySelector('title').textContent, 40 | 'Document Title from page-title' 41 | ); 42 | }); 43 | 44 | test('the URL discovery function can crawl the running app', function (assert) { 45 | // this test is relying on configuration in our ember-cli-build.js 46 | let doc = findDocument('discovered/index.html'); 47 | assert.equal(doc.querySelector('h1').textContent, 'Discovered'); 48 | }); 49 | 50 | test('the URL discovery function can inspect the app build output', function (assert) { 51 | // this test is relying on configuration in our ember-cli-build.js 52 | let doc = findDocument('from-sample-data/index.html'); 53 | assert.equal(doc.querySelector('h1').textContent, 'From Sample Data'); 54 | }); 55 | 56 | test('fastboot-rendered routes have access to static assets', function (assert) { 57 | // this test is relying on configuration in our ember-cli-build.js 58 | let doc = findDocument('use-static-asset/index.html'); 59 | assert.equal( 60 | doc.querySelector('.message').textContent, 61 | 'This is from static json' 62 | ); 63 | }); 64 | 65 | test('redirects via meta http-eqiv refresh', function (assert) { 66 | // this test is relying on configuration in our ember-cli-build.js 67 | let doc = findDocument('redirects/index.html'); 68 | assert.equal( 69 | doc.querySelector('meta[http-equiv=refresh]').getAttribute('content'), 70 | '0;url=/from-sample-data' 71 | ); 72 | }); 73 | 74 | test('redirects have rel canonical', function (assert) { 75 | // this test is relying on configuration in our ember-cli-build.js 76 | let doc = findDocument('redirects/index.html'); 77 | assert.equal( 78 | doc.querySelector('link[rel=canonical]').getAttribute('href'), 79 | '/from-sample-data' 80 | ); 81 | }); 82 | }); 83 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "prember", 3 | "version": "2.1.0", 4 | "description": "Prerender Ember apps using Fastboot at build time.", 5 | "keywords": [ 6 | "ember-addon", 7 | "fastboot", 8 | "prerender" 9 | ], 10 | "repository": "https://github.com/ef4/prember", 11 | "license": "MIT", 12 | "author": "Edward Faulkner ", 13 | "directories": { 14 | "doc": "doc", 15 | "test": "tests" 16 | }, 17 | "scripts": { 18 | "build": "ember build --environment=production", 19 | "lint": "npm-run-all --aggregate-output --continue-on-error --parallel \"lint:!(fix)\"", 20 | "lint:fix": "npm-run-all --aggregate-output --continue-on-error --parallel lint:*:fix", 21 | "lint:js": "eslint . --cache", 22 | "lint:js:fix": "eslint . --fix", 23 | "start": "ember serve", 24 | "test": "qunit node-tests/*-test.js" 25 | }, 26 | "dependencies": { 27 | "broccoli-debug": "^0.6.3", 28 | "broccoli-merge-trees": "^4.2.0", 29 | "broccoli-plugin": "^4.0.7", 30 | "chalk": "^4.0.0", 31 | "ember-cli-babel": "^7.26.11", 32 | "express": "^4.18.2", 33 | "fastboot": "^4.1.1", 34 | "mkdirp": "^3.0.1" 35 | }, 36 | "devDependencies": { 37 | "@ember/optional-features": "^2.0.0", 38 | "@ember/string": "^3.0.1", 39 | "@ember/test-helpers": "^2.4.2", 40 | "@embroider/test-setup": "^0.43.5", 41 | "@glimmer/component": "^1.0.4", 42 | "@glimmer/tracking": "^1.0.4", 43 | "babel-eslint": "^10.1.0", 44 | "broccoli-asset-rev": "^3.0.0", 45 | "ember-auto-import": "^2.2.3", 46 | "ember-cli": "~3.28.3", 47 | "ember-cli-dependency-checker": "^3.2.0", 48 | "ember-cli-fastboot": "^4.1.1", 49 | "ember-cli-head": "^2.0.0", 50 | "ember-cli-htmlbars": "^5.7.1", 51 | "ember-cli-inject-live-reload": "^2.1.0", 52 | "ember-cli-sri": "^2.1.1", 53 | "ember-cli-terser": "^4.0.2", 54 | "ember-disable-prototype-extensions": "^1.1.3", 55 | "ember-fetch": "^8.1.1", 56 | "ember-load-initializers": "^2.1.2", 57 | "ember-maybe-import-regenerator": "^1.0.0", 58 | "ember-page-title": "^7.0.0", 59 | "ember-qunit": "^5.1.4", 60 | "ember-resolver": "^8.0.3", 61 | "ember-source": "~3.28.0", 62 | "ember-source-channel-url": "^3.0.0", 63 | "ember-try": "^3.0.0", 64 | "eslint": "^7.32.0", 65 | "eslint-config-prettier": "^8.3.0", 66 | "eslint-plugin-ember": "^10.5.4", 67 | "eslint-plugin-node": "^11.1.0", 68 | "eslint-plugin-prettier": "^3.4.1", 69 | "eslint-plugin-qunit": "^6.2.0", 70 | "jsdom": "^11.5.1", 71 | "loader.js": "^4.7.0", 72 | "npm-run-all": "^4.1.5", 73 | "prettier": "^2.3.2", 74 | "qunit": "^2.16.0", 75 | "qunit-dom": "^1.6.0", 76 | "release-plan": "^0.9.0", 77 | "webpack": "^5.59.0" 78 | }, 79 | "engines": { 80 | "node": "12.* || 14.* || >= 16" 81 | }, 82 | "ember": { 83 | "edition": "octane" 84 | }, 85 | "ember-addon": { 86 | "configPath": "tests/dummy/config", 87 | "after": [ 88 | "ember-cli-fastboot", 89 | "ember-engines", 90 | "ember-auto-import" 91 | ], 92 | "before": [ 93 | "broccoli-asset-rev" 94 | ] 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const premberConfig = require('./lib/config'); 4 | const path = require('path'); 5 | const fs = require('fs'); 6 | 7 | module.exports = { 8 | name: require('./package').name, 9 | premberConfig, 10 | 11 | included(app) { 12 | this.fastbootOptions = fastbootOptionsFor(app.env, app.project); 13 | }, 14 | 15 | postprocessTree(type, tree) { 16 | if (type !== 'all') { 17 | return tree; 18 | } 19 | 20 | return this._prerenderTree(tree); 21 | }, 22 | 23 | /** 24 | * This function is *not* called by ember-cli directly, but supposed to be imported by an app to wrap the app's 25 | * tree, to add the prerendered HTML files. This workaround is currently needed for Embroider-based builds that 26 | * don't support the `postprocessTree('all', tree)` hook used here. 27 | */ 28 | prerender(app, tree) { 29 | let premberAddon = app.project.addons.find( 30 | ({ name }) => name === 'prember' 31 | ); 32 | 33 | if (!premberAddon) { 34 | throw new Error( 35 | "Could not find initialized prember addon. It must be part of your app's dependencies!" 36 | ); 37 | } 38 | 39 | return premberAddon._prerenderTree(tree); 40 | }, 41 | 42 | _prerenderTree(tree) { 43 | let config = this.premberConfig(); 44 | if (!config.enabled) { 45 | return tree; 46 | } 47 | 48 | config.fastbootOptions = this.fastbootOptions; 49 | 50 | let Prerender = require('./lib/prerender'); 51 | let BroccoliDebug = require('broccoli-debug'); 52 | let Merge = require('broccoli-merge-trees'); 53 | let debug = BroccoliDebug.buildDebugCallback(`prember`); 54 | let ui = this.project.ui; 55 | let plugins = loadPremberPlugins(this); 56 | 57 | return debug( 58 | new Merge( 59 | [ 60 | tree, 61 | new Prerender( 62 | debug(tree, 'input'), 63 | config, 64 | ui, 65 | plugins, 66 | this._rootURL 67 | ), 68 | ], 69 | { 70 | overwrite: true, 71 | } 72 | ), 73 | 'output' 74 | ); 75 | }, 76 | 77 | config: function (env, baseConfig) { 78 | this._rootURL = baseConfig.rootURL; 79 | }, 80 | }; 81 | 82 | function loadPremberPlugins(context) { 83 | let addons = context.project.addons || []; 84 | 85 | return addons 86 | .filter((addon) => addon.pkg.keywords.includes('prember-plugin')) 87 | .filter((addon) => { 88 | return ( 89 | typeof addon.urlsForPrember === 'function' || 90 | typeof addon.urlsFromPrember === 'function' 91 | ); 92 | }) 93 | .map((addon) => { 94 | const premberPlugin = {}; 95 | 96 | if (addon.urlsForPrember) { 97 | premberPlugin.urlsForPrember = addon.urlsForPrember.bind(addon); 98 | } 99 | 100 | if (addon.urlsFromPrember) { 101 | premberPlugin.urlsFromPrember = addon.urlsFromPrember.bind(addon); 102 | } 103 | 104 | return premberPlugin; 105 | }); 106 | } 107 | 108 | function fastbootOptionsFor(environment, project) { 109 | const configPath = path.join( 110 | path.dirname(project.configPath()), 111 | 'fastboot.js' 112 | ); 113 | 114 | if (fs.existsSync(configPath)) { 115 | return require(configPath)(environment); 116 | } 117 | return {}; 118 | } 119 | -------------------------------------------------------------------------------- /.github/workflows/plan-release.yml: -------------------------------------------------------------------------------- 1 | name: Release Plan Review 2 | on: 3 | push: 4 | branches: 5 | - main 6 | - master 7 | pull_request_target: # This workflow has permissions on the repo, do NOT run code from PRs in this workflow. See https://securitylab.github.com/research/github-actions-preventing-pwn-requests/ 8 | types: 9 | - labeled 10 | - unlabeled 11 | 12 | concurrency: 13 | group: plan-release # only the latest one of these should ever be running 14 | cancel-in-progress: true 15 | 16 | jobs: 17 | check-plan: 18 | name: "Check Release Plan" 19 | runs-on: ubuntu-latest 20 | outputs: 21 | command: ${{ steps.check-release.outputs.command }} 22 | 23 | steps: 24 | - uses: actions/checkout@v4 25 | with: 26 | fetch-depth: 0 27 | ref: 'master' 28 | # This will only cause the `check-plan` job to have a "command" of `release` 29 | # when the .release-plan.json file was changed on the last commit. 30 | - id: check-release 31 | run: if git diff --name-only HEAD HEAD~1 | grep -w -q ".release-plan.json"; then echo "command=release"; fi >> $GITHUB_OUTPUT 32 | 33 | prepare_release_notes: 34 | name: Prepare Release Notes 35 | runs-on: ubuntu-latest 36 | timeout-minutes: 5 37 | needs: check-plan 38 | permissions: 39 | contents: write 40 | pull-requests: write 41 | outputs: 42 | explanation: ${{ steps.explanation.outputs.text }} 43 | # only run on push event if plan wasn't updated (don't create a release plan when we're releasing) 44 | # only run on labeled event if the PR has already been merged 45 | if: (github.event_name == 'push' && needs.check-plan.outputs.command != 'release') || (github.event_name == 'pull_request_target' && github.event.pull_request.merged == true) 46 | 47 | steps: 48 | - uses: actions/checkout@v4 49 | # We need to download lots of history so that 50 | # github-changelog can discover what's changed since the last release 51 | with: 52 | fetch-depth: 0 53 | ref: 'master' 54 | - uses: actions/setup-node@v4 55 | with: 56 | node-version: 18 57 | 58 | - uses: pnpm/action-setup@v3 59 | with: 60 | version: 8 61 | - run: pnpm install --frozen-lockfile 62 | 63 | - name: "Generate Explanation and Prep Changelogs" 64 | id: explanation 65 | run: | 66 | set +e 67 | 68 | pnpm release-plan prepare 2> >(tee -a release-plan-stderr.txt >&2) 69 | 70 | 71 | if [ $? -ne 0 ]; then 72 | echo 'text<> $GITHUB_OUTPUT 73 | cat release-plan-stderr.txt >> $GITHUB_OUTPUT 74 | echo 'EOF' >> $GITHUB_OUTPUT 75 | else 76 | echo 'text<> $GITHUB_OUTPUT 77 | jq .description .release-plan.json -r >> $GITHUB_OUTPUT 78 | echo 'EOF' >> $GITHUB_OUTPUT 79 | rm release-plan-stderr.txt 80 | fi 81 | env: 82 | GITHUB_AUTH: ${{ secrets.GITHUB_TOKEN }} 83 | 84 | - uses: peter-evans/create-pull-request@v6 85 | with: 86 | commit-message: "Prepare Release using 'release-plan'" 87 | labels: "internal" 88 | branch: release-preview 89 | title: Prepare Release 90 | body: | 91 | This PR is a preview of the release that [release-plan](https://github.com/embroider-build/release-plan) has prepared. To release you should just merge this PR 👍 92 | 93 | ----------------------------------------- 94 | 95 | ${{ steps.explanation.outputs.text }} 96 | -------------------------------------------------------------------------------- /lib/prerender.js: -------------------------------------------------------------------------------- 1 | const Plugin = require('broccoli-plugin'); 2 | const FastBoot = require('fastboot'); 3 | const { writeFile, readFile } = require('fs/promises'); 4 | const { mkdirp } = require('mkdirp'); 5 | const path = require('path'); 6 | const chalk = require('chalk'); 7 | const express = require('express'); 8 | const { URL } = require('url'); 9 | const protocol = 'http'; 10 | const port = 7784; 11 | 12 | // We need to have some origin for the purpose of serving redirects 13 | // and static assets 14 | 15 | class Prerender extends Plugin { 16 | constructor( 17 | builtAppTree, 18 | { urls, indexFile, emptyFile, fastbootOptions, requestsPerFastboot }, 19 | ui, 20 | plugins, 21 | rootURL 22 | ) { 23 | super([builtAppTree], { name: 'prember', needsCache: false }); 24 | this.urls = urls || []; 25 | this.indexFile = indexFile || 'index.html'; 26 | this.emptyFile = emptyFile || '_empty.html'; 27 | this.ui = ui; 28 | this.plugins = plugins; 29 | this.rootURL = rootURL; 30 | this.protocol = protocol; 31 | this.port = port; 32 | this.host = `localhost:${port}`; 33 | this.fastbootOptions = fastbootOptions || {}; 34 | this.requestsPerFastboot = requestsPerFastboot || 1000; 35 | this.app = undefined; 36 | this.visitCounter = 0; 37 | } 38 | 39 | async listUrls(protocol, host) { 40 | let visit = async (url) => { 41 | return this._visit(protocol, host, url); 42 | }; 43 | 44 | if (typeof this.urls === 'function') { 45 | this.urls = await this.urls({ distDir: this.inputPaths[0], visit }); 46 | } 47 | 48 | for (let plugin of this.plugins) { 49 | if (plugin.urlsForPrember) { 50 | this.urls = this.urls.concat( 51 | await plugin.urlsForPrember(this.inputPaths[0], visit) 52 | ); 53 | } 54 | } 55 | 56 | for (let plugin of this.plugins) { 57 | if (plugin.urlsFromPrember) { 58 | await plugin.urlsFromPrember(this.urls); 59 | } 60 | } 61 | 62 | return this.urls; 63 | } 64 | 65 | async build() { 66 | let pkg; 67 | try { 68 | pkg = require(path.join(this.inputPaths[0], 'package.json')); 69 | } catch (err) { 70 | throw new Error( 71 | `Unable to load package.json from within your built application. Did you forget to add ember-cli-fastboot to your app? ${err}` 72 | ); 73 | } 74 | 75 | /* Move the original "empty" index.html HTML file to an 76 | out-of-the-way place, and rewrite the fastboot manifest to 77 | point at it. This ensures that: 78 | 79 | (1) even if you have prerendered the contents of your homepage, 80 | causing the empty "index.html" to get replaced with actual 81 | content, we still keep a copy of the original empty 82 | index.html file that can be used to serve URLs that don't 83 | have a prerendered version. You wouldn't want a flash of 84 | your homepage content to appear on every non-prerendered 85 | route. 86 | 87 | (2) if you choose to run the prerendered app inside a fastboot 88 | server for some reason (this happens in development by 89 | default), fastboot will still work correctly because it can 90 | find the empty index.html file. 91 | */ 92 | 93 | let fastbootManifestSchema = pkg.fastboot.schemaVersion; 94 | let htmlFilename = 95 | fastbootManifestSchema < 5 96 | ? pkg.fastboot.manifest.htmlFile 97 | : pkg.fastboot.htmlEntrypoint; 98 | let htmlFile = await readFile( 99 | path.join(this.inputPaths[0], htmlFilename), 100 | 'utf8' 101 | ); 102 | await writeFile(path.join(this.outputPath, this.emptyFile), htmlFile); 103 | pkg = JSON.parse(JSON.stringify(pkg)); 104 | if (fastbootManifestSchema < 5) { 105 | pkg.fastboot.manifest.htmlFile = this.emptyFile; 106 | } else { 107 | pkg.fastboot.htmlEntrypoint = this.emptyFile; 108 | } 109 | 110 | await writeFile( 111 | path.join(this.outputPath, 'package.json'), 112 | JSON.stringify(pkg) 113 | ); 114 | 115 | let expressServer = express() 116 | .use(this.rootURL, express.static(this.inputPaths[0])) 117 | .listen(this.port); 118 | 119 | let hadFailures = false; 120 | 121 | for (let url of await this.listUrls(this.protocol, this.host)) { 122 | try { 123 | hadFailures = 124 | !(await this._prerender(this.protocol, this.host, url)) || 125 | hadFailures; 126 | } catch (err) { 127 | hadFailures = true; 128 | this.ui.writeLine( 129 | `pre-render ${url} ${chalk.red('failed with exception')}: ${err}` 130 | ); 131 | } 132 | } 133 | 134 | expressServer.close(); 135 | 136 | if (hadFailures) { 137 | throw new Error('Some pre-rendered URLs had failures'); 138 | } 139 | } 140 | 141 | async _visit(protocol, host, url) { 142 | // keep track of calls to visit so we know when we can recycle the fastboot instance 143 | this.visitCounter++; 144 | 145 | if (!this.app || this.visitCounter > this.requestsPerFastboot) { 146 | this.visitCounter = 0; 147 | this.app = new FastBoot( 148 | Object.assign({}, this.fastbootOptions, { 149 | distPath: this.inputPaths[0], 150 | }) 151 | ); 152 | } 153 | 154 | let opts = { 155 | request: { 156 | url, 157 | protocol, 158 | headers: { 159 | host, 160 | 'x-broccoli': { 161 | outputPath: this.inputPaths[0], 162 | }, 163 | }, 164 | }, 165 | }; 166 | return await this.app.visit(url, opts); 167 | } 168 | 169 | async _prerender(protocol, host, url) { 170 | let page = await this._visit(protocol, host, url); 171 | if (page.statusCode === 200) { 172 | let html = await page.html(); 173 | await this._writeFile(url, html); 174 | this.ui.writeLine(`pre-render ${url} ${chalk.green('200 OK')}`); 175 | return true; 176 | } else if (page.statusCode >= 300 && page.statusCode < 400) { 177 | let location = page.headers.headers.location[0]; 178 | let redirectTo = new URL(location, `http://${host}${this.rootURL}`) 179 | .pathname; 180 | let html = ``; 181 | await this._writeFile(url, html); 182 | this.ui.writeLine( 183 | `pre-render ${url} ${chalk.yellow(page.statusCode)} ${location}` 184 | ); 185 | return true; 186 | } else { 187 | this.ui.writeLine(`pre-render ${url} ${chalk.red(page.statusCode)}`); 188 | } 189 | } 190 | 191 | async _writeFile(url, html) { 192 | let filename = path.join( 193 | this.outputPath, 194 | url.replace(this.rootURL, '/'), 195 | this.indexFile 196 | ); 197 | await mkdirp(path.dirname(filename)); 198 | await writeFile(filename, html); 199 | } 200 | } 201 | 202 | module.exports = Prerender; 203 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # prember = Pre Render Ember 2 | 3 | A **progressive static-site generator** for Ember 4 | 5 | 6 | This Ember addon allows you to pre-render any list of URLs into static HTML files *at build time*. It has no opinions about how you generate the list of URLs. 7 | 8 | ## Features 9 | 10 | - 💯100% Ember 11 | - 🚀 [Blazing](https://runspired.com/2018/06/03/ember-in-2018-part-2/) optimized for speed. 12 | - 🚚 Data Agnostic. Supply your site with data from anywhere, however you want! 13 | - 💥 Instant navigation and page views 14 | - ☔️ Progressively Enhanced and mobile-ready 15 | - 🎯 SEO Friendly. 16 | - 🥇 Ember-centric developer experience. 17 | - 😌 Painless project setup & migration. 18 | - 📦 Embroider support 19 | 20 | ## Quick Start 21 | 22 | Add these packages to your app: 23 | 24 | ```sh 25 | ember install ember-cli-fastboot 26 | ember install prember 27 | ``` 28 | 29 | And configure some URLs that you would like to prerender: 30 | 31 | ``` 32 | // In ember-cli-build.js 33 | let app = new EmberApp(defaults, { 34 | prember: { 35 | urls: [ 36 | '/', 37 | '/about', 38 | '/contact' 39 | ] 40 | } 41 | }); 42 | ``` 43 | 44 | When you do `ember build --environment=production`, your built app will include fastboot-rendered HTML in the following files: 45 | 46 | ``` 47 | /index.html 48 | /about/index.html 49 | /contact/index.html 50 | ``` 51 | 52 | ## Explanation 53 | 54 | When you build a normal ember app (`ember build --environment=production`) you get a structure something like this: 55 | 56 | ``` 57 | dist/ 58 | ├── assets 59 | │   ├── my-app-0d31988c08747007cb982909a0b2c9db.css 60 | │   ├── my-app-bdaaa766a1077911a7dae138cbd9e39d.js 61 | │   ├── vendor-553c722f80bed2ea90c42b2c6a54238a.js 62 | │   └── vendor-9eda64f0de2569c64ba0d33f08940fbf.css 63 | ├── index.html 64 | └── robots.txt 65 | ``` 66 | 67 | To serve this app to users, you just need to configure a webserver to use `index.html` in response to *all* URLs that don't otherwise map to files (because the Ember app will boot and take care of the routing). 68 | 69 | If you add [ember-cli-fastboot](https://github.com/ember-fastboot/ember-cli-fastboot) to your app, it augments your build with a few things that are needed to run the app within node via [fastboot](https://github.com/ember-fastboot/fastboot): 70 | 71 | ``` 72 | dist/ 73 | ├── assets 74 | │   ├── assetMap.json 75 | │   ├── my-app-0d31988c08747007cb982909a0b2c9db.css 76 | │   ├── my-app-a72732b0d2468246920fa5401610caf4.js 77 | │   ├── my-app-fastboot-af717865dadf95003aaf6903aefcd125.js 78 | │   ├── vendor-553c722f80bed2ea90c42b2c6a54238a.js 79 | │   └── vendor-9eda64f0de2569c64ba0d33f08940fbf.css 80 | ├── index.html 81 | ├── package.json 82 | └── robots.txt 83 | ``` 84 | 85 | You can still serve the resulting app in the normal way, but to get the benefits of server-side rendering you would probably serve it from a fastboot server that knows how to combine the JS files and the `index.html` file and generate unique output per URL. The downside of this is that your fastboot server is now in the critical path, which increases your ops complexity and is necessarily slower than serving static files. 86 | 87 | `prember` starts with an app that's already capable of running in fastboot and augments it further. You configure it with a source of URLs to prerender, and it uses Fastboot to visit each one *during the build process*, saving the resulting HTML files: 88 | 89 | ``` 90 | dist/ 91 | ├── _empty.html <--------- A copy of the original index.html 92 | ├── about 93 | │   └── index.html <--------- Pre-rendered content 94 | ├── assets 95 | │   ├── assetMap.json 96 | │   ├── my-app-0d31988c08747007cb982909a0b2c9db.css 97 | │   ├── my-app-a72732b0d2468246920fa5401610caf4.js 98 | │   ├── my-app-fastboot-af717865dadf95003aaf6903aefcd125.js 99 | │   ├── vendor-553c722f80bed2ea90c42b2c6a54238a.js 100 | │   └── vendor-9eda64f0de2569c64ba0d33f08940fbf.css 101 | ├── contact 102 | │   └── index.html <--------- Pre-rendered content 103 | ├── index.html <--------- Rewritten with pre-rendered content 104 | ├── package.json 105 | └── robots.txt 106 | ``` 107 | 108 | The resulting application can be served entirely statically, like a normal Ember app. But it has the fast-first-paint and SEO benefits of a Fastboot-rendered application for all of the URLs that you pre-rendered. 109 | 110 | ## Configuring Your Webserver 111 | 112 | Your webserver needs to do two things correctly for this to work: 113 | 114 | 1. It should use a file like `about/index.html` to respond to URLs like `/about`. This is a pretty normal default behavior. 115 | 2. It should use `_empty.html` to respond to unknown URLs (404s). In a normal Ember app, you would configure `index.html` here instead, but we may have already overwritten `index.html` with content that only belongs on the homepage, not on every route. This is why `prember` gives you a separate `_empty.html` file with no prerendered content. 116 | 117 | ## Options 118 | 119 | You pass options to `prember` by setting them in `ember-cli-build.js`: 120 | 121 | ``` 122 | // In ember-cli-build.js 123 | let app = new EmberApp(defaults, { 124 | prember: { 125 | urls: [ 126 | '/', 127 | '/about', 128 | '/contact' 129 | ] 130 | } 131 | }); 132 | ``` 133 | 134 | The supported options are: 135 | 136 | - `urls`: this can be an array or a promise-returning function that resolves to an array. How you generate the list of URLs is up to you, there are many valid strategies. See next section about using a custom url discovery function. 137 | - `enabled`: defaults to `environment === 'production'` so that `prember` only runs during production builds. 138 | - `indexFile`: defaults to `"index.html"`. This is the name we will give to each of the files we create during pre-rendering. 139 | - `emptyFile`: defaults to `"_empty.html"`. This is where we will put a copy of your empty `index.html` as it was before any pre-rendering. 140 | - `requestsPerFastboot`: defaults to `1000`. This tells prember how many requests to pass to a single fastboot instance before creating a new one. This can be useful for memory management. 141 | 142 | ## Using a custom URL discovery function 143 | 144 | If you pass a function as the `urls` option, prember will invoke it like: 145 | 146 | ```js 147 | let listOfUrls = await yourUrlFunction({ distDir, visit }); 148 | ``` 149 | 150 | `distDir` is the directory containing your built application. This allows your function to inspect the build output to discover URLs. 151 | 152 | `visit` is an asynchronous function that takes a URL string and resolves to a response from a running fastboot server. This lets your function crawl the running application to discover URLs. 153 | 154 | For an example of both these strategies in action, see `./node-tests/url-tester.js` in this repo's test suite. 155 | 156 | ## Using prember in development 157 | 158 | In addition to the `enabled` option, you can temporarily turn `prember` on by setting the environment variable `PREMBER=true`, like: 159 | 160 | ```sh 161 | PREMBER=true ember serve 162 | ``` 163 | 164 | **However**, by default ember-cli doesn't understand that it should use a file like `about/index.html` to respond to a URL like `/about`. So you should do: 165 | 166 | ```sh 167 | ember install prember-middleware 168 | ``` 169 | 170 | It's harmless to keep prember-middleware permanently installed in your app, it has no impact on your production application. 171 | 172 | When running in development, you will see console output from ember-cli that distinguishes whether a given page was handled by prember vs handled on-the-fly by fastboot: 173 | 174 | ``` 175 | prember: serving prerendered static HTML for /about <--- served by prember 176 | 2017-10-27T05:25:02.161Z 200 OK /some-other-page <--- served by fastboot 177 | ``` 178 | 179 | ## Using prember from an addon 180 | 181 | Addon authors may declare urls *for* prember during compilation. To do so, you will want to: 182 | 183 | - Add `prember-plugin` to your addon's package.json `keywords` array; 184 | - Consider also using package.json's `ember-addon` object to configure your addon to run `before: 'prember'` 185 | - Define a `urlsForPrember(distDir, visit)` function in your addon's main file; 186 | - This function shares an interface with the "custom URL discovery" function, as defined above; and 187 | - Advise your addon's users to install & configure `prember` in the host application. 188 | 189 | Addon authors may also get access to urls *from* prember. To do so, you will want to: 190 | 191 | - Add `prember-plugin` to your addon's package.json `keywords` array; 192 | - Consider also using package.json's `ember-addon` object to configure your addon to run `before: 'prember'` 193 | - Define a `urlsFromPrember(urls)` function in your addon's main file; 194 | - This function will receive the array of urls prember knows about as the only argument; and 195 | - Advise your addon's users to install & configure `prember` in the host application. 196 | 197 | ## Using prember with Embroider 198 | 199 | You can use prember in an Embroider-based build, however you must apply some changes to your `ember-cli-build.js` for it to work. 200 | Embroider does not support the `postprocessTree` (type `all`) hook that this addon uses to *implicitly* hook into the build pipeline. 201 | But it exposes a `prerender` function to do so *explicitly*. 202 | 203 | In a [typical Embroider setup](https://github.com/embroider-build/embroider), your `ember-cli-build.js` will look like this: 204 | 205 | ```js 206 | const { Webpack } = require('@embroider/webpack'); 207 | return require('@embroider/compat').compatBuild(app, Webpack); 208 | ``` 209 | 210 | For prember to add its prerendered HTML pages on top of what Embroider already emitted, wrap the compiled output with the `prerender` function like this: 211 | 212 | ```diff 213 | const { Webpack } = require('@embroider/webpack'); 214 | - return require('@embroider/compat').compatBuild(app, Webpack); 215 | + const compiledApp = require('@embroider/compat').compatBuild(app, Webpack); 216 | + 217 | + return require('prember').prerender(app, compiledApp); 218 | ``` 219 | 220 | # Deployment 221 | 222 | You shouldn't need to do much special -- just make sure the html files get copied along with all your other files. 223 | 224 | If you're using `ember-cli-deploy-s3`, you just need to customize the `filePattern` setting so it includes `.html` files. For example: 225 | 226 | ```js 227 | ENV.s3 = { 228 | bucket: 'cardstack.com', 229 | region: 'us-east-1', 230 | filePattern: '**/*.{js,css,png,gif,ico,jpg,map,xml,txt,svg,swf,eot,ttf,woff,woff2,otf,html}' 231 | allowOverwrite: true 232 | }; 233 | ``` 234 | 235 | # Compared to other addons 236 | 237 | There are other ways to pre-render content: 238 | 239 | - [ember-prerender](https://github.com/zipfworks/ember-prerender) depends on having a real browser to do prerendering, which is heavy and complex. It's old and unmaintained. 240 | - [ember-cli-prerender](https://github.com/Motokaptia/ember-cli-prerender) uses Fastboot like we do, but it is not integrated with the build pipeline (so it's harder to make it Just Work™ with things like [ember-cli-deploy](http://ember-cli-deploy.com/)) and it has stronger opinions about what URLs it will discover, including blueprint-driven sitemap configuration. 241 | - [ember-cli-staticboot](https://github.com/robwebdev/ember-cli-staticboot) is quite similar to this addon, and I didn't realize it existed before I started making this one. I do think `prember` does a better job of integrating the static build output with the existing ember app in a way that requires the minimal webserver configuration. 242 | 243 | 244 | Contributing 245 | ------------------------------------------------------------------------------ 246 | 247 | See the [Contributing](CONTRIBUTING.md) guide for details. 248 | 249 | 250 | License 251 | ------------------------------------------------------------------------------ 252 | 253 | This project is licensed under the [MIT License](LICENSE.md). 254 | --------------------------------------------------------------------------------