@analogjs/router 2.7.0-beta.1 → 2.7.0-beta.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,416 @@
1
+ # RFC: Progressive Streaming SSR (`renderStream`)
2
+
3
+ **Status:** Prototype (feat/streaming-ssr branch)
4
+ **Author:** Brandon Roberts
5
+ **Date:** 2026-07-06
6
+ **Packages:** @analogjs/router (`@analogjs/router/server`), @analogjs/platform, @analogjs/vite-plugin-nitro
7
+
8
+ ---
9
+
10
+ ## Summary
11
+
12
+ A streaming SSR renderer for Analog that flushes bytes to the client **during**
13
+ the render instead of after it. `renderStream(App, config)` is an alternative to
14
+ `render(App, config)` in `main.server.ts` that returns a
15
+ `ReadableStream<Uint8Array>`:
16
+
17
+ 1. the document head + a small client runtime flush immediately, before the app
18
+ finishes rendering;
19
+ 2. each `@defer (hydrate …)` block flushes the moment it resolves on the server
20
+ — out of document order — so a slow block never holds back an earlier one;
21
+ 3. once the app is stable, the authoritative, fully hydration-annotated document
22
+ flushes as the tail, and the client runtime swaps it in before Angular's
23
+ incremental hydration runs.
24
+
25
+ The Angular capability it needs — a per-`@defer`-block resolution signal — is
26
+ delivered as a small **additive** string patch to `@angular/core`, applied only
27
+ to SSR builds via a Vite plugin (`deferStreamingPlugin`) and gated behind an
28
+ opt-in `experimental.streaming` flag. No hand-patching of `node_modules`.
29
+
30
+ ## Motivation
31
+
32
+ The standard `render()` path calls `renderApplication` from
33
+ `@angular/platform-server`, which is **fully buffered**: it renders the entire
34
+ app to a Domino DOM, waits for `ApplicationRef.whenStable()` (which waits for
35
+ _every_ `@defer (hydrate …)` block to resolve), annotates the whole document for
36
+ hydration, and only then serializes and returns a single string.
37
+
38
+ Consequences:
39
+
40
+ 1. **Time-to-first-byte is bounded by the slowest `@defer` block.** Nothing —
41
+ not even `<head>`/`<title>` or linked CSS/JS — reaches the browser until the
42
+ entire render finishes. A page with one slow hydrate-on-defer block delays
43
+ the whole response by that block's latency.
44
+ 2. **No progressive paint.** With incremental hydration, `@defer (hydrate …)`
45
+ blocks _are_ rendered eagerly on the server, so their content already exists
46
+ in the DOM well before `whenStable` — but the buffered path holds all of it
47
+ until the last block resolves.
48
+
49
+ Streaming decouples TTFB from render completion and lets each block paint as it
50
+ resolves, which is the point of server rendering heavy/expensive subtrees behind
51
+ `@defer`.
52
+
53
+ ## Design
54
+
55
+ Four pieces. How they interact over the lifetime of one request:
56
+
57
+ ```mermaid
58
+ sequenceDiagram
59
+ autonumber
60
+ participant B as Browser
61
+ participant R as "renderStream (server)"
62
+ participant A as "@angular/core (SSR, patched)"
63
+
64
+ B->>R: GET /
65
+ Note over R: crawlers (bot user-agent) take the buffered render()<br/>path with a fully-resolved head instead of streaming
66
+ Note over R: platformServer + bootstrapApplication<br/>drives the platform directly
67
+ R-->>B: chunk 1 — document head + reconcile runtime<br/>+ empty stream region
68
+ Note over B: browser starts fetching CSS/JS at once<br/>— TTFB no longer waits on the slowest block
69
+
70
+ loop each @defer (hydrate) block, as it resolves — out of order
71
+ A->>R: __analogSsrDeferCapture(lContainer)
72
+ Note over R: serialize the block subtree one macrotask later,<br/>after change detection fills interpolations
73
+ R-->>B: data-analog-defer="sN" template + __analogPaint("sN")
74
+ Note over B: paints the block into the live region<br/>(progressive, resolution order)
75
+ end
76
+
77
+ Note over R: await whenStable() — all blocks resolved
78
+ Note over R: ɵrenderInternal → authoritative document<br/>(whole-document hydration annotation)
79
+ R-->>B: data-analog-head + data-analog-authoritative templates<br/>+ __analogReconcileHead() + __analogFinalize()
80
+ Note over B: apply the app-set title/meta to the live head,<br/>then swap body to the authoritative DOM
81
+ Note over B: Angular incremental hydration boots<br/>against the finalized DOM
82
+ ```
83
+
84
+ The head flushes before the app has rendered, each `@defer` block flushes the
85
+ moment it resolves (out of order), and the authoritative document — the only
86
+ thing hydration runs against — is the tail. The rest of this section covers each
87
+ piece in turn.
88
+
89
+ ### 1. `renderStream` (`@analogjs/router/server`)
90
+
91
+ Drives the platform directly (`platformServer` + `bootstrapApplication` +
92
+ `ɵrenderInternal`) rather than calling `renderApplication`, so it can interleave
93
+ flushes with rendering. The `start()` of the returned `ReadableStream`:
94
+
95
+ - flushes `document` head + the reconcile runtime + an empty streaming region;
96
+ - installs `globalThis.__analogSsrDeferCapture`; on each resolved block it serializes
97
+ the block's live Domino subtree (a macrotask later, once change detection has
98
+ filled interpolations) and flushes it as a `<template>` + paint script;
99
+ - after `whenStable()`, calls `ɵrenderInternal(platformRef, appRef)` to produce
100
+ the authoritative, hydration-annotated document (byte-identical to a buffered
101
+ render) and flushes both its `<head>` (in a `<template data-analog-head>`) and
102
+ its body as the tail;
103
+ - falls back to a single buffered chunk for server-component requests, for
104
+ **crawlers** (bot user-agents, so they index a fully-resolved head — see
105
+ _Head & title_ below), or when the streaming primitive is absent, so output
106
+ matches the classic path.
107
+
108
+ ### 2. Client reconcile runtime (`defer-reconcile-runtime.ts`)
109
+
110
+ A tiny inlined script exposing `window.__analogPaint(id)` (paints a streamed
111
+ block into the live region as it arrives — progressive, out of order),
112
+ `window.__analogReconcileHead()` (applies the authoritative `<head>`'s
113
+ title/meta/link to the live document — idempotent, so unchanged shell tags are
114
+ left alone), and `window.__analogFinalize()` (swaps the whole body to the
115
+ authoritative document before hydration boots, so the reconciled DOM matches a
116
+ buffered render byte-for-byte).
117
+
118
+ ### 3. `deferStreamingPlugin` — the additive Angular seam (`@analogjs/platform`)
119
+
120
+ A Vite plugin (same shape and ssr-gate as the existing `i18nDefRegistryPlugin`)
121
+ that string-patches `@angular/core` during SSR builds. It is **purely additive**
122
+ — two edits keyed on single-occurrence anchors:
123
+
124
+ 1. inside `applyDeferBlockState`, fire `globalThis.__analogSsrDeferCapture` when a
125
+ block reaches `Complete` on the server, passing its live `lContainer`;
126
+ 2. expose `collectNativeNodesInLContainer` on `globalThis.__analogSsrInternals` so
127
+ the renderer can serialize a block's subtree.
128
+
129
+ The transform is exported as a pure function (`injectDeferStreamingHook`) and
130
+ unit-tested against a bundle string. It is registered only when
131
+ `ssr && experimental.streaming`, so default builds are untouched.
132
+
133
+ **Angular version floor.** Streaming is gated on **Angular ≥ 21**. Incremental
134
+ hydration is a stable public API from v20 (`withIncrementalHydration`,
135
+ `@publicApi 20.0`), but v20's compiled FESM inlines the `DeferBlockStateEnd`
136
+ profiler event to its numeric ordinal, while v21+ keeps the symbolic
137
+ `ProfilerEvent.DeferBlockStateEnd` the anchor matches on — verified by checking
138
+ the published `@angular/core` FESM for v20/v21/v22. The gate lives in
139
+ `@analogjs/platform`: below the floor it warns and disables streaming (falling
140
+ back to buffered), and the value is reflected onto the options so the nitro
141
+ renderer selection and this plugin never disagree.
142
+
143
+ ### 4. `ssrStreamRenderer` (`@analogjs/vite-plugin-nitro`)
144
+
145
+ An h3 event handler that returns the `ReadableStream` with chunked transfer
146
+ encoding, preserving the `x-analog-no-ssr` bypass. A `streaming: false` route
147
+ rule sets an `x-analog-no-streaming` response header (mirroring how `ssr: false`
148
+ becomes `x-analog-no-ssr`); `renderStream` then emits the buffered `render()`
149
+ output and the handler returns it as a normal, non-chunked document — so a route
150
+ can opt out of streaming while keeping SSR. The buffered `ssrRenderer` is
151
+ unchanged.
152
+
153
+ ### Runtime & concurrency
154
+
155
+ The per-`@defer` resolution signal is a process-global entry point (the patched
156
+ `@angular/core` can only call one `globalThis` function), so `renderStream`
157
+ installs a **stable dispatcher once** and routes each resolved block to the
158
+ render that owns it using `AsyncLocalStorage` (`node:async_hooks`). Concurrent
159
+ renders in one process are therefore isolated — a block resolving in render A is
160
+ never enqueued into render B's stream.
161
+
162
+ Runtime portability is delegated to **Nitro**: Analog does not shim runtimes.
163
+ `node:async_hooks` is provided across Nitro's deployment presets (native Node,
164
+ and non-Node targets via `nodejs_compat` / unenv), so `renderStream` depends on
165
+ it directly rather than carrying its own edge fallbacks.
166
+
167
+ ### Resilience to Angular drift
168
+
169
+ Because the patch anchors on internal symbol names, `deferStreamingPlugin`
170
+ classifies each `@angular/core` module: it warns if it finds the `@defer`
171
+ runtime but the anchors have drifted (Angular changed internals), and at
172
+ `buildEnd` if that module was never encountered — instead of silently producing
173
+ a build that falls back to buffered. `renderStream` likewise warns in dev when
174
+ the streaming primitive is absent at request time.
175
+
176
+ ### Honest boundary
177
+
178
+ Angular's hydration annotation is **whole-document** — the root component's `ngh`
179
+ index references every `@defer` container — so the authoritative hydration
180
+ payload can only be finalized once all blocks have resolved. Therefore
181
+ **rendering streams progressively, but hydration begins when the tail arrives.**
182
+ The blocks are effectively sent twice (progressive preview during render +
183
+ authoritative copy in the tail); this is the byte cost measured below, and the
184
+ target of the "Future work" section.
185
+
186
+ ### Head & title
187
+
188
+ Because the shell `<head>` is flushed **before** the app renders, a title or
189
+ meta set _during_ render (`Title`/`Meta` services, route meta) is not yet known
190
+ when the head goes out. Two mechanisms keep this correct, mirroring how Nuxt
191
+ handles the identical problem in its streaming renderer:
192
+
193
+ 1. **Finalize-time reconcile (interactive clients).** The authoritative `<head>`
194
+ is streamed in the tail (`<template data-analog-head>`) and
195
+ `__analogReconcileHead()` applies its title/meta/link to the live document
196
+ before hydration boots. This is the same "inject the resolved head late"
197
+ strategy Nuxt uses — Nuxt flushes a head _shell_ then streams
198
+ `renderSSRHeadSuspenseChunk` `<script>` patches as Suspense boundaries
199
+ resolve; Analog's whole-document model needs only a single patch at the tail,
200
+ since the authoritative head is known in one shot at `whenStable`.
201
+ 2. **Buffered path for crawlers.** A late `<script>` reconcile does not help a
202
+ bot that doesn't execute it, so crawlers (matched by user-agent) are routed
203
+ to the buffered `render()` path, whose `<head>` is fully resolved and
204
+ byte-identical to the classic render. Nuxt does the same — its `prefersStream`
205
+ check excludes bot user-agents from streaming.
206
+
207
+ Net: JS clients get the correct dynamic head (with a brief shell-title interval
208
+ before finalize), and crawlers get a fully-resolved head with no streaming
209
+ scaffolding. A static `<title>` in `index.html` is correct on every path.
210
+
211
+ ## Benchmarks
212
+
213
+ In-process (no network), dev-mode Angular, synthetic per-block dependency
214
+ delays, median of 12 cold-cache runs. These measure render + serialization +
215
+ flush scheduling, **not** wire TTFB — on a real network the TTFB win is larger,
216
+ because the browser can begin fetching linked assets during the server's defer
217
+ wait.
218
+
219
+ **Latency shape (2 blocks, deps [150, 60] ms):**
220
+
221
+ | | Buffered | Streaming |
222
+ | ------------------- | -------- | ---------- |
223
+ | TTFB | 201 ms | **0.6 ms** |
224
+ | first block visible | — | 70 ms |
225
+ | complete | 201 ms | 201 ms |
226
+
227
+ TTFB is flat (~0.5 ms) regardless of block latency; completion time is identical
228
+ (streaming adds negligible CPU). The win is entirely latency-shape.
229
+
230
+ **Byte cost (heavier ~1.5 KB blocks):** the buffered document is 3716 bytes; the
231
+ streaming response is 7439 bytes (**+100%**), because each block ships in both
232
+ the progressive preview and the authoritative tail. The overhead is smaller in
233
+ relative terms with more/larger blocks (shell overhead amortizes) but is the
234
+ main downside of the additive-seam design.
235
+
236
+ ### Core Web Vitals (over HTTP, throttled)
237
+
238
+ The in-process numbers above deliberately exclude the network. To measure the
239
+ client-perceived shape, `apps/streaming-app` was built for production, served
240
+ over HTTP, and driven in Chromium under Lighthouse-style throttling (Slow-4G,
241
+ 4× CPU). The same route (`/`) was measured both ways — a browser user-agent
242
+ streams, a Googlebot user-agent takes the buffered fallback — so the page,
243
+ bundle, and ~600 ms `httpResource` dependency are identical and only the render
244
+ strategy differs. Median of 9 runs (reproduce with
245
+ [`tools/cwv-bench.mjs`](../../apps/streaming-app/tools/cwv-bench.mjs)):
246
+
247
+ | Metric | Streamed | Buffered | Note |
248
+ | ------------ | ---------- | -------- | ----------------------------------- |
249
+ | TTFB | **3 ms** | 608 ms | head flushes before the app renders |
250
+ | FCP | **200 ms** | 648 ms | first `@defer` block paints early |
251
+ | LCP | 660 ms | 648 ms | wash — see below |
252
+ | CLS | 0.006 | 0.000 | finalize body-swap; both "good" |
253
+ | Fully loaded | 1996 ms | 2434 ms | assets fetch during the defer wait |
254
+
255
+ **TTFB and FCP are the wins**, and they are large: buffered blocks the first
256
+ byte on the full render (which waits on the 600 ms data), while streaming
257
+ flushes the head immediately and paints the first resolved block at ~200 ms.
258
+
259
+ **LCP is a wash, and the reason is instructive.** The largest element here is the
260
+ static shell paragraph, and in this implementation the eager app shell (the
261
+ non-`@defer` chrome: `<h1>`, intro copy, eager components) renders in the
262
+ **authoritative tail**, flushed only once the app is stable (~600 ms) — only
263
+ `@defer` blocks stream early. So the LCP element is gated behind the same wait as
264
+ the buffered path. **Streaming improves LCP only when the LCP element streams
265
+ early** — i.e. lives in an early `@defer` block, or in a shell that is itself
266
+ streamed (see [Stream the eager shell early](#stream-the-eager-shell-early)).
267
+
268
+ **CLS is a small, real regression** (0.006 vs a perfect 0.000). `__analogFinalize`
269
+ replaces the whole body in one `replaceChildren`, so the progressively-painted
270
+ preview is torn down and rebuilt; that swap registers as a shift. It is well
271
+ within the "good" bar (< 0.1) for this app but scales with above-the-fold content.
272
+
273
+ **INP is equivalent by construction** — both paths ship the identical client
274
+ bundle with `withIncrementalHydration()`, so server render strategy does not
275
+ change interaction cost (measured interactions stayed at/under the ~16 ms
276
+ event-timing floor on both).
277
+
278
+ Caveats: localhost, one small route, one machine, the bot-UA fallback standing in
279
+ for buffered. Absolute values are optimistic versus the field; the **deltas** are
280
+ what transfer.
281
+
282
+ ## Validation
283
+
284
+ **End-to-end** — a real Vite/nitro app (`apps/streaming-app`) driven in Chromium:
285
+ chunked HTTP with head-first / out-of-order block / tail ordering; an eager
286
+ component and `@defer` blocks on `hydrate on immediate`, `httpResource`-backed
287
+ data, and `hydrate on interaction` all hydrate and stay interactive; a
288
+ title/meta set during render is reconciled onto the live head after the stream;
289
+ a Googlebot user-agent gets the buffered render with the resolved head and no
290
+ streaming scaffolding; a `streaming: false` route rule serves a buffered,
291
+ non-chunked document. Zero console errors.
292
+
293
+ **Unit tests**
294
+
295
+ - `@analogjs/platform`: the Angular-core patch transform and drift inspection
296
+ (`injectDeferStreamingHook`, `inspectAngularCoreModule`), the version gate
297
+ (`streamingSupportedOnAngular`), and the `streaming: false` → header route-rule
298
+ transform.
299
+ - `@analogjs/router/server`: the document-slicing helpers (`headInner`,
300
+ `bodyInner`, `afterBodyOpen`), the buffered-fallback request checks
301
+ (`isLikelyBot`, `streamingDisabledByRoute`), and the client reconcile runtime
302
+ (`__analogPaint`, `__analogReconcileHead`, `__analogFinalize`) exercised
303
+ against a DOM.
304
+
305
+ **Concurrency** — per-render `@defer` capture is routed through
306
+ `AsyncLocalStorage` (see _Runtime & concurrency_), so interleaved renders in one
307
+ process cannot enqueue into each other's streams.
308
+
309
+ The prototype's single seam is the plugin-applied Angular patch; everything else
310
+ is standard Analog/Angular.
311
+
312
+ ## Example usage
313
+
314
+ ```ts
315
+ // main.server.ts
316
+ import { renderStream } from '@analogjs/router/server';
317
+ import { config } from './app/app.config.server';
318
+ import { App } from './app/app';
319
+
320
+ export default renderStream(App, config);
321
+ ```
322
+
323
+ ```ts
324
+ // vite.config.ts
325
+ export default defineConfig({
326
+ plugins: [analog({ experimental: { streaming: true } })],
327
+ });
328
+ ```
329
+
330
+ Opt a route out of streaming (buffered render for that route) with a route rule,
331
+ the same way `ssr: false` disables SSR:
332
+
333
+ ```ts
334
+ // vite.config.ts
335
+ analog({
336
+ experimental: { streaming: true },
337
+ nitro: {
338
+ routeRules: {
339
+ '/report': { streaming: false }, // buffered render, no streaming
340
+ },
341
+ },
342
+ });
343
+ ```
344
+
345
+ ## Future work
346
+
347
+ ### Single-send via a per-block incremental annotator
348
+
349
+ The +100% byte cost comes entirely from re-sending block content in the
350
+ authoritative tail. It can be eliminated by **annotating each block for
351
+ hydration at resolve time** so the block is streamed once, already carrying its
352
+ `ngh`/`jsaction`/`ngb`, and the tail is the shell with block content sliced out
353
+ (replaced by slot markers) plus the transfer state.
354
+
355
+ This has been prototyped and validated (single-send document hydrates identically
356
+ to buffered, 5/5). The mechanism is a larger patch to `@angular/core`:
357
+
358
+ - **A one-line edit inside `serializeLContainer`** so the defer-block id is
359
+ memoized by container when streaming is active, instead of `d${deferBlocks.size}`.
360
+ This is what keeps per-block annotation and the final whole-document pass
361
+ agreeing on ids — ids become **resolution-order** and consistent everywhere.
362
+ - **An appended driver** (`__analogSsrStreamBegin/End`, `__analogSsrAnnotateBlock`) that
363
+ reuses Angular's own `serializeLContainer` to annotate a single resolved
364
+ block's DOM.
365
+
366
+ **Byte results (same ~1.5 KB × 2 block scenario):**
367
+
368
+ | | bytes | vs buffered |
369
+ | --------------------------------------- | ----- | ----------- |
370
+ | buffered (baseline) | 3716 | — |
371
+ | single-send (incremental annotator) | 4603 | **+24%** |
372
+ | double-send (this RFC's implementation) | 7439 | +100% |
373
+
374
+ The residual +24% is streaming scaffolding (per-block `<template>` wrappers,
375
+ mount scripts, slot markers, runtime), not re-sent content, and it amortizes
376
+ toward `buffered + small constant` as blocks grow. There is a **crossover**: for
377
+ very small blocks the per-block wrapper (~90 B) exceeds the saved content, so
378
+ single-send is net-negative — it wins only for realistically-sized `@defer`
379
+ blocks.
380
+
381
+ **Why this is deferred:** unlike the additive seam (two append-only anchors,
382
+ one private symbol), the incremental annotator _edits the middle of_
383
+ `serializeLContainer` and references several more private core symbols
384
+ (`SerializedViewCollection`, `isIncrementalHydrationEnabled`,
385
+ `IS_EVENT_REPLAY_ENABLED`, …). It rides entirely on non-minified FESM symbol
386
+ names. That maintenance surface is exactly the line where a framework-carried
387
+ string patch stops being reasonable.
388
+
389
+ ### Upstream primitive
390
+
391
+ Both the additive seam and the incremental annotator are prototypes for a
392
+ capability that belongs in Angular proper: a first-class streaming SSR entry
393
+ point (e.g. `renderApplicationStream`) that emits `@defer` blocks progressively
394
+ with per-block hydration annotation. The Analog prototype demonstrates the design
395
+ and quantifies the payoff; the durable form is an upstream API, not a patch a
396
+ framework maintains against private symbols.
397
+
398
+ ### Stream the eager shell early
399
+
400
+ The [Core Web Vitals](#core-web-vitals-over-http-throttled) measurement shows LCP
401
+ is not improved, because the eager app shell (the non-`@defer` chrome) ships only
402
+ in the authoritative tail — today the first flush is just the head plus an empty
403
+ stream region, and only `@defer` blocks stream before the tail. Streaming the
404
+ eager shell in the first flush, into its final document position, would let the
405
+ LCP element paint at ~FCP time rather than at `whenStable`; and because the shell
406
+ would arrive already in place rather than being swapped in at finalize, it would
407
+ also shrink the small CLS cost the body-swap introduces. This is the
408
+ highest-leverage CWV follow-up and pairs naturally with progressive-paint
409
+ positioning below.
410
+
411
+ ### Other
412
+
413
+ - Progressive-paint positioning (mount blocks into their document position as they
414
+ arrive, rather than a preview region) — orthogonal to bytes, and a prerequisite
415
+ for streaming the eager shell in place.
416
+ - A `create-analog` template and nitro config flag once the primitive stabilizes.