diff --git a/apps/analog-app/src/app/pages/client/(client).page.ts b/apps/analog-app/src/app/pages/client/(client).page.ts deleted file mode 100644 index 729287b03..000000000 --- a/apps/analog-app/src/app/pages/client/(client).page.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { ChangeDetectionStrategy, Component, signal } from '@angular/core'; -import { ServerOnly } from '@analogjs/router'; - -@Component({ - imports: [ServerOnly], - changeDetection: ChangeDetectionStrategy.OnPush, - template: ` -

Client Component

- - - - - -

- -

- `, -}) -export default class ClientComponent { - props = signal({ name: 'Brandon', count: 0 }); - props2 = signal({ name: 'Brandon', count: 4 }); - - update() { - this.props.update((data) => ({ ...data, count: ++data.count })); - } - - log($event: object) { - console.log({ outputs: $event }); - } -} diff --git a/apps/analog-app/src/app/pages/server/(server).page.ts b/apps/analog-app/src/app/pages/server/(server).page.ts deleted file mode 100644 index 657fc1afe..000000000 --- a/apps/analog-app/src/app/pages/server/(server).page.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { RouteMeta } from '@analogjs/router'; - -import { ServerOnly } from '@analogjs/router'; - -export const routeMeta: RouteMeta = { - data: { - component: 'hello', - }, -}; - -export default ServerOnly; diff --git a/apps/analog-app/src/server/components/hello.ts b/apps/analog-app/src/server/components/hello.ts deleted file mode 100644 index 007965095..000000000 --- a/apps/analog-app/src/server/components/hello.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type { OnInit } from '@angular/core'; -import { ChangeDetectionStrategy, Component, computed } from '@angular/core'; - -import { - injectStaticOutputs, - injectStaticProps, -} from '@analogjs/router/server'; - -@Component({ - selector: 'analogjs-hello', - changeDetection: ChangeDetectionStrategy.OnPush, - template: ` -

Hello From the Server

- -

Props: {{ json() }}

- -

Time: {{ Date.now().toString() }}

- `, - styles: ` - h3 { - color: blue; - } - `, -}) -export default class HelloComponent implements OnInit { - Date = Date; - props = injectStaticProps(); - outputs = injectStaticOutputs<{ loaded: boolean }>(); - json = computed(() => JSON.stringify(this.props)); - - ngOnInit() { - this.outputs.set({ loaded: true }); - } -} diff --git a/apps/docs-app/docs/features/server/streaming-ssr.md b/apps/docs-app/docs/features/server/streaming-ssr.md new file mode 100644 index 000000000..c608da584 --- /dev/null +++ b/apps/docs-app/docs/features/server/streaming-ssr.md @@ -0,0 +1,122 @@ +# Streaming SSR + +Analog supports progressive streaming server-side rendering, flushing the +response to the browser as the app renders instead of buffering the whole +document until it is complete. + +The document head is sent immediately, each `@defer (hydrate …)` block is sent +the moment it resolves on the server, and the authoritative document arrives +last — so a slow block never holds back the rest of the page. + +:::info Experimental + +Streaming SSR is experimental and opt-in. It requires **Angular 21 or later** +and builds on [incremental hydration](https://angular.dev/guide/incremental-hydration). +The default buffered [Server Side Rendering](/docs/features/server/server-side-rendering) +path is unchanged. + +::: + +## Enabling streaming + +Enable the `experimental.streaming` option in your Vite config: + +```ts +// vite.config.ts +import analog from '@analogjs/platform'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [analog({ experimental: { streaming: true } })], +}); +``` + +Then use `renderStream` instead of `render` in `main.server.ts`: + +```ts +// src/main.server.ts +import { renderStream } from '@analogjs/router/server'; +import { config } from './app/app.config.server'; +import { AppComponent } from './app/app.component'; + +export default renderStream(AppComponent, config); +``` + +Streaming builds on incremental hydration, so enable it in your client +providers. On Angular 21 use `withIncrementalHydration()`; on Angular 22+ it is +enabled by default with `provideClientHydration()`: + +```ts +// src/app/app.config.ts +import { + provideClientHydration, + withIncrementalHydration, +} from '@angular/platform-browser'; + +export const appConfig: ApplicationConfig = { + providers: [ + provideClientHydration(withIncrementalHydration()), + // ... + ], +}; +``` + +## Streaming deferred blocks + +Content that should stream progressively goes in a `@defer` block with a +`hydrate` trigger. Each block is rendered eagerly on the server, streamed as it +resolves, and hydrated on the client when its trigger fires: + +```markup +

Dashboard

+ +@defer (hydrate on immediate) { + +} @placeholder { +

Loading activity…

+} + +@defer (hydrate on viewport) { + +} @placeholder { +

Loading recommendations…

+} +``` + +A block backed by asynchronous data (for example an +[`httpResource`](https://angular.dev/guide/http/http-resource)) keeps the render +pending until its data resolves, so the block streams with its final content and +the page's time-to-first-byte is unaffected. + +## Title and meta + +Because the document head is flushed before the app renders, a title or meta set +during render (via the `Title`/`Meta` services or route metadata) is applied to +the streamed document once the render completes, before hydration runs. Search +engine crawlers are served a buffered render with a fully resolved head instead +of the streamed shell. + +## Opting a route out of streaming + +Disable streaming for specific routes with a `streaming: false` route rule, the +same way `ssr: false` disables SSR. Matching routes fall back to a buffered +render (no streaming, but SSR and hydration still work): + +```ts +// vite.config.ts +import analog from '@analogjs/platform'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [ + analog({ + experimental: { streaming: true }, + nitro: { + routeRules: { + '/report': { streaming: false }, + }, + }, + }), + ], +}); +``` diff --git a/apps/docs-app/sidebars.js b/apps/docs-app/sidebars.js index 7e3dd76d8..3aac9b7fb 100644 --- a/apps/docs-app/sidebars.js +++ b/apps/docs-app/sidebars.js @@ -118,6 +118,12 @@ const sidebars = { label: 'Overview', key: 'server-side-rendering-overview', }, + { + type: 'doc', + id: 'features/server/streaming-ssr', + label: 'Streaming SSR', + key: 'streaming-ssr', + }, ], }, { diff --git a/apps/streaming-app/index.html b/apps/streaming-app/index.html new file mode 100644 index 000000000..d33a5eb84 --- /dev/null +++ b/apps/streaming-app/index.html @@ -0,0 +1,13 @@ + + + + + Analog Streaming SSR + + + + + + + + diff --git a/apps/streaming-app/project.json b/apps/streaming-app/project.json new file mode 100644 index 000000000..550872270 --- /dev/null +++ b/apps/streaming-app/project.json @@ -0,0 +1,51 @@ +{ + "name": "streaming-app", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "projectType": "application", + "sourceRoot": "apps/streaming-app/src", + "prefix": "analogjs", + "tags": [], + "implicitDependencies": ["platform", "router"], + "targets": { + "build": { + "executor": "@nx/vite:build", + "outputs": [ + "{options.outputPath}", + "{workspaceRoot}/dist/apps/streaming-app/.nitro", + "{workspaceRoot}/dist/apps/streaming-app/ssr", + "{workspaceRoot}/dist/apps/streaming-app/analog" + ], + "options": { + "configFile": "apps/streaming-app/vite.config.ts", + "outputPath": "dist/apps/streaming-app/client" + }, + "defaultConfiguration": "production", + "configurations": { + "development": { + "mode": "development" + }, + "production": { + "sourcemap": false, + "mode": "production" + } + } + }, + "serve": { + "executor": "@nx/vite:dev-server", + "defaultConfiguration": "development", + "options": { + "buildTarget": "streaming-app:build", + "port": 3200 + }, + "configurations": { + "development": { + "buildTarget": "streaming-app:build:development", + "hmr": true + }, + "production": { + "buildTarget": "streaming-app:build:production" + } + } + } + } +} diff --git a/apps/streaming-app/src/app/app.component.ts b/apps/streaming-app/src/app/app.component.ts new file mode 100644 index 000000000..ee74d2a94 --- /dev/null +++ b/apps/streaming-app/src/app/app.component.ts @@ -0,0 +1,10 @@ +import { Component } from '@angular/core'; +import { RouterOutlet } from '@angular/router'; + +@Component({ + selector: 'streaming-root', + standalone: true, + imports: [RouterOutlet], + template: ``, +}) +export class AppComponent {} diff --git a/apps/streaming-app/src/app/app.config.server.ts b/apps/streaming-app/src/app/app.config.server.ts new file mode 100644 index 000000000..0da63b09b --- /dev/null +++ b/apps/streaming-app/src/app/app.config.server.ts @@ -0,0 +1,10 @@ +import { mergeApplicationConfig, ApplicationConfig } from '@angular/core'; +import { provideServerRendering } from '@angular/platform-server'; + +import { appConfig } from './app.config'; + +const serverConfig: ApplicationConfig = { + providers: [provideServerRendering()], +}; + +export const config = mergeApplicationConfig(appConfig, serverConfig); diff --git a/apps/streaming-app/src/app/app.config.ts b/apps/streaming-app/src/app/app.config.ts new file mode 100644 index 000000000..b64ac083d --- /dev/null +++ b/apps/streaming-app/src/app/app.config.ts @@ -0,0 +1,22 @@ +import { provideFileRouter, requestContextInterceptor } from '@analogjs/router'; +import { + provideHttpClient, + withFetch, + withInterceptors, +} from '@angular/common/http'; +import { ApplicationConfig } from '@angular/core'; +import { + provideClientHydration, + withIncrementalHydration, +} from '@angular/platform-browser'; + +export const appConfig: ApplicationConfig = { + providers: [ + provideHttpClient( + withFetch(), + withInterceptors([requestContextInterceptor]), + ), + provideClientHydration(withIncrementalHydration()), + provideFileRouter(), + ], +}; diff --git a/apps/streaming-app/src/app/blocks/fast-a.component.ts b/apps/streaming-app/src/app/blocks/fast-a.component.ts new file mode 100644 index 000000000..c826d1589 --- /dev/null +++ b/apps/streaming-app/src/app/blocks/fast-a.component.ts @@ -0,0 +1,17 @@ +import { Component, signal } from '@angular/core'; + +@Component({ + selector: 'app-fast-a', + standalone: true, + template: `
+ Block A — deferred (fast, no async) + incrementally hydrated. count = + {{ count() }} + +
`, +}) +export class FastA { + count = signal(41); + inc() { + this.count.set(this.count() + 1); + } +} diff --git a/apps/streaming-app/src/app/blocks/shell-status.component.ts b/apps/streaming-app/src/app/blocks/shell-status.component.ts new file mode 100644 index 000000000..bd263206e --- /dev/null +++ b/apps/streaming-app/src/app/blocks/shell-status.component.ts @@ -0,0 +1,20 @@ +import { Component, signal } from '@angular/core'; + +// Eager, non-@defer component: it renders as part of the app body (so it lands +// in the authoritative tail, not a deferred preview) and — unlike the @defer +// blocks — hydrates immediately on bootstrap rather than via an incremental +// hydration trigger. Covers the ordinary, non-deferred hydration path. +@Component({ + selector: 'app-shell-status', + standalone: true, + template: `

