feat: resolve images through the file systems on applicable runtimes (#8698)
* feat: add a node image endpoint * test: fix
This commit is contained in:
parent
db83237dd3
commit
47ea310f01
10 changed files with 118 additions and 18 deletions
6
.changeset/gold-trains-eat.md
Normal file
6
.changeset/gold-trains-eat.md
Normal file
|
@ -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
|
|
@ -56,7 +56,7 @@
|
||||||
"./components/*": "./components/*",
|
"./components/*": "./components/*",
|
||||||
"./assets": "./dist/assets/index.js",
|
"./assets": "./dist/assets/index.js",
|
||||||
"./assets/utils": "./dist/assets/utils/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/sharp": "./dist/assets/services/sharp.js",
|
||||||
"./assets/services/squoosh": "./dist/assets/services/squoosh.js",
|
"./assets/services/squoosh": "./dist/assets/services/squoosh.js",
|
||||||
"./assets/services/noop": "./dist/assets/services/noop.js",
|
"./assets/services/noop": "./dist/assets/services/noop.js",
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
import { isRemotePath } from '@astrojs/internal-helpers/path';
|
import { isRemotePath } from '@astrojs/internal-helpers/path';
|
||||||
import mime from 'mime/lite.js';
|
import mime from 'mime/lite.js';
|
||||||
import type { APIRoute } from '../@types/astro.js';
|
import type { APIRoute } from '../../@types/astro.js';
|
||||||
import { getConfiguredImageService, isRemoteAllowed } from './internal.js';
|
import { getConfiguredImageService, isRemoteAllowed } from '../internal.js';
|
||||||
import { etag } from './utils/etag.js';
|
import { etag } from '../utils/etag.js';
|
||||||
// @ts-expect-error
|
// @ts-expect-error
|
||||||
import { imageConfig } from 'astro:assets';
|
import { imageConfig } from 'astro:assets';
|
||||||
|
|
||||||
|
@ -40,7 +40,6 @@ export const GET: APIRoute = async ({ request }) => {
|
||||||
|
|
||||||
let inputBuffer: Buffer | undefined = undefined;
|
let inputBuffer: Buffer | undefined = undefined;
|
||||||
|
|
||||||
// TODO: handle config subpaths?
|
|
||||||
const sourceUrl = isRemotePath(transform.src)
|
const sourceUrl = isRemotePath(transform.src)
|
||||||
? new URL(transform.src)
|
? new URL(transform.src)
|
||||||
: new URL(transform.src, url.origin);
|
: new URL(transform.src, url.origin);
|
88
packages/astro/src/assets/endpoint/node.ts
Normal file
88
packages/astro/src/assets/endpoint/node.ts
Normal file
|
@ -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 });
|
||||||
|
}
|
||||||
|
};
|
|
@ -10,10 +10,11 @@ import type {
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
import { matchHostname, matchPattern } from './utils/remotePattern.js';
|
import { matchHostname, matchPattern } from './utils/remotePattern.js';
|
||||||
|
|
||||||
export function injectImageEndpoint(settings: AstroSettings) {
|
export function injectImageEndpoint(settings: AstroSettings, mode: 'dev' | 'build') {
|
||||||
const endpointEntrypoint = settings.config.image.endpoint ?? 'astro/assets/image-endpoint';
|
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({
|
settings.injectedRoutes.push({
|
||||||
pattern: '/_image',
|
pattern: '/_image',
|
||||||
entryPoint: endpointEntrypoint,
|
entryPoint: endpointEntrypoint,
|
||||||
|
|
|
@ -10,6 +10,7 @@ import {
|
||||||
prependForwardSlash,
|
prependForwardSlash,
|
||||||
removeQueryString,
|
removeQueryString,
|
||||||
} from '../core/path.js';
|
} from '../core/path.js';
|
||||||
|
import { isServerLikeOutput } from '../prerender/utils.js';
|
||||||
import { VALID_INPUT_FORMATS, VIRTUAL_MODULE_ID, VIRTUAL_SERVICE_ID } from './consts.js';
|
import { VALID_INPUT_FORMATS, VIRTUAL_MODULE_ID, VIRTUAL_SERVICE_ID } from './consts.js';
|
||||||
import { emitESMImage } from './utils/emitAsset.js';
|
import { emitESMImage } from './utils/emitAsset.js';
|
||||||
import { hashTransform, propsToFilename } from './utils/transformToPath.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 { default as Image } from "astro/components/Image.astro";
|
||||||
|
|
||||||
export const imageConfig = ${JSON.stringify(settings.config.image)};
|
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);
|
export const getImage = async (options) => await getImageInternal(options, imageConfig);
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
|
@ -111,7 +111,7 @@ class AstroBuilder {
|
||||||
});
|
});
|
||||||
|
|
||||||
if (isServerLikeOutput(this.settings.config)) {
|
if (isServerLikeOutput(this.settings.config)) {
|
||||||
this.settings = injectImageEndpoint(this.settings);
|
this.settings = injectImageEndpoint(this.settings, 'build');
|
||||||
}
|
}
|
||||||
|
|
||||||
this.manifest = createRouteManifest({ settings: this.settings }, this.logger);
|
this.manifest = createRouteManifest({ settings: this.settings }, this.logger);
|
||||||
|
|
|
@ -50,7 +50,7 @@ export async function createContainer({
|
||||||
isRestart,
|
isRestart,
|
||||||
});
|
});
|
||||||
|
|
||||||
settings = injectImageEndpoint(settings);
|
settings = injectImageEndpoint(settings, 'dev');
|
||||||
|
|
||||||
const {
|
const {
|
||||||
base,
|
base,
|
||||||
|
|
|
@ -180,8 +180,6 @@ describe('astro:image', () => {
|
||||||
let html = await res.text();
|
let html = await res.text();
|
||||||
$ = cheerio.load(html);
|
$ = cheerio.load(html);
|
||||||
|
|
||||||
console.log(html);
|
|
||||||
|
|
||||||
let $img = $('img');
|
let $img = $('img');
|
||||||
expect($img).to.have.a.lengthOf(1);
|
expect($img).to.have.a.lengthOf(1);
|
||||||
|
|
||||||
|
@ -854,17 +852,14 @@ describe('astro:image', () => {
|
||||||
output: 'server',
|
output: 'server',
|
||||||
adapter: testAdapter(),
|
adapter: testAdapter(),
|
||||||
image: {
|
image: {
|
||||||
|
endpoint: 'astro/assets/endpoint/node',
|
||||||
service: testImageService(),
|
service: testImageService(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
await fixture.build();
|
await fixture.build();
|
||||||
});
|
});
|
||||||
|
|
||||||
// TODO
|
it('dynamic route images are built at response time sss', async () => {
|
||||||
// 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 () => {
|
|
||||||
const app = await fixture.loadTestAdapterApp();
|
const app = await fixture.loadTestAdapterApp();
|
||||||
let request = new Request('http://example.com/');
|
let request = new Request('http://example.com/');
|
||||||
let response = await app.render(request);
|
let response = await app.render(request);
|
||||||
|
|
|
@ -30,8 +30,11 @@ export default function createIntegration(userOptions: UserOptions): AstroIntegr
|
||||||
return {
|
return {
|
||||||
name: '@astrojs/node',
|
name: '@astrojs/node',
|
||||||
hooks: {
|
hooks: {
|
||||||
'astro:config:setup': ({ updateConfig }) => {
|
'astro:config:setup': ({ updateConfig, config }) => {
|
||||||
updateConfig({
|
updateConfig({
|
||||||
|
image: {
|
||||||
|
endpoint: config.image.endpoint ?? 'astro/assets/endpoint/node',
|
||||||
|
},
|
||||||
vite: {
|
vite: {
|
||||||
ssr: {
|
ssr: {
|
||||||
noExternal: ['@astrojs/node'],
|
noExternal: ['@astrojs/node'],
|
||||||
|
|
Loading…
Reference in a new issue