Skip to content

Commit 9720eef

Browse files
MajorTalclaude
andcommitted
fix(astro): Astro 6 adapter API compat (#403)
createRun402Adapter previously aborted Astro 6 builds with NoAdapterInstalled even when wired up. Root cause was a stack of Astro-5-era patterns: entrypointResolution defaulted to deprecated "explicit", the legacy `exports` array was set, `sharpImageService` was not declared, `adapterFeatures.buildOutput: "server"` forced server output before the adapter was findable, and the `run402()` preset pushed the adapter into integrations[] instead of the top-level `adapter:` field that Astro 6's check (`!config.adapter && buildOutput === 'server'`) actually reads. The runtime entry was also stuck on the manifest.mjs + new App(manifest) pattern, which Vite can't resolve under auto mode. Switch to Astro 6 contract: entrypointResolution "auto", drop exports, declare sharpImageService, stop forcing buildOutput, return adapter on the preset's top-level field, migrate runtime/server.ts to createApp() from astro/app/entrypoint, and fix the build:done URL construction (new URL(rel, pathnameString) is invalid). Devdep astro bumped to ^6.1.3 so the auto-mode field exists in the types. SSR adapter now needs Astro 6+ at runtime; image-only path still works on Astro 5. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 34f3418 commit 9720eef

7 files changed

Lines changed: 552 additions & 1171 deletions

File tree

astro/CHANGELOG.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,34 @@ All notable changes to `@run402/astro`.
66

77
### Fixed
88

9+
- **`createRun402Adapter` incompatible with Astro 6** ([#403](https://github.com/kychee-com/run402/issues/403)). `astro build` on Astro 6.x previously aborted with `NoAdapterInstalled` even when the adapter was wired up, and printed a deprecation warning about `entrypointResolution: "explicit"` plus an `[ERROR] [config] adapter does not currently support sharp` line. Root cause was a mix of stale Astro-5-era adapter API usage: the adapter omitted `entrypointResolution` (defaulting to deprecated `"explicit"`), declared the legacy `exports: ["handler", "default"]` array, did not declare `sharpImageService` support, and the `run402()` preset pushed the adapter into `integrations[]` instead of the `adapter:` field — leaving `config.adapter` empty so Astro 6's check `!config.adapter && buildOutput === 'server'` threw `NoAdapterInstalled`. Fix:
10+
- Adapter now declares `entrypointResolution: "auto"` (Astro 6 recommended path) and drops the deprecated `exports` array — `runtime/server.ts` already exports `handler` + `default` directly.
11+
- Adapter declares `sharpImageService: "stable"` in `supportedAstroFeatures`.
12+
- Adapter no longer forces `adapterFeatures.buildOutput: "server"` — Astro derives the build shape from `output` + per-page `prerender`.
13+
- `run402()` preset returns `{ adapter: createRun402Adapter(...) }` on the top-level config (where Astro 6 looks for it) instead of pushing it into `integrations[]`.
14+
- `runtime/server.ts` migrated from the Astro-5 `manifest.mjs` + `new App(manifest)` pattern to Astro 6's `createApp()` from `astro/app/entrypoint` — Vite no longer fails to resolve `./manifest.mjs` because the virtual entrypoint module bakes the manifest in.
15+
- `astro:build:done` no longer uses `new URL("./...", pathnameString)` (invalid base) for the client dir — uses `path.join(buildOutputDir, "...")` against the resolved filesystem path instead.
16+
Devdep bumped to `astro ^6.1.3` so the TypeScript types include `entrypointResolution`; peer dep range is unchanged (`>=5 <7`), but **the SSR adapter portion now requires Astro 6+ at runtime** (the image-only integration still works on Astro 5). Users on the integrations-array pattern should migrate to `adapter: createRun402Adapter()`:
17+
18+
```ts
19+
// Before (Astro 5, broken on Astro 6):
20+
import { defineConfig } from "astro/config";
21+
import { createRun402Adapter } from "@run402/astro";
22+
export default defineConfig({
23+
integrations: [createRun402Adapter()],
24+
});
25+
26+
// After (Astro 6):
27+
import { defineConfig } from "astro/config";
28+
import { createRun402Adapter } from "@run402/astro";
29+
export default defineConfig({
30+
adapter: createRun402Adapter(),
31+
});
32+
33+
// Or, with the preset (handles the above for you):
34+
import run402 from "@run402/astro";
35+
export default run402();
36+
```
937
- **Stale `dist/_assets-manifest.json` entries missing v1.54 AssetRef fields.** The build cache at `node_modules/.run402/assetMap.json` stores AssetRefs verbatim by source SHA; when the gateway started emitting `blurhash_data_url` + `asset_schema` (v1.54), existing caches kept returning pre-v1.54 AssetRefs on hit, silently producing manifests that looked correct (legacy fields populated) but lacked the v1.54 additions. Bumped `CACHE_SCHEMA_VERSION` from `1` to `2` so existing caches invalidate on first run after upgrade. Reproducer: `rm -f node_modules/.run402/assetMap.json && npm run build` then `jq '.assets[<key>] | {blurhash_data_url, asset_schema}' dist/_assets-manifest.json` populates both fields.
1038

1139
### Changed

astro/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@
5454
],
5555
"scripts": {
5656
"build": "tsc && cp src/Image.astro dist/Image.astro && mkdir -p dist/components && cp src/components/Run402Picture.astro dist/components/Run402Picture.astro && cp src/components/Run402Image.astro dist/components/Run402Image.astro",
57-
"test": "node --experimental-test-module-mocks --test --import tsx src/resolver.test.ts src/cache.test.ts src/scanner.test.ts src/uploader.test.ts src/component.test.ts src/manifest.test.ts src/blurhash-decoder.test.ts src/build-manifest.test.ts src/components/Run402Image/types.test.ts src/components/Run402Image/core.test.ts src/components/Run402Image/react.test.tsx src/components/Run402Image/byte-identity.test.tsx src/components/Run402Image/degradation-manifest.test.ts src/components/Run402Image/wrong-entry-point.test.tsx src/components/Run402Image/avif-deferral.test.ts src/components/Run402Image/jsx-smoke.test.tsx"
57+
"test": "node --experimental-test-module-mocks --test --import tsx src/resolver.test.ts src/cache.test.ts src/scanner.test.ts src/uploader.test.ts src/component.test.ts src/manifest.test.ts src/blurhash-decoder.test.ts src/build-manifest.test.ts src/ssr-adapter.test.ts src/components/Run402Image/types.test.ts src/components/Run402Image/core.test.ts src/components/Run402Image/react.test.tsx src/components/Run402Image/byte-identity.test.tsx src/components/Run402Image/degradation-manifest.test.ts src/components/Run402Image/wrong-entry-point.test.tsx src/components/Run402Image/avif-deferral.test.ts src/components/Run402Image/jsx-smoke.test.tsx"
5858
},
5959
"engines": {
6060
"node": ">=18"
@@ -87,7 +87,7 @@
8787
"@types/node": "^22.0.0",
8888
"@types/react": "^19.2.14",
8989
"@types/react-dom": "^19.2.3",
90-
"astro": "^5.18.1",
90+
"astro": "^6.1.3",
9191
"react": "^19.2.6",
9292
"react-dom": "^19.2.6",
9393
"tsx": "^4.20.0",

astro/src/index.ts

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -394,15 +394,23 @@ function preset(options: Run402PresetOptions = {}): AstroUserConfig {
394394
if (options.images !== false) {
395395
integrations.push(run402Image(options) as RealAstroIntegration);
396396
}
397-
if (options.ssr !== false) {
398-
integrations.push(
399-
createRun402Adapter({ projectId: options.projectId }) as RealAstroIntegration,
400-
);
401-
}
402397
if (options.integrations) integrations.push(...options.integrations);
398+
// The SSR adapter MUST live on the `adapter:` field, not `integrations[]`.
399+
// Astro 6's NoAdapterInstalled check looks at `settings.config.adapter`
400+
// (the user's adapter field), not `settings.adapter` (what setAdapter()
401+
// populates from within an integration hook). If we pushed the adapter
402+
// into integrations[], static-output projects with no `prerender = false`
403+
// routes still pass — but as soon as the first SSR route appears,
404+
// buildOutput flips to 'server' and Astro throws NoAdapterInstalled
405+
// because config.adapter is empty.
406+
const adapter =
407+
options.ssr === false
408+
? undefined
409+
: (createRun402Adapter({ projectId: options.projectId }) as RealAstroIntegration);
403410
return {
404411
output: options.output ?? "server",
405412
integrations,
413+
adapter,
406414
site: options.site,
407415
};
408416
}

astro/src/runtime/server.ts

Lines changed: 13 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -108,34 +108,27 @@ interface SsrResponseEnvelope {
108108
}
109109

110110
/**
111-
* The Astro App reference. Astro emits a "manifest" build artifact
112-
* containing the `App` instance; the build system replaces this
113-
* dynamic import at bundle time with a real reference. In dev/test,
114-
* this returns null and `handler` no-ops.
111+
* The Astro App reference. Astro 6's auto-resolution contract bundles the
112+
* adapter's `serverEntrypoint` together with a virtual `astro/app/entrypoint`
113+
* module that exposes `createApp()` — the manifest is baked in at build
114+
* time and emitted next to this file. In dev/test (no Astro bundle), the
115+
* dynamic import throws and `handler` falls back to a stub.
115116
*/
116-
async function getAstroApp(): Promise<{ render: (req: Request) => Promise<Response> } | null> {
117+
type AstroApp = { render: (req: Request) => Promise<Response> };
118+
119+
async function getAstroApp(): Promise<AstroApp | null> {
117120
try {
118-
// The actual bundled SSR entry exports a default `app` factory.
119-
// This dynamic import resolves at runtime against the Astro build's
120-
// manifest module. At build time, Astro's `serverEntrypoint`
121-
// contract emits a manifest at `./manifest.mjs` next to the entry;
122-
// see Astro's adapter docs.
123-
const { manifest } = (await import(
124-
// @ts-expect-error — resolved at Lambda runtime against Astro's emitted manifest
125-
"./manifest.mjs"
126-
)) as { manifest: unknown };
127-
const { App } = (await import("astro/app")) as {
128-
App: new (manifest: unknown) => { render: (req: Request) => Promise<Response> };
121+
const { createApp } = (await import("astro/app/entrypoint")) as {
122+
createApp: (opts?: { streaming?: boolean }) => AstroApp;
129123
};
130-
return new App(manifest);
124+
return createApp({ streaming: true });
131125
} catch {
132-
// Missing in dev or unit tests — fall back to a stub.
133126
return null;
134127
}
135128
}
136129

137-
let appPromise: Promise<Awaited<ReturnType<typeof getAstroApp>>> | null = null;
138-
function app(): Promise<Awaited<ReturnType<typeof getAstroApp>>> {
130+
let appPromise: Promise<AstroApp | null> | null = null;
131+
function app(): Promise<AstroApp | null> {
139132
if (!appPromise) appPromise = getAstroApp();
140133
return appPromise;
141134
}

astro/src/ssr-adapter.test.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import assert from "node:assert/strict";
2+
import { describe, it } from "node:test";
3+
import { createRun402Adapter } from "./ssr-adapter.js";
4+
5+
type CapturedAdapter = {
6+
name?: string;
7+
entrypointResolution?: unknown;
8+
serverEntrypoint?: unknown;
9+
exports?: unknown;
10+
adapterFeatures?: { buildOutput?: unknown } & Record<string, unknown>;
11+
supportedAstroFeatures?: Record<string, unknown>;
12+
};
13+
14+
function runConfigDone(integration: ReturnType<typeof createRun402Adapter>): CapturedAdapter {
15+
const captured: { adapter?: CapturedAdapter } = {};
16+
const fakeOutDir = new URL("file:///tmp/run402-astro-test/dist/");
17+
const hook = integration.hooks["astro:config:done"];
18+
if (!hook) throw new Error("expected astro:config:done hook");
19+
(hook as (params: unknown) => unknown)({
20+
setAdapter: (a: CapturedAdapter) => {
21+
captured.adapter = a;
22+
},
23+
config: { outDir: fakeOutDir },
24+
logger: { info() {}, warn() {}, error() {} },
25+
setRoutes() {},
26+
});
27+
if (!captured.adapter) throw new Error("setAdapter was not called");
28+
return captured.adapter;
29+
}
30+
31+
describe("createRun402Adapter — Astro 6 shape (kychee-com/run402#403)", () => {
32+
it("declares entrypointResolution: 'auto'", () => {
33+
const adapter = runConfigDone(createRun402Adapter());
34+
assert.equal(
35+
adapter.entrypointResolution,
36+
"auto",
37+
"must opt into Astro 6 auto resolution; explicit is deprecated and prints a warning on every build",
38+
);
39+
});
40+
41+
it("does not pass deprecated `exports` field", () => {
42+
const adapter = runConfigDone(createRun402Adapter());
43+
assert.equal(
44+
adapter.exports,
45+
undefined,
46+
"the `exports` array is only used by the deprecated explicit mode; auto mode reads exports from the runtime module directly",
47+
);
48+
});
49+
50+
it("does not force adapterFeatures.buildOutput", () => {
51+
const adapter = runConfigDone(createRun402Adapter());
52+
assert.equal(
53+
adapter.adapterFeatures?.buildOutput,
54+
undefined,
55+
"leave buildOutput unset so Astro derives it from output + per-page prerender",
56+
);
57+
});
58+
59+
it("declares sharpImageService so default-sharp users don't get an [ERROR]", () => {
60+
const adapter = runConfigDone(createRun402Adapter());
61+
assert.equal(
62+
adapter.supportedAstroFeatures?.sharpImageService,
63+
"stable",
64+
"Astro 6 will print '[config] adapter does not currently support sharp' otherwise",
65+
);
66+
});
67+
68+
it("declares static + server + hybrid output support", () => {
69+
const adapter = runConfigDone(createRun402Adapter());
70+
const feats = adapter.supportedAstroFeatures ?? {};
71+
assert.equal(feats.staticOutput, "stable");
72+
assert.equal(feats.serverOutput, "stable");
73+
assert.equal(feats.hybridOutput, "stable");
74+
});
75+
76+
it("points serverEntrypoint at the runtime export", () => {
77+
const adapter = runConfigDone(createRun402Adapter());
78+
assert.equal(adapter.serverEntrypoint, "@run402/astro/runtime/server");
79+
});
80+
});

astro/src/ssr-adapter.ts

Lines changed: 15 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323

2424
import { writeFile, mkdir } from "node:fs/promises";
2525
import path from "node:path";
26+
import { fileURLToPath } from "node:url";
2627

2728
import type { AstroIntegration } from "astro";
2829

@@ -95,31 +96,31 @@ export function createRun402Adapter(options: CreateRun402AdapterOptions = {}): A
9596

9697
"astro:config:done": ({ setAdapter, config }) => {
9798
// Register as the deploy adapter so Astro emits a server build
98-
// pointing at our runtime entry shim.
99+
// pointing at our runtime entry shim. Astro 6+ contract:
100+
// - entrypointResolution: "auto" — runtime/server.ts directly
101+
// exports `handler` + `default`, so Astro resolves the
102+
// module by import (no legacy createExports/exports list).
103+
// - no adapterFeatures.buildOutput force: let Astro derive
104+
// the shape from `output` + per-page `prerender`. Static
105+
// sites stay static; routes that opt into `prerender = false`
106+
// pull the build into server shape.
99107
setAdapter({
100108
name: "@run402/astro",
109+
entrypointResolution: "auto",
101110
serverEntrypoint: "@run402/astro/runtime/server",
102-
previewEntrypoint: undefined,
103-
exports: ["handler", "default"],
104-
adapterFeatures: {
105-
edgeMiddleware: false,
106-
// Astro 5+ uses "Functions Per Route" or "Single Function"
107-
// for serverless. We use "Single Function" — one Lambda
108-
// handles every SSR route via the catchall.
109-
buildOutput: "server",
110-
},
111111
supportedAstroFeatures: {
112112
staticOutput: "stable",
113113
serverOutput: "stable",
114114
hybridOutput: "stable",
115115
i18nDomains: "experimental",
116116
envGetSecret: "stable",
117+
sharpImageService: "stable",
117118
},
118119
});
119120

120-
manifest.astroVersion = "5.x"; // resolved at runtime in real impl
121-
buildOutputDir = config.outDir.pathname;
122-
serverDir = new URL("./run402/server/", config.outDir).pathname;
121+
manifest.astroVersion = "6.x"; // resolved at runtime in real impl
122+
buildOutputDir = fileURLToPath(config.outDir);
123+
serverDir = fileURLToPath(new URL("./run402/server/", config.outDir));
123124
},
124125

125126
"astro:build:setup": ({ logger }) => {
@@ -158,7 +159,7 @@ export function createRun402Adapter(options: CreateRun402AdapterOptions = {}): A
158159
"astro:build:done": async ({ pages }) => {
159160
// Compose the final manifest and write to dist/run402/adapter.json.
160161
manifest.serverEntrypoint = path.join(serverDir, "entry.mjs");
161-
manifest.clientDir = new URL("./run402/client/", buildOutputDir).pathname;
162+
manifest.clientDir = path.join(buildOutputDir, "run402/client/");
162163
// Astro 5 exposes `pages` (each with a `pathname`) on the
163164
// build:done args; the prerender bool isn't directly available
164165
// here, so we treat every page as prerendered for now. The

0 commit comments

Comments
 (0)