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
6 changes: 4 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ jobs:
fail-fast: false
matrix:
node-version: [22.x]
astro-version: ["3", "4", "5", "6"]
astro-version: ["3", "4", "5", "6", "7"]
include:
- astro-version: "3"
node-adapter: "@astrojs/node@^6"
Expand All @@ -23,12 +23,14 @@ jobs:
node-adapter: "@astrojs/node@^9"
- astro-version: "6"
node-adapter: "@astrojs/node@^10"
- astro-version: "7"
node-adapter: "@astrojs/node@^11"

steps:
- uses: actions/checkout@v4

- name: Install pnpm
uses: pnpm/action-setup@v4
run: npm install -g pnpm@9.5.0
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ jobs:
- uses: actions/checkout@v4

- name: Install pnpm
uses: pnpm/action-setup@v4
run: npm install -g pnpm@9.5.0

- uses: actions/setup-node@v4
with:
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ The service is **host-aware**. For each image, it inspects the `src` and routes

This means you can drop the integration into an existing Astro project without breaking any of its existing local-asset usage. Local assets keep working via sharp; ImageKit URLs get the full ImageKit treatment.

Both paths work in every output mode — `static`, `server`, and prerendered routes inside a server build. At build time the sharp path emits optimized files into `dist/_astro/`, while the ImageKit path always emits CDN URLs and never writes files.

## Installation

