Skip to content
Merged
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
91 changes: 91 additions & 0 deletions src/lib/headers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { describe, it, expect, vi } from 'vitest';
import { applyHeaderRules } from './headers';

// Mock the raw import for testing
vi.mock('../../public/_headers?raw', () => ({
default: `# Headers configuration file for Cloudflare Pages
/*
Referrer-Policy: no-referrer-when-downgrade
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
X-Content-Type-Options: nosniff
X-Frame-Options: SAMEORIGIN
X-XSS-Protection: 1; mode=block

/_astro/*
Cache-Control: public, max-age=31536000, immutable

/fonts/*
Cache-Control: public, max-age=31536000, immutable

/movies/:title
x-movie-name: You are watching ":title"
`,
}));

describe('Header parsing and application', () => {
it('should apply global headers to all pages', () => {
const mockResponse = new Response('test', {
headers: new Headers(),
});

applyHeaderRules('https://example.com/some-page', mockResponse);

expect(mockResponse.headers.get('Referrer-Policy')).toBe(
'no-referrer-when-downgrade',
);
expect(mockResponse.headers.get('X-Content-Type-Options')).toBe('nosniff');
expect(mockResponse.headers.get('X-Frame-Options')).toBe('SAMEORIGIN');
});

it('should apply cache headers to astro assets', () => {
const mockResponse = new Response('test', {
headers: new Headers(),
});

applyHeaderRules('https://example.com/_astro/chunk-123.js', mockResponse);

expect(mockResponse.headers.get('Cache-Control')).toBe(
'public, max-age=31536000, immutable',
);
});

it('should apply cache headers to font assets', () => {
const mockResponse = new Response('test', {
headers: new Headers(),
});

applyHeaderRules('https://example.com/fonts/arial.woff2', mockResponse);

expect(mockResponse.headers.get('Cache-Control')).toBe(
'public, max-age=31536000, immutable',
);
});

it('should support placeholder substitution', () => {
const mockResponse = new Response('test', {
headers: new Headers(),
});

applyHeaderRules('https://example.com/movies/inception', mockResponse);

expect(mockResponse.headers.get('x-movie-name')).toBe(
'You are watching "inception"',
);
});

it('should combine headers from multiple matching rules', () => {
const mockResponse = new Response('test', {
headers: new Headers(),
});

applyHeaderRules('https://example.com/_astro/styles.css', mockResponse);

// Should have both global headers and cache headers
expect(mockResponse.headers.get('Referrer-Policy')).toBe(
'no-referrer-when-downgrade',
);
expect(mockResponse.headers.get('Cache-Control')).toBe(
'public, max-age=31536000, immutable',
);
});
});
194 changes: 194 additions & 0 deletions src/lib/headers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
import content from '../../public/_headers?raw';

interface HeaderRule {
pattern: string;
headers: Record<string, string>;
detachedHeaders: string[];
}

let cachedRules: HeaderRule[] | null = null;

/**
* Parse the _headers file and return an array of header rules
*/
function parseHeadersFile(): HeaderRule[] {
if (cachedRules) {
return cachedRules;
}

try {
const lines = content.split('\n');

const rules: HeaderRule[] = [];
let currentRule: HeaderRule | null = null;

for (const line of lines) {
// Remove comments and trim whitespace
const cleanLine = line.split('#')[0].trim();
if (!cleanLine) continue;

// Check if this is a URL pattern (not indented)
if (!line.startsWith(' ') && !line.startsWith('\t')) {
// Save previous rule if exists
if (currentRule) {
rules.push(currentRule);
}

// Start new rule
currentRule = {
pattern: cleanLine,
headers: {},
detachedHeaders: [],
};
} else if (currentRule && cleanLine.includes(':')) {
// This is a header line (indented)
const colonIndex = cleanLine.indexOf(':');
let headerName = cleanLine.substring(0, colonIndex).trim();
const headerValue = cleanLine.substring(colonIndex + 1).trim();

// Check if this is a detached header (starts with !)
if (headerName.startsWith('!')) {
headerName = headerName.substring(1).trim();
currentRule.detachedHeaders.push(headerName);
} else {
currentRule.headers[headerName] = headerValue;
}
}
}

// Don't forget the last rule
if (currentRule) {
rules.push(currentRule);
}

cachedRules = rules;
return rules;
} catch (error) {
console.warn('Could not parse _headers file:', error);
return [];
}
}

