@analogjs/router 2.6.4 → 2.7.0-beta.2

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.
@@ -1,13 +1,316 @@
1
- import { ɵresetCompiledComponents as _resetCompiledComponents, InjectionToken, assertInInjectionContext, inject, TransferState, makeStateKey, reflectComponentType, APP_ID, ɵConsole as _Console, enableProdMode } from '@angular/core';
2
- import { ɵSERVER_CONTEXT as _SERVER_CONTEXT, provideServerRendering, renderApplication } from '@angular/platform-server';
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
- import { bootstrapApplication } from '@angular/platform-browser';
5
- import { getHeader, createEvent, readBody } from 'h3';
4
+ import { SERVER_FN_DISPATCHER, createServerFnRef } from '@analogjs/router';
5
+ import { HttpErrorResponse } from '@angular/common/http';
6
+ import { bootstrapApplication, createApplication } from '@angular/platform-browser';
7
+ import { AsyncLocalStorage } from 'node:async_hooks';
8
+ import { eventHandler, getRouterParam, readBody } from 'h3';
9
+
10
+ /**
11
+ * Server-side registry of server functions, keyed by id. A `.server.ts` module
12
+ * populates it as a side effect of `serverFn(...)` running at import time; the
13
+ * Nitro dispatch route imports those modules to fill it, then looks up by id.
14
+ */
15
+ const serverFnRegistry = new Map();
16
+
17
+ const SERVER_FN_INTERCEPTORS = new InjectionToken('SERVER_FN_INTERCEPTORS');
18
+ /** `withServerFnInterceptors([...])` — registers the chain (DI, ordered). */
19
+ function withServerFnInterceptors(interceptors) {
20
+ return {
21
+ providers: interceptors.map((fn) => ({
22
+ provide: SERVER_FN_INTERCEPTORS,
23
+ useValue: fn,
24
+ multi: true,
25
+ })),
26
+ };
27
+ }
28
+ /** `provideServerFns(withServerFnInterceptors(...))` — mirrors provideHttpClient. */
29
+ function provideServerFns(...features) {
30
+ return features.flatMap((f) => f.providers);
31
+ }
32
+ function makeCtx(input, context) {
33
+ return {
34
+ input,
35
+ context,
36
+ with(patch) {
37
+ return makeCtx(input, { ...context, ...patch });
38
+ },
39
+ };
40
+ }
41
+ /**
42
+ * Run the interceptor chain, then the handler, threading the context.
43
+ *
44
+ * `runInCtx` re-establishes the DI injection context around each interceptor
45
+ * and the handler individually. This is what keeps `inject()` working in a
46
+ * handler even when an upstream interceptor `await`s before calling `next`
47
+ * (which would otherwise resume outside Angular's synchronous injection
48
+ * context). It defaults to a pass-through for non-DI callers/tests.
49
+ */
50
+ async function runInterceptors(interceptors, input, handler, runInCtx = (fn) => fn()) {
51
+ let i = -1;
52
+ const dispatch = async (ctx) => {
53
+ i += 1;
54
+ if (i < interceptors.length) {
55
+ return runInCtx(() => interceptors[i](ctx, dispatch));
56
+ }
57
+ return runInCtx(() => handler(ctx.input, ctx.context));
58
+ };
59
+ return dispatch(makeCtx(input, {}));
60
+ }
61
+
62
+ /**
63
+ * Same-origin enforcement for the server-function HTTP transport.
64
+ *
65
+ * Server functions are same-origin RPC: a client proxy only ever calls the
66
+ * relative `/_analog/fn/:id` URL of its own app. A cross-origin page must not be
67
+ * able to invoke them against a logged-in user (a CSRF-shaped attack), so the
68
+ * transport rejects browser requests whose origin is not the app's own — out of
69
+ * the box, with no per-app configuration.
70
+ *
71
+ * The signals used (`Sec-Fetch-Site`, `Origin`) are added by the browser and
72
+ * cannot be forged by a cross-origin page's `fetch`. Non-browser callers (curl,
73
+ * server-to-server, SSR in-process) send neither, so they are unaffected: the
74
+ * guard blocks the cross-origin browser attack it is meant to, and nothing else.
75
+ */
76
+ /**
77
+ * Origins permitted beyond the app's own, registered through DI:
78
+ * `provideServerFns(withAllowedOrigins([...]))`. Empty by default — the
79
+ * transport is same-origin unless an app opts out explicitly.
80
+ */
81
+ const SERVER_FN_ALLOWED_ORIGINS = new InjectionToken('SERVER_FN_ALLOWED_ORIGINS');
82
+ /**
83
+ * `withAllowedOrigins([...])` — permit cross-origin browser calls from the
84
+ * listed origins, or pass `'*'` to disable the same-origin guard entirely.
85
+ * Server functions are frequently cookie-authenticated, so this is an explicit
86
+ * opt-out of CSRF protection: allow-list the exact origins you control.
87
+ */
88
+ function withAllowedOrigins(origins) {
89
+ return {
90
+ providers: origins.map((origin) => ({
91
+ provide: SERVER_FN_ALLOWED_ORIGINS,
92
+ useValue: origin,
93
+ multi: true,
94
+ })),
95
+ };
96
+ }
97
+ function firstHeader(value) {
98
+ return Array.isArray(value) ? value[0] : value;
99
+ }
100
+ /**
101
+ * Whether an HTTP request to a server function may proceed.
102
+ *
103
+ * Allowed when the request is same-origin, carries no browser-origin signal at
104
+ * all (a non-browser client, or a same-origin GET that omits `Origin`), or its
105
+ * `Origin` is listed in `allowedOrigins`. Passing `'*'` in `allowedOrigins`
106
+ * disables the check — the explicit opt-in to cross-origin access.
107
+ *
108
+ * `Sec-Fetch-Site` is the authoritative signal when present: `same-origin` and
109
+ * `none` (a direct navigation, not a cross-site fetch) pass; `same-site` and
110
+ * `cross-site` require an explicit `allowedOrigins` entry. When the header is
111
+ * absent (older browsers, some proxies) the `Origin` host is compared to the
112
+ * request host as a fallback.
113
+ */
114
+ function isServerFnOriginAllowed(headers, allowedOrigins = []) {
115
+ if (allowedOrigins.includes('*')) {
116
+ return true;
117
+ }
118
+ const origin = firstHeader(headers['origin']);
119
+ const originAllowlisted = origin !== undefined && allowedOrigins.includes(origin);
120
+ const site = firstHeader(headers['sec-fetch-site']);
121
+ if (site) {
122
+ if (site === 'same-origin' || site === 'none') {
123
+ return true;
124
+ }
125
+ // same-site / cross-site: only when the origin is explicitly permitted.
126
+ return originAllowlisted;
127
+ }
128
+ // No `Sec-Fetch-Site`: fall back to comparing the `Origin` host to the host.
129
+ if (!origin) {
130
+ // Non-browser client, or a same-origin GET with no `Origin` — not the
131
+ // cross-origin browser request this guard exists to reject.
132
+ return true;
133
+ }
134
+ if (originAllowlisted) {
135
+ return true;
136
+ }
137
+ const host = firstHeader(headers['x-forwarded-host']) ?? firstHeader(headers['host']);
138
+ if (!host) {
139
+ return false;
140
+ }
141
+ try {
142
+ return new URL(origin).host === host;
143
+ }
144
+ catch {
145
+ return false;
146
+ }
147
+ }
148
+
149
+ /**
150
+ * Server-side dispatch for a server function call.
151
+ *
152
+ * 1. reject cross-origin browser calls (403), unless allow-listed — HTTP
153
+ * transport only (in-process callers omit `method` and are exempt)
154
+ * 2. look up the function by id
155
+ * 3. enforce the configured HTTP method (405 on mismatch)
156
+ * 4. require a JSON body on input-bearing calls (415 otherwise)
157
+ * 5. validate `input` against the Standard-Schema (4xx on failure)
158
+ * 6. build a per-request injector (REQUEST/RESPONSE + app providers)
159
+ * 7. run the interceptor chain, then the handler, re-entering
160
+ * `runInInjectionContext` at every hop so `inject()` works even after an
161
+ * interceptor `await`s before calling `next`
162
+ * 8. a `Response` returned by an interceptor/handler (`fail`/`redirect`)
163
+ * short-circuits with its status AND headers
164
+ *
165
+ * `options.method` is the request's HTTP method; when provided it is enforced
166
+ * against the function's configured method AND it turns on the same-origin
167
+ * guard. Transports (the generated Nitro handler) always pass it; trusted
168
+ * in-process callers may omit it, which also exempts them from the origin guard.
169
+ */
170
+ async function dispatchServerFn(id, rawInput, event, options = {}) {
171
+ const { parent, providers = [], method, allowedOrigins = [] } = options;
172
+ const headers = (event.node.req.headers ?? {});
173
+ // Same-origin guard runs first — before we even confirm the function exists —
174
+ // so a cross-origin page cannot probe which ids are registered. Gated on
175
+ // `method` so only HTTP-transport calls are checked; in-process callers omit
176
+ // it. The signals (`Origin`/`Sec-Fetch-Site`) are browser-set and unforgeable.
177
+ if (method) {
178
+ const allowed = [
179
+ ...allowedOrigins,
180
+ ...(parent?.get(SERVER_FN_ALLOWED_ORIGINS, []) ?? []),
181
+ ];
182
+ if (!isServerFnOriginAllowed(headers, allowed)) {
183
+ return {
184
+ status: 403,
185
+ body: { message: 'Cross-origin server function call rejected' },
186
+ };
187
+ }
188
+ }
189
+ const def = serverFnRegistry.get(id);
190
+ if (!def) {
191
+ return { status: 404, body: { message: `Unknown server function: ${id}` } };
192
+ }
193
+ // Enforce the transport method: a GET-only read must not be POSTable, and an
194
+ // input-bearing POST must not be reachable via GET.
195
+ if (method && method.toUpperCase() !== def.method) {
196
+ return {
197
+ status: 405,
198
+ body: { message: `Method ${method} not allowed for ${id}` },
199
+ headers: { Allow: def.method },
200
+ };
201
+ }
202
+ // Input travels as a JSON body. Reject anything else before decoding, so a
203
+ // form post from a cross-origin page (which cannot set a JSON content type
204
+ // without a CORS preflight) never reaches a handler. HTTP transport only.
205
+ if (method && def.method === 'POST' && !isJsonContentType(headers)) {
206
+ return {
207
+ status: 415,
208
+ body: { message: 'Server functions accept an application/json body' },
209
+ };
210
+ }
211
+ let input = rawInput;
212
+ if (def.config.input) {
213
+ const result = await def.config.input['~standard'].validate(rawInput);
214
+ if ('issues' in result && result.issues) {
215
+ return { status: 400, body: { errors: result.issues } };
216
+ }
217
+ input = result.value;
218
+ }
219
+ // Child of the app injector: only the request tokens are per-request; app
220
+ // services + interceptors resolve up the parent chain. All four are provided
221
+ // here so a handler resolves them the same way it would inside a component
222
+ // during SSR, whether it was reached over HTTP or in-process.
223
+ const req = event.node.req;
224
+ const locale = detectLocale(req);
225
+ const injector = Injector.create({
226
+ parent,
227
+ providers: [
228
+ { provide: REQUEST, useValue: event.node.req },
229
+ { provide: RESPONSE, useValue: event.node.res },
230
+ { provide: BASE_URL, useValue: getBaseUrl(req) },
231
+ ...(locale ? [{ provide: LOCALE, useValue: locale }] : []),
232
+ ...providers,
233
+ ],
234
+ });
235
+ // Re-enter the injection context at each hop rather than wrapping the whole
236
+ // chain once: an interceptor that awaits before `next` would otherwise run the
237
+ // handler outside the context and break `inject()`.
238
+ const runInCtx = (fn) => runInInjectionContext(injector, fn);
239
+ const interceptors = injector.get(SERVER_FN_INTERCEPTORS, []);
240
+ const outcome = await runInterceptors(interceptors, input, def.handler, runInCtx);
241
+ if (outcome instanceof Response) {
242
+ const text = await outcome.text();
243
+ const body = text ? safeJson(text) : null;
244
+ const headers = {};
245
+ outcome.headers.forEach((value, key) => {
246
+ if (key.toLowerCase() !== 'set-cookie') {
247
+ headers[key] = value;
248
+ }
249
+ });
250
+ // `Headers.forEach` yields cookies comma-joined into a single value, which
251
+ // is not a valid way to send more than one; `getSetCookie` keeps them apart.
252
+ const setCookie = outcome.headers.getSetCookie?.() ?? [];
253
+ if (setCookie.length) {
254
+ headers['set-cookie'] = setCookie;
255
+ }
256
+ return {
257
+ status: outcome.status,
258
+ body,
259
+ headers: Object.keys(headers).length ? headers : undefined,
260
+ };
261
+ }
262
+ return { status: 200, body: outcome };
263
+ }
264
+ function isJsonContentType(headers) {
265
+ const contentType = headers['content-type'];
266
+ const value = Array.isArray(contentType) ? contentType[0] : contentType;
267
+ if (!value) {
268
+ return false;
269
+ }
270
+ const mediaType = value.split(';')[0].trim().toLowerCase();
271
+ return mediaType === 'application/json' || mediaType.endsWith('+json');
272
+ }
273
+ function safeJson(text) {
274
+ try {
275
+ return JSON.parse(text);
276
+ }
277
+ catch {
278
+ return text;
279
+ }
280
+ }
281
+
282
+ /**
283
+ * The in-process transport used during SSR. `ServerFnClient` picks this up from
284
+ * DI and calls the handler directly instead of issuing an HTTP request back
285
+ * into the app — the render and the handler already share a process and a
286
+ * request, so the round-trip only adds latency (and would need an absolute URL).
287
+ *
288
+ * `method` is deliberately not passed to `dispatchServerFn`: this is a trusted
289
+ * in-process caller, so the HTTP-transport-only checks (same-origin, method
290
+ * enforcement, content type) do not apply. Validation and the interceptor chain
291
+ * still run, so an SSR call behaves like a browser call in every other respect.
292
+ *
293
+ * A non-2xx result is thrown as an `HttpErrorResponse` so the failure surfaces
294
+ * on `resource.error()` exactly as it does in the browser.
295
+ */
296
+ function createServerFnDispatcher(req, res) {
297
+ const event = { node: { req, res } };
298
+ return async (fn, input, injector) => {
299
+ const { status, body } = await dispatchServerFn(fn.id, input, event, {
300
+ parent: injector,
301
+ });
302
+ if (status < 200 || status > 299) {
303
+ throw new HttpErrorResponse({ status, error: body, url: fn.url });
304
+ }
305
+ return body;
306
+ };
307
+ }
6
308
 