```bash
Expand Down
12 changes: 12 additions & 0 deletions imagekit-astro/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,17 @@ All notable changes to `@imagekit/astro` will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [1.0.2] - 2026-09-09

### Fixed

- **Static builds and prerendered routes no longer fail for ImageKit images.** Because the image service delegates local assets to sharp (and therefore exposes sharp's `transform`), Astro classifies it as a *local* service. During `astro build` with `output: 'static'` — or for any prerendered route in `output: 'server'` — Astro handed every image to its static image pipeline, which rewrote the ImageKit URL to a `/_astro/<hash>` file and then tried to read the source from disk. Bare ImageKit paths (`src="/photo.jpg"`) crashed the build with `ENOENT: no such file or directory, open '<outDir>/photo.jpg'`, and absolute ImageKit URLs were silently fetched and re-encoded by sharp, dropping every transformation. The service now keeps ImageKit-eligible images out of that pipeline so they resolve to CDN URLs in every output mode, while local imports and allow-listed third-party hosts still go through sharp.

### Changed

- **Astro 7 is now part of the test matrix.** End-to-end tests run against Astro 3, 4, 5, 6, and 7.
- **End-to-end coverage for prerendered routes.** The test app now includes a prerendered page in its server-output build, so the static image pipeline bypass is exercised on every Astro version in CI.

## [1.0.1] - 2026-05-05

### Fixed
Expand Down Expand Up @@ -38,3 +49,4 @@ First stable release of the official ImageKit SDK for Astro. Drop-in components

[1.0.0]: https://github.com/imagekit-developer/imagekit-astro/releases/tag/v1.0.0
[1.0.1]: https://github.com/imagekit-developer/imagekit-astro/releases/tag/v1.0.1
[1.0.2]: https://github.com/imagekit-developer/imagekit-astro/releases/tag/v1.0.2
2 changes: 1 addition & 1 deletion imagekit-astro/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@imagekit/astro",
"version": "1.0.1",
"version": "1.0.2",
"type": "module",
"license": "MIT",
"description": "Astro SDK for ImageKit.io - optimized image & video delivery with transformations",
Expand Down
96 changes: 83 additions & 13 deletions imagekit-astro/src/services/imagekit-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,76 @@ function getIKHosts(imageConfig: AstroConfig['image']): string[] {
return Array.from(new Set([...hosts, 'ik.imagekit.io'].map((h) => h.toLowerCase())));
}

/**
* Builds the final ImageKit URL for an IK-eligible transform.
*/
function buildIKUrl(opts: IKImageTransform, imageConfig: AstroConfig['image']): string {
const src = typeof opts.src === 'string' ? opts.src : opts.src.src;
const { urlEndpoint, transformationPosition } = resolveConfig(opts, imageConfig);

return buildSrc({
src,
urlEndpoint,
transformation: buildIKTransformations(opts),
queryParameters: opts.queryParameters,
transformationPosition,
});
}

/**
* URLs we generated for IK-eligible transforms, keyed by the exact transform
* object Astro handed to `getURL`. Astro passes that same object to
* `addStaticImage`, which lets the wrapper below short-circuit it.
*/
const ikUrlByTransform = new WeakMap<object, string>();

/** Last image config seen by the service; used as a fallback by the wrapper. */
let lastImageConfig: AstroConfig['image'] | undefined;

const WRAPPED_FLAG = '__imagekitAstroWrapped';

/**
* Keep IK URLs out of Astro's static image pipeline.
*
* Because this service exposes sharp's `transform`, Astro classifies it as a
* *local* service. During `astro build` (static output, or any prerendered
* route in server output) Astro's `getImage()` therefore hands every result
* to `globalThis.astroAsset.addStaticImage`, which replaces the URL with a
* `/_astro/<hash>.<ext>` path and later tries to read the source from disk
* (bare IK paths → ENOENT) or fetch it and re-encode it with sharp (absolute
* IK URLs → transformations silently dropped).
*
* Astro only skips that step when the service returned the src unchanged,
* which is never the case for IK URLs carrying transformations. So we wrap
* `addStaticImage` and return our CDN URL for the transforms we own, letting
* everything else (local assets, allow-listed third-party hosts) flow through
* to sharp untouched. `addStaticImage` only exists at build time, so this is
* a no-op in dev and in on-demand SSR rendering.
*/
function bypassStaticImagePipeline(): void {
const astroAsset = (globalThis as any).astroAsset;
const original = astroAsset?.addStaticImage;
if (typeof original !== 'function' || original[WRAPPED_FLAG]) return;

const wrapped = function (this: unknown, options: IKImageTransform, ...rest: unknown[]) {
const known = ikUrlByTransform.get(options);
if (known !== undefined) return known;

// Fallback in case Astro passes a transform object we haven't seen.
if (lastImageConfig) {
const src = typeof options.src === 'string' ? options.src : options.src?.src;
if (typeof src === 'string' && isImageKitSrc(src, getIKHosts(lastImageConfig))) {
return buildIKUrl(options, lastImageConfig);
}
}

return original.call(this, options, ...rest);
} as ((...args: unknown[]) => unknown) & Record<string, unknown>;
wrapped[WRAPPED_FLAG] = true;

astroAsset.addStaticImage = wrapped;
}

const service: LocalImageService = {
validateOptions(options: ImageTransform, imageConfig: AstroConfig['image']) {
const src = typeof options.src === 'string' ? options.src : options.src.src;
Expand All @@ -217,15 +287,15 @@ const service: LocalImageService = {
return sharpService.getURL!(options, imageConfig);
}

const { urlEndpoint, transformationPosition } = resolveConfig(opts, imageConfig);
const url = buildIKUrl(opts, imageConfig);

return buildSrc({
src,
urlEndpoint,
transformation: buildIKTransformations(opts),
queryParameters: opts.queryParameters,
transformationPosition,
});
// Remember this transform so the build-time static image pipeline
// returns our CDN URL instead of trying to process the file with sharp.
ikUrlByTransform.set(opts, url);
lastImageConfig = imageConfig;
bypassStaticImagePipeline();

return url;
},

getHTMLAttributes(options: ImageTransform, imageConfig: AstroConfig['image']) {
Expand Down Expand Up @@ -320,11 +390,11 @@ const service: LocalImageService = {
// --- Local-service hooks (delegated to sharp) ---
//
// We register as a `LocalImageService` so Astro's `/_image` endpoint
// accepts requests for non-IK srcs (local assets, foreign hosts in
// `image.domains`/`remotePatterns`). These hooks only fire when our
// `getURL` returned a `/_image?...` URL — which only happens for
// sharp-delegated srcs. IK URLs go straight to `ik.imagekit.io` and
// never hit `/_image`, so this is purely additive.
// (SSR) and static image generation (build) handle non-IK srcs: local
// assets and foreign hosts in `image.domains`/`remotePatterns`. These
// hooks only fire for sharp-delegated srcs. IK URLs go straight to the
// CDN — `bypassStaticImagePipeline()` keeps them out of Astro's build-time
// pipeline, and they never hit `/_image` at runtime.
parseURL: sharpService.parseURL,
transform: sharpService.transform,
propertiesToHash: sharpService.propertiesToHash,
Expand Down
7 changes: 4 additions & 3 deletions scripts/test-all-versions.sh
Original file line number Diff line number Diff line change
Expand Up @@ -33,15 +33,15 @@ while [[ $# -gt 0 ]]; do
;;
*)
echo "Unknown option: $1"
echo "Usage: $0 [--update] [--versions 3 4 5 6]"
echo "Usage: $0 [--update] [--versions 3 4 5 6 7]"
exit 1
;;
esac
done

# Default: all supported versions
if [[ ${#VERSIONS[@]} -eq 0 ]]; then
VERSIONS=("3" "4" "5" "6")
VERSIONS=("3" "4" "5" "6" "7")
fi

# --- Adapter lookup ---
Expand All @@ -52,6 +52,7 @@ get_adapter() {
4) echo "@astrojs/node@^8" ;;
5) echo "@astrojs/node@^9" ;;
6) echo "@astrojs/node@^10" ;;
7) echo "@astrojs/node@^11" ;;
*) echo "@astrojs/node@latest" ;;
esac
}
Expand Down Expand Up @@ -85,7 +86,7 @@ trap restore_deps EXIT

# --- Install Playwright browsers (once) ---
echo "==> Installing Playwright browsers..."
(cd "$TEST_APP_DIR" && pnpm exec playwright install --with-deps)
(cd "$TEST_APP_DIR" && pnpm exec playwright install --with-deps chromium)

# --- Run for each version ---
FAILED_VERSIONS=()
Expand Down
1 change: 1 addition & 0 deletions test-app/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ This is a demo Astro application for testing the `@imagekit/astro` package.
- `/` - Home page with navigation links
- `/videos` - Test cases for the `Video` component
- `/images` - Test cases for the `Image` component
- `/prerendered` - Prerendered route (`export const prerender = true`) inside the server-output app; verifies ImageKit srcs stay on the CDN and local imports go through sharp at build time

## Running the App

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<div class="container"> <h1>Prerendered Page Tests</h1> <p>This page is prerendered at build time (<code>export const prerender = true</code>) in a server-output app. ImageKit srcs must resolve to CDN URLs; local imports must be processed by sharp.</p> <!-- Bare ImageKit path --> <div class="example" data-test-id="prerender-ik-bare-path"> <h3>Bare ImageKit path</h3> <img src="https://ik.imagekit.io/demo/default-image.jpg?tr=c-at_max,w-300" alt="Bare ImageKit path" width="300" height="300" loading="lazy" decoding="async"> </div> <!-- Absolute ImageKit URL --> <div class="example" data-test-id="prerender-ik-absolute-url"> <h3>Absolute ImageKit URL with transformation</h3> <img src="https://ik.imagekit.io/demo/default-image.jpg?tr=h-100,w-100:c-at_max,w-300" alt="Absolute ImageKit URL" width="300" height="300" loading="lazy" decoding="async"> </div> <!-- additionalEndpoints host --> <div class="example" data-test-id="prerender-ik-additional-endpoint"> <h3>additionalEndpoints host</h3> <img src="https://ik.imgkit.net/demo/default-image.jpg?tr=c-at_max,w-300" alt="Additional endpoint host" width="300" height="300" loading="lazy" decoding="async"> </div> <!-- Picture with multiple formats --> <div class="example" data-test-id="prerender-ik-picture"> <h3>Picture with multiple formats</h3> <picture> <source srcset="https://ik.imagekit.io/demo/default-image.jpg?tr=c-at_max,w-300,f-avif" type="image/avif"><source srcset="https://ik.imagekit.io/demo/default-image.jpg?tr=c-at_max,w-300,f-webp" type="image/webp"> <img src="https://ik.imagekit.io/demo/default-image.jpg?tr=c-at_max,w-300,f-png" alt="ImageKit picture" width="300" height="300" loading="lazy" decoding="async"> </picture> </div> <!-- Local imported asset (sharp) --> <div class="example" data-test-id="prerender-sharp-local-import"> <h3>Local imported asset (sharp)</h3> <img src="/_astro/hero.d25e9c19_jhnLe.webp" alt="Local hero image processed by sharp" width="300" height="200" loading="lazy" decoding="async"> </div> </div>
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<div class="container"> <h1>Prerendered Page Tests</h1> <p>This page is prerendered at build time (<code>export const prerender = true</code>) in a server-output app. ImageKit srcs must resolve to CDN URLs; local imports must be processed by sharp.</p> <!-- Bare ImageKit path --> <div class="example" data-test-id="prerender-ik-bare-path"> <h3>Bare ImageKit path</h3> <img src="https://ik.imagekit.io/demo/default-image.jpg?tr=c-at_max,w-300" alt="Bare ImageKit path" width="300" height="300" loading="lazy" decoding="async"> </div> <!-- Absolute ImageKit URL --> <div class="example" data-test-id="prerender-ik-absolute-url"> <h3>Absolute ImageKit URL with transformation</h3> <img src="https://ik.imagekit.io/demo/default-image.jpg?tr=h-100,w-100:c-at_max,w-300" alt="Absolute ImageKit URL" width="300" height="300" loading="lazy" decoding="async"> </div> <!-- additionalEndpoints host --> <div class="example" data-test-id="prerender-ik-additional-endpoint"> <h3>additionalEndpoints host</h3> <img src="https://ik.imgkit.net/demo/default-image.jpg?tr=c-at_max,w-300" alt="Additional endpoint host" width="300" height="300" loading="lazy" decoding="async"> </div> <!-- Picture with multiple formats --> <div class="example" data-test-id="prerender-ik-picture"> <h3>Picture with multiple formats</h3> <picture> <source srcset="https://ik.imagekit.io/demo/default-image.jpg?tr=c-at_max,w-300,f-avif" type="image/avif"><source srcset="https://ik.imagekit.io/demo/default-image.jpg?tr=c-at_max,w-300,f-webp" type="image/webp"> <img src="https://ik.imagekit.io/demo/default-image.jpg?tr=c-at_max,w-300,f-png" alt="ImageKit picture" width="300" height="300" loading="lazy" decoding="async"> </picture> </div> <!-- Local imported asset (sharp) --> <div class="example" data-test-id="prerender-sharp-local-import"> <h3>Local imported asset (sharp)</h3> <img src="/_astro/hero.CfX7EzL3_1PvIdG.webp" alt="Local hero image processed by sharp" width="300" height="200" loading="lazy" decoding="async"> </div> </div>
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<div class="container"> <h1>Prerendered Page Tests</h1> <p>This page is prerendered at build time (<code>export const prerender = true</code>) in a server-output app. ImageKit srcs must resolve to CDN URLs; local imports must be processed by sharp.</p> <!-- Bare ImageKit path --> <div class="example" data-test-id="prerender-ik-bare-path"> <h3>Bare ImageKit path</h3> <img src="https://ik.imagekit.io/demo/default-image.jpg?tr=c-at_max,w-300" srcset="https://ik.imagekit.io/demo/default-image.jpg?tr=c-at_max,w-300 300w, https://ik.imagekit.io/demo/default-image.jpg?tr=c-at_max,w-600 600w" alt="Bare ImageKit path" width="300" height="300" loading="lazy" decoding="async" fetchpriority="auto" sizes="(min-width: 300px) 300px, 100vw" style="--fit: cover; --pos: center;" data-astro-image="constrained"> </div> <!-- Absolute ImageKit URL --> <div class="example" data-test-id="prerender-ik-absolute-url"> <h3>Absolute ImageKit URL with transformation</h3> <img src="https://ik.imagekit.io/demo/default-image.jpg?tr=h-100,w-100:c-at_max,w-300" srcset="https://ik.imagekit.io/demo/default-image.jpg?tr=h-100,w-100:c-at_max,w-300 300w, https://ik.imagekit.io/demo/default-image.jpg?tr=h-100,w-100:c-at_max,w-600 600w" alt="Absolute ImageKit URL" width="300" height="300" loading="lazy" decoding="async" fetchpriority="auto" sizes="(min-width: 300px) 300px, 100vw" style="--fit: cover; --pos: center;" data-astro-image="constrained"> </div> <!-- additionalEndpoints host --> <div class="example" data-test-id="prerender-ik-additional-endpoint"> <h3>additionalEndpoints host</h3> <img src="https://ik.imgkit.net/demo/default-image.jpg?tr=c-at_max,w-300" srcset="https://ik.imgkit.net/demo/default-image.jpg?tr=c-at_max,w-300 300w, https://ik.imgkit.net/demo/default-image.jpg?tr=c-at_max,w-600 600w" alt="Additional endpoint host" width="300" height="300" loading="lazy" decoding="async" fetchpriority="auto" sizes="(min-width: 300px) 300px, 100vw" style="--fit: cover; --pos: center;" data-astro-image="constrained"> </div> <!-- Picture with multiple formats --> <div class="example" data-test-id="prerender-ik-picture"> <h3>Picture with multiple formats</h3> <picture> <source srcset="https://ik.imagekit.io/demo/default-image.jpg?tr=c-at_max,w-300,f-avif 300w, https://ik.imagekit.io/demo/default-image.jpg?tr=c-at_max,w-600,f-avif 600w" type="image/avif"><source srcset="https://ik.imagekit.io/demo/default-image.jpg?tr=c-at_max,w-300,f-webp 300w, https://ik.imagekit.io/demo/default-image.jpg?tr=c-at_max,w-600,f-webp 600w" type="image/webp"> <img src="https://ik.imagekit.io/demo/default-image.jpg?tr=c-at_max,w-300,f-png" srcset="https://ik.imagekit.io/demo/default-image.jpg?tr=c-at_max,w-300,f-png 300w, https://ik.imagekit.io/demo/default-image.jpg?tr=c-at_max,w-600,f-png 600w" alt="ImageKit picture" width="300" height="300" loading="lazy" decoding="async" fetchpriority="auto" sizes="(min-width: 300px) 300px, 100vw" style="--fit: cover; --pos: center;" data-astro-image="constrained"> </picture> </div> <!-- Local imported asset (sharp) --> <div class="example" data-test-id="prerender-sharp-local-import"> <h3>Local imported asset (sharp)</h3> <img src="/_astro/hero.CfX7EzL3_ZahhtW.webp" srcset="/_astro/hero.CfX7EzL3_ZahhtW.webp 300w, /_astro/hero.CfX7EzL3_Z2ahQ9E.webp 600w" alt="Local hero image processed by sharp" loading="lazy" decoding="async" fetchpriority="auto" sizes="(min-width: 300px) 300px, 100vw" style="--fit: cover; --pos: center;" data-astro-image="constrained" width="300" height="200"> </div> </div>

Large diffs are not rendered by default.

Loading
Loading