Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .changeset/honest-impalas-read.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
'astro': minor
---

Adds `context.styles`, `context.scripts`, and `context.links` to the middleware and page context, exposing the route's assets:

- `context.styles` — inline CSS strings
- `context.links` — external stylesheet URLs
- `context.scripts` — external script URLs

```ts
// src/middleware.ts
export const onRequest = async (context, next) => {
const response = await next();
for (const href of context.links) {
response.headers.append('Link', `<${href}>; rel=preload; as=style`);
}
return response;
};
```
3 changes: 3 additions & 0 deletions packages/astro/src/actions/runtime/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,9 @@ export type ActionAPIContext = Pick<
| 'cache'
| 'csp'
| 'logger'
| 'styles'
| 'scripts'
| 'links'
>;

export type MaybePromise<T> = T | Promise<T>;
Expand Down
37 changes: 36 additions & 1 deletion packages/astro/src/core/fetch/fetch-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
computePreferredLocaleList as computePreferredLocaleListUtil,
} from '../../i18n/utils.js';

import type { HeadElements } from '../base-pipeline.js';
import { getParams, getProps } from '../render/index.js';
import { Rewrites } from '../rewrites/handler.js';
import { isRoute404or500, isRouteServerIsland } from '../routing/match.js';
Expand Down Expand Up @@ -221,6 +222,8 @@ export class FetchState implements AstroFetchState {
actionApiContext: ActionAPIContext | null = null;
/** Memoized `APIContext` (see `getAPIContext`). */
apiContext: APIContext | null = null;
/** Resolved head elements from the pipeline (styles, scripts, links). */
#headElements: HeadElements | null = null;

/** Registered context providers keyed by name. Lazy-initialized on first provide(). */
#providers: Map<string, ContextProvider<unknown>> | undefined;
Expand Down Expand Up @@ -302,7 +305,7 @@ export class FetchState implements AstroFetchState {
const { clientDirectives, inlinedScripts, compressHTML, manifest, renderers, resolve } =
pipeline;
const routeData = this.routeData!;
const { links, scripts, styles } = await pipeline.headElements(routeData);
const { links, scripts, styles } = this.#headElements ?? await pipeline.headElements(routeData);

const extraStyleHashes: string[] = [];
const extraScriptHashes: string[] = [];
Expand Down Expand Up @@ -866,6 +869,17 @@ export class FetchState implements AstroFetchState {
return this.props;
}

/**
* Resolves and caches the head elements (styles, scripts, links) for
* the current route so they are available synchronously in
* `getActionAPIContext()`.
*/
async resolveHeadElements(): Promise<HeadElements> {
if (this.#headElements !== null) return this.#headElements;
this.#headElements = await this.pipeline.headElements(this.routeData!);
return this.#headElements;
}

/**
* Returns the `ActionAPIContext` for this render, creating it lazily.
* Used by middleware, actions, and page dispatch.
Expand All @@ -875,12 +889,33 @@ export class FetchState implements AstroFetchState {

const state = this;

const styles: string[] = [];
const links: string[] = [];
const scripts: string[] = [];
if (this.#headElements) {
for (const el of this.#headElements.styles) {
if (el.props.href) {
links.push(el.props.href);
} else if (el.children) {
styles.push(el.children);
}
}
for (const el of this.#headElements.scripts) {
if (el.props.src) {
scripts.push(el.props.src);
}
}
}

const ctx = {
get cookies() {
return state.cookies;
},
routePattern: this.routeData!.route,
isPrerendered: this.routeData!.prerender,
styles,
scripts,
links,
get clientAddress() {
return state.getClientAddress();
},
Expand Down
6 changes: 4 additions & 2 deletions packages/astro/src/core/middleware/astro-middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,11 @@ export class AstroMiddleware {
state.pipeline.usedFeatures |= PipelineFeatures.middleware;
const pipeline = this.#pipeline;

// Resolve props first (the async bit) so downstream consumers can
// call `state.getAPIContext()` synchronously on the hot path.
// Resolve props and head elements first (the async bits) so
// downstream consumers can call `state.getAPIContext()` synchronously
// on the hot path.
await state.getProps();
await state.resolveHeadElements();
const apiContext = state.getAPIContext();

state.counter++;
Expand Down
3 changes: 3 additions & 0 deletions packages/astro/src/core/middleware/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,9 @@ function createContext({
});
},
isPrerendered: false,
styles: [],
scripts: [],
links: [],
get preferredLocale(): string | undefined {
return (preferredLocale ??= computePreferredLocale(request, userDefinedLocales));
},
Expand Down
1 change: 1 addition & 0 deletions packages/astro/src/core/render/ssr-element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,3 +118,4 @@ function createModuleScriptElementWithSrc(
children: '',
};
}

9 changes: 9 additions & 0 deletions packages/astro/src/runtime/server/astro-global.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,5 +101,14 @@ export function createAstro(site: string | undefined): AstroGlobal {
get logger(): any {
throw createError('logger');
},
get styles(): any {
throw createError('styles');
},
get scripts(): any {
throw createError('scripts');
},
get links(): any {
throw createError('links');
},
};
}
29 changes: 29 additions & 0 deletions packages/astro/src/types/public/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -608,4 +608,33 @@ export interface APIContext<
* [Astro reference](https://docs.astro.build/en/reference/routing-reference/#routepattern)
*/
routePattern: string;

/**
* The inline CSS strings associated with the current route.
*/
styles: string[];

/**
* The external script URLs associated with the current route.
*/
scripts: string[];

/**
* The external stylesheet URLs associated with the current route.
* Useful for adding `Link: rel=preload` response headers in middleware.
*
* ## Example
*
* ```ts
* // src/middleware.ts
* export const onRequest = async (context, next) => {
* const response = await next();
* for (const href of context.links) {
* response.headers.append('Link', `<${href}>; rel=preload; as=style`);
* }
* return response;
* };
* ```
*/
links: string[];
}
3 changes: 3 additions & 0 deletions packages/astro/test/units/mocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,9 @@ export function createMockAPIContext(overrides: MockAPIContextOverrides = {}): A
props: overrides.props ?? {},
routePattern: overrides.routePattern ?? '',
isPrerendered,
styles: overrides.styles ?? [],
scripts: overrides.scripts ?? [],
links: overrides.links ?? [],
site: overrides.site,
generator: overrides.generator ?? 'astro-test',
clientAddress: overrides.clientAddress ?? '127.0.0.1',
Expand Down
115 changes: 115 additions & 0 deletions packages/astro/test/units/render/route-asset-urls.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';

/**
* Tests the SSRElement → string extraction logic used by FetchState.getActionAPIContext().
* The extraction reads `el.props.href` for stylesheet links, `el.children` for
* inline styles, and `el.props.src` for external scripts.
*/

function extractAssets(headElements: {
styles: Set<{ props: Record<string, any>; children: string }>;
scripts: Set<{ props: Record<string, any>; children: string }>;
links: Set<{ props: Record<string, any>; children: string }>;
} | undefined) {
const styles: string[] = [];
const links: string[] = [];
const scripts: string[] = [];
if (headElements) {
for (const el of headElements.styles) {
if (el.props.href) {
links.push(el.props.href);
} else if (el.children) {
styles.push(el.children);
}
}
for (const el of headElements.scripts) {
if (el.props.src) {
scripts.push(el.props.src);
}
}
}
return { styles, scripts, links };
}

describe('extractAssets from headElements', () => {
it('returns empty arrays when headElements is undefined', () => {
const result = extractAssets(undefined);
assert.deepEqual(result, { styles: [], scripts: [], links: [] });
});

it('returns empty arrays when headElements has empty sets', () => {
const result = extractAssets({ styles: new Set(), scripts: new Set(), links: new Set() });
assert.deepEqual(result, { styles: [], scripts: [], links: [] });
});

it('extracts external stylesheet URLs into links', () => {
const styles = new Set([
{ props: { rel: 'stylesheet', href: '/_astro/index.abc12.css' }, children: '' },
{ props: { rel: 'stylesheet', href: '/_astro/global.def34.css' }, children: '' },
]);
const result = extractAssets({ styles, scripts: new Set(), links: new Set() });
assert.deepEqual(result.links, ['/_astro/index.abc12.css', '/_astro/global.def34.css']);
assert.deepEqual(result.styles, []);
});

it('extracts inline CSS into styles', () => {
const styles = new Set([
{ props: {}, children: 'body { color: red }' },
{ props: {}, children: '.header { font-size: 2rem }' },
]);
const result = extractAssets({ styles, scripts: new Set(), links: new Set() });
assert.deepEqual(result.styles, ['body { color: red }', '.header { font-size: 2rem }']);
assert.deepEqual(result.links, []);
});

it('splits mixed inline and external styles correctly', () => {
const styles = new Set([
{ props: {}, children: 'body { color: red }' },
{ props: { rel: 'stylesheet', href: '/_astro/style.css' }, children: '' },
]);
const result = extractAssets({ styles, scripts: new Set(), links: new Set() });
assert.deepEqual(result.styles, ['body { color: red }']);
assert.deepEqual(result.links, ['/_astro/style.css']);
});

it('extracts external script URLs', () => {
const scripts = new Set([
{ props: { type: 'module', src: '/_astro/app.abc12.js' }, children: '' },
]);
const result = extractAssets({ styles: new Set(), scripts, links: new Set() });
assert.deepEqual(result.scripts, ['/_astro/app.abc12.js']);
});

it('skips inline scripts', () => {
const scripts = new Set([
{ props: { type: 'module' }, children: 'console.log("hi")' },
{ props: { type: 'module', src: '/_astro/app.js' }, children: '' },
]);
const result = extractAssets({ styles: new Set(), scripts, links: new Set() });
assert.deepEqual(result.scripts, ['/_astro/app.js']);
});

it('handles multiple external scripts', () => {
const scripts = new Set([
{ props: { type: 'module', src: '/_astro/app.js' }, children: '' },
{ props: { type: 'module', src: '/_astro/vendor.js' }, children: '' },
]);
const result = extractAssets({ styles: new Set(), scripts, links: new Set() });
assert.deepEqual(result.scripts, ['/_astro/app.js', '/_astro/vendor.js']);
});

it('handles mixed styles and scripts together', () => {
const styles = new Set([
{ props: {}, children: 'body { color: red }' },
{ props: { rel: 'stylesheet', href: '/_astro/home.css' }, children: '' },
]);
const scripts = new Set([
{ props: { type: 'module', src: '/_astro/app.js' }, children: '' },
]);
const result = extractAssets({ styles, scripts, links: new Set() });
assert.deepEqual(result.styles, ['body { color: red }']);
assert.deepEqual(result.links, ['/_astro/home.css']);
assert.deepEqual(result.scripts, ['/_astro/app.js']);
});
});
Loading