/**
* Check if a URL matches a pattern from the _headers file
* Supports splats (*) and placeholders (:name)
*/
function matchesPattern(
url: string,
pattern: string,
): { matches: boolean; captures: Record<string, string> } {
const captures: Record<string, string> = {};

// Handle absolute URLs in patterns
if (pattern.startsWith('https://')) {
const patternUrl = new URL(pattern);
const requestUrl = new URL(url, 'https://example.com');

// Check if hostname matches (with placeholder support)
const hostnameMatches = matchSegment(
requestUrl.hostname,
patternUrl.hostname,
captures,
);
if (!hostnameMatches) {
return { matches: false, captures: {} };
}

// Check if pathname matches
const pathnameMatches = matchSegment(
requestUrl.pathname,
patternUrl.pathname,
captures,
);
return { matches: pathnameMatches, captures };
}

// For relative patterns, just match the pathname
const pathname = url.startsWith('/')
? url
: new URL(url, 'https://example.com').pathname;
const matches = matchSegment(pathname, pattern, captures);

return { matches, captures };
}

/**
* Match a single segment (hostname or pathname) against a pattern
*/
function matchSegment(
segment: string,
pattern: string,
captures: Record<string, string>,
): boolean {
// Convert pattern to regex
const regexPattern = pattern
// Escape special regex characters except * and :
.replace(/[.+?^${}()|[\]\\]/g, '\\$&')
// Handle splats (*)
.replace(/\*/g, '(.*)')
// Handle placeholders (:name)
.replace(/:([a-zA-Z]\w*)/g, '([^/.]+)');

const regex = new RegExp(`^${regexPattern}$`);
const match = segment.match(regex);

if (!match) {
return false;
}

// Extract captures for splats and placeholders
let captureIndex = 1;

// Handle splats
const splatMatches = pattern.match(/\*/g);
if (splatMatches) {
captures.splat = match[captureIndex++] || '';
}

// Handle placeholders
const placeholderMatches = pattern.match(/:([a-zA-Z]\w*)/g);
if (placeholderMatches) {
for (const placeholder of placeholderMatches) {
const name = placeholder.substring(1); // Remove the :
captures[name] = match[captureIndex++] || '';
}
}

return true;
}

/**
* Apply header rules to a response based on the request URL
*/
export function applyHeaderRules(url: string, response: Response): void {
const rules = parseHeadersFile();

for (const rule of rules) {
const { matches, captures } = matchesPattern(url, rule.pattern);

if (matches) {
// Apply headers with placeholder substitution
for (const [name, value] of Object.entries(rule.headers)) {
let finalValue = value;

// Replace placeholders in header values
for (const [captureName, captureValue] of Object.entries(captures)) {
finalValue = finalValue.replace(`:${captureName}`, captureValue);
}

// If header already exists, combine values with comma
const existingValue = response.headers.get(name);
if (existingValue) {
response.headers.set(name, `${existingValue}, ${finalValue}`);
} else {
response.headers.set(name, finalValue);
}
}

// Remove detached headers
for (const headerName of rule.detachedHeaders) {
response.headers.delete(headerName);
}
}
}
}
15 changes: 15 additions & 0 deletions src/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
DATOCMS_READONLY_API_TOKEN,
HEAD_START_PREVIEW,
} from 'astro:env/client';
import { applyHeaderRules } from './lib/headers';

export const previewCookieName = 'HEAD_START_PREVIEW';

Expand All @@ -18,6 +19,19 @@ export const hashSecret = async (secret: string) => {
return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');
};

/**
* Headers middleware: Apply headers from _headers file to dynamic responses
* These headers are not automatically applied by Cloudflare Pages to dynamic responses
*/
const headers = defineMiddleware(async ({ request }, next) => {
const response = await next();

// Apply all header rules from the _headers file
applyHeaderRules(request.url, response);

return response;
});

export const datocms = defineMiddleware(async ({ locals }, next) => {
Object.assign(locals, {
datocmsEnvironment,
Expand Down Expand Up @@ -96,6 +110,7 @@ const redirects = defineMiddleware(async ({ request, redirect }, next) => {
});

export const onRequest = sequence(
headers,
datocms,
i18n,
preview,
Expand Down
Loading