diff --git a/docs-app/app/app-ssr.ts b/docs-app/app/app-ssr.ts new file mode 100644 index 000000000..4a7090ebd --- /dev/null +++ b/docs-app/app/app-ssr.ts @@ -0,0 +1,27 @@ +import PageTitleService from 'ember-page-title/services/page-title'; +import Application from 'ember-strict-application-resolver'; + +import Router from './router.ts'; + +export default class SsrApp extends Application { + modules = { + ...import.meta.glob('./router.ts', { eager: true }), + ...import.meta.glob('./templates/**/*.{gjs,gts,md}', { eager: true }), + ...import.meta.glob('./routes/**/*.{gjs,gts,js,ts,md}', { eager: true }), + './router': Router, + './services/page-title': PageTitleService, + }; +} + +/** + * Exported so vite-ember-ssr's worker can await it with a timeout per + * render (`settledTimeout`, default 10s). Demos with `ReactiveImage` + * register `waitForPromise` waiters that never resolve under Node + * (happy-dom doesn't fire `` onload), so an unbounded `settled()` + * inside `app.visit` would hang the whole render forever. + */ +export { settled } from '@ember/test-helpers'; + +export function createSsrApp() { + return SsrApp.create({ autoboot: false }); +} diff --git a/docs-app/app/boot.ts b/docs-app/app/boot.ts new file mode 100644 index 000000000..2b50b8949 --- /dev/null +++ b/docs-app/app/boot.ts @@ -0,0 +1,14 @@ +import { shouldRehydrate } from 'vite-ember-ssr/client'; + +import Application from './app.ts'; +import environment from './config/environment.ts'; + +if (shouldRehydrate()) { + const app = Application.create({ ...environment.APP, autoboot: false }); + + void app.visit(window.location.pathname + window.location.search, { + _renderMode: 'rehydrate', + }); +} else { + Application.create(environment.APP); +} diff --git a/docs-app/app/routes/application.ts b/docs-app/app/routes/application.ts index 90677cacf..28c5c560a 100644 --- a/docs-app/app/routes/application.ts +++ b/docs-app/app/routes/application.ts @@ -33,7 +33,7 @@ export default class Application extends Route { }); await Promise.all([ - setupTabster(this), + import.meta.env?.SSR ? Promise.resolve() : setupTabster(this), setupKolay(this, { // This won't work, because the compiler can't find the element to rendedr in to. // remarkPlugins: [ diff --git a/docs-app/index.html b/docs-app/index.html index 3003a4016..bbc1df1fa 100644 --- a/docs-app/index.html +++ b/docs-app/index.html @@ -13,13 +13,10 @@ as="font" rel="font/woff2" /> + - + + diff --git a/docs-app/package.json b/docs-app/package.json index 73d19ce9f..32cac8394 100644 --- a/docs-app/package.json +++ b/docs-app/package.json @@ -112,7 +112,8 @@ "testem": "^3.17.0", "typedoc": "^0.28.15", "typescript": "^6.0.2", - "vite": "^8.0.3" + "vite": "^8.0.3", + "vite-ember-ssr": "^0.1.0" }, "engines": { "node": ">= 20" diff --git a/docs-app/vite.config.mjs b/docs-app/vite.config.mjs index cd2dbf56f..7fb544b66 100644 --- a/docs-app/vite.config.mjs +++ b/docs-app/vite.config.mjs @@ -2,13 +2,130 @@ import { ember, extensions } from "@embroider/vite"; import { babel } from "@rollup/plugin-babel"; import { kolay } from "kolay/vite"; +import { globby } from "globby"; import { defineConfig } from "vite"; import { scopedCSS } from "ember-scoped-css/vite"; +import { emberSsg } from "vite-ember-ssr/vite-plugin"; import rehypeShiki from "@shikijs/rehype"; -export default defineConfig(async (/* { mode } */) => { +/** + * Replace runtime-only deps with empty stubs during SSR. Pre-rendering + * never exercises the REPL's runtime template compilation, but bundling + * content-tag/babel-standalone into the SSR build trips up rolldown + * (and isn't useful even if it built). + */ +function stubRuntimeOnlyDepsForSsr() { + const stubs = new Map([ + ["content-tag", "export class Preprocessor {}; export default { Preprocessor };"], + ["@babel/standalone", "export default {};"], + /** + * Imported via named export by demo snippets in markdown samples. + * lorem-ipsum is CJS and rolldown can't extract named exports from + * CJS during SSR builds, so substitute an ESM stub. The real package + * loads client-side after rehydration. + */ + [ + "lorem-ipsum", + 'export const loremIpsum = () => ""; export class LoremIpsum { generateSentences() { return ""; } generateParagraphs() { return ""; } generateWords() { return ""; } } export default { loremIpsum, LoremIpsum };', + ], + ]); + return { + name: "docs-app:stub-runtime-only-deps-for-ssr", + enforce: "pre", + resolveId(id, _importer, options) { + if (!options?.ssr) return; + if (stubs.has(id)) return `\0virtual:ssr-stub:${id}`; + }, + load(id) { + const prefix = "\0virtual:ssr-stub:"; + if (!id.startsWith(prefix)) return; + return stubs.get(id.slice(prefix.length)); + }, + }; +} + +export default defineConfig(async ({ isSsrBuild } = {}) => { + const docsRoutes = (await globby("app/templates/**/*.gjs.md", { cwd: import.meta.dirname })) + .map((p) => p.replace(/^app\/templates\//, "").replace(/\.gjs\.md$/, ".md")) + .sort(); + + return { + /** + * Force a single SSR bundle. Without this, rolldown splits Ember + * into chunks that circular-import `@ember/object` internals — the + * `ComputedProperty` class isn't initialized by the time + * `proxy.js`'s top-level `computed()` calls run. + * + * The banner installs no-op shims for globals that downstream + * modules read while loading (repl-sdk constructs a `new Worker(...)` + * at module-eval; @embroider/macros expects `process.env`). Rolldown + * inlines our app's module bodies after vendor inits, so a polyfill + * import isn't early enough — the banner runs before the first + * statement of the bundle. + */ + build: isSsrBuild + ? { + rollupOptions: { + output: { + codeSplitting: false, + banner: [ + "globalThis.process ??= { env: {} };", + "globalThis.Buffer ??= {};", + "globalThis.Worker ??= class { postMessage(){} terminate(){} addEventListener(){} removeEventListener(){} };", + /** + * repl-sdk captures browser-only globals into a + * `standardScope` map at module init. Provide minimal + * shims so the capture step doesn't throw under Node. + */ + "globalThis.postMessage ??= () => {};", + "globalThis.localStorage ??= { getItem(){return null}, setItem(){}, removeItem(){}, clear(){}, key(){return null}, length: 0 };", + "globalThis.sessionStorage ??= globalThis.localStorage;", + "globalThis.isSecureContext ??= false;", + /** + * happy-dom installs `window` but not its DOM + * interfaces or layout APIs on globalThis. A few demos + * touch them at module init. + */ + "if (globalThis.window) {", + " globalThis.Text ??= globalThis.window.Text;", + " globalThis.getComputedStyle ??= globalThis.window.getComputedStyle?.bind(globalThis.window);", + /** + * happy-dom lacks the Popover API. Stub it so demos + * that call showPopover() on render don't throw. + */ + " if (globalThis.window.HTMLElement) {", + " globalThis.window.HTMLElement.prototype.showPopover ??= function() {};", + " globalThis.window.HTMLElement.prototype.hidePopover ??= function() {};", + " globalThis.window.HTMLElement.prototype.togglePopover ??= function() {};", + " }", + /** + * happy-dom never fires onload/onerror on `` + * elements (no resource fetching). Make src setter + * synthesise an onload microtask so `ReactiveImage`'s + * waitForPromise waiter resolves and settled() can + * complete without hitting the per-route timeout. + */ + " const HTMLImageElement = globalThis.window.HTMLImageElement;", + " if (HTMLImageElement) {", + " const desc = Object.getOwnPropertyDescriptor(HTMLImageElement.prototype, 'src');", + " Object.defineProperty(HTMLImageElement.prototype, 'src', {", + " configurable: true,", + " get() { return desc?.get ? desc.get.call(this) : this.getAttribute('src'); },", + " set(v) {", + " if (desc?.set) desc.set.call(this, v); else this.setAttribute('src', v);", + " queueMicrotask(() => { try { this.onload?.(new Event('load')); } catch {} });", + " },", + " });", + " }", + "}", + ].join("\n"), + }, + }, + } + : {}, plugins: [ + stubRuntimeOnlyDepsForSsr(), scopedCSS(), ember(), kolay({ @@ -42,7 +159,15 @@ export default defineConfig(async (/* { mode } */) => { babelHelpers: "runtime", extensions, }), + emberSsg({ + routes: ["index", ...docsRoutes], + ssrEntry: "app/app-ssr.ts", + rehydrate: true, + }), ], + ssr: { + noExternal: [/./], + }, optimizeDeps: { exclude: [ // a wasm-providing dependency diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c9f5e1725..1216eb913 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -288,6 +288,9 @@ importers: vite: specifier: ^8.0.3 version: 8.0.10(d985db06b97e1f56cc68cb1e11366df8) + vite-ember-ssr: + specifier: ^0.1.0 + version: 0.1.0(14d69d31e7b7879e941bb8c997a05f7e) ember-primitives: dependencies: @@ -2627,6 +2630,9 @@ packages: '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + '@types/whatwg-mimetype@3.0.2': + resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==} + '@types/ws@8.18.1': resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} @@ -4039,6 +4045,10 @@ packages: resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} engines: {node: '>=0.12'} + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + env-paths@2.2.1: resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} engines: {node: '>=6'} @@ -4695,6 +4705,10 @@ packages: engines: {node: '>=0.4.7'} hasBin: true + happy-dom@20.9.0: + resolution: {integrity: sha512-GZZ9mKe8r646NUAf/zemnGbjYh4Bt8/MqASJY+pSm5ZDtc3YQox+4gsLI7yi1hba6o+eCsGxpHn5+iEVn31/FQ==} + engines: {node: '>=20.0.0'} + has-bigints@1.1.0: resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} engines: {node: '>= 0.4'} @@ -7006,6 +7020,10 @@ packages: resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} engines: {node: '>=12.0.0'} + tinypool@2.1.0: + resolution: {integrity: sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw==} + engines: {node: ^20.0.0 || >=22.0.0} + tldts-core@6.1.86: resolution: {integrity: sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==} @@ -7311,6 +7329,12 @@ packages: vfile@6.0.3: resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + vite-ember-ssr@0.1.0: + resolution: {integrity: sha512-5AD9mvznfkNApNxWqMgshnzERGOARrcT6eKSbIKwtmgLV58NBhv2bFSr+P5e3BrbabQRGdWjNU92Wix3k9p5mA==} + engines: {node: '>=22'} + peerDependencies: + vite: ^6.0.0 || ^7.0.0 + vite@8.0.10: resolution: {integrity: sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -7438,6 +7462,10 @@ packages: engines: {node: '>=18'} deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation + whatwg-mimetype@3.0.0: + resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} + engines: {node: '>=12'} + whatwg-mimetype@4.0.0: resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} engines: {node: '>=18'} @@ -9953,6 +9981,8 @@ snapshots: '@types/unist@3.0.3': {} + '@types/whatwg-mimetype@3.0.2': {} + '@types/ws@8.18.1': dependencies: '@types/node': 25.6.0 @@ -11607,6 +11637,8 @@ snapshots: entities@6.0.1: {} + entities@7.0.1: {} + env-paths@2.2.1: {} environment@1.1.0: {} @@ -12484,6 +12516,18 @@ snapshots: optionalDependencies: uglify-js: 3.19.3 + happy-dom@20.9.0: + dependencies: + '@types/node': 25.6.0 + '@types/whatwg-mimetype': 3.0.2 + '@types/ws': 8.18.1 + entities: 7.0.1 + whatwg-mimetype: 3.0.0 + ws: 8.20.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + has-bigints@1.1.0: {} has-flag@3.0.0: {} @@ -15377,6 +15421,8 @@ snapshots: fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 + tinypool@2.1.0: {} + tldts-core@6.1.86: {} tldts@6.1.86: @@ -15737,6 +15783,15 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 + vite-ember-ssr@0.1.0(14d69d31e7b7879e941bb8c997a05f7e): + dependencies: + happy-dom: 20.9.0 + tinypool: 2.1.0 + vite: 8.0.10(d985db06b97e1f56cc68cb1e11366df8) + transitivePeerDependencies: + - bufferutil + - utf-8-validate + vite@8.0.10(d985db06b97e1f56cc68cb1e11366df8): dependencies: lightningcss: 1.32.0 @@ -15845,6 +15900,8 @@ snapshots: dependencies: iconv-lite: 0.6.3 + whatwg-mimetype@3.0.0: {} + whatwg-mimetype@4.0.0: {} whatwg-url@14.2.0: