diff --git a/.changeset/gold-trains-eat.md b/.changeset/gold-trains-eat.md new file mode 100644 index 000000000..207c819a7 --- /dev/null +++ b/.changeset/gold-trains-eat.md @@ -0,0 +1,6 @@ +--- +'@astrojs/node': patch +'astro': patch +--- + +Use a Node-specific image endpoint to resolve images in dev and Node SSR. This should fix many issues related to getting 404 from the \_image endpoint under certain configurations diff --git a/packages/astro/package.json b/packages/astro/package.json index afa6a47d7..4cdadee47 100644 --- a/packages/astro/package.json +++ b/packages/astro/package.json @@ -56,7 +56,7 @@ "./components/*": "./components/*", "./assets": "./dist/assets/index.js", "./assets/utils": "./dist/assets/utils/index.js", - "./assets/image-endpoint": "./dist/assets/image-endpoint.js", + "./assets/endpoint/*": "./dist/assets/endpoint/*.js", "./assets/services/sharp": "./dist/assets/services/sharp.js", "./assets/services/squoosh": "./dist/assets/services/squoosh.js", "./assets/services/noop": "./dist/assets/services/noop.js", diff --git a/packages/astro/src/assets/image-endpoint.ts b/packages/astro/src/assets/endpoint/generic.ts similarity index 89% rename from packages/astro/src/assets/image-endpoint.ts rename to packages/astro/src/assets/endpoint/generic.ts index b7f027536..140189fe0 100644 --- a/packages/astro/src/assets/image-endpoint.ts +++ b/packages/astro/src/assets/endpoint/generic.ts @@ -1,8 +1,8 @@ import { isRemotePath } from '@astrojs/internal-helpers/path'; import mime from 'mime/lite.js'; -import type { APIRoute } from '../@types/astro.js'; -import { getConfiguredImageService, isRemoteAllowed } from './internal.js'; -import { etag } from './utils/etag.js'; +import type { APIRoute } from '../../@types/astro.js'; +import { getConfiguredImageService, isRemoteAllowed } from '../internal.js'; +import { etag } from '../utils/etag.js'; // @ts-expect-error import { imageConfig } from 'astro:assets'; @@ -40,7 +40,6 @@ export const GET: APIRoute = async ({ request }) => { let inputBuffer: Buffer | undefined = undefined; - // TODO: handle config subpaths? const sourceUrl = isRemotePath(transform.src) ? new URL(transform.src) : new URL(transform.src, url.origin); diff --git a/packages/astro/src/assets/endpoint/node.ts b/packages/astro/src/assets/endpoint/node.ts new file mode 100644 index 000000000..1e9616264 --- /dev/null +++ b/packages/astro/src/assets/endpoint/node.ts @@ -0,0 +1,88 @@ +import { isRemotePath, removeQueryString } from '@astrojs/internal-helpers/path'; +import { readFile } from 'fs/promises'; +import mime from 'mime/lite.js'; +import type { APIRoute } from '../../@types/astro.js'; +import { getConfiguredImageService, isRemoteAllowed } from '../internal.js'; +import { etag } from '../utils/etag.js'; +// @ts-expect-error +import { assetsDir, imageConfig } from 'astro:assets'; + +async function loadLocalImage(src: string, url: URL) { + const filePath = import.meta.env.DEV + ? removeQueryString(src.slice('/@fs'.length)) + : new URL('.' + src, assetsDir); + let buffer: Buffer | undefined = undefined; + + try { + buffer = await readFile(filePath); + } catch (e) { + const sourceUrl = new URL(src, url.origin); + buffer = await loadRemoteImage(sourceUrl); + } + + return buffer; +} + +async function loadRemoteImage(src: URL) { + try { + const res = await fetch(src); + + if (!res.ok) { + return undefined; + } + + return Buffer.from(await res.arrayBuffer()); + } catch (err: unknown) { + return undefined; + } +} + +/** + * Endpoint used in dev and SSR to serve optimized images by the base image services + */ +export const GET: APIRoute = async ({ request }) => { + try { + const imageService = await getConfiguredImageService(); + + if (!('transform' in imageService)) { + throw new Error('Configured image service is not a local service'); + } + + const url = new URL(request.url); + const transform = await imageService.parseURL(url, imageConfig); + + if (!transform?.src) { + throw new Error('Incorrect transform returned by `parseURL`'); + } + + let inputBuffer: Buffer | undefined = undefined; + + if (isRemotePath(transform.src)) { + if (isRemoteAllowed(transform.src, imageConfig) === false) { + return new Response('Forbidden', { status: 403 }); + } + + inputBuffer = await loadRemoteImage(new URL(transform.src)); + } else { + inputBuffer = await loadLocalImage(transform.src, url); + } + + if (!inputBuffer) { + return new Response('Not Found', { status: 404 }); + } + + const { data, format } = await imageService.transform(inputBuffer, transform, imageConfig); + + return new Response(data, { + status: 200, + headers: { + 'Content-Type': mime.getType(format) ?? `image/${format}`, + 'Cache-Control': 'public, max-age=31536000', + ETag: etag(data.toString()), + Date: new Date().toUTCString(), + }, + }); + } catch (err: unknown) { + return new Response(`Server Error: ${err}`, { status: 500 }); + } +}; diff --git a/packages/astro/src/assets/internal.ts b/packages/astro/src/assets/internal.ts index f6c3b0b52..9cb48f588 100644 --- a/packages/astro/src/assets/internal.ts +++ b/packages/astro/src/assets/internal.ts @@ -10,10 +10,11 @@ import type { } from './types.js'; import { matchHostname, matchPattern } from './utils/remotePattern.js'; -export function injectImageEndpoint(settings: AstroSettings) { - const endpointEntrypoint = settings.config.image.endpoint ?? 'astro/assets/image-endpoint'; +export function injectImageEndpoint(settings: AstroSettings, mode: 'dev' | 'build') { + const endpointEntrypoint = + settings.config.image.endpoint ?? + (mode === 'dev' ? 'astro/assets/endpoint/node' : 'astro/assets/endpoint/generic'); - // TODO: Add a setting to disable the image endpoint settings.injectedRoutes.push({ pattern: '/_image', entryPoint: endpointEntrypoint, diff --git a/packages/astro/src/assets/vite-plugin-assets.ts b/packages/astro/src/assets/vite-plugin-assets.ts index 9c95b6dc4..fd3ca2c32 100644 --- a/packages/astro/src/assets/vite-plugin-assets.ts +++ b/packages/astro/src/assets/vite-plugin-assets.ts @@ -10,6 +10,7 @@ import { prependForwardSlash, removeQueryString, } from '../core/path.js'; +import { isServerLikeOutput } from '../prerender/utils.js'; import { VALID_INPUT_FORMATS, VIRTUAL_MODULE_ID, VIRTUAL_SERVICE_ID } from './consts.js'; import { emitESMImage } from './utils/emitAsset.js'; import { hashTransform, propsToFilename } from './utils/transformToPath.js'; @@ -58,6 +59,13 @@ export default function assets({ export { default as Image } from "astro/components/Image.astro"; export const imageConfig = ${JSON.stringify(settings.config.image)}; + export const assetsDir = new URL(${JSON.stringify( + new URL( + isServerLikeOutput(settings.config) + ? settings.config.build.client + : settings.config.outDir + ) + )}); export const getImage = async (options) => await getImageInternal(options, imageConfig); `; } diff --git a/packages/astro/src/core/build/index.ts b/packages/astro/src/core/build/index.ts index aefea5080..5f5ae69a1 100644 --- a/packages/astro/src/core/build/index.ts +++ b/packages/astro/src/core/build/index.ts @@ -111,7 +111,7 @@ class AstroBuilder { }); if (isServerLikeOutput(this.settings.config)) { - this.settings = injectImageEndpoint(this.settings); + this.settings = injectImageEndpoint(this.settings, 'build'); } this.manifest = createRouteManifest({ settings: this.settings }, this.logger); diff --git a/packages/astro/src/core/dev/container.ts b/packages/astro/src/core/dev/container.ts index 52dd4c1a4..6cc5713f2 100644 --- a/packages/astro/src/core/dev/container.ts +++ b/packages/astro/src/core/dev/container.ts @@ -50,7 +50,7 @@ export async function createContainer({ isRestart, }); - settings = injectImageEndpoint(settings); + settings = injectImageEndpoint(settings, 'dev'); const { base, diff --git a/packages/astro/test/core-image.test.js b/packages/astro/test/core-image.test.js index a6ee4342c..653ad5dfd 100644 --- a/packages/astro/test/core-image.test.js +++ b/packages/astro/test/core-image.test.js @@ -180,8 +180,6 @@ describe('astro:image', () => { let html = await res.text(); $ = cheerio.load(html); - console.log(html); - let $img = $('img'); expect($img).to.have.a.lengthOf(1); @@ -854,17 +852,14 @@ describe('astro:image', () => { output: 'server', adapter: testAdapter(), image: { + endpoint: 'astro/assets/endpoint/node', service: testImageService(), }, }); await fixture.build(); }); - // TODO - // This is not working because the image service does a fetch() on the underlying - // image and we do not have an HTTP server in these tests. We either need - // to start one, or find another way to tell the image service how to load these files. - it.skip('dynamic route images are built at response time', async () => { + it('dynamic route images are built at response time sss', async () => { const app = await fixture.loadTestAdapterApp(); let request = new Request('http://example.com/'); let response = await app.render(request); diff --git a/packages/integrations/node/src/index.ts b/packages/integrations/node/src/index.ts index 5978371e4..1f3707949 100644 --- a/packages/integrations/node/src/index.ts +++ b/packages/integrations/node/src/index.ts @@ -30,8 +30,11 @@ export default function createIntegration(userOptions: UserOptions): AstroIntegr return { name: '@astrojs/node', hooks: { - 'astro:config:setup': ({ updateConfig }) => { + 'astro:config:setup': ({ updateConfig, config }) => { updateConfig({ + image: { + endpoint: config.image.endpoint ?? 'astro/assets/endpoint/node', + }, vite: { ssr: { noExternal: ['@astrojs/node'],