diff --git a/package.json b/package.json index b1fd699f547..1cae5149d06 100644 --- a/package.json +++ b/package.json @@ -287,6 +287,7 @@ "@ember/routing/router-service.js": "ember-source/@ember/routing/router-service.js", "@ember/routing/router.js": "ember-source/@ember/routing/router.js", "@ember/runloop/index.js": "ember-source/@ember/runloop/index.js", + "@ember/server-rendering/index.js": "ember-source/@ember/server-rendering/index.js", "@ember/service/index.js": "ember-source/@ember/service/index.js", "@ember/template-compilation/index.js": "ember-source/@ember/template-compilation/index.js", "@ember/template-compiler/-internal-primitives.js": "ember-source/@ember/template-compiler/-internal-primitives.js", @@ -347,7 +348,6 @@ "@simple-dom/document/index.js": "ember-source/@simple-dom/document/index.js", "backburner.js/index.js": "ember-source/backburner.js/index.js", "dag-map/index.js": "ember-source/dag-map/index.js", - "ember-template-compiler/index.js": "ember-source/ember-template-compiler/index.js", "ember-testing/index.js": "ember-source/ember-testing/index.js", "ember-testing/lib/adapters/adapter.js": "ember-source/ember-testing/lib/adapters/adapter.js", "ember-testing/lib/adapters/qunit.js": "ember-source/ember-testing/lib/adapters/qunit.js", diff --git a/packages/@ember/server-rendering/index.js b/packages/@ember/server-rendering/index.js new file mode 100644 index 00000000000..33ca2e5ce55 --- /dev/null +++ b/packages/@ember/server-rendering/index.js @@ -0,0 +1,118 @@ +/** + @module @ember/server-rendering + @public +*/ + +import createHTMLDocument from '@simple-dom/document'; +import HTMLSerializer from '@simple-dom/serializer'; +import voidMap from '@simple-dom/void-map'; + +// The glimmer serialization markers are HTML comments used during rehydration. +// When pre-rendering (SSG), we strip these to produce clean static HTML. +const GLIMMER_COMMENT_PATTERN = //g; + +// Patches a SimpleDOM node tree with minimal selector stubs so that +// SSR-unaware addons that call `querySelector`/`querySelectorAll` during +// render don't throw. SimpleDOM does not implement CSS selectors. +function patchSimpleDocument(node) { + if (node && typeof node === 'object' && !node.querySelectorAll) { + node.querySelectorAll = function () { + return { length: 0 }; + }; + node.querySelector = function () { + return null; + }; + let child = node.firstChild; + while (child) { + patchSimpleDocument(child); + child = child.nextSibling; + } + } +} + +/** + Renders an Ember application to an HTML string suitable for server-side + rendering (SSR). The output includes Glimmer rehydration markers so the + client can efficiently rehydrate the server-rendered DOM rather than + re-rendering from scratch. + + This is compatible with Vite's SSR mode. Use this in your `entry-server.js` + to render the app on the server, then use `_renderMode: 'rehydrate'` on the + client to rehydrate the result. + + @method renderToHTML + @for @ember/server-rendering + @param {string} url The URL path to render (e.g. '/', '/about') + @param {object} AppClass Your Ember Application subclass + @return {Promise} resolves to `{ html }` with the serialized body HTML + @public +*/ +export function renderToHTML(url, AppClass) { + let ssrDocument = createHTMLDocument(); + let rootElement = ssrDocument.body; + + // Patch the SimpleDOM nodes with minimal selector stubs so that + // SSR-unaware addons (e.g. ember-page-title in development mode) that + // call `document.head.querySelectorAll()` don't throw. + patchSimpleDocument(ssrDocument); + + // Some addons access the global `document` directly rather than via Ember's + // DI container. Set it to our SimpleDOM instance during rendering so those + // accesses don't throw in Node.js, then restore the previous value. + let previousDocument = window.document; + window.document = ssrDocument; + + let app = AppClass.create({ autoboot: false }); + + function cleanup() { + if (previousDocument === undefined) { + delete window.document; + } else { + window.document = previousDocument; + } + } + + return app + .visit(url, { + isBrowser: false, + document: ssrDocument, + rootElement: rootElement, + _renderMode: 'serialize', + }) + .then( + function (instance) { + let serializer = new HTMLSerializer(voidMap); + let html = serializer.serializeChildren(rootElement); + instance.destroy(); + app.destroy(); + cleanup(); + return { html }; + }, + function (err) { + app.destroy(); + cleanup(); + throw err; + } + ); +} + +/** + Pre-renders an Ember application to a clean HTML string for static site + generation (SSG). Unlike `renderToHTML`, the output does NOT include + Glimmer rehydration markers, making it suitable for documentation sites + or any scenario where the client performs a full render. + + @method prerender + @for @ember/server-rendering + @param {string} url The URL path to render (e.g. '/', '/about') + @param {object} AppClass Your Ember Application subclass + @return {Promise} resolves to `{ html }` with clean static HTML + @public +*/ +export function prerender(url, AppClass) { + return renderToHTML(url, AppClass).then(function (result) { + // Strip Glimmer's serialization markers (block boundaries, etc.) + let html = result.html.replace(GLIMMER_COMMENT_PATTERN, ''); + return { html }; + }); +} diff --git a/packages/@ember/server-rendering/package.json b/packages/@ember/server-rendering/package.json new file mode 100644 index 00000000000..852c4992d97 --- /dev/null +++ b/packages/@ember/server-rendering/package.json @@ -0,0 +1,15 @@ +{ + "name": "@ember/server-rendering", + "private": true, + "type": "module", + "exports": { + ".": "./index.js" + }, + "dependencies": { + "@ember/application": "workspace:*", + "@glimmer/node": "workspace:*", + "@simple-dom/document": "^1.4.0", + "@simple-dom/serializer": "^1.4.0", + "@simple-dom/void-map": "^1.4.0" + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1d3f416ec4e..576134079f3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1085,6 +1085,24 @@ importers: specifier: workspace:* version: link:../../internal-test-helpers + packages/@ember/server-rendering: + dependencies: + '@ember/application': + specifier: workspace:* + version: link:../application + '@glimmer/node': + specifier: workspace:* + version: link:../../@glimmer/node + '@simple-dom/document': + specifier: ^1.4.0 + version: 1.4.0 + '@simple-dom/serializer': + specifier: ^1.4.0 + version: 1.4.0 + '@simple-dom/void-map': + specifier: ^1.4.0 + version: 1.4.0 + packages/@ember/service: dependencies: '@ember/-internals': @@ -2911,6 +2929,9 @@ importers: smoke-tests/node-template: dependencies: + ember-source: + specifier: workspace:* + version: link:../.. git-repo-info: specifier: ^2.1.1 version: 2.1.1 @@ -2983,6 +3004,9 @@ importers: '@ember/optional-features': specifier: ^2.3.0 version: 2.3.0 + '@ember/server-rendering': + specifier: workspace:* + version: link:../../packages/@ember/server-rendering '@ember/string': specifier: ^4.0.1 version: 4.0.1 diff --git a/smoke-tests/node-template/package.json b/smoke-tests/node-template/package.json index c33b74d91c5..da0f4ae1773 100644 --- a/smoke-tests/node-template/package.json +++ b/smoke-tests/node-template/package.json @@ -7,6 +7,7 @@ "test:node": "qunit tests/node/**/*-test.js" }, "dependencies": { + "ember-source": "workspace:*", "git-repo-info": "^2.1.1", "html-differ": "^1.4.0", "qunit": "^2.20.1", diff --git a/smoke-tests/node-template/tests/node/ssr-test.js b/smoke-tests/node-template/tests/node/ssr-test.js new file mode 100644 index 00000000000..40a55894f33 --- /dev/null +++ b/smoke-tests/node-template/tests/node/ssr-test.js @@ -0,0 +1,86 @@ +const SimpleDOM = require('simple-dom'); +const setupAppTest = require('./helpers/setup-app'); + +let htmlSerializer = new SimpleDOM.HTMLSerializer(SimpleDOM.voidMap); + +// Renders using _renderMode: 'serialize' which produces Glimmer rehydration +// markers in the HTML output (used for SSR with client-side rehydration). +function ssrVisit(App, url) { + let doc = new SimpleDOM.Document(); + let rootElement = doc.body; + + return App.visit(url, { + isBrowser: false, + document: doc, + rootElement: rootElement, + _renderMode: 'serialize', + }).then(function (instance) { + try { + return htmlSerializer.serialize(rootElement); + } finally { + instance.destroy(); + } + }); +} + +QUnit.module('SSR - _renderMode serialize', function (hooks) { + setupAppTest(hooks); + + QUnit.test('renders HTML with Glimmer rehydration markers', function (assert) { + this.template('application', '