Skip to content

Commit

Permalink
feat(netlify): add support for image.remotePatterns and images.domain…
Browse files Browse the repository at this point in the history
…s with Netlify Image CDN (#187)

* feat(netlify): support for image.remotePatterns and images.domains

* chore: add sample remote image

* chore: lint

* chore: add changeset

* chore: better comments

* chore: format

* fix: handle dots in domains

* Update .changeset/nine-lamps-cry.md

Co-authored-by: Alexander Niebuhr <alexander@nbhr.io>

* fix: warn if invalid regex is generated

---------

Co-authored-by: Alexander Niebuhr <alexander@nbhr.io>
  • Loading branch information
ascorbic and alexanderniebuhr authored Mar 19, 2024
1 parent 739cf5d commit 79ebfa4
Show file tree
Hide file tree
Showing 7 changed files with 224 additions and 9 deletions.
5 changes: 5 additions & 0 deletions .changeset/nine-lamps-cry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@astrojs/netlify': minor
---

Adds support for `image.remotePatterns` and `images.domains` with Netlify Image CDN
113 changes: 105 additions & 8 deletions packages/netlify/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import type { IncomingMessage } from 'node:http';
import { fileURLToPath } from 'node:url';
import { createRedirectsFromAstroRoutes } from '@astrojs/underscore-redirects';
import type { Context } from '@netlify/functions';
import type { AstroConfig, AstroIntegration, RouteData } from 'astro';
import type { AstroConfig, AstroIntegration, AstroIntegrationLogger, RouteData } from 'astro';
import { AstroError } from 'astro/errors';
import { build } from 'esbuild';
import type { Args } from './ssr-function.js';
Expand All @@ -24,6 +24,106 @@ const isStaticRedirect = (route: RouteData) =>

const clearDirectory = (dir: URL) => rm(dir, { recursive: true }).catch(() => {});

type RemotePattern = AstroConfig['image']['remotePatterns'][number];

/**
* Convert a remote pattern object to a regex string
*/
export function remotePatternToRegex(
pattern: RemotePattern,
logger: AstroIntegrationLogger
): string | undefined {
let { protocol, hostname, port, pathname } = pattern;

let regexStr = '';

if (protocol) {
regexStr += `${protocol}://`;
} else {
// Default to matching any protocol
regexStr += '[a-z]+://';
}

if (hostname) {
if (hostname.startsWith('**.')) {
// match any number of subdomains
regexStr += '([a-z0-9]+\\.)*';
hostname = hostname.substring(3);
} else if (hostname.startsWith('*.')) {
// match one subdomain
regexStr += '([a-z0-9]+\\.)?';
hostname = hostname.substring(2); // Remove '*.' from the beginning
}
// Escape dots in the hostname
regexStr += hostname.replace(/\./g, '\\.');
} else {
regexStr += '[a-z0-9.-]+';
}

if (port) {
regexStr += `:${port}`;
} else {
// Default to matching any port
regexStr += '(:[0-9]+)?';
}

if (pathname) {
if (pathname.endsWith('/**')) {
// Match any path.
regexStr += `(\\${pathname.replace('/**', '')}.*)`;
}
if (pathname.endsWith('/*')) {
// Match one level of path
regexStr += `(\\${pathname.replace('/*', '')}\/[^/?#]+)\/?`;
} else {
// Exact match
regexStr += `(\\${pathname})`;
}
} else {
// Default to matching any path
regexStr += '(\\/[^?#]*)?';
}
if (!regexStr.endsWith('.*)')) {
// Match query, but only if it's not already matched by the pathname
regexStr += '([?][^#]*)?';
}
try {
new RegExp(regexStr);
} catch (e) {
logger.warn(
`Could not generate a valid regex from the remotePattern "${JSON.stringify(
pattern
)}". Please check the syntax.`
);
return undefined;
}
return regexStr;
}

async function writeNetlifyDeployConfig(config: AstroConfig, logger: AstroIntegrationLogger) {
const remoteImages: Array<string> = [];
// Domains get a simple regex match
remoteImages.push(
...config.image.domains.map((domain) => `https?:\/\/${domain.replaceAll('.', '\\.')}\/.*`)
);
// Remote patterns need to be converted to regexes
remoteImages.push(
...config.image.remotePatterns
.map((pattern) => remotePatternToRegex(pattern, logger))
.filter(Boolean as unknown as (pattern?: string) => pattern is string)
);

// See https://docs.netlify.com/image-cdn/create-integration/
const deployConfigDir = new URL('.netlify/deploy/v1/', config.root);
await mkdir(deployConfigDir, { recursive: true });
await writeFile(
new URL('./config.json', deployConfigDir),
JSON.stringify({
images: { remote_images: remoteImages },
})
);
}

export interface NetlifyIntegrationConfig {
/**
* If enabled, On-Demand-Rendered pages are cached for up to a year.
Expand Down Expand Up @@ -127,7 +227,7 @@ export default function netlifyIntegration(
await mkdir(middlewareOutputDir(), { recursive: true });
await writeFile(
new URL('./entry.mjs', middlewareOutputDir()),
`
/* ts */ `
import { onRequest } from "${fileURLToPath(entrypoint).replaceAll('\\', '/')}";
import { createContext, trySerializeLocals } from 'astro/middleware';
Expand Down Expand Up @@ -266,15 +366,12 @@ export default function netlifyIntegration(
},
});
},
'astro:config:done': ({ config, setAdapter }) => {
'astro:config:done': async ({ config, setAdapter, logger }) => {
rootDir = config.root;
_config = config;

if (config.image.domains.length || config.image.remotePatterns.length) {
throw new AstroError(
"config.image.domains and config.image.remotePatterns aren't supported by the Netlify adapter.",
'See https://github.com/withastro/adapters/tree/main/packages/netlify#image-cdn for more.'
);
if (config.image?.domains?.length || config.image?.remotePatterns?.length) {
await writeNetlifyDeployConfig(config, logger);
}

const edgeMiddleware = integrationConfig?.edgeMiddleware ?? false;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,13 @@ export default defineConfig({
edgeMiddleware: process.env.EDGE_MIDDLEWARE === 'true',
imageCDN: process.env.DISABLE_IMAGE_CDN ? false : undefined,
}),
image: {
remotePatterns: [{
protocol: 'https',
hostname: '*.example.org',
pathname: '/images/*',
}],
domains: ['example.net', 'secret.example.edu'],
},
site: `http://example.com`,
});
88 changes: 87 additions & 1 deletion packages/netlify/test/functions/image-cdn.test.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import * as assert from 'node:assert/strict';
import { after, describe, it } from 'node:test';
import { after, before, describe, it } from 'node:test';
import { remotePatternToRegex } from '@astrojs/netlify';
import { loadFixture } from '@astrojs/test-utils';