+ Eager component — rendered in the shell, hydrated immediately. n = {{ n() }} + +

`, +}) +export class ShellStatus { + n = signal(7); + inc() { + this.n.set(this.n() + 1); + } +} diff --git a/apps/streaming-app/src/app/blocks/slow-b.component.ts b/apps/streaming-app/src/app/blocks/slow-b.component.ts new file mode 100644 index 000000000..1e1ddb4fa --- /dev/null +++ b/apps/streaming-app/src/app/blocks/slow-b.component.ts @@ -0,0 +1,25 @@ +import { Component, linkedSignal } from '@angular/core'; +import { httpResource } from '@angular/common/http'; + +@Component({ + selector: 'app-slow-b', + standalone: true, + template: ``, +}) +export class SlowB { + // Real per-request data fetch against a slow API route. During SSR the render + // stays unstable until this resolves, so the authoritative document is the + // last thing flushed — the head and Block A have already streamed. + private data = httpResource<{ label: string }>(() => '/api/slow-b'); + + // Seed a writable signal from the resolved data; the click can still override + // it locally, and it re-seeds if the source ever changes. + label = linkedSignal(() => this.data.value()?.label ?? 'loading'); + + toggle() { + this.label.set(this.label() === 'summer' ? 'winter' : 'summer'); + } +} diff --git a/apps/streaming-app/src/app/blocks/trigger-c.component.ts b/apps/streaming-app/src/app/blocks/trigger-c.component.ts new file mode 100644 index 000000000..7e9105ad1 --- /dev/null +++ b/apps/streaming-app/src/app/blocks/trigger-c.component.ts @@ -0,0 +1,22 @@ +import { Component, signal } from '@angular/core'; + +// Deferred block hydrated by a *non-immediate* trigger (`hydrate on +// interaction`). It renders eagerly on the server like the others, but stays +// dormant on the client until the first interaction — the click both triggers +// hydration and is replayed to the handler. Proves the streaming reconcile +// (the swap to the authoritative body) preserves the dehydrated DOM, jsaction +// markers, and event contract that triggered hydration depends on. +@Component({ + selector: 'app-trigger-c', + standalone: true, + template: `
+ Block C — deferred + hydrated on interaction. count = {{ count() }} + +
`, +}) +export class TriggerC { + count = signal(100); + inc() { + this.count.set(this.count() + 1); + } +} diff --git a/apps/streaming-app/src/app/pages/buffered.page.ts b/apps/streaming-app/src/app/pages/buffered.page.ts new file mode 100644 index 000000000..7b25c5633 --- /dev/null +++ b/apps/streaming-app/src/app/pages/buffered.page.ts @@ -0,0 +1,22 @@ +import { Component } from '@angular/core'; + +import { FastA } from '../blocks/fast-a.component'; + +// This route opts out of streaming via a `streaming: false` route rule (see +// vite.config.ts). It still renders and hydrates like any incremental-hydration +// page, but the response is a single buffered document — no streaming +// scaffolding, no chunked transfer-encoding. +@Component({ + selector: 'buffered-page', + standalone: true, + imports: [FastA], + template: ` +

Buffered route (streaming disabled)

+ @defer (hydrate on immediate) { + + } @placeholder { +

Loading A…

+ } + `, +}) +export default class BufferedPage {} diff --git a/apps/streaming-app/src/app/pages/index.page.ts b/apps/streaming-app/src/app/pages/index.page.ts new file mode 100644 index 000000000..7ce7ed543 --- /dev/null +++ b/apps/streaming-app/src/app/pages/index.page.ts @@ -0,0 +1,58 @@ +import { Component, inject } from '@angular/core'; +import { Meta, Title } from '@angular/platform-browser'; + +import { FastA } from '../blocks/fast-a.component'; +import { SlowB } from '../blocks/slow-b.component'; +import { ShellStatus } from '../blocks/shell-status.component'; +import { TriggerC } from '../blocks/trigger-c.component'; + +@Component({ + selector: 'streaming-home', + standalone: true, + imports: [FastA, SlowB, ShellStatus, TriggerC], + template: ` +

Analog Streaming SSR

+

+ The document head streams first, then each @defer block streams as it + resolves; this shell and the eager component below arrive with the + authoritative tail. +

