Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow middleware to override the filename or media type of Response #13024

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
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
5 changes: 5 additions & 0 deletions .changeset/purple-spies-give.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'astro': minor
---

Add support for Content-Type and Content-Disposition headers in SSG
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ package-lock.json
.turbo/
.eslintcache
.pnpm-store
.envrc
devbox.json
devbox.lock

# do not commit .env files or any files that end with `.env`
*.env
Expand Down
1 change: 1 addition & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
{
"biome.enabled": true,
"[json]": {
"editor.defaultFormatter": "biomejs.biome"
},
Expand Down
4 changes: 4 additions & 0 deletions packages/astro/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,8 @@
"ci-info": "^4.1.0",
"clsx": "^2.1.1",
"common-ancestor-path": "^1.0.1",
"content-disposition": "^0.5.4",
"content-type": "^1.0.5",
"cookie": "^0.7.2",
"cssesc": "^3.0.0",
"debug": "^4.4.0",
Expand Down Expand Up @@ -186,6 +188,8 @@
"@playwright/test": "^1.49.1",
"@types/aria-query": "^5.0.4",
"@types/common-ancestor-path": "^1.0.2",
"@types/content-disposition": "^0.5.8",
"@types/content-type": "^1.1.8",
"@types/cssesc": "^3.0.2",
"@types/debug": "^4.1.12",
"@types/diff": "^5.2.3",
Expand Down
23 changes: 23 additions & 0 deletions packages/astro/src/core/build/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { appendForwardSlash } from '../../core/path.js';
import type { AstroSettings } from '../../types/astro.js';
import type { AstroConfig } from '../../types/public/config.js';
import type { RouteData } from '../../types/public/internal.js';
import type { FileDescriptor } from './util.js';

const STATUS_CODE_PAGES = new Set(['/404', '/500']);
const FALLBACK_OUT_DIR_NAME = './.astro/';
Expand All @@ -20,6 +21,7 @@ export function getOutFolder(
astroSettings: AstroSettings,
pathname: string,
routeData: RouteData,
fileDescriptor?: FileDescriptor,
): URL {
const outRoot = getOutRoot(astroSettings);
const routeType = routeData.type;
Expand All @@ -31,6 +33,15 @@ export function getOutFolder(
case 'fallback':
case 'page':
case 'redirect':
if (fileDescriptor?.isHtml === false) {
if (pathname === '' || routeData.isIndex) {
throw new Error(`Root must be html`);
}
const dirname = npath.dirname(pathname);
const result = new URL('.' + appendForwardSlash(dirname), outRoot);

return result;
}
switch (astroSettings.config.build.format) {
case 'directory': {
if (STATUS_CODE_PAGES.has(pathname)) {
Expand Down Expand Up @@ -62,6 +73,7 @@ export function getOutFile(
outFolder: URL,
pathname: string,
routeData: RouteData,
fileDescriptor: FileDescriptor | null = null,
): URL {
const routeType = routeData.type;
switch (routeType) {
Expand All @@ -70,6 +82,17 @@ export function getOutFile(
case 'page':
case 'fallback':
case 'redirect':
if (fileDescriptor?.isHtml === false) {
let baseName = fileDescriptor.filename ?? npath.basename(pathname);
// If there is no base name this is the root route.
// If this is an index route, the name should be `index.html`.
if (!baseName || routeData.isIndex) {
throw new Error(`Root must be html`);
}
const result = new URL(`./${baseName}`, outFolder);

return result;
}
switch (astroConfig.build.format) {
case 'directory': {
if (STATUS_CODE_PAGES.has(pathname)) {
Expand Down
56 changes: 56 additions & 0 deletions packages/astro/src/core/build/contentHeaders.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import contentTypeLib from 'content-type';
import contentDispositionLib from 'content-disposition';

export class ContentHeaders {
#contentType: contentTypeLib.ParsedMediaType | null;
#contentDisposition: contentDispositionLib.ContentDisposition | null;

constructor(headers: Headers) {
this.#contentType = ContentHeaders.#parseContentType(headers);
this.#contentDisposition = ContentHeaders.#parseContentDisposition(headers);
}

get charset(): string | undefined {
return this.#contentType?.parameters.charset;
}

get mediaType(): string | undefined {
return this.#contentType?.type;
}

get filename(): string | undefined {
return this.#contentDisposition?.parameters.filename;
}

static #parseContentType(headers: Headers): contentTypeLib.ParsedMediaType | null {
const header = headers.get('Content-Type');
if (header == null) {
return null;
}

try {
return contentTypeLib.parse(header);
} catch (err) {
console.error(`Had trouble parsing Content-Type header = "${header}"`, err);
}

return null;
}

static #parseContentDisposition(
headers: Headers,
): contentDispositionLib.ContentDisposition | null {
const header = headers.get('Content-Disposition');
if (header == null) {
return null;
}

try {
return contentDispositionLib.parse(header);
} catch (err) {
console.error(`Had trouble parsing Content-Disposition header = "${header}"`, err);
}

return null;
}
}
10 changes: 6 additions & 4 deletions packages/astro/src/core/build/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ import type {
StaticBuildOptions,
StylesheetAsset,
} from './types.js';
import { getTimeStat, shouldAppendForwardSlash } from './util.js';
import { getFileDescriptorFromResponse, getTimeStat, shouldAppendForwardSlash } from './util.js';

export async function generatePages(options: StaticBuildOptions, internals: BuildInternals) {
const generatePagesTimer = performance.now();
Expand Down Expand Up @@ -561,16 +561,18 @@ async function generatePath(
// We encode the path because some paths will received encoded characters, e.g. /[page] VS /%5Bpage%5D.
// Node.js decodes the paths, so to avoid a clash between paths, do encode paths again, so we create the correct files and folders requested by the user.
const encodedPath = encodeURI(pathname);
const outFolder = getOutFolder(pipeline.settings, encodedPath, route);
const outFile = getOutFile(config, outFolder, encodedPath, route);
const fileDescriptor = getFileDescriptorFromResponse(response);
const outFolder = getOutFolder(pipeline.settings, encodedPath, route, fileDescriptor);
const outFile = getOutFile(config, outFolder, encodedPath, route, fileDescriptor);

if (route.distURL) {
route.distURL.push(outFile);
} else {
route.distURL = [outFile];
}

await fs.promises.mkdir(outFolder, { recursive: true });
await fs.promises.writeFile(outFile, body);
await fs.promises.writeFile(outFile, body, fileDescriptor.encoding);

return true;
}
Expand Down
10 changes: 8 additions & 2 deletions packages/astro/src/core/build/plugins/plugin-manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,8 +201,14 @@ function buildManifest(
if (!route.prerender) continue;
if (!route.pathname) continue;

const outFolder = getOutFolder(opts.settings, route.pathname, route);
const outFile = getOutFile(opts.settings.config, outFolder, route.pathname, route);
const outFile = route.distURL
? route.distURL
: getOutFile(
opts.settings.config,
getOutFolder(opts.settings, route.pathname, route),
route.pathname,
route,
);
const file = outFile.toString().replace(opts.settings.config.build.client.toString(), '');
routes.push({
file,
Expand Down
32 changes: 32 additions & 0 deletions packages/astro/src/core/build/util.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { Rollup } from 'vite';
import type { AstroConfig } from '../../types/public/config.js';
import type { ViteBuildReturn } from './types.js';
import { ContentHeaders } from './contentHeaders.js';

export function getTimeStat(timeStart: number, timeEnd: number) {
const buildTime = timeEnd - timeStart;
Expand Down Expand Up @@ -67,3 +68,34 @@ export function viteBuildReturnToRollupOutputs(
}
return result;
}

export type FileDescriptor = {
filename: string | undefined;
encoding: BufferEncoding | undefined;
isHtml: boolean;
};

export function getFileDescriptorFromResponse(response: Response): FileDescriptor {
const headers = new ContentHeaders(response.headers);
return {
encoding: isBufferEncoding(headers.charset) ? headers.charset : undefined,
isHtml: headers.mediaType === 'text/html',
filename: headers.filename,
};
}

function isBufferEncoding(val: string | null | undefined): val is BufferEncoding {
return (
val === 'ascii' ||
val === 'utf8' ||
val === 'utf-8' ||
val === 'utf16le' ||
val === 'ucs2' ||
val === 'ucs-2' ||
val === 'base64' ||
val === 'base64url' ||
val === 'latin1' ||
val === 'binary' ||
val === 'hex'
);
}
1 change: 1 addition & 0 deletions packages/astro/src/types/public/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -608,6 +608,7 @@ export interface ViteUserConfig extends OriginalViteUserConfig {
* }
* ```
*
* This option is ignored when an astro template resolves to a non-html file, e.g. if middleware is used to override the filename of the template by providing a Content-Disposition header.
*
*
* #### Effect on Astro.url
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { defineConfig } from 'astro/config';

export default defineConfig({
output: "static",
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"name": "@test/middleware-non-html-ssg",
"version": "0.0.0",
"private": true,
"dependencies": {
"astro": "workspace:*"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { defineMiddleware, sequence } from 'astro:middleware';
import { promises as fs } from 'node:fs';

const first = defineMiddleware(async (context, next) => {
if (context.request.url.includes('/placeholder.png')) {
const imgURL = new URL('../../non-html-pages/src/images/placeholder.png', import.meta.url);
const buffer = await fs.readFile(imgURL);
return new Response(buffer.buffer, {
headers: {
"Content-Type": "image/png",
"Content-Disposition": `inline; filename="placeholder.png"`,
},
});
} else if (context.request.url.includes('/rename-me.json')) {
const content = JSON.stringify({name: "alan"})
return new Response(content, {
headers: {
"Content-Type": "application/json",
"Content-Disposition": `inline; filename="data.json"`,
},
});
}

return next();
});

export const onRequest = sequence(first);
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { defineConfig } from 'astro/config';
import node from "@astrojs/node";

export default defineConfig({
output: "server",
adapter: node({
mode: "standalone"
})
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"name": "@test/middleware-non-html-ssr",
"version": "0.0.0",
"private": true,
"dependencies": {
"astro": "workspace:*",
"@astrojs/node": "^8.3.4"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { defineMiddleware, sequence } from 'astro:middleware';
import { promises as fs } from 'node:fs';

const first = defineMiddleware(async (context, next) => {
if (context.request.url.includes('/placeholder.png')) {
const buffer = await fs.readFile('./test/fixtures/non-html-pages/src/images/placeholder.png');
return new Response(buffer.buffer, {
headers: {
"Content-Type": "image/png",
"Content-Disposition": `inline; filename="placeholder.png"`,
},
});
} else if (context.request.url.includes('/rename-me.json')) {
const content = JSON.stringify({name: "alan"})
return new Response(content, {
headers: {
"Content-Type": "application/json",
"Content-Disposition": `inline; filename="data.json"`,
},
});
}

return next();
});

export const onRequest = sequence(
first
);
1 change: 1 addition & 0 deletions packages/astro/test/non-html-pages.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { before, describe, it } from 'node:test';
import { loadFixture } from './test-utils.js';

describe('Non-HTML Pages', () => {
/** @type {import('./test-utils').Fixture} */
let fixture;

before(async () => {
Expand Down
4 changes: 4 additions & 0 deletions packages/astro/test/test-utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,12 @@ process.env.ASTRO_TELEMETRY_DISABLED = true;
* @typedef {import('../src/core/dev/dev').DevServer} DevServer
* @typedef {import('../src/types/public/config.js').AstroInlineConfig & { root?: string | URL }} AstroInlineConfig
* @typedef {import('../src/types/public/config.js').AstroConfig} AstroConfig
* @typedef {import('../src/types/public/config.js').RouteData} RouteData
* @typedef {import('../src/types/public/config.js').RouteOptions} RouteOptions
* @typedef {import('../src/core/preview/index').PreviewServer} PreviewServer
* @typedef {import('../src/core/app/index').App} App
* @typedef {import('../src/core/app/types').SerializedSSRManifest} SerializedSSRManifest
* @typedef {import('../src/types/public/integrations').IntegrationResolvedRoute} IntegrationResolvedRoute
* @typedef {import('../src/cli/check/index').AstroChecker} AstroChecker
* @typedef {import('../src/cli/check/index').CheckPayload} CheckPayload
* @typedef {import('http').IncomingMessage} NodeRequest
Expand Down
Loading
Loading