describe('Image CDN', () => {
Expand Down Expand Up @@ -40,4 +41,89 @@ describe('Image CDN', () => {
assert.equal(astronautPage.includes(`src="/_astro/astronaut.`), true);
});
});

describe('remote image config', () => {
let regexes;

before(async () => {
const fixture = await loadFixture({ root });
await fixture.build();

const config = await fixture.readFile('../.netlify/deploy/v1/config.json');
if (config) {
regexes = JSON.parse(config).images.remote_images.map((pattern) => new RegExp(pattern));
}
});

it('generates remote image config patterns', async () => {
assert.equal(regexes?.length, 3);
});

it('generates correct config for domains', async () => {
const domain = regexes[0];
assert.equal(domain.test('https://example.net/image.jpg'), true);
assert.equal(
domain.test('https://www.example.net/image.jpg'),
false,
'subdomain should not match'
);
assert.equal(domain.test('http://example.net/image.jpg'), true, 'http should match');
assert.equal(
domain.test('https://example.net/subdomain/image.jpg'),
true,
'subpath should match'
);
const subdomain = regexes[1];
assert.equal(
subdomain.test('https://secret.example.edu/image.jpg'),
true,
'should match subdomains'
);
assert.equal(
subdomain.test('https://secretxexample.edu/image.jpg'),
false,
'should not use dots in domains as wildcards'
);
});

it('generates correct config for remotePatterns', async () => {
const patterns = regexes[2];
assert.equal(patterns.test('https://example.org/images/1.jpg'), true, 'should match domain');
assert.equal(
patterns.test('https://www.example.org/images/2.jpg'),
true,
'www subdomain should match'
);
assert.equal(
patterns.test('https://www.subdomain.example.org/images/2.jpg'),
false,
'second level subdomain should not match'
);
assert.equal(
patterns.test('https://example.org/not-images/2.jpg'),
false,
'wrong path should not match'
);
});

it('warns when remotepatterns generates an invalid regex', async (t) => {
const logger = {
warn: t.mock.fn(),
};
const regex = remotePatternToRegex(
{
hostname: '*.examp[le.org',
pathname: '/images/*',
},
logger
);
assert.strictEqual(regex, undefined);
const calls = logger.warn.mock.calls;
assert.strictEqual(calls.length, 1);
assert.equal(
calls[0].arguments[0],
'Could not generate a valid regex from the remotePattern "{"hostname":"*.examp[le.org","pathname":"/images/*"}". Please check the syntax.'
);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,13 @@ import { defineConfig } from 'astro/config';
export default defineConfig({
output: 'server',
adapter: netlify(),
image: {
remotePatterns: [
{
protocol: 'https',
hostname: 'images.unsplash.com',
pathname: '/photo-1567674867291-b2595ac53ab4',
},
],
},
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
[build]
command = "pnpm run --filter @test/netlify-hosted-astro-project... build"
publish = "/packages/netlify/test/hosted/hosted-astro-project/dist"
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,10 @@ import penguin from '../assets/penguin.png';
---

<Image src={penguin} width={300} alt="" />

<Image
src="https://images.unsplash.com/photo-1567674867291-b2595ac53ab4"
width={300}
height={400}
alt="Astro"
/>

0 comments on commit 79ebfa4

Please sign in to comment.