@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.
- package/RFC-streaming-ssr.md +416 -0
- package/fesm2022/analogjs-router-server.mjs +390 -146
- package/fesm2022/analogjs-router-server.mjs.map +1 -1
- package/fesm2022/analogjs-router.mjs +5 -91
- package/fesm2022/analogjs-router.mjs.map +1 -1
- package/package.json +2 -2
- package/types/analogjs-router-server.d.ts +43 -11
- package/types/analogjs-router.d.ts +1 -32
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import { InjectionToken, Injector, runInInjectionContext, ɵresetCompiledComponents as _resetCompiledComponents,
|
|
2
|
-
import { ɵSERVER_CONTEXT as _SERVER_CONTEXT,
|
|
1
|
+
import { InjectionToken, Injector, runInInjectionContext, ɵresetCompiledComponents as _resetCompiledComponents, enableProdMode } from '@angular/core';
|
|
2
|
+
import { ɵSERVER_CONTEXT as _SERVER_CONTEXT, renderApplication, platformServer, INITIAL_CONFIG, ɵrenderInternal as _renderInternal, provideServerRendering } from '@angular/platform-server';
|
|
3
3
|
import { REQUEST, RESPONSE, BASE_URL, LOCALE } from '@analogjs/router/tokens';
|
|
4
4
|
import { SERVER_FN_DISPATCHER, createServerFnRef } from '@analogjs/router';
|
|
5
5
|
import { HttpErrorResponse } from '@angular/common/http';
|
|
6
6
|
import { bootstrapApplication, createApplication } from '@angular/platform-browser';
|
|
7
|
-
import {
|
|
7
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
8
|
+
import { eventHandler, getRouterParam, readBody } from 'h3';
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* Server-side registry of server functions, keyed by id. A `.server.ts` module
|
|
@@ -392,145 +393,6 @@ function getRequestProtocol(req, opts = {}) {
|
|
|
392
393
|
return req.connection?.encrypted ? 'https' : 'http';
|
|
393
394
|
}
|
|
394
395
|
|
|
395
|
-
const STATIC_PROPS = new InjectionToken('Static Props');
|
|
396
|
-
function provideStaticProps(props) {
|
|
397
|
-
return {
|
|
398
|
-
provide: STATIC_PROPS,
|
|
399
|
-
useFactory() {
|
|
400
|
-
return props;
|
|
401
|
-
},
|
|
402
|
-
};
|
|
403
|
-
}
|
|
404
|
-
function injectStaticProps() {
|
|
405
|
-
assertInInjectionContext(injectStaticProps);
|
|
406
|
-
return inject(STATIC_PROPS);
|
|
407
|
-
}
|
|
408
|
-
function injectStaticOutputs() {
|
|
409
|
-
const transferState = inject(TransferState);
|
|
410
|
-
const outputsKey = makeStateKey('_analog_output');
|
|
411
|
-
return {
|
|
412
|
-
set(data) {
|
|
413
|
-
transferState.set(outputsKey, data);
|
|
414
|
-
},
|
|
415
|
-
};
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
function serverComponentRequest(serverContext) {
|
|
419
|
-
const serverComponentId = getHeader(createEvent(serverContext.req, serverContext.res), 'X-Analog-Component');
|
|
420
|
-
if (!serverComponentId &&
|
|
421
|
-
serverContext.req.url &&
|
|
422
|
-
serverContext.req.url.startsWith('/_analog/components')) {
|
|
423
|
-
const componentId = serverContext.req.url.split('/')?.[3];
|
|
424
|
-
return componentId;
|
|
425
|
-
}
|
|
426
|
-
return serverComponentId;
|
|
427
|
-
}
|
|
428
|
-
// Deferred so the module can be imported outside a Vite context (e.g. by the
|
|
429
|
-
// Nitro server-function dispatch path, or tooling) without eagerly evaluating
|
|
430
|
-
// the Vite-only `import.meta.glob` macro at load time. Vite still statically
|
|
431
|
-
// rewrites the macro; it is just invoked lazily on first server-component render.
|
|
432
|
-
let _components;
|
|
433
|
-
function serverComponents() {
|
|
434
|
-
return (_components ??= import.meta.glob([
|
|
435
|
-
'/src/server/components/**/*.{ts,analog,ag}',
|
|
436
|
-
]));
|
|
437
|
-
}
|
|
438
|
-
async function renderServerComponent(url, serverContext, config) {
|
|
439
|
-
const componentReqId = serverComponentRequest(serverContext);
|
|
440
|
-
const { componentLoader, componentId } = getComponentLoader(componentReqId);
|
|
441
|
-
if (!componentLoader) {
|
|
442
|
-
return new Response(`Server Component Not Found ${componentId}`, {
|
|
443
|
-
status: 404,
|
|
444
|
-
});
|
|
445
|
-
}
|
|
446
|
-
const component = (await componentLoader())['default'];
|
|
447
|
-
if (!component) {
|
|
448
|
-
return new Response(`No default export for ${componentId}`, {
|
|
449
|
-
status: 422,
|
|
450
|
-
});
|
|
451
|
-
}
|
|
452
|
-
const mirror = reflectComponentType(component);
|
|
453
|
-
const selector = mirror?.selector.split(',')?.[0] || 'server-component';
|
|
454
|
-
const event = createEvent(serverContext.req, serverContext.res);
|
|
455
|
-
const body = (await readBody(event)) || {};
|
|
456
|
-
const appId = `analog-server-${selector.toLowerCase()}-${new Date().getTime()}`;
|
|
457
|
-
const bootstrap = (context) => bootstrapApplication(component, {
|
|
458
|
-
providers: [
|
|
459
|
-
provideServerRendering(),
|
|
460
|
-
provideStaticProps(body),
|
|
461
|
-
{ provide: _SERVER_CONTEXT, useValue: 'analog-server-component' },
|
|
462
|
-
{
|
|
463
|
-
provide: APP_ID,
|
|
464
|
-
useFactory() {
|
|
465
|
-
return appId;
|
|
466
|
-
},
|
|
467
|
-
},
|
|
468
|
-
...(config?.providers || []),
|
|
469
|
-
],
|
|
470
|
-
}, context);
|
|
471
|
-
const html = await renderApplication(bootstrap, {
|
|
472
|
-
url,
|
|
473
|
-
document: `<${selector}></${selector}>`,
|
|
474
|
-
platformProviders: [
|
|
475
|
-
{
|
|
476
|
-
provide: _Console,
|
|
477
|
-
useFactory() {
|
|
478
|
-
return {
|
|
479
|
-
warn: () => { },
|
|
480
|
-
log: () => { },
|
|
481
|
-
};
|
|
482
|
-
},
|
|
483
|
-
},
|
|
484
|
-
],
|
|
485
|
-
});
|
|
486
|
-
const outputs = retrieveTransferredState(html, appId);
|
|
487
|
-
const responseData = {
|
|
488
|
-
html,
|
|
489
|
-
outputs,
|
|
490
|
-
};
|
|
491
|
-
return new Response(JSON.stringify(responseData), {
|
|
492
|
-
headers: {
|
|
493
|
-
'X-Analog-Component': 'true',
|
|
494
|
-
},
|
|
495
|
-
});
|
|
496
|
-
}
|
|
497
|
-
function getComponentLoader(componentReqId) {
|
|
498
|
-
let _componentId = `/src/server/components/${componentReqId.toLowerCase()}`;
|
|
499
|
-
let componentLoader = undefined;
|
|
500
|
-
let componentId = _componentId;
|
|
501
|
-
const components = serverComponents();
|
|
502
|
-
if (components[`${_componentId}.ts`]) {
|
|
503
|
-
componentId = `${_componentId}.ts`;
|
|
504
|
-
componentLoader = components[componentId];
|
|
505
|
-
}
|
|
506
|
-
return { componentLoader, componentId };
|
|
507
|
-
}
|
|
508
|
-
function retrieveTransferredState(html, appId) {
|
|
509
|
-
const regex = new RegExp(`<script id="${appId}-state" type="application/json">(.*?)<\/script>`);
|
|
510
|
-
const match = html.match(regex);
|
|
511
|
-
if (match) {
|
|
512
|
-
const scriptContent = match[1];
|
|
513
|
-
if (scriptContent) {
|
|
514
|
-
try {
|
|
515
|
-
const parsedContent = JSON.parse(scriptContent);
|
|
516
|
-
return parsedContent._analog_output || {};
|
|
517
|
-
}
|
|
518
|
-
catch (e) {
|
|
519
|
-
console.warn('Exception while parsing static outputs for ' + appId, e);
|
|
520
|
-
}
|
|
521
|
-
}
|
|
522
|
-
return {};
|
|
523
|
-
}
|
|
524
|
-
else {
|
|
525
|
-
return {};
|
|
526
|
-
}
|
|
527
|
-
}
|
|
528
|
-
|
|
529
|
-
// Optional chaining: the server-function dispatch endpoint imports this entry
|
|
530
|
-
// from a Nitro bundle, where `import.meta.env` is not defined at all.
|
|
531
|
-
if (import.meta.env?.PROD) {
|
|
532
|
-
enableProdMode();
|
|
533
|
-
}
|
|
534
396
|
/**
|
|
535
397
|
* Nulls `def.tView` on every component definition that Angular has
|
|
536
398
|
* compiled in this process. Angular caches the result of `consts()` on
|
|
@@ -552,6 +414,12 @@ function resetComponentDefTViews() {
|
|
|
552
414
|
def.tView = null;
|
|
553
415
|
}
|
|
554
416
|
}
|
|
417
|
+
|
|
418
|
+
// Optional chaining: the server-function dispatch endpoint imports this entry
|
|
419
|
+
// from a Nitro bundle, where `import.meta.env` is not defined at all.
|
|
420
|
+
if (import.meta.env?.PROD) {
|
|
421
|
+
enableProdMode();
|
|
422
|
+
}
|
|
555
423
|
/**
|
|
556
424
|
* Returns a function that accepts the navigation URL,
|
|
557
425
|
* the root HTML, and server context.
|
|
@@ -566,9 +434,6 @@ function render(rootComponent, config, platformProviders = []) {
|
|
|
566
434
|
return bootstrapApplication(rootComponent, config, context);
|
|
567
435
|
}
|
|
568
436
|
return async function render(url, document, serverContext) {
|
|
569
|
-
if (serverComponentRequest(serverContext)) {
|
|
570
|
-
return await renderServerComponent(url, serverContext);
|
|
571
|
-
}
|
|
572
437
|
resetComponentDefTViews();
|
|
573
438
|
const html = await renderApplication(bootstrap, {
|
|
574
439
|
document,
|
|
@@ -582,6 +447,385 @@ function render(rootComponent, config, platformProviders = []) {
|
|
|
582
447
|
};
|
|
583
448
|
}
|
|
584
449
|
|
|
450
|
+
/**
|
|
451
|
+
* Pure string helpers for slicing a fully rendered SSR document into the parts
|
|
452
|
+
* the streaming renderer flushes: the shell up to `<body>`, the authoritative
|
|
453
|
+
* `<body>` inner HTML for the tail, and the authoritative `<head>` inner HTML
|
|
454
|
+
* for the finalize-time head reconcile. Extracted from `render-stream` so they
|
|
455
|
+
* can be unit tested without driving the platform.
|
|
456
|
+
*/
|
|
457
|
+
/** Byte offset just after the opening `<body>` tag, or 0 if none. */
|
|
458
|
+
function afterBodyOpen(html) {
|
|
459
|
+
const m = /<body[^>]*>/i.exec(html);
|
|
460
|
+
return m ? m.index + m[0].length : 0;
|
|
461
|
+
}
|
|
462
|
+
/** Inner HTML of `<body>` from a fully rendered document string. */
|
|
463
|
+
function bodyInner(html) {
|
|
464
|
+
const start = afterBodyOpen(html);
|
|
465
|
+
const end = html.lastIndexOf('</body>');
|
|
466
|
+
return html.slice(start, end > -1 ? end : html.length);
|
|
467
|
+
}
|
|
468
|
+
/** Inner HTML of `<head>` from a fully rendered document string. */
|
|
469
|
+
function headInner(html) {
|
|
470
|
+
const open = /<head[^>]*>/i.exec(html);
|
|
471
|
+
if (!open)
|
|
472
|
+
return '';
|
|
473
|
+
const start = open.index + open[0].length;
|
|
474
|
+
const end = html.indexOf('</head>', start);
|
|
475
|
+
return html.slice(start, end > -1 ? end : start);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* Per-request decisions about whether the streaming renderer should fall back
|
|
480
|
+
* to a buffered render. Extracted from `render-stream` so they can be unit
|
|
481
|
+
* tested without driving the platform.
|
|
482
|
+
*/
|
|
483
|
+
/**
|
|
484
|
+
* User agents that receive a fully buffered render (with a resolved `<head>`)
|
|
485
|
+
* instead of the streamed shell. Streaming flushes the head before the app has
|
|
486
|
+
* set a dynamic title/meta and reconciles it via a finalize script; a crawler
|
|
487
|
+
* that does not run that script would index the shell's static head. Mirrors
|
|
488
|
+
* Nuxt's bot bypass — streaming targets interactive clients, bots get the
|
|
489
|
+
* buffered path whose head is byte-identical to the classic `render()`.
|
|
490
|
+
*/
|
|
491
|
+
const SSR_BOT_RE = /bot|crawl|spider|slurp|mediapartners|facebookexternalhit|embedly|quora link preview|outbrain|pinterest|vkshare|w3c_validator|whatsapp|telegrambot|lighthouse|google-inspectiontool|headlesschrome|bingpreview/i;
|
|
492
|
+
function isLikelyBot(serverContext) {
|
|
493
|
+
const ua = serverContext?.req?.headers?.['user-agent'];
|
|
494
|
+
return typeof ua === 'string' && SSR_BOT_RE.test(ua);
|
|
495
|
+
}
|
|
496
|
+
/**
|
|
497
|
+
* Whether streaming is disabled for this request by a `streaming: false` route
|
|
498
|
+
* rule. The platform plugin translates that rule into an `x-analog-no-streaming`
|
|
499
|
+
* response header (mirroring how `ssr: false` becomes `x-analog-no-ssr`); when
|
|
500
|
+
* present, `renderStream` produces the buffered `render()` output for this
|
|
501
|
+
* route instead of streaming.
|
|
502
|
+
*/
|
|
503
|
+
function streamingDisabledByRoute(serverContext) {
|
|
504
|
+
return serverContext?.res?.getHeader?.('x-analog-no-streaming') === 'true';
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* Tiny client runtime for progressive streaming SSR — EXPERIMENTAL.
|
|
509
|
+
*
|
|
510
|
+
* `renderStream` streams the document in three parts:
|
|
511
|
+
* 1. the head + this runtime + an empty `<div data-analog-stream>` region;
|
|
512
|
+
* 2. each `@defer` block, as it resolves on the server, as a
|
|
513
|
+
* `<template data-analog-defer="ID">…</template>` followed by a call to
|
|
514
|
+
* `window.__analogPaint("ID")` — this runtime paints the block into the
|
|
515
|
+
* streaming region immediately, so content appears progressively and out
|
|
516
|
+
* of document order;
|
|
517
|
+
* 3. the authoritative document tail: the app's resolved `<head>` in a
|
|
518
|
+
* `<template data-analog-head>` and the hydration-annotated body in a
|
|
519
|
+
* `<template data-analog-authoritative>`, followed by
|
|
520
|
+
* `window.__analogReconcileHead()` + `window.__analogFinalize()`. The head
|
|
521
|
+
* is reconciled first (a dynamically-set `<title>`/meta is applied to the
|
|
522
|
+
* live document, since the streamed shell head was flushed before the app
|
|
523
|
+
* ran), then the body is swapped to the exact document Angular's
|
|
524
|
+
* incremental hydration expects.
|
|
525
|
+
*
|
|
526
|
+
* Emitted into the document by `renderStream`. Exported as a string so it can
|
|
527
|
+
* be injected verbatim and unit-tested against a DOM.
|
|
528
|
+
*/
|
|
529
|
+
const DEFER_RECONCILE_RUNTIME = /* js */ `
|
|
530
|
+
(function () {
|
|
531
|
+
function region() {
|
|
532
|
+
return document.querySelector('[data-analog-stream]');
|
|
533
|
+
}
|
|
534
|
+
window.__analogPaint = function (id) {
|
|
535
|
+
var tpl = document.querySelector('template[data-analog-defer="' + id + '"]');
|
|
536
|
+
var r = region();
|
|
537
|
+
if (!tpl || !r) return;
|
|
538
|
+
r.appendChild(tpl.content.cloneNode(true));
|
|
539
|
+
tpl.remove();
|
|
540
|
+
};
|
|
541
|
+
window.__analogReconcileHead = function () {
|
|
542
|
+
// The shell head was flushed before the app rendered, so any title/meta the
|
|
543
|
+
// app set during render (Title/Meta services, route meta) is missing from
|
|
544
|
+
// the live document. Apply the authoritative head here, before hydration —
|
|
545
|
+
// matching how a buffered render would have produced the head. Idempotent:
|
|
546
|
+
// tags already present (charset, viewport, stylesheet/preload links) are
|
|
547
|
+
// matched and left as-is; only changed/added ones are updated.
|
|
548
|
+
var tpl = document.querySelector('template[data-analog-head]');
|
|
549
|
+
if (!tpl) return;
|
|
550
|
+
var frag = tpl.content;
|
|
551
|
+
var head = document.head;
|
|
552
|
+
var title = frag.querySelector('title');
|
|
553
|
+
if (title) document.title = title.textContent || '';
|
|
554
|
+
function metaKey(m) {
|
|
555
|
+
if (m.hasAttribute('charset')) return 'charset';
|
|
556
|
+
var attrs = ['name', 'property', 'http-equiv', 'itemprop'];
|
|
557
|
+
for (var i = 0; i < attrs.length; i++) {
|
|
558
|
+
if (m.hasAttribute(attrs[i])) return attrs[i] + '=' + m.getAttribute(attrs[i]);
|
|
559
|
+
}
|
|
560
|
+
return null;
|
|
561
|
+
}
|
|
562
|
+
var existingMeta = {};
|
|
563
|
+
var metas = head.querySelectorAll('meta');
|
|
564
|
+
for (var i = 0; i < metas.length; i++) {
|
|
565
|
+
var k = metaKey(metas[i]);
|
|
566
|
+
if (k) existingMeta[k] = metas[i];
|
|
567
|
+
}
|
|
568
|
+
frag.querySelectorAll('meta').forEach(function (m) {
|
|
569
|
+
var key = metaKey(m);
|
|
570
|
+
if (key == null) return;
|
|
571
|
+
if (existingMeta[key]) existingMeta[key].replaceWith(m.cloneNode(true));
|
|
572
|
+
else head.appendChild(m.cloneNode(true));
|
|
573
|
+
});
|
|
574
|
+
var existingHref = {};
|
|
575
|
+
var links = head.querySelectorAll('link[href]');
|
|
576
|
+
for (var j = 0; j < links.length; j++) {
|
|
577
|
+
existingHref[links[j].getAttribute('href')] = true;
|
|
578
|
+
}
|
|
579
|
+
frag.querySelectorAll('link').forEach(function (l) {
|
|
580
|
+
var href = l.getAttribute('href');
|
|
581
|
+
if (href && existingHref[href]) return;
|
|
582
|
+
head.appendChild(l.cloneNode(true));
|
|
583
|
+
if (href) existingHref[href] = true;
|
|
584
|
+
});
|
|
585
|
+
tpl.remove();
|
|
586
|
+
};
|
|
587
|
+
window.__analogFinalize = function () {
|
|
588
|
+
var auth = document.querySelector('template[data-analog-authoritative]');
|
|
589
|
+
if (!auth) return;
|
|
590
|
+
// Replace the entire body — preview region, block templates and runtime
|
|
591
|
+
// scripts — with just the authoritative body, so the reconciled DOM matches
|
|
592
|
+
// a buffered render byte-for-byte before hydration boots.
|
|
593
|
+
document.body.replaceChildren(auth.content.cloneNode(true));
|
|
594
|
+
};
|
|
595
|
+
})();
|
|
596
|
+
`;
|
|
597
|
+
|
|
598
|
+
/**
|
|
599
|
+
* Progressive streaming SSR renderer — EXPERIMENTAL.
|
|
600
|
+
*
|
|
601
|
+
* Returns a `ReadableStream<Uint8Array>` that flushes bytes DURING the render,
|
|
602
|
+
* not after it:
|
|
603
|
+
* 1. the document head + a client reconcile runtime are flushed immediately,
|
|
604
|
+
* before the app has finished rendering, so the browser starts fetching
|
|
605
|
+
* assets right away;
|
|
606
|
+
* 2. each `@defer (hydrate …)` block's content is flushed the moment it
|
|
607
|
+
* resolves on the server — out of document order — while later blocks are
|
|
608
|
+
* still pending (proven: a slow block does not hold back an early one);
|
|
609
|
+
* 3. once the app is stable, the authoritative, fully hydration-annotated
|
|
610
|
+
* document is flushed as the tail. This is byte-identical to a buffered
|
|
611
|
+
* `renderApplication`, and is what Angular's incremental hydration runs
|
|
612
|
+
* against on the client.
|
|
613
|
+
*
|
|
614
|
+
* Unlike a buffered renderer, this drives the platform directly
|
|
615
|
+
* (`platformServer` + `bootstrapApplication` + `ɵrenderInternal`) so it can
|
|
616
|
+
* interleave flushes with rendering. Angular's hydration annotation is
|
|
617
|
+
* whole-document (the root's `ngh` index references every `@defer` container),
|
|
618
|
+
* so the authoritative hydration payload is necessarily the tail: RENDERING
|
|
619
|
+
* streams progressively, and hydration begins once the tail arrives.
|
|
620
|
+
*
|
|
621
|
+
* Depends on an upstream Angular per-block resolution hook exposed on two
|
|
622
|
+
* globals (see {@link SsrStreamingGlobals}). When the primitive is absent,
|
|
623
|
+
* `renderStream` degrades to a single buffered chunk so behaviour matches the
|
|
624
|
+
* classic `render()` path, which is unchanged and remains the default.
|
|
625
|
+
*/
|
|
626
|
+
if (import.meta.env.PROD) {
|
|
627
|
+
enableProdMode();
|
|
628
|
+
}
|
|
629
|
+
function streamingPrimitiveAvailable() {
|
|
630
|
+
const g = globalThis;
|
|
631
|
+
return (typeof g.__analogSsrInternals?.collectNativeNodesInLContainer === 'function');
|
|
632
|
+
}
|
|
633
|
+
/**
|
|
634
|
+
* Per-render capture handlers live in async-local storage, not a single shared
|
|
635
|
+
* global slot, so concurrent renders in one process do not clobber each other.
|
|
636
|
+
* `globalThis.__analogSsrDeferCapture` is a stable dispatcher installed once; it
|
|
637
|
+
* routes each resolved `@defer` block to the handler of the render whose async
|
|
638
|
+
* context it fired in. A block that resolves outside any render (no store) is a
|
|
639
|
+
* no-op.
|
|
640
|
+
*/
|
|
641
|
+
const captureStore = new AsyncLocalStorage();
|
|
642
|
+
function installCaptureDispatcher() {
|
|
643
|
+
const g = globalThis;
|
|
644
|
+
if (g.__analogSsrDeferCapture?.__analogDispatcher)
|
|
645
|
+
return;
|
|
646
|
+
const dispatch = ((ev) => {
|
|
647
|
+
captureStore.getStore()?.(ev);
|
|
648
|
+
});
|
|
649
|
+
dispatch.__analogDispatcher = true;
|
|
650
|
+
g.__analogSsrDeferCapture = dispatch;
|
|
651
|
+
}
|
|
652
|
+
let warnedMissingPrimitive = false;
|
|
653
|
+
function warnMissingPrimitiveOnce() {
|
|
654
|
+
if (warnedMissingPrimitive || !import.meta.env.DEV)
|
|
655
|
+
return;
|
|
656
|
+
warnedMissingPrimitive = true;
|
|
657
|
+
console.warn('[@analogjs/router] renderStream: the streaming hook was not found on ' +
|
|
658
|
+
'@angular/core, so rendering falls back to buffered. Enable ' +
|
|
659
|
+
'`experimental.streaming` in your Analog config; if it already is, your ' +
|
|
660
|
+
'installed Angular version may be incompatible with the streaming patch.');
|
|
661
|
+
}
|
|
662
|
+
/**
|
|
663
|
+
* Serialize a `@defer` block's live domino subtree to HTML. Called a macrotask
|
|
664
|
+
* after the block resolves, by which point change detection has filled in the
|
|
665
|
+
* block's interpolations.
|
|
666
|
+
*/
|
|
667
|
+
function serializeLContainerHtml(lContainer) {
|
|
668
|
+
const g = globalThis;
|
|
669
|
+
const collect = g.__analogSsrInternals?.collectNativeNodesInLContainer;
|
|
670
|
+
if (!collect)
|
|
671
|
+
return '';
|
|
672
|
+
const nodes = [];
|
|
673
|
+
collect(lContainer, nodes);
|
|
674
|
+
let html = '';
|
|
675
|
+
for (const n of nodes)
|
|
676
|
+
html += n?.outerHTML ?? n?.data ?? n?.nodeValue ?? '';
|
|
677
|
+
return html;
|
|
678
|
+
}
|
|
679
|
+
/** Destroy the platform on a macrotask, matching `renderApplication`. */
|
|
680
|
+
function asyncDestroyPlatform(platformRef) {
|
|
681
|
+
return new Promise((resolve) => {
|
|
682
|
+
setTimeout(() => {
|
|
683
|
+
platformRef.destroy();
|
|
684
|
+
resolve();
|
|
685
|
+
}, 0);
|
|
686
|
+
});
|
|
687
|
+
}
|
|
688
|
+
/**
|
|
689
|
+
* Returns a function that renders a URL to a `ReadableStream<Uint8Array>`.
|
|
690
|
+
*
|
|
691
|
+
* Usage in main.server.ts:
|
|
692
|
+
* ```ts
|
|
693
|
+
* import { renderStream } from '@analogjs/router/server';
|
|
694
|
+
* export default renderStream(App, config);
|
|
695
|
+
* ```
|
|
696
|
+
*/
|
|
697
|
+
function renderStream(rootComponent, config, platformProviders = []) {
|
|
698
|
+
function bootstrap(context) {
|
|
699
|
+
return bootstrapApplication(rootComponent, config, context);
|
|
700
|
+
}
|
|
701
|
+
return async function renderStream(url, document, serverContext) {
|
|
702
|
+
// Reset before every render — both the buffered fallback below and the
|
|
703
|
+
// streaming path — so a prior render's locale/consts are not frozen for the
|
|
704
|
+
// process lifetime (parity with render.ts).
|
|
705
|
+
resetComponentDefTViews();
|
|
706
|
+
// Fall back to a single buffered chunk so output matches the classic path
|
|
707
|
+
// for:
|
|
708
|
+
// - crawlers, which may not run the finalize script that reconciles a
|
|
709
|
+
// dynamic <head>, so they get a buffered render with a resolved head;
|
|
710
|
+
// - routes with a `streaming: false` rule (opt out per route);
|
|
711
|
+
// - a missing streaming primitive.
|
|
712
|
+
const primitiveAvailable = streamingPrimitiveAvailable();
|
|
713
|
+
const bot = isLikelyBot(serverContext);
|
|
714
|
+
const routeDisabled = streamingDisabledByRoute(serverContext);
|
|
715
|
+
if (bot || routeDisabled || !primitiveAvailable) {
|
|
716
|
+
// Warn only when the primitive is genuinely absent — the bot and
|
|
717
|
+
// route-opt-out paths fall back to buffered by design, not by degradation.
|
|
718
|
+
if (!bot && !routeDisabled && !primitiveAvailable) {
|
|
719
|
+
warnMissingPrimitiveOnce();
|
|
720
|
+
}
|
|
721
|
+
const html = await renderApplication((context) => bootstrapApplication(rootComponent, config, context), {
|
|
722
|
+
document,
|
|
723
|
+
url,
|
|
724
|
+
platformProviders: [
|
|
725
|
+
provideServerContext(serverContext),
|
|
726
|
+
platformProviders,
|
|
727
|
+
],
|
|
728
|
+
});
|
|
729
|
+
return new ReadableStream({
|
|
730
|
+
start(controller) {
|
|
731
|
+
controller.enqueue(new TextEncoder().encode(html));
|
|
732
|
+
controller.close();
|
|
733
|
+
},
|
|
734
|
+
});
|
|
735
|
+
}
|
|
736
|
+
installCaptureDispatcher();
|
|
737
|
+
const encoder = new TextEncoder();
|
|
738
|
+
// The stream is returned immediately; `start` fills it as the render
|
|
739
|
+
// progresses, so the consumer receives the head, then each @defer block the
|
|
740
|
+
// moment it resolves, then the authoritative tail — true streaming.
|
|
741
|
+
return new ReadableStream({
|
|
742
|
+
async start(controller) {
|
|
743
|
+
const enqueue = (s) => controller.enqueue(encoder.encode(s));
|
|
744
|
+
// The capture handler fires once per @defer block as it resolves. The
|
|
745
|
+
// block's DOM is not filled until the next change-detection tick, so we
|
|
746
|
+
// serialize + flush it a macrotask later — flushing DURING the render.
|
|
747
|
+
// It is scoped to this render via async-local storage (see
|
|
748
|
+
// installCaptureDispatcher) so concurrent renders never cross-talk.
|
|
749
|
+
let blockIndex = 0;
|
|
750
|
+
let capturing = true;
|
|
751
|
+
const seen = new Set();
|
|
752
|
+
const pendingFlushes = [];
|
|
753
|
+
const onBlockResolved = (ev) => {
|
|
754
|
+
// A block can reach `Complete` more than once during a render; only
|
|
755
|
+
// stream each container once. Also ignore any resolution that fires
|
|
756
|
+
// after the render has moved on to serializing the authoritative tail.
|
|
757
|
+
if (!capturing || seen.has(ev.lContainer))
|
|
758
|
+
return;
|
|
759
|
+
seen.add(ev.lContainer);
|
|
760
|
+
const id = `s${blockIndex++}`;
|
|
761
|
+
pendingFlushes.push(new Promise((resolve) => {
|
|
762
|
+
setTimeout(() => {
|
|
763
|
+
const html = serializeLContainerHtml(ev.lContainer);
|
|
764
|
+
enqueue(`<template data-analog-defer="${id}">${html}</template>` +
|
|
765
|
+
`<script>window.__analogPaint&&window.__analogPaint(${JSON.stringify(id)})</script>`);
|
|
766
|
+
resolve();
|
|
767
|
+
}, 0);
|
|
768
|
+
}));
|
|
769
|
+
};
|
|
770
|
+
// Run the whole render inside the async-local context so every
|
|
771
|
+
// change-detection tick and @defer resolution it schedules routes back
|
|
772
|
+
// to THIS render's handler.
|
|
773
|
+
await captureStore.run(onBlockResolved, async () => {
|
|
774
|
+
const platformRef = platformServer([
|
|
775
|
+
{ provide: INITIAL_CONFIG, useValue: { document, url } },
|
|
776
|
+
provideServerContext(serverContext),
|
|
777
|
+
platformProviders,
|
|
778
|
+
]);
|
|
779
|
+
// 1. Flush the head + reconcile runtime immediately (before the app is
|
|
780
|
+
// rendered), then open the live streaming region.
|
|
781
|
+
enqueue(document.slice(0, afterBodyOpen(document)) +
|
|
782
|
+
`<script>${DEFER_RECONCILE_RUNTIME}</script>` +
|
|
783
|
+
`<div data-analog-stream></div>`);
|
|
784
|
+
let appRef;
|
|
785
|
+
let errored = false;
|
|
786
|
+
try {
|
|
787
|
+
// 2. Bootstrap + render. Blocks resolve out of order during this
|
|
788
|
+
// phase and flush via the capture handler above.
|
|
789
|
+
appRef = await bootstrap({ platformRef });
|
|
790
|
+
await appRef.whenStable();
|
|
791
|
+
await Promise.all(pendingFlushes);
|
|
792
|
+
// Stop capturing before serializing the tail so late resolutions
|
|
793
|
+
// triggered by the hydration pass are not streamed as extra blocks.
|
|
794
|
+
capturing = false;
|
|
795
|
+
// 3. Flush the authoritative, fully hydration-annotated document as
|
|
796
|
+
// the tail. Carried in <template>s (their inert `ng-state`
|
|
797
|
+
// script survives). The app's resolved <head> ships alongside so
|
|
798
|
+
// a dynamically-set title/meta — set during render, after the
|
|
799
|
+
// shell head was already flushed — is reconciled onto the live
|
|
800
|
+
// document before the runtime swaps in the body and hydration
|
|
801
|
+
// boots.
|
|
802
|
+
const authoritative = await _renderInternal(platformRef, appRef);
|
|
803
|
+
enqueue(`<template data-analog-head>${headInner(authoritative)}</template>` +
|
|
804
|
+
`<template data-analog-authoritative>${bodyInner(authoritative)}</template>` +
|
|
805
|
+
`<script>window.__analogReconcileHead&&window.__analogReconcileHead();` +
|
|
806
|
+
`window.__analogFinalize&&window.__analogFinalize()</script>` +
|
|
807
|
+
`</body></html>`);
|
|
808
|
+
}
|
|
809
|
+
catch (err) {
|
|
810
|
+
// The head + runtime were already flushed, so the status/headers are
|
|
811
|
+
// committed; error the stream (a no-op silent close would hand the
|
|
812
|
+
// client a truncated, non-hydratable 200) and log with block context.
|
|
813
|
+
errored = true;
|
|
814
|
+
console.error(`[@analogjs/router] renderStream failed for ${url} after ` +
|
|
815
|
+
`${blockIndex} block(s); response truncated.`, err);
|
|
816
|
+
controller.error(err);
|
|
817
|
+
}
|
|
818
|
+
finally {
|
|
819
|
+
await asyncDestroyPlatform(platformRef);
|
|
820
|
+
if (!errored)
|
|
821
|
+
controller.close();
|
|
822
|
+
}
|
|
823
|
+
});
|
|
824
|
+
},
|
|
825
|
+
});
|
|
826
|
+
};
|
|
827
|
+
}
|
|
828
|
+
|
|
585
829
|
function serverFn(arg1, arg2) {
|
|
586
830
|
const { config, handler } = normalizeArgs(arg1, arg2);
|
|
587
831
|
// GET is reserved for input-less reads; an input schema requires POST (the
|
|
@@ -715,5 +959,5 @@ async function handleServerFnRequest(event, appInjector) {
|
|
|
715
959
|
* Generated bundle index. Do not edit.
|
|
716
960
|
*/
|
|
717
961
|
|
|
718
|
-
export { SERVER_FN_ALLOWED_ORIGINS, SERVER_FN_INTERCEPTORS, createServerFnAppInjector, createServerFnEventHandler, dispatchServerFn, handleServerFnRequest,
|
|
962
|
+
export { SERVER_FN_ALLOWED_ORIGINS, SERVER_FN_INTERCEPTORS, createServerFnAppInjector, createServerFnEventHandler, dispatchServerFn, handleServerFnRequest, isServerFnOriginAllowed, provideServerContext, provideServerFns, render, renderStream, runInterceptors, serverFn, serverFnRegistry, withAllowedOrigins, withServerFnInterceptors };
|
|
719
963
|
//# sourceMappingURL=analogjs-router-server.mjs.map
|