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', '

Hello SSR

{{outlet}}'); + + let App = this.createApplication(); + + return ssrVisit(App, '/').then(function (html) { + assert.ok(html.includes('Hello SSR'), 'rendered content is present'); + // Glimmer serialization markers are HTML comments of the form + assert.ok(/ - Application.create(environment.APP); - + + {{content-for "body-footer"}} diff --git a/smoke-tests/v2-app-template/package.json b/smoke-tests/v2-app-template/package.json index 2d40e14e1c0..ad6224c42a8 100644 --- a/smoke-tests/v2-app-template/package.json +++ b/smoke-tests/v2-app-template/package.json @@ -16,6 +16,7 @@ }, "scripts": { "build": "vite build", + "build:ssr": "vite build && vite build --ssr entry-server.js --outDir dist/server", "format": "prettier . --cache --write", "lint": "concurrently \"npm:lint:*(!fix)\" --names \"lint:\" --prefixColors auto", "lint:css": "stylelint \"**/*.css\"", @@ -26,8 +27,11 @@ "lint:hbs:fix": "ember-template-lint . --fix", "lint:js": "eslint . --cache", "lint:js:fix": "eslint . --fix", + "preview:ssr": "NODE_ENV=production node server.js", "start": "vite", - "test": "vite build --mode development && ember test --path dist" + "start:ssr": "node server.js", + "test": "vite build --mode development && ember test --path dist", + "test:ssr": "node --test tests/ssr/ssr-test.mjs" }, "devDependencies": { "@babel/core": "^7.29.0", @@ -35,6 +39,7 @@ "@babel/plugin-transform-runtime": "^7.29.0", "@babel/runtime": "^7.28.6", "@ember/optional-features": "^2.3.0", + "@ember/server-rendering": "workspace:*", "@ember/string": "^4.0.1", "@ember/test-helpers": "^5.4.1", "@ember/test-waiters": "^4.1.1", diff --git a/smoke-tests/v2-app-template/server.js b/smoke-tests/v2-app-template/server.js new file mode 100644 index 00000000000..1d996fed011 --- /dev/null +++ b/smoke-tests/v2-app-template/server.js @@ -0,0 +1,135 @@ +/** + * Vite SSR development and preview server for the v2-app-template. + * + * In development mode (NODE_ENV !== 'production'), Vite's dev server is used + * as middleware so HMR and on-demand module transformation work as expected. + * The server entry is loaded via `vite.ssrLoadModule()` on every request so + * code changes are picked up without a full server restart. + * + * In production mode the pre-built server bundle is loaded once at startup. + * + * Usage: + * Development: node server.js + * Production: NODE_ENV=production node server.js + * + * Build for production SSR: + * vite build # builds the client + * vite build --ssr app/entry-server.js # builds the server bundle + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createServer as createViteServer } from 'vite'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const isProduction = process.env['NODE_ENV'] === 'production'; +const port = process.env['PORT'] ? parseInt(process.env['PORT'], 10) : 4200; + +async function createServer() { + // The index.html template – in production this is the already-built file. + const templateHtml = isProduction + ? fs.readFileSync(path.resolve(__dirname, 'dist/client/index.html'), 'utf-8') + : fs.readFileSync(path.resolve(__dirname, 'index.html'), 'utf-8'); + + // In production we load the pre-built server entry directly. + // In development Vite handles module loading and transformation. + let ssrManifest; + if (isProduction) { + ssrManifest = JSON.parse( + fs.readFileSync(path.resolve(__dirname, 'dist/client/.vite/ssr-manifest.json'), 'utf-8') + ); + } + + /** @type {import('vite').ViteDevServer | undefined} */ + let vite; + + // We use a minimal built-in HTTP server that mimics what an Express server + // would do, without requiring Express as a dependency. + const { default: http } = await import('node:http'); + + if (!isProduction) { + vite = await createViteServer({ + server: { middlewareMode: true }, + appType: 'custom', + }); + } + + /** + * Handles an incoming SSR request. + * @param {import('node:http').IncomingMessage} req + * @param {import('node:http').ServerResponse} res + */ + async function handleRequest(req, res) { + const url = req.url ?? '/'; + + try { + let template = templateHtml; + let render; + + if (!isProduction && vite) { + // Apply Vite's HTML transforms (injects HMR client, etc.). + template = await vite.transformIndexHtml(url, template); + // Load the server entry fresh on every request during development. + ({ render } = await vite.ssrLoadModule('/entry-server.js')); + } else { + // Production: use the pre-built server bundle. + ({ render } = await import('./dist/server/entry-server.js')); + } + + const rendered = await render(url); + + // Inject the rendered HTML and mark the body so the client knows to + // rehydrate rather than do a fresh client-side render. + const html = template + .replace('', rendered.html) + .replace('', ''); + + res.writeHead(200, { 'Content-Type': 'text/html' }); + res.end(html); + } catch (e) { + // In development, Vite can fix source maps for better error messages. + if (vite) { + vite.ssrFixStacktrace(/** @type {Error} */ (e)); + } + console.error(/** @type {Error} */ (e).stack); + res.writeHead(500, { 'Content-Type': 'text/plain' }); + res.end(/** @type {Error} */ (e).message); + } + } + + const server = http.createServer(async (req, res) => { + // In development, let Vite handle asset requests (JS, CSS, source maps…). + if (!isProduction && vite) { + let handled = false; + + // Vite middleware uses the connect-style callback interface. + await new Promise((resolve) => { + vite.middlewares(req, res, () => { + handled = false; + resolve(undefined); + }); + // If Vite handled the request it won't call next(), so we resolve + // via a short-circuit after the middleware chain finishes. + setImmediate(() => { + if (!res.writableEnded) resolve(undefined); + else { + handled = true; + resolve(undefined); + } + }); + }); + + if (handled || res.writableEnded) return; + } + + // All non-asset requests go through the SSR handler. + await handleRequest(req, res); + }); + + server.listen(port, () => { + console.log(`SSR server running at http://localhost:${port}`); + }); +} + +createServer(); diff --git a/smoke-tests/v2-app-template/tests/ssr/ssr-test.mjs b/smoke-tests/v2-app-template/tests/ssr/ssr-test.mjs new file mode 100644 index 00000000000..f366b3cd388 --- /dev/null +++ b/smoke-tests/v2-app-template/tests/ssr/ssr-test.mjs @@ -0,0 +1,120 @@ +/** + * SSR and SSG integration tests for the v2-app-template. + * + * These tests exercise the full SSR/SSG render pipeline using Vite's + * programmatic API. Vite is started once in middleware mode so all tests share + * a single server instance. + * + * Run: + * node --test tests/ssr/ssr-test.mjs + */ + +import { createServer } from 'vite'; +import { describe, it, before, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { fileURLToPath } from 'node:url'; +import { dirname } from 'node:path'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const projectRoot = dirname(dirname(__dirname)); + +/** @type {import('vite').ViteDevServer} */ +let vite; +/** @type {(url: string) => Promise<{ html: string }>} */ +let render; +/** @type {(url: string) => Promise<{ html: string }>} */ +let prerender; + +before(async () => { + vite = await createServer({ + root: projectRoot, + server: { middlewareMode: true }, + appType: 'custom', + logLevel: 'silent', + }); + + ({ render } = await vite.ssrLoadModule('/entry-server.js')); + ({ render: prerender } = await vite.ssrLoadModule('/entry-prerender.js')); +}); + +after(async () => { + await vite.close(); +}); + +// --------------------------------------------------------------------------- +// SSR (with rehydration markers) +// --------------------------------------------------------------------------- + +describe('renderToHTML (SSR)', () => { + it('renders the home page and returns an html string', async () => { + const result = await render('/'); + assert.ok( + typeof result.html === 'string' && result.html.length > 0, + 'render() should return a non-empty html string' + ); + }); + + it('renders the application template content', async () => { + const result = await render('/'); + assert.ok( + result.html.includes('Welcome to Ember'), + `Expected html to contain "Welcome to Ember" but got:\n${result.html}` + ); + }); + + it('includes Glimmer rehydration markers for client-side rehydration', async () => { + const result = await render('/'); + // The serializeBuilder inserts block markers like and + // which the rehydration builder uses on the client. + assert.ok( + result.html.includes('%+b:') || result.html.includes('%-b:') || result.html.includes('%glmr%'), + `Expected html to contain Glimmer rehydration markers but got:\n${result.html}` + ); + }); + + it('can render multiple URLs sequentially', async () => { + const result1 = await render('/'); + const result2 = await render('/'); + assert.equal(result1.html, result2.html, 'Sequential renders should produce identical output'); + }); +}); + +// --------------------------------------------------------------------------- +// SSG pre-rendering (no rehydration markers) +// --------------------------------------------------------------------------- + +describe('prerender (SSG)', () => { + it('returns a non-empty html string', async () => { + const result = await prerender('/'); + assert.ok( + typeof result.html === 'string' && result.html.length > 0, + 'prerender() should return a non-empty html string' + ); + }); + + it('renders the application template content', async () => { + const result = await prerender('/'); + assert.ok( + result.html.includes('Welcome to Ember'), + `Expected html to contain "Welcome to Ember" but got:\n${result.html}` + ); + }); + + it('does NOT include Glimmer rehydration markers', async () => { + const result = await prerender('/'); + assert.ok( + !result.html.includes('%+b:') && + !result.html.includes('%-b:') && + !result.html.includes('%glmr%'), + `Expected pre-rendered html to be free of Glimmer markers but got:\n${result.html}` + ); + }); + + it('produces cleaner html than renderToHTML', async () => { + const ssr = await render('/'); + const ssg = await prerender('/'); + // Pre-rendered output should be shorter (markers stripped) but contain the same content. + assert.ok(ssg.html.length < ssr.html.length, 'Pre-rendered html should be shorter (no markers)'); + assert.ok(ssg.html.includes('Welcome to Ember'), 'Content should still be present'); + }); +}); diff --git a/smoke-tests/v2-app-template/vite.config.mjs b/smoke-tests/v2-app-template/vite.config.mjs index 219253dbea9..0ad5f0174d9 100644 --- a/smoke-tests/v2-app-template/vite.config.mjs +++ b/smoke-tests/v2-app-template/vite.config.mjs @@ -2,6 +2,13 @@ import { defineConfig } from 'vite'; import { extensions, classicEmberSupport, ember } from '@embroider/vite'; import { babel } from '@rollup/plugin-babel'; +// Exclude plain TypeScript file extensions from Babel so that Vite's built-in +// esbuild transform handles them. Babel is still applied to .gjs/.gts files +// (Glimmer template-tag syntax) which need the template compilation plugin. +const babelExtensions = extensions.filter( + (ext) => ext !== '.ts' && ext !== '.mts' +); + export default defineConfig({ plugins: [ classicEmberSupport(), @@ -9,7 +16,18 @@ export default defineConfig({ // extra plugins here babel({ babelHelpers: 'runtime', - extensions, + extensions: babelExtensions, }), ], + build: { + // Output client assets to dist/client so the SSR server can serve them. + outDir: 'dist/client', + }, + ssr: { + // Ensure Vite processes packages that import workspace-only packages (such + // as @ember/* or @glimmer/*) through its own module resolver in SSR mode. + // Without this, Node's native ESM resolution would be used for these + // packages but the workspace packages they depend on wouldn't be findable. + noExternal: [/^@ember\//, /^ember/, /^@embroider\//, /^@glimmer\//], + }, }); diff --git a/tests/docs/expected.js b/tests/docs/expected.js index 57caa8fb9a3..92b982f328b 100644 --- a/tests/docs/expected.js +++ b/tests/docs/expected.js @@ -377,6 +377,7 @@ module.exports = { 'popObject', 'positionalParams', 'promise', + 'prerender', 'pushObject', 'pushObjects', 'pushState', @@ -422,6 +423,7 @@ module.exports = { 'removeTestHelpers', 'renderComponent', 'renderSettled', + 'renderToHTML', 'reopen', 'reopenClass', 'replace', @@ -645,6 +647,7 @@ module.exports = { '@ember/routing/router-service', '@ember/routing/transition', '@ember/runloop', + '@ember/server-rendering', '@ember/service', '@ember/template', '@ember/test',