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/big-games-burn.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;
};
```
2 changes: 1 addition & 1 deletion .github/workflows/preview-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ name: Preview release

on:
pull_request:
branches: [main]
branches: [main, 5-legacy]
types: [labeled]

concurrency:
Expand Down
3 changes: 3 additions & 0 deletions packages/astro/src/actions/runtime/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ export type ActionAPIContext = Pick<
| 'originPathname'
| 'session'
| 'csp'
| 'styles'
| 'scripts'
| 'links'
> & {
// TODO: remove in Astro 6.0
/**
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 @@ -86,6 +86,9 @@ function createContext({
});
},
isPrerendered: false,
styles: [],
scripts: [],
links: [],
get preferredLocale(): string | undefined {
return (preferredLocale ??= computePreferredLocale(request, userDefinedLocales));
},
Expand Down
34 changes: 31 additions & 3 deletions packages/astro/src/core/render-context.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import colors from 'piccolore';

Check notice on line 1 in packages/astro/src/core/render-context.ts

View workflow job for this annotation

GitHub Actions / Lint

assist/source/organizeImports

The imports and exports are not sorted.
import { getActionContext } from '../actions/runtime/server.js';
import { deserializeActionResult } from '../actions/runtime/shared.js';
import type { ActionAPIContext } from '../actions/runtime/utils.js';
Expand Down Expand Up @@ -33,6 +33,7 @@
import { callMiddleware } from './middleware/callMiddleware.js';
import { sequence } from './middleware/index.js';
import { renderRedirect } from './redirects/render.js';
import type { HeadElements } from './base-pipeline.js';
import { getParams, getProps, type Pipeline, Slots } from './render/index.js';
import { isRoute404or500, isRouteExternalRedirect, isRouteServerIsland } from './routing/match.js';
import { copyRequest, getOriginPathname, setOriginPathname } from './routing/rewrite.js';
Expand Down Expand Up @@ -67,6 +68,8 @@
: undefined,
) {}

headElements?: HeadElements;

static #createNormalizedUrl(requestUrl: string): URL {
const url = new URL(requestUrl);
try {
Expand Down Expand Up @@ -178,7 +181,8 @@
serverLike,
base: manifest.base,
});
const actionApiContext = this.createActionAPIContext();
this.headElements = await pipeline.headElements(this.routeData);
const actionApiContext = this.createActionAPIContext(this.headElements);
const apiContext = this.createAPIContext(props, actionApiContext);

this.counter++;
Expand Down Expand Up @@ -397,7 +401,7 @@
return await this.render(componentInstance);
}

createActionAPIContext(): ActionAPIContext {
createActionAPIContext(headElements?: HeadElements): ActionAPIContext {
const renderContext = this;
const { params, pipeline, url } = this;
const generator = `Astro v${ASTRO_VERSION}`;
Expand All @@ -406,13 +410,34 @@
return await this.#executeRewrite(reroutePayload);
};

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 {
// Don't allow reassignment of cookies because it doesn't work
get cookies() {
return renderContext.cookies;
},
routePattern: this.routeData.route,
isPrerendered: this.routeData.prerender,
styles,
scripts,
links,
get clientAddress() {
return renderContext.getClientAddress();
},
Expand Down Expand Up @@ -507,7 +532,7 @@
const { cookies, pathname, pipeline, routeData, status } = this;
const { clientDirectives, inlinedScripts, compressHTML, manifest, renderers, resolve } =
pipeline;
const { links, scripts, styles } = await pipeline.headElements(routeData);
const { links, scripts, styles } = this.headElements ?? await pipeline.headElements(routeData);

const extraStyleHashes = [];
const extraScriptHashes = [];
Expand Down Expand Up @@ -689,6 +714,9 @@
glob: astroStaticPartial.glob,
routePattern: this.routeData.route,
isPrerendered: this.routeData.prerender,
styles: apiContext.styles,
scripts: apiContext.scripts,
links: apiContext.links,
cookies,
get session() {
if (this.isPrerendered) {
Expand Down
2 changes: 2 additions & 0 deletions packages/astro/src/core/render/ssr-element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,3 +89,5 @@ function createModuleScriptElementWithSrc(
children: '',
};
}


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 @@ -616,6 +616,35 @@ export interface AstroSharedContext<
* [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[];
}

/**
Expand Down
111 changes: 111 additions & 0 deletions packages/astro/test/units/render/route-assets.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';

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

function extractAssets(headElements) {
const styles = [];
const links = [];
const scripts = [];
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