7
309
  function provideServerContext({ req, res, }) {
8
310
  const baseUrl = getBaseUrl(req);
9
311
  const locale = detectLocale(req);
10
- if (import.meta.env.DEV) {
312
+ // Optional chaining: a Nitro-bundled caller has no `import.meta.env` at all.
313
+ if (import.meta.env?.DEV) {
11
314
  _resetCompiledComponents();
12
315
  }
13
316
  return [
@@ -15,6 +318,11 @@ function provideServerContext({ req, res, }) {
15
318
  { provide: REQUEST, useValue: req },
16
319
  { provide: RESPONSE, useValue: res },
17
320
  { provide: BASE_URL, useValue: baseUrl },
321
+ // Server functions called while rendering run in-process, in this injector.
322
+ {
323
+ provide: SERVER_FN_DISPATCHER,
324
+ useValue: createServerFnDispatcher(req, res),
325
+ },
18
326
  ...(locale ? [{ provide: LOCALE, useValue: locale }] : []),
19
327
  ];
20
328
  }
@@ -67,7 +375,10 @@ function parseAcceptLanguage(header) {
67
375
  }
68
376
  function getBaseUrl(req) {
69
377
  const protocol = getRequestProtocol(req);
70
- const { originalUrl, headers } = req;
378
+ const { headers } = req;
379
+ // Node's `IncomingMessage` has no `originalUrl`, and a server function
380
+ // endpoint is reached with a plain request, so fall back before dereferencing.
381
+ const originalUrl = req.originalUrl || req.url || '/';
71
382
  const parsedUrl = new URL('', `${protocol}://${headers.host}${originalUrl.endsWith('/')
72
383
  ? originalUrl.substring(0, originalUrl.length - 1)
73
384
  : originalUrl}`);
@@ -82,135 +393,6 @@ function getRequestProtocol(req, opts = {}) {
82
393
  return req.connection?.encrypted ? 'https' : 'http';
83
394
  }
84
395
 
85
- const STATIC_PROPS = new InjectionToken('Static Props');
86
- function provideStaticProps(props) {
87
- return {
88
- provide: STATIC_PROPS,
89
- useFactory() {
90
- return props;
91
- },
92
- };
93
- }
94
- function injectStaticProps() {
95
- assertInInjectionContext(injectStaticProps);
96
- return inject(STATIC_PROPS);
97
- }
98
- function injectStaticOutputs() {
99
- const transferState = inject(TransferState);
100
- const outputsKey = makeStateKey('_analog_output');
101
- return {
102
- set(data) {
103
- transferState.set(outputsKey, data);
104
- },
105
- };
106
- }
107
-
108
- function serverComponentRequest(serverContext) {
109
- const serverComponentId = getHeader(createEvent(serverContext.req, serverContext.res), 'X-Analog-Component');
110
- if (!serverComponentId &&
111
- serverContext.req.url &&
112
- serverContext.req.url.startsWith('/_analog/components')) {
113
- const componentId = serverContext.req.url.split('/')?.[3];
114
- return componentId;
115
- }
116
- return serverComponentId;
117
- }
118
- const components = import.meta.glob([
119
- '/src/server/components/**/*.{ts,analog,ag}',
120
- ]);
121
- async function renderServerComponent(url, serverContext, config) {
122
- const componentReqId = serverComponentRequest(serverContext);
123
- const { componentLoader, componentId } = getComponentLoader(componentReqId);
124
- if (!componentLoader) {
125
- return new Response(`Server Component Not Found ${componentId}`, {
126
- status: 404,
127
- });
128
- }
129
- const component = (await componentLoader())['default'];
130
- if (!component) {
131
- return new Response(`No default export for ${componentId}`, {
132
- status: 422,
133
- });
134
- }
135
- const mirror = reflectComponentType(component);
136
- const selector = mirror?.selector.split(',')?.[0] || 'server-component';
137
- const event = createEvent(serverContext.req, serverContext.res);
138
- const body = (await readBody(event)) || {};
139
- const appId = `analog-server-${selector.toLowerCase()}-${new Date().getTime()}`;
140
- const bootstrap = (context) => bootstrapApplication(component, {
141
- providers: [
142
- provideServerRendering(),
143
- provideStaticProps(body),
144
- { provide: _SERVER_CONTEXT, useValue: 'analog-server-component' },
145
- {
146
- provide: APP_ID,
147
- useFactory() {
148
- return appId;
149
- },
150
- },
151
- ...(config?.providers || []),
152
- ],
153
- }, context);
154
- const html = await renderApplication(bootstrap, {
155
- url,
156
- document: `<${selector}></${selector}>`,
157
- platformProviders: [
158
- {
159
- provide: _Console,
160
- useFactory() {
161
- return {
162
- warn: () => { },
163
- log: () => { },
164
- };
165
- },
166
- },
167
- ],
168
- });
169
- const outputs = retrieveTransferredState(html, appId);
170
- const responseData = {
171
- html,
172
- outputs,
173
- };
174
- return new Response(JSON.stringify(responseData), {
175
- headers: {
176
- 'X-Analog-Component': 'true',
177
- },
178
- });
179
- }
180
- function getComponentLoader(componentReqId) {
181
- let _componentId = `/src/server/components/${componentReqId.toLowerCase()}`;
182
- let componentLoader = undefined;
183
- let componentId = _componentId;
184
- if (components[`${_componentId}.ts`]) {
185
- componentId = `${_componentId}.ts`;
186
- componentLoader = components[componentId];
187
- }
188
- return { componentLoader, componentId };
189
- }
190
- function retrieveTransferredState(html, appId) {
191
- const regex = new RegExp(`<script id="${appId}-state" type="application/json">(.*?)<\/script>`);
192
- const match = html.match(regex);
193
- if (match) {
194
- const scriptContent = match[1];
195
- if (scriptContent) {
196
- try {
197
- const parsedContent = JSON.parse(scriptContent);
198
- return parsedContent._analog_output || {};
199
- }
200
- catch (e) {
201
- console.warn('Exception while parsing static outputs for ' + appId, e);
202
- }
203
- }
204
- return {};
205
- }
206
- else {
207
- return {};
208
- }
209
- }
210
-
211
- if (import.meta.env.PROD) {
212
- enableProdMode();
213
- }
214
396
  /**
215
397
  * Nulls `def.tView` on every component definition that Angular has
216
398
  * compiled in this process. Angular caches the result of `consts()` on
@@ -232,6 +414,12 @@ function resetComponentDefTViews() {
232
414
  def.tView = null;
233
415
  }
234
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
+ }
235
423
  /**
236
424
  * Returns a function that accepts the navigation URL,
237
425
  * the root HTML, and server context.
@@ -246,9 +434,6 @@ function render(rootComponent, config, platformProviders = []) {
246
434
  return bootstrapApplication(rootComponent, config, context);
247
435
  }
248
436
  return async function render(url, document, serverContext) {
249
- if (serverComponentRequest(serverContext)) {
250
- return await renderServerComponent(url, serverContext);
251
- }
252
437
  resetComponentDefTViews();
253
438
  const html = await renderApplication(bootstrap, {
254
439
  document,
@@ -262,9 +447,517 @@ function render(rootComponent, config, platformProviders = []) {
262
447
  };
263
448
  }
264
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
+
829
+ function serverFn(arg1, arg2) {
830
+ const { config, handler } = normalizeArgs(arg1, arg2);
831
+ // GET is reserved for input-less reads; an input schema requires POST (the
832
+ // input travels in the body, not the query). Build transforms reject this too.
833
+ if (config.method === 'GET' && config.input) {
834
+ throw new Error('[analog] a serverFn with `input` must use POST; GET is reserved for input-less reads.');
835
+ }
836
+ // `createServerFnRef` throws if the build-derived id is missing, so `ref.id`
837
+ // is the authoritative route key here.
838
+ const ref = createServerFnRef(config);
839
+ serverFnRegistry.set(ref.id, {
840
+ id: ref.id,
841
+ method: ref.method,
842
+ config,
843
+ handler,
844
+ });
845
+ return ref;
846
+ }
847
+ function normalizeArgs(arg1, arg2) {
848
+ // serverFn(handler) — input-less GET.
849
+ if (typeof arg1 === 'function') {
850
+ return {
851
+ config: {},
852
+ handler: arg1,
853
+ };
854
+ }
855
+ // serverFn(schema, handler) — a Standard Schema ⇒ POST + input.
856
+ if (isStandardSchema(arg1)) {
857
+ return {
858
+ config: { input: arg1 },
859
+ handler: arg2,
860
+ };
861
+ }
862
+ // serverFn(config, handler) — explicit config object.
863
+ return {
864
+ config: arg1 ?? {},
865
+ handler: arg2,
866
+ };
867
+ }
868
+ function isStandardSchema(value) {
869
+ return typeof value === 'object' && value !== null && '~standard' in value;
870
+ }
871
+
872
+ /**
873
+ * Builds the parent injector the server-function dispatch endpoint runs handlers
874
+ * against, over HTTP.
875
+ *
876
+ * A plain `Injector.create({ providers })` resolves explicitly-listed providers
877
+ * but not tree-shakeable `providedIn: 'root'` services — those attach to a
878
+ * *bootstrapped* application's root injector, which `Injector.create` is not.
879
+ * So the in-process SSR leg (whose parent is the app's own bootstrapped
880
+ * injector) resolved `root` services while the HTTP leg did not — the same
881
+ * handler could work while rendering and fail when called from the browser.
882
+ *
883
+ * Bootstrapping a real application on the server platform closes that gap: the
884
+ * returned `appRef.injector` is a root environment injector, so both listed
885
+ * providers and `providedIn: 'root'` services resolve, matching SSR.
886
+ *
887
+ * The generated endpoint passes the app's own server `ApplicationConfig` (the
888
+ * one `main.server.ts` renders with), so a handler sees exactly the DI the app
889
+ * configured — services, tokens, and interceptors alike — with no second
890
+ * provider list to keep in sync. No root component is bootstrapped
891
+ * (`createApplication`, not `bootstrapApplication`), so nothing renders, no
892
+ * change detection runs, and the router registers but never navigates. It is a
893
+ * DI container with the app's providers, built once and reused for the process,
894
+ * with only `REQUEST`/`RESPONSE` rebuilt per call in the child.
895
+ *
896
+ * A bare provider array is also accepted (direct callers and tests without an
897
+ * app config); it is wrapped with `provideServerRendering` so the server tokens
898
+ * resolve the same way.
899
+ */
900
+ async function createServerFnAppInjector(configOrProviders = []) {
901
+ const config = Array.isArray(configOrProviders)
902
+ ? { providers: [provideServerRendering(), ...configOrProviders] }
903
+ : configOrProviders;
904
+ const appRef = await createApplication(config, {
905
+ platformRef: platformServer(),
906
+ });
907
+ return appRef.injector;
908
+ }
909
+
910
+ /**
911
+ * The h3 request/response layer for the server-function dispatch route.
912
+ *
913
+ * `createServerFnAppInjector` bootstraps the parent injector once; this wraps
914
+ * that in the `/_analog/fn/:id` handler the Nitro build registers. Kept as a
915
+ * runtime function (rather than inlined into the generated module) so the
916
+ * transport behaviour — body decoding, the malformed-body contract, and header
917
+ * propagation — is unit-tested directly instead of by matching generated source.
918
+ *
919
+ * `appInjector` may be a promise: the generated module bootstraps the app at
920
+ * import time and passes the pending injector, which is awaited on first request
921
+ * and resolved instantly thereafter.
922
+ */
923
+ function createServerFnEventHandler(appInjector) {
924
+ return eventHandler((event) => handleServerFnRequest(event, appInjector));
925
+ }
926
+ /**
927
+ * Decode a server-function request, dispatch it, and write the result to the
928
+ * h3 response. Same-origin, method, content-type, validation, and interceptors
929
+ * are enforced inside `dispatchServerFn`; this owns only the h3 I/O around it.
930
+ */
931
+ async function handleServerFnRequest(event, appInjector) {
932
+ const id = getRouterParam(event, 'id') ?? '';
933
+ // h3 parses the body before dispatch gets a say, and its parse error is an
934
+ // HTML/500-shaped response rather than the JSON contract callers expect.
935
+ let input;
936
+ if (event.method !== 'GET') {
937
+ try {
938
+ input = await readBody(event);
939
+ }
940
+ catch {
941
+ event.node.res.statusCode = 400;
942
+ return { message: 'Malformed request body' };
943
+ }
944
+ }
945
+ const { status, body, headers } = await dispatchServerFn(id, input, event, {
946
+ parent: await appInjector,
947
+ method: event.method,
948
+ });
949
+ event.node.res.statusCode = status;
950
+ if (headers) {
951
+ for (const [key, value] of Object.entries(headers)) {
952
+ event.node.res.setHeader(key, value);
953
+ }
954
+ }
955
+ return body;
956
+ }
957
+
265
958
  /**
266
959
  * Generated bundle index. Do not edit.
267
960
  */
268
961
 
269
- export { injectStaticOutputs, injectStaticProps, provideServerContext, render, renderServerComponent, serverComponentRequest };
962
+ export { SERVER_FN_ALLOWED_ORIGINS, SERVER_FN_INTERCEPTORS, createServerFnAppInjector, createServerFnEventHandler, dispatchServerFn, handleServerFnRequest, isServerFnOriginAllowed, provideServerContext, provideServerFns, render, renderStream, runInterceptors, serverFn, serverFnRegistry, withAllowedOrigins, withServerFnInterceptors };
270
963
  //# sourceMappingURL=analogjs-router-server.mjs.map