+ + + + + + @defer (hydrate on immediate) { + + } @placeholder { +

Loading A…

+ } + + + @defer (hydrate on immediate) { + + } @placeholder { +

Loading B…

+ } + + + @defer (hydrate on interaction) { + + } @placeholder { +

Loading C…

+ } + `, +}) +export default class IndexPage { + // Set the head dynamically *during* render. The streaming shell head was + // already flushed by this point, so this exercises the finalize-time head + // reconcile (see __analogReconcileHead): the title/meta below must appear in + // the live document even though they were unknown when the head streamed. + constructor() { + inject(Title).setTitle('Streamed dynamically — Analog SSR'); + inject(Meta).updateTag({ + name: 'description', + content: 'Head set during render, reconciled after the stream.', + }); + } +} diff --git a/apps/streaming-app/src/main.server.ts b/apps/streaming-app/src/main.server.ts new file mode 100644 index 000000000..bfa9aaf30 --- /dev/null +++ b/apps/streaming-app/src/main.server.ts @@ -0,0 +1,8 @@ +import 'zone.js/node'; +import '@angular/platform-server/init'; +import { renderStream } from '@analogjs/router/server'; + +import { config } from './app/app.config.server'; +import { AppComponent } from './app/app.component'; + +export default renderStream(AppComponent, config); diff --git a/apps/streaming-app/src/main.ts b/apps/streaming-app/src/main.ts new file mode 100644 index 000000000..c4f73244a --- /dev/null +++ b/apps/streaming-app/src/main.ts @@ -0,0 +1,9 @@ +import 'zone.js'; +import { bootstrapApplication } from '@angular/platform-browser'; + +import { AppComponent } from './app/app.component'; +import { appConfig } from './app/app.config'; + +bootstrapApplication(AppComponent, appConfig).catch((err) => + console.error(err), +); diff --git a/apps/streaming-app/src/server/routes/api/slow-b.get.ts b/apps/streaming-app/src/server/routes/api/slow-b.get.ts new file mode 100644 index 000000000..f92a2f0ed --- /dev/null +++ b/apps/streaming-app/src/server/routes/api/slow-b.get.ts @@ -0,0 +1,10 @@ +import { eventHandler } from 'h3'; + +// Simulate a slow data source (~600ms) so Block B's `httpResource` keeps the +// app unstable until it resolves. This is a genuine per-request delay: the head +// and Block A stream immediately while the authoritative, data-filled document +// is flushed only once this resolves. +export default eventHandler(async () => { + await new Promise((resolve) => setTimeout(resolve, 600)); + return { label: 'summer' }; +}); diff --git a/apps/streaming-app/tools/cwv-bench.mjs b/apps/streaming-app/tools/cwv-bench.mjs new file mode 100644 index 000000000..7b4a1a5a0 --- /dev/null +++ b/apps/streaming-app/tools/cwv-bench.mjs @@ -0,0 +1,151 @@ +/** + * Core Web Vitals benchmark: streamed vs buffered render of the SAME page. + * + * Usage: + * npx nx build streaming-app --configuration=production + * PORT=3210 HOST=127.0.0.1 node dist/apps/streaming-app/analog/server/index.mjs & + * node apps/streaming-app/tools/cwv-bench.mjs # BASE/ITERS overridable via env + * + * The same route (`/`) is measured both ways, decided by User-Agent: a browser + * UA streams, a Googlebot UA takes the buffered fallback. Page, bundle, and the + * ~600ms httpResource dependency are identical, so only the render strategy + * differs. Throttled to Lighthouse mobile-ish conditions (Slow-4G + 4x CPU) so + * the TTFB/FCP deltas are meaningful. + * + * Playwright is used rather than Lighthouse on purpose: Lighthouse's own + * user-agent matches `SSR_BOT_RE`, so it would only ever measure the buffered + * path. Requires the repo's `playwright` dev dependency + `npx playwright install`. + */ +import pw from 'playwright'; +const { chromium } = pw; + +const BASE = process.env.BASE || 'http://127.0.0.1:3210'; +const URL = `${BASE}/`; +const ITERS = Number(process.env.ITERS || 9); + +const UA_BROWSER = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'; +const UA_BOT = + 'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)'; + +// Lighthouse "Slow 4G": 150ms RTT, ~1.6 Mbps down / 0.75 up, 4x CPU. +const NET = { + offline: false, + latency: 150, + downloadThroughput: (1.6 * 1024 * 1024) / 8, + uploadThroughput: (0.75 * 1024 * 1024) / 8, +}; +const CPU_RATE = 4; + +const OBSERVERS = ` +window.__cwv = { lcp: 0, lcpEl: '', cls: 0, fcp: 0, events: [] }; +new PerformanceObserver((l) => { + for (const e of l.getEntries()) { + window.__cwv.lcp = e.startTime; + const el = e.element; + window.__cwv.lcpEl = el ? (el.tagName.toLowerCase() + (el.className ? '.' + String(el.className).trim().split(/\\s+/)[0] : '')) : ''; + } +}).observe({ type: 'largest-contentful-paint', buffered: true }); +new PerformanceObserver((l) => { + for (const e of l.getEntries()) if (!e.hadRecentInput) window.__cwv.cls += e.value; +}).observe({ type: 'layout-shift', buffered: true }); +new PerformanceObserver((l) => { + for (const e of l.getEntries()) if (e.name === 'first-contentful-paint') window.__cwv.fcp = e.startTime; +}).observe({ type: 'paint', buffered: true }); +new PerformanceObserver((l) => { + for (const e of l.getEntries()) window.__cwv.events.push({ name: e.name, dur: e.duration }); +}).observe({ type: 'event', buffered: true, durationThreshold: 0 }); +`; + +function median(xs) { + const s = [...xs].sort((a, b) => a - b); + const m = Math.floor(s.length / 2); + return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2; +} + +async function runOnce(browser, ua) { + const context = await browser.newContext({ + userAgent: ua, + viewport: { width: 390, height: 844 }, + }); + const page = await context.newPage(); + const cdp = await context.newCDPSession(page); + await cdp.send('Network.emulateNetworkConditions', NET); + await cdp.send('Emulation.setCPUThrottlingRate', { rate: CPU_RATE }); + await page.addInitScript(OBSERVERS); + + await page.goto(URL, { waitUntil: 'load', timeout: 60000 }); + // Let the ~600ms data + tail + hydration settle, and give LCP time to finalize. + await page.waitForTimeout(2500); + + // INP proxy: click the interaction-hydrated block, take the slowest pointer/ + // click event-timing entry it produces. + const before = await page.evaluate(() => window.__cwv.events.length); + const btn = page.locator('.btn-c'); + if (await btn.count()) { + await btn.first().click(); + await page.waitForTimeout(600); + } + const inp = await page.evaluate((n) => { + const evs = window.__cwv.events + .slice(n) + .filter((e) => ['pointerdown', 'pointerup', 'click'].includes(e.name)); + return evs.length ? Math.max(...evs.map((e) => e.dur)) : null; + }, before); + + const nav = await page.evaluate(() => { + const n = performance.getEntriesByType('navigation')[0] || {}; + return { ttfb: n.responseStart || 0, load: n.loadEventEnd || 0 }; + }); + const cwv = await page.evaluate(() => window.__cwv); + + await context.close(); + return { + ttfb: nav.ttfb, + fcp: cwv.fcp, + lcp: cwv.lcp, + lcpEl: cwv.lcpEl, + cls: cwv.cls, + load: nav.load, + inp, + }; +} + +async function bench(browser, label, ua) { + const rows = []; + for (let i = 0; i < ITERS; i++) rows.push(await runOnce(browser, ua)); + const pick = (k) => + rows.map((r) => r[k]).filter((v) => v != null && !Number.isNaN(v)); + return { + label, + ttfb: median(pick('ttfb')), + fcp: median(pick('fcp')), + lcp: median(pick('lcp')), + lcpEl: + rows + .map((r) => r.lcpEl) + .filter(Boolean) + .pop() || '(none)', + cls: median(pick('cls')), + load: median(pick('load')), + inp: pick('inp').length ? median(pick('inp')) : null, + }; +} + +const browser = await chromium.launch({ headless: true }); +const streamed = await bench(browser, 'streamed', UA_BROWSER); +const buffered = await bench(browser, 'buffered', UA_BOT); +await browser.close(); + +const ms = (v) => (v == null ? ' n/a' : `${Math.round(v)}ms`.padStart(6)); +const fmt = (r) => + `${r.label.padEnd(9)} TTFB=${ms(r.ttfb)} FCP=${ms(r.fcp)} LCP=${ms(r.lcp)} CLS=${r.cls + .toFixed(3) + .padStart(6)} load=${ms(r.load)} INP≈${ms(r.inp)} LCPel=${r.lcpEl}`; + +console.log( + `\n== CWV: streamed vs buffered, same page, Slow-4G + 4x CPU, median of ${ITERS} ==`, +); +console.log(fmt(streamed)); +console.log(fmt(buffered)); +console.log('\nJSON ' + JSON.stringify({ streamed, buffered })); diff --git a/apps/streaming-app/tsconfig.app.json b/apps/streaming-app/tsconfig.app.json new file mode 100644 index 000000000..ccb631681 --- /dev/null +++ b/apps/streaming-app/tsconfig.app.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "types": [] + }, + "files": ["src/main.ts"], + "include": ["src/**/*.ts"] +} diff --git a/apps/streaming-app/tsconfig.editor.json b/apps/streaming-app/tsconfig.editor.json new file mode 100644 index 000000000..b81b3b862 --- /dev/null +++ b/apps/streaming-app/tsconfig.editor.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "include": ["**/*.ts"] +} diff --git a/apps/streaming-app/tsconfig.json b/apps/streaming-app/tsconfig.json new file mode 100644 index 000000000..0a89050bd --- /dev/null +++ b/apps/streaming-app/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "types": ["vite/client"] + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.app.json" + }, + { + "path": "./tsconfig.editor.json" + } + ] +} diff --git a/apps/streaming-app/vite.config.ts b/apps/streaming-app/vite.config.ts new file mode 100644 index 000000000..46de8e3cf --- /dev/null +++ b/apps/streaming-app/vite.config.ts @@ -0,0 +1,38 @@ +/// + +import analog from '@analogjs/platform'; +import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin'; +import { defineConfig } from 'vite'; + +export default defineConfig(() => { + return { + root: __dirname, + publicDir: 'src/assets', + optimizeDeps: { + include: ['@angular/common'], + }, + build: { + outDir: '../../dist/apps/streaming-app/client', + reportCompressedSize: true, + target: ['es2020'], + }, + plugins: [ + analog({ + experimental: { + streaming: true, + }, + // Render at request time (no prerender) so streaming is exercised over HTTP. + prerender: { + routes: [], + }, + // Opt a route out of streaming — it falls back to a buffered render. + nitro: { + routeRules: { + '/buffered': { streaming: false }, + }, + }, + }), + nxViteTsPaths(), + ], + }; +}); diff --git a/packages/platform/src/lib/options.ts b/packages/platform/src/lib/options.ts index 44d5c28b1..4753db9a5 100644 --- a/packages/platform/src/lib/options.ts +++ b/packages/platform/src/lib/options.ts @@ -12,10 +12,16 @@ import { ContentPluginOptions } from './content-plugin.js'; declare module 'nitropack' { interface NitroRouteConfig { ssr?: boolean; + /** + * Disable progressive streaming SSR for matching routes (falls back to a + * buffered render). Only meaningful when `experimental.streaming` is on. + */ + streaming?: boolean; } interface NitroRouteRules { ssr?: boolean; + streaming?: boolean; } } @@ -144,6 +150,19 @@ export interface Options { * the LOCALE injection token. */ i18n?: I18nOptions; + /** + * Opt-in experimental features that are not yet stable. + */ + experimental?: { + /** + * Opt into progressive streaming SSR. When enabled, the SSR build patches + * `@angular/core` with a per-`@defer` block resolution hook so + * `@analogjs/router`'s `renderStream` can flush the document head first and + * each `@defer (hydrate …)` block as it resolves on the server. Has no + * effect unless the server entry uses `renderStream`. + */ + streaming?: boolean; + }; } export { PrerenderContentDir, PrerenderContentFile }; diff --git a/packages/platform/src/lib/platform-plugin.spec.ts b/packages/platform/src/lib/platform-plugin.spec.ts index 0da585525..a925397b4 100644 --- a/packages/platform/src/lib/platform-plugin.spec.ts +++ b/packages/platform/src/lib/platform-plugin.spec.ts @@ -48,4 +48,24 @@ describe('platformPlugin', () => { expect(viteNitroPluginSpy).toHaveBeenCalledWith({ ssr: false }, undefined); expect(ssrBuildPluginSpy).not.toHaveBeenCalled(); }); + + it('emits the x-analog-no-streaming header for routes with streaming: false', async () => { + const { viteNitroPluginSpy, platformPlugin } = await setup(); + platformPlugin({ + nitro: { + routeRules: { + '/buffered': { streaming: false }, + '/streamed': {}, + }, + }, + }); + + const [, nitroOptions] = viteNitroPluginSpy.mock.calls[0]; + expect( + nitroOptions.routeRules['/buffered'].headers['x-analog-no-streaming'], + ).toBe('true'); + expect( + nitroOptions.routeRules['/streamed'].headers['x-analog-no-streaming'], + ).toBeUndefined(); + }); }); diff --git a/packages/platform/src/lib/platform-plugin.ts b/packages/platform/src/lib/platform-plugin.ts index 996f22aee..9edf88d64 100644 --- a/packages/platform/src/lib/platform-plugin.ts +++ b/packages/platform/src/lib/platform-plugin.ts @@ -1,4 +1,6 @@ import { Plugin } from 'vite'; +import { createRequire } from 'node:module'; +import { join } from 'node:path'; import viteNitroPlugin from '@analogjs/vite-plugin-nitro'; import angular from '@analogjs/vite-plugin-angular'; @@ -8,6 +10,11 @@ import { ssrBuildPlugin, i18nDefRegistryPlugin, } from './ssr/ssr-build-plugin.js'; +import { + deferStreamingPlugin, + streamingSupportedOnAngular, + MIN_STREAMING_ANGULAR_MAJOR, +} from './ssr/defer-streaming-plugin.js'; import { contentPlugin } from './content-plugin.js'; import { clearClientPageEndpointsPlugin } from './clear-client-page-endpoint.js'; import { ssrXhrBuildPlugin } from './ssr/ssr-xhr-plugin.js'; @@ -16,6 +23,25 @@ import { injectHTMLPlugin } from './ssr/inject-html-plugin.js'; import { serverModePlugin } from '../server-mode-plugin.js'; import { i18nExtractPlugin } from './i18n-extract-plugin.js'; +/** + * The installed `@angular/core` major version, resolved from the workspace root + * (where the app's Angular is installed). Returns `null` when it cannot be + * detected, in which case streaming is not blocked and the build-time + * anchor-drift detection is relied on instead. + */ +function getAngularCoreMajor(workspaceRoot: string): number | null { + try { + const req = createRequire(join(workspaceRoot, 'noop.js')); + const { version } = req('@angular/core/package.json') as { + version: string; + }; + const major = Number.parseInt(version.split('.')[0], 10); + return Number.isNaN(major) ? null : major; + } catch { + return null; + } +} + export function platformPlugin(opts: Options = {}): Plugin[] { const isTest = process.env['NODE_ENV'] === 'test' || !!process.env['VITEST']; const { ...platformOptions } = { @@ -23,6 +49,28 @@ export function platformPlugin(opts: Options = {}): Plugin[] { ...opts, }; + // Gate experimental streaming SSR on an Angular version whose compiled + // @angular/core matches the patch anchors (see MIN_STREAMING_ANGULAR_MAJOR). + // Reflect the gated value back onto platformOptions so the nitro renderer + // selection and the deferStreamingPlugin registration below stay consistent — + // never select the streaming renderer without applying the patch. + if (platformOptions.ssr && platformOptions.experimental?.streaming) { + const major = getAngularCoreMajor( + platformOptions.workspaceRoot ?? process.cwd(), + ); + if (!streamingSupportedOnAngular(major)) { + console.warn( + `[@analogjs/platform] experimental.streaming requires Angular ` + + `${MIN_STREAMING_ANGULAR_MAJOR}+ (detected v${major}); streaming is ` + + `disabled and rendering falls back to buffered SSR.`, + ); + platformOptions.experimental = { + ...platformOptions.experimental, + streaming: false, + }; + } + } + let nitroOptions = platformOptions?.nitro; if (nitroOptions?.routeRules) { @@ -38,6 +86,8 @@ export function platformPlugin(opts: Options = {}): Plugin[] { ...config[curr].headers, 'x-analog-no-ssr': config[curr]?.ssr === false ? 'true' : undefined, + 'x-analog-no-streaming': + config[curr]?.streaming === false ? 'true' : undefined, } as any, }, }; @@ -50,6 +100,9 @@ export function platformPlugin(opts: Options = {}): Plugin[] { return [ ...viteNitroPlugin(platformOptions, nitroOptions), ...(platformOptions.ssr ? [ssrBuildPlugin(), ...injectHTMLPlugin()] : []), + ...(platformOptions.ssr && platformOptions.experimental?.streaming + ? [deferStreamingPlugin()] + : []), ...(!isTest ? depsPlugin(platformOptions) : []), ...routerPlugin(platformOptions), ...contentPlugin(platformOptions?.content, platformOptions), diff --git a/packages/platform/src/lib/ssr/defer-streaming-plugin.spec.ts b/packages/platform/src/lib/ssr/defer-streaming-plugin.spec.ts new file mode 100644 index 000000000..53231d932 --- /dev/null +++ b/packages/platform/src/lib/ssr/defer-streaming-plugin.spec.ts @@ -0,0 +1,128 @@ +import { describe, expect, it } from 'vitest'; +import { + injectDeferStreamingHook, + inspectAngularCoreModule, + streamingSupportedOnAngular, + MIN_STREAMING_ANGULAR_MAJOR, +} from './defer-streaming-plugin.js'; + +describe('streamingSupportedOnAngular', () => { + it('supports the floor version and above', () => { + expect(streamingSupportedOnAngular(MIN_STREAMING_ANGULAR_MAJOR)).toBe(true); + expect(streamingSupportedOnAngular(MIN_STREAMING_ANGULAR_MAJOR + 1)).toBe( + true, + ); + }); + + it('rejects versions below the floor (v20 inlines the profiler anchor)', () => { + expect(streamingSupportedOnAngular(MIN_STREAMING_ANGULAR_MAJOR - 1)).toBe( + false, + ); + expect(streamingSupportedOnAngular(19)).toBe(false); + }); + + it('does not block when the version is undetectable', () => { + expect(streamingSupportedOnAngular(null)).toBe(true); + }); +}); + +describe('injectDeferStreamingHook', () => { + // A minimal stand-in for the two anchors the real @angular/core defer module + // carries: the applyDeferBlockState function and its DeferBlockStateEnd + // profiler call, plus the collectNativeNodesInLContainer function. + const bundle = [ + 'function applyDeferBlockState(newState, lDetails, lContainer, tNode, hostLView) {', + ' renderDeferBlockState(newState, tNode, lContainer);', + ' profiler(ProfilerEvent.DeferBlockStateEnd);', + '}', + 'function collectNativeNodesInLContainer(lContainer, result) {}', + ].join('\n'); + + it('injects the resolution hook before the DeferBlockStateEnd anchor', () => { + const out = injectDeferStreamingHook(bundle); + expect(out).not.toBeNull(); + expect(out).toContain('globalThis.__analogSsrDeferCapture'); + // capture must precede the anchor it is threaded in front of + const capture = out!.indexOf('globalThis.__analogSsrDeferCapture({'); + const anchor = out!.indexOf('profiler(ProfilerEvent.DeferBlockStateEnd);'); + expect(capture).toBeGreaterThan(-1); + expect(capture).toBeLessThan(anchor); + }); + + it('gates the capture on server mode and Complete state', () => { + const out = injectDeferStreamingHook(bundle)!; + expect(out).toContain('newState === DeferBlockState.Complete'); + expect(out).toContain('ngServerMode'); + }); + + it('wraps the whole guard in try/catch so drifted internals no-op', () => { + const out = injectDeferStreamingHook(bundle)!; + expect(out).toMatch( + /try\s*\{\s*if \(newState === DeferBlockState\.Complete/, + ); + }); + + it('exposes collectNativeNodesInLContainer on __analogSsrInternals', () => { + const out = injectDeferStreamingHook(bundle)!; + expect(out).toContain( + 'globalThis.__analogSsrInternals = Object.assign(globalThis.__analogSsrInternals || {}, { collectNativeNodesInLContainer })', + ); + }); + + it('is a no-op for unrelated modules', () => { + expect(injectDeferStreamingHook('export const x = 1;')).toBeNull(); + expect( + injectDeferStreamingHook('function applyDeferBlockState() {}'), + ).toBeNull(); + }); + + it('preserves the original bundle content', () => { + const out = injectDeferStreamingHook(bundle)!; + expect(out).toContain('function applyDeferBlockState('); + expect(out).toContain('profiler(ProfilerEvent.DeferBlockStateEnd);'); + expect(out.length).toBeGreaterThan(bundle.length); + }); +}); + +describe('inspectAngularCoreModule', () => { + const bundle = [ + 'function applyDeferBlockState(newState, lDetails, lContainer, tNode, hostLView) {', + ' profiler(ProfilerEvent.DeferBlockStateEnd);', + '}', + 'function collectNativeNodesInLContainer(lContainer, result) {}', + ].join('\n'); + + it('reports patchable when all anchors are present', () => { + expect(inspectAngularCoreModule(bundle)).toEqual({ kind: 'patchable' }); + }); + + it('reports not-target for unrelated core modules', () => { + expect(inspectAngularCoreModule('export const x = 1;')).toEqual({ + kind: 'not-target', + }); + }); + + it('reports drift when the defer module lost the profiler anchor', () => { + const drifted = bundle.replace( + 'profiler(ProfilerEvent.DeferBlockStateEnd);', + 'profiler(ProfilerEvent.DeferBlockRenamed);', + ); + const info = inspectAngularCoreModule(drifted); + expect(info.kind).toBe('drifted'); + expect(info.kind === 'drifted' && info.reason).toContain( + 'DeferBlockStateEnd', + ); + }); + + it('reports drift when the subtree collector is gone', () => { + const drifted = bundle.replace( + 'function collectNativeNodesInLContainer(lContainer, result) {}', + '', + ); + const info = inspectAngularCoreModule(drifted); + expect(info.kind).toBe('drifted'); + expect(info.kind === 'drifted' && info.reason).toContain( + 'collectNativeNodesInLContainer', + ); + }); +}); diff --git a/packages/platform/src/lib/ssr/defer-streaming-plugin.ts b/packages/platform/src/lib/ssr/defer-streaming-plugin.ts new file mode 100644 index 000000000..3ccf6f291 --- /dev/null +++ b/packages/platform/src/lib/ssr/defer-streaming-plugin.ts @@ -0,0 +1,153 @@ +import type { Plugin } from 'vite'; + +/** + * Minimum Angular major whose compiled `@angular/core` FESM matches the + * streaming patch anchors. Incremental hydration is a stable public API from + * v20 (`withIncrementalHydration`, `@publicApi 20.0`), but v20's FESM inlines + * the injection anchor's `DeferBlockStateEnd` profiler event to its numeric + * ordinal, whereas v21+ keeps the symbolic `ProfilerEvent.DeferBlockStateEnd` + * form the anchor matches on. Keying on the literal ordinal would be fragile + * (enum values shift between versions), so v21 is the floor. + */ +export const MIN_STREAMING_ANGULAR_MAJOR = 21; + +/** + * Whether the streaming SSR patch can be applied to the installed Angular. + * A `null` major (version undetectable) returns `true`: don't block on a failed + * detection — the plugin's anchor-drift detection catches a real mismatch at + * build time and falls back to buffered rendering. + */ +export function streamingSupportedOnAngular(major: number | null): boolean { + return major === null || major >= MIN_STREAMING_ANGULAR_MAJOR; +} + +/** + * Pure transform that injects the streaming-SSR per-block resolution hook into + * `@angular/core`'s compiled output. It is the same class of transform as + * `i18nDefRegistryPlugin` in ./ssr-build-plugin — a string patch of Angular's + * bundle applied only to SSR builds. Exported separately so it can be unit + * tested against a bundle string. + * + * Two edits, both keyed on single-occurrence anchors in the module that holds + * the defer runtime: + * 1. Inside `applyDeferBlockState`, fire `globalThis.__analogSsrDeferCapture` + * when a `@defer` block reaches `Complete` on the server, passing the + * block's live `lContainer` — this is the per-block resolution signal a + * streaming renderer consumes. + * 2. Expose `collectNativeNodesInLContainer` via `globalThis.__analogSsrInternals` + * so the renderer can serialize a block's subtree. + * + * Returns `null` when the module is not the one carrying the defer runtime, so + * it is a no-op on every other Angular module. + */ +export function injectDeferStreamingHook(code: string): string | null { + const END_ANCHOR = 'profiler(ProfilerEvent.DeferBlockStateEnd);'; + if ( + !code.includes('function applyDeferBlockState(') || + !code.includes(END_ANCHOR) + ) { + return null; + } + + // Wrap the entire guard — not just the call — so a drifted Angular runtime + // that still matches the anchors but renamed these locals fails as a silent + // no-op instead of throwing a ReferenceError inside applyDeferBlockState. + const capture = + `try { if (newState === DeferBlockState.Complete && ` + + `typeof ngServerMode !== 'undefined' && ngServerMode && ` + + `typeof globalThis.__analogSsrDeferCapture === 'function') { ` + + `globalThis.__analogSsrDeferCapture({ ssrUniqueId: lDetails[SSR_UNIQUE_ID], lContainer, hostLView }); } } catch (e) {}\n `; + + let out = code.replace(END_ANCHOR, capture + END_ANCHOR); + + if (code.includes('function collectNativeNodesInLContainer(')) { + out += + '\nglobalThis.__analogSsrInternals = Object.assign(globalThis.__analogSsrInternals || {}, { collectNativeNodesInLContainer });\n'; + } + + return out; +} + +/** + * Classify an `@angular/core` module for the streaming patch. The patch anchors + * on internal symbol names, so when Angular changes those the transform would + * otherwise become a silent no-op and streaming would degrade to buffered with + * no signal. This distinguishes "not the target module" (skip quietly) from + * "this IS the `@defer` runtime module but the anchors drifted" (worth warning). + */ +export function inspectAngularCoreModule( + code: string, +): + | { kind: 'not-target' } + | { kind: 'patchable' } + | { kind: 'drifted'; reason: string } { + if (!code.includes('function applyDeferBlockState(')) { + return { kind: 'not-target' }; + } + const missing: string[] = []; + if (!code.includes('profiler(ProfilerEvent.DeferBlockStateEnd);')) { + missing.push('DeferBlockStateEnd profiler anchor'); + } + if (!code.includes('function collectNativeNodesInLContainer(')) { + missing.push('collectNativeNodesInLContainer'); + } + return missing.length === 0 + ? { kind: 'patchable' } + : { kind: 'drifted', reason: `missing ${missing.join(', ')}` }; +} + +/** + * Vite plugin that applies {@link injectDeferStreamingHook} to `@angular/core` + * during SSR builds, so `@analogjs/router`'s `renderStream` can flush `@defer` + * blocks as they resolve on the server — without hand-patching node_modules. + * + * Only active for SSR. Mirrors the enforce/filter/ssr-gate shape of + * `i18nDefRegistryPlugin`. If the `@defer` runtime module is found but its + * anchors have drifted (Angular changed internals), or if it is never + * encountered at all, the plugin warns rather than silently producing a build + * that falls back to buffered rendering. + */ +export function deferStreamingPlugin(): Plugin { + let applied = false; + let warnedDrift = false; + return { + name: 'analogjs-defer-streaming', + enforce: 'post', + transform: { + filter: { + id: /\/@angular\/core\//, + }, + handler(code, _id, options) { + if (!options?.ssr) return; + const info = inspectAngularCoreModule(code); + if (info.kind === 'not-target') return; + if (info.kind === 'drifted') { + if (!warnedDrift) { + warnedDrift = true; + this.warn( + `experimental streaming SSR: found @angular/core's @defer runtime ` + + `but could not apply the resolution hook (${info.reason}). The ` + + `installed Angular version likely changed internals the patch ` + + `depends on; streaming will fall back to buffered rendering.`, + ); + } + return; + } + const out = injectDeferStreamingHook(code); + if (!out) return; + applied = true; + return { code: out }; + }, + }, + buildEnd() { + if (!applied && !warnedDrift) { + this.warn( + `experimental streaming SSR is enabled but @angular/core's @defer ` + + `runtime module was never encountered during the SSR build, so the ` + + `resolution hook was not injected. Streaming will fall back to ` + + `buffered rendering.`, + ); + } + }, + }; +} diff --git a/packages/router/RFC-streaming-ssr.md b/packages/router/RFC-streaming-ssr.md new file mode 100644 index 000000000..e60ad1853 --- /dev/null +++ b/packages/router/RFC-streaming-ssr.md @@ -0,0 +1,416 @@ +# RFC: Progressive Streaming SSR (`renderStream`) + +**Status:** Prototype (feat/streaming-ssr branch) +**Author:** Brandon Roberts +**Date:** 2026-07-06 +**Packages:** @analogjs/router (`@analogjs/router/server`), @analogjs/platform, @analogjs/vite-plugin-nitro + +--- + +## Summary + +A streaming SSR renderer for Analog that flushes bytes to the client **during** +the render instead of after it. `renderStream(App, config)` is an alternative to +`render(App, config)` in `main.server.ts` that returns a +`ReadableStream`: + +1. the document head + a small client runtime flush immediately, before the app + finishes rendering; +2. each `@defer (hydrate …)` block flushes the moment it resolves on the server + — out of document order — so a slow block never holds back an earlier one; +3. once the app is stable, the authoritative, fully hydration-annotated document + flushes as the tail, and the client runtime swaps it in before Angular's + incremental hydration runs. + +The Angular capability it needs — a per-`@defer`-block resolution signal — is +delivered as a small **additive** string patch to `@angular/core`, applied only +to SSR builds via a Vite plugin (`deferStreamingPlugin`) and gated behind an +opt-in `experimental.streaming` flag. No hand-patching of `node_modules`. + +## Motivation + +The standard `render()` path calls `renderApplication` from +`@angular/platform-server`, which is **fully buffered**: it renders the entire +app to a Domino DOM, waits for `ApplicationRef.whenStable()` (which waits for +_every_ `@defer (hydrate …)` block to resolve), annotates the whole document for +hydration, and only then serializes and returns a single string. + +Consequences: + +1. **Time-to-first-byte is bounded by the slowest `@defer` block.** Nothing — + not even ``/`` or linked CSS/JS — reaches the browser until the + entire render finishes. A page with one slow hydrate-on-defer block delays + the whole response by that block's latency. +2. **No progressive paint.** With incremental hydration, `@defer (hydrate …)` + blocks _are_ rendered eagerly on the server, so their content already exists + in the DOM well before `whenStable` — but the buffered path holds all of it + until the last block resolves. + +Streaming decouples TTFB from render completion and lets each block paint as it +resolves, which is the point of server rendering heavy/expensive subtrees behind +`@defer`. + +## Design + +Four pieces. How they interact over the lifetime of one request: + +```mermaid +sequenceDiagram + autonumber + participant B as Browser + participant R as "renderStream (server)" + participant A as "@angular/core (SSR, patched)" + + B->>R: GET / + Note over R: crawlers (bot user-agent) take the buffered render()<br/>path with a fully-resolved head instead of streaming + Note over R: platformServer + bootstrapApplication<br/>drives the platform directly + R-->>B: chunk 1 — document head + reconcile runtime<br/>+ empty stream region + Note over B: browser starts fetching CSS/JS at once<br/>— TTFB no longer waits on the slowest block + + loop each @defer (hydrate) block, as it resolves — out of order + A->>R: __analogSsrDeferCapture(lContainer) + Note over R: serialize the block subtree one macrotask later,<br/>after change detection fills interpolations + R-->>B: data-analog-defer="sN" template + __analogPaint("sN") + Note over B: paints the block into the live region<br/>(progressive, resolution order) + end + + Note over R: await whenStable() — all blocks resolved + Note over R: ɵrenderInternal → authoritative document<br/>(whole-document hydration annotation) + R-->>B: data-analog-head + data-analog-authoritative templates<br/>+ __analogReconcileHead() + __analogFinalize() + Note over B: apply the app-set title/meta to the live head,<br/>then swap body to the authoritative DOM + Note over B: Angular incremental hydration boots<br/>against the finalized DOM +``` + +The head flushes before the app has rendered, each `@defer` block flushes the +moment it resolves (out of order), and the authoritative document — the only +thing hydration runs against — is the tail. The rest of this section covers each +piece in turn. + +### 1. `renderStream` (`@analogjs/router/server`) + +Drives the platform directly (`platformServer` + `bootstrapApplication` + +`ɵrenderInternal`) rather than calling `renderApplication`, so it can interleave +flushes with rendering. The `start()` of the returned `ReadableStream`: + +- flushes `document` head + the reconcile runtime + an empty streaming region; +- installs `globalThis.__analogSsrDeferCapture`; on each resolved block it serializes + the block's live Domino subtree (a macrotask later, once change detection has + filled interpolations) and flushes it as a `<template>` + paint script; +- after `whenStable()`, calls `ɵrenderInternal(platformRef, appRef)` to produce + the authoritative, hydration-annotated document (byte-identical to a buffered + render) and flushes both its `<head>` (in a `<template data-analog-head>`) and + its body as the tail; +- falls back to a single buffered chunk for server-component requests, for + **crawlers** (bot user-agents, so they index a fully-resolved head — see + _Head & title_ below), or when the streaming primitive is absent, so output + matches the classic path. + +### 2. Client reconcile runtime (`defer-reconcile-runtime.ts`) + +A tiny inlined script exposing `window.__analogPaint(id)` (paints a streamed +block into the live region as it arrives — progressive, out of order), +`window.__analogReconcileHead()` (applies the authoritative `<head>`'s +title/meta/link to the live document — idempotent, so unchanged shell tags are +left alone), and `window.__analogFinalize()` (swaps the whole body to the +authoritative document before hydration boots, so the reconciled DOM matches a +buffered render byte-for-byte). + +### 3. `deferStreamingPlugin` — the additive Angular seam (`@analogjs/platform`) + +A Vite plugin (same shape and ssr-gate as the existing `i18nDefRegistryPlugin`) +that string-patches `@angular/core` during SSR builds. It is **purely additive** +— two edits keyed on single-occurrence anchors: + +1. inside `applyDeferBlockState`, fire `globalThis.__analogSsrDeferCapture` when a + block reaches `Complete` on the server, passing its live `lContainer`; +2. expose `collectNativeNodesInLContainer` on `globalThis.__analogSsrInternals` so + the renderer can serialize a block's subtree. + +The transform is exported as a pure function (`injectDeferStreamingHook`) and +unit-tested against a bundle string. It is registered only when +`ssr && experimental.streaming`, so default builds are untouched. + +**Angular version floor.** Streaming is gated on **Angular ≥ 21**. Incremental +hydration is a stable public API from v20 (`withIncrementalHydration`, +`@publicApi 20.0`), but v20's compiled FESM inlines the `DeferBlockStateEnd` +profiler event to its numeric ordinal, while v21+ keeps the symbolic +`ProfilerEvent.DeferBlockStateEnd` the anchor matches on — verified by checking +the published `@angular/core` FESM for v20/v21/v22. The gate lives in +`@analogjs/platform`: below the floor it warns and disables streaming (falling +back to buffered), and the value is reflected onto the options so the nitro +renderer selection and this plugin never disagree. + +### 4. `ssrStreamRenderer` (`@analogjs/vite-plugin-nitro`) + +An h3 event handler that returns the `ReadableStream` with chunked transfer +encoding, preserving the `x-analog-no-ssr` bypass. A `streaming: false` route +rule sets an `x-analog-no-streaming` response header (mirroring how `ssr: false` +becomes `x-analog-no-ssr`); `renderStream` then emits the buffered `render()` +output and the handler returns it as a normal, non-chunked document — so a route +can opt out of streaming while keeping SSR. The buffered `ssrRenderer` is +unchanged. + +### Runtime & concurrency + +The per-`@defer` resolution signal is a process-global entry point (the patched +`@angular/core` can only call one `globalThis` function), so `renderStream` +installs a **stable dispatcher once** and routes each resolved block to the +render that owns it using `AsyncLocalStorage` (`node:async_hooks`). Concurrent +renders in one process are therefore isolated — a block resolving in render A is +never enqueued into render B's stream. + +Runtime portability is delegated to **Nitro**: Analog does not shim runtimes. +`node:async_hooks` is provided across Nitro's deployment presets (native Node, +and non-Node targets via `nodejs_compat` / unenv), so `renderStream` depends on +it directly rather than carrying its own edge fallbacks. + +### Resilience to Angular drift + +Because the patch anchors on internal symbol names, `deferStreamingPlugin` +classifies each `@angular/core` module: it warns if it finds the `@defer` +runtime but the anchors have drifted (Angular changed internals), and at +`buildEnd` if that module was never encountered — instead of silently producing +a build that falls back to buffered. `renderStream` likewise warns in dev when +the streaming primitive is absent at request time. + +### Honest boundary + +Angular's hydration annotation is **whole-document** — the root component's `ngh` +index references every `@defer` container — so the authoritative hydration +payload can only be finalized once all blocks have resolved. Therefore +**rendering streams progressively, but hydration begins when the tail arrives.** +The blocks are effectively sent twice (progressive preview during render + +authoritative copy in the tail); this is the byte cost measured below, and the +target of the "Future work" section. + +### Head & title + +Because the shell `<head>` is flushed **before** the app renders, a title or +meta set _during_ render (`Title`/`Meta` services, route meta) is not yet known +when the head goes out. Two mechanisms keep this correct, mirroring how Nuxt +handles the identical problem in its streaming renderer: + +1. **Finalize-time reconcile (interactive clients).** The authoritative `<head>` + is streamed in the tail (`<template data-analog-head>`) and + `__analogReconcileHead()` applies its title/meta/link to the live document + before hydration boots. This is the same "inject the resolved head late" + strategy Nuxt uses — Nuxt flushes a head _shell_ then streams + `renderSSRHeadSuspenseChunk` `<script>` patches as Suspense boundaries + resolve; Analog's whole-document model needs only a single patch at the tail, + since the authoritative head is known in one shot at `whenStable`. +2. **Buffered path for crawlers.** A late `<script>` reconcile does not help a + bot that doesn't execute it, so crawlers (matched by user-agent) are routed + to the buffered `render()` path, whose `<head>` is fully resolved and + byte-identical to the classic render. Nuxt does the same — its `prefersStream` + check excludes bot user-agents from streaming. + +Net: JS clients get the correct dynamic head (with a brief shell-title interval +before finalize), and crawlers get a fully-resolved head with no streaming +scaffolding. A static `<title>` in `index.html` is correct on every path. + +## Benchmarks + +In-process (no network), dev-mode Angular, synthetic per-block dependency +delays, median of 12 cold-cache runs. These measure render + serialization + +flush scheduling, **not** wire TTFB — on a real network the TTFB win is larger, +because the browser can begin fetching linked assets during the server's defer +wait. + +**Latency shape (2 blocks, deps [150, 60] ms):** + +| | Buffered | Streaming | +| ------------------- | -------- | ---------- | +| TTFB | 201 ms | **0.6 ms** | +| first block visible | — | 70 ms | +| complete | 201 ms | 201 ms | + +TTFB is flat (~0.5 ms) regardless of block latency; completion time is identical +(streaming adds negligible CPU). The win is entirely latency-shape. + +**Byte cost (heavier ~1.5 KB blocks):** the buffered document is 3716 bytes; the +streaming response is 7439 bytes (**+100%**), because each block ships in both +the progressive preview and the authoritative tail. The overhead is smaller in +relative terms with more/larger blocks (shell overhead amortizes) but is the +main downside of the additive-seam design. + +### Core Web Vitals (over HTTP, throttled) + +The in-process numbers above deliberately exclude the network. To measure the +client-perceived shape, `apps/streaming-app` was built for production, served +over HTTP, and driven in Chromium under Lighthouse-style throttling (Slow-4G, +4× CPU). The same route (`/`) was measured both ways — a browser user-agent +streams, a Googlebot user-agent takes the buffered fallback — so the page, +bundle, and ~600 ms `httpResource` dependency are identical and only the render +strategy differs. Median of 9 runs (reproduce with +[`tools/cwv-bench.mjs`](../../apps/streaming-app/tools/cwv-bench.mjs)): + +| Metric | Streamed | Buffered | Note | +| ------------ | ---------- | -------- | ----------------------------------- | +| TTFB | **3 ms** | 608 ms | head flushes before the app renders | +| FCP | **200 ms** | 648 ms | first `@defer` block paints early | +| LCP | 660 ms | 648 ms | wash — see below | +| CLS | 0.006 | 0.000 | finalize body-swap; both "good" | +| Fully loaded | 1996 ms | 2434 ms | assets fetch during the defer wait | + +**TTFB and FCP are the wins**, and they are large: buffered blocks the first +byte on the full render (which waits on the 600 ms data), while streaming +flushes the head immediately and paints the first resolved block at ~200 ms. + +**LCP is a wash, and the reason is instructive.** The largest element here is the +static shell paragraph, and in this implementation the eager app shell (the +non-`@defer` chrome: `<h1>`, intro copy, eager components) renders in the +**authoritative tail**, flushed only once the app is stable (~600 ms) — only +`@defer` blocks stream early. So the LCP element is gated behind the same wait as +the buffered path. **Streaming improves LCP only when the LCP element streams +early** — i.e. lives in an early `@defer` block, or in a shell that is itself +streamed (see [Stream the eager shell early](#stream-the-eager-shell-early)). + +**CLS is a small, real regression** (0.006 vs a perfect 0.000). `__analogFinalize` +replaces the whole body in one `replaceChildren`, so the progressively-painted +preview is torn down and rebuilt; that swap registers as a shift. It is well +within the "good" bar (< 0.1) for this app but scales with above-the-fold content. + +**INP is equivalent by construction** — both paths ship the identical client +bundle with `withIncrementalHydration()`, so server render strategy does not +change interaction cost (measured interactions stayed at/under the ~16 ms +event-timing floor on both). + +Caveats: localhost, one small route, one machine, the bot-UA fallback standing in +for buffered. Absolute values are optimistic versus the field; the **deltas** are +what transfer. + +## Validation + +**End-to-end** — a real Vite/nitro app (`apps/streaming-app`) driven in Chromium: +chunked HTTP with head-first / out-of-order block / tail ordering; an eager +component and `@defer` blocks on `hydrate on immediate`, `httpResource`-backed +data, and `hydrate on interaction` all hydrate and stay interactive; a +title/meta set during render is reconciled onto the live head after the stream; +a Googlebot user-agent gets the buffered render with the resolved head and no +streaming scaffolding; a `streaming: false` route rule serves a buffered, +non-chunked document. Zero console errors. + +**Unit tests** + +- `@analogjs/platform`: the Angular-core patch transform and drift inspection + (`injectDeferStreamingHook`, `inspectAngularCoreModule`), the version gate + (`streamingSupportedOnAngular`), and the `streaming: false` → header route-rule + transform. +- `@analogjs/router/server`: the document-slicing helpers (`headInner`, + `bodyInner`, `afterBodyOpen`), the buffered-fallback request checks + (`isLikelyBot`, `streamingDisabledByRoute`), and the client reconcile runtime + (`__analogPaint`, `__analogReconcileHead`, `__analogFinalize`) exercised + against a DOM. + +**Concurrency** — per-render `@defer` capture is routed through +`AsyncLocalStorage` (see _Runtime & concurrency_), so interleaved renders in one +process cannot enqueue into each other's streams. + +The prototype's single seam is the plugin-applied Angular patch; everything else +is standard Analog/Angular. + +## Example usage + +```ts +// main.server.ts +import { renderStream } from '@analogjs/router/server'; +import { config } from './app/app.config.server'; +import { App } from './app/app'; + +export default renderStream(App, config); +``` + +```ts +// vite.config.ts +export default defineConfig({ + plugins: [analog({ experimental: { streaming: true } })], +}); +``` + +Opt a route out of streaming (buffered render for that route) with a route rule, +the same way `ssr: false` disables SSR: + +```ts +// vite.config.ts +analog({ + experimental: { streaming: true }, + nitro: { + routeRules: { + '/report': { streaming: false }, // buffered render, no streaming + }, + }, +}); +``` + +## Future work + +### Single-send via a per-block incremental annotator + +The +100% byte cost comes entirely from re-sending block content in the +authoritative tail. It can be eliminated by **annotating each block for +hydration at resolve time** so the block is streamed once, already carrying its +`ngh`/`jsaction`/`ngb`, and the tail is the shell with block content sliced out +(replaced by slot markers) plus the transfer state. + +This has been prototyped and validated (single-send document hydrates identically +to buffered, 5/5). The mechanism is a larger patch to `@angular/core`: + +- **A one-line edit inside `serializeLContainer`** so the defer-block id is + memoized by container when streaming is active, instead of `d${deferBlocks.size}`. + This is what keeps per-block annotation and the final whole-document pass + agreeing on ids — ids become **resolution-order** and consistent everywhere. +- **An appended driver** (`__analogSsrStreamBegin/End`, `__analogSsrAnnotateBlock`) that + reuses Angular's own `serializeLContainer` to annotate a single resolved + block's DOM. + +**Byte results (same ~1.5 KB × 2 block scenario):** + +| | bytes | vs buffered | +| --------------------------------------- | ----- | ----------- | +| buffered (baseline) | 3716 | — | +| single-send (incremental annotator) | 4603 | **+24%** | +| double-send (this RFC's implementation) | 7439 | +100% | + +The residual +24% is streaming scaffolding (per-block `<template>` wrappers, +mount scripts, slot markers, runtime), not re-sent content, and it amortizes +toward `buffered + small constant` as blocks grow. There is a **crossover**: for +very small blocks the per-block wrapper (~90 B) exceeds the saved content, so +single-send is net-negative — it wins only for realistically-sized `@defer` +blocks. + +**Why this is deferred:** unlike the additive seam (two append-only anchors, +one private symbol), the incremental annotator _edits the middle of_ +`serializeLContainer` and references several more private core symbols +(`SerializedViewCollection`, `isIncrementalHydrationEnabled`, +`IS_EVENT_REPLAY_ENABLED`, …). It rides entirely on non-minified FESM symbol +names. That maintenance surface is exactly the line where a framework-carried +string patch stops being reasonable. + +### Upstream primitive + +Both the additive seam and the incremental annotator are prototypes for a +capability that belongs in Angular proper: a first-class streaming SSR entry +point (e.g. `renderApplicationStream`) that emits `@defer` blocks progressively +with per-block hydration annotation. The Analog prototype demonstrates the design +and quantifies the payoff; the durable form is an upstream API, not a patch a +framework maintains against private symbols. + +### Stream the eager shell early + +The [Core Web Vitals](#core-web-vitals-over-http-throttled) measurement shows LCP +is not improved, because the eager app shell (the non-`@defer` chrome) ships only +in the authoritative tail — today the first flush is just the head plus an empty +stream region, and only `@defer` blocks stream before the tail. Streaming the +eager shell in the first flush, into its final document position, would let the +LCP element paint at ~FCP time rather than at `whenStable`; and because the shell +would arrive already in place rather than being swapped in at finalize, it would +also shrink the small CLS cost the body-swap introduces. This is the +highest-leverage CWV follow-up and pairs naturally with progressive-paint +positioning below. + +### Other + +- Progressive-paint positioning (mount blocks into their document position as they + arrive, rather than a preview region) — orthogonal to bytes, and a prerequisite + for streaming the eager shell in place. +- A `create-analog` template and nitro config flag once the primitive stabilizes. diff --git a/packages/router/server/src/defer-reconcile-runtime.spec.ts b/packages/router/server/src/defer-reconcile-runtime.spec.ts new file mode 100644 index 000000000..d06dee8bb --- /dev/null +++ b/packages/router/server/src/defer-reconcile-runtime.spec.ts @@ -0,0 +1,92 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { DEFER_RECONCILE_RUNTIME } from './defer-reconcile-runtime'; + +// Install the runtime's window globals by evaluating the emitted IIFE, exactly +// as the browser would when it parses the streamed <script>. +function installRuntime() { + new Function(DEFER_RECONCILE_RUNTIME)(); +} + +type Runtime = { + __analogPaint: (id: string) => void; + __analogReconcileHead: () => void; + __analogFinalize: () => void; +}; +const rt = () => window as unknown as Runtime; + +describe('DEFER_RECONCILE_RUNTIME', () => { + beforeEach(() => { + document.head.innerHTML = ''; + document.body.innerHTML = ''; + installRuntime(); + }); + + describe('__analogPaint', () => { + it('paints a streamed block into the live region and removes the template', () => { + document.body.innerHTML = + '<div data-analog-stream></div>' + + '<template data-analog-defer="s0"><p class="a">A</p></template>'; + + rt().__analogPaint('s0'); + + const region = document.querySelector('[data-analog-stream]')!; + expect(region.querySelector('.a')?.textContent).toBe('A'); + expect( + document.querySelector('template[data-analog-defer="s0"]'), + ).toBeNull(); + }); + }); + + describe('__analogReconcileHead', () => { + it('applies the authoritative title, upserts meta idempotently, then removes the template', () => { + document.head.innerHTML = + '<title>Shell' + + '' + + ''; + document.body.innerHTML = + ''; + + rt().__analogReconcileHead(); + + expect(document.title).toBe('Resolved'); + // existing description updated in place, not duplicated + const descs = document.head.querySelectorAll('meta[name="description"]'); + expect(descs.length).toBe(1); + expect(descs[0].getAttribute('content')).toBe('new'); + // new meta appended + expect( + document.head + .querySelector('meta[property="og:title"]') + ?.getAttribute('content'), + ).toBe('OG'); + // charset not duplicated + expect(document.head.querySelectorAll('meta[charset]').length).toBe(1); + // template cleaned up + expect(document.querySelector('template[data-analog-head]')).toBeNull(); + }); + + it('is a no-op when there is no head template', () => { + document.head.innerHTML = 'Shell'; + expect(() => rt().__analogReconcileHead()).not.toThrow(); + expect(document.title).toBe('Shell'); + }); + }); + + describe('__analogFinalize', () => { + it('swaps the body to the authoritative document', () => { + document.body.innerHTML = + '
' + + ''; + + rt().__analogFinalize(); + + expect(document.querySelector('[data-analog-stream]')).toBeNull(); + expect(document.getElementById('app')?.textContent).toBe('hi'); + }); + }); +}); diff --git a/packages/router/server/src/defer-reconcile-runtime.ts b/packages/router/server/src/defer-reconcile-runtime.ts new file mode 100644 index 000000000..3068ff2d7 --- /dev/null +++ b/packages/router/server/src/defer-reconcile-runtime.ts @@ -0,0 +1,90 @@ +/** + * Tiny client runtime for progressive streaming SSR — EXPERIMENTAL. + * + * `renderStream` streams the document in three parts: + * 1. the head + this runtime + an empty `
` region; + * 2. each `@defer` block, as it resolves on the server, as a + * `` followed by a call to + * `window.__analogPaint("ID")` — this runtime paints the block into the + * streaming region immediately, so content appears progressively and out + * of document order; + * 3. the authoritative document tail: the app's resolved `` in a + * `