Skip to content

Commit 7e20f7e

Browse files
MajorTalclaude
andcommitted
fix(astro): SDK preflight check + implicit-mode default to opt out of inherited public_paths
Kychon's v1.2.0 re-test surfaced two issues neither the helper nor the gateway-side work caught: 1. SDK version mismatch surfaced as a misleading error. The helper emits `class: 'ssr'` (gateway v1.52+ field). `@run402/sdk` versions older than 2.18.0 don't include `class` in their local `FUNCTION_SPEC_FIELDS` allowlist, so `validateKnownFields` rejects the spec LOCALLY before any network call with the message "Unknown ReleaseSpec field: functions.replace.<name>.class". The error looks gateway-shaped (phase: "validate") but doesn't carry a trace_id — that's because there's never been a request. Fix: bumped peerDep `@run402/sdk` from `>=2.3` to `>=2.18.0`, and added a runtime preflight (`assertSdkSupportsClassField`) that reads `@run402/sdk/package.json` via createRequire and throws a precise `Run402AstroSdkVersionError` with the installed vs required versions and an actionable suggestedFix (`npm install @run402/sdk@latest run402@latest`). 2. Inherited explicit public_paths blocked the deploy AFTER the class field landed. The gateway's `resolvePublicPathMode` carries the prior release's public-paths mode forward when the new release doesn't declare one (`stable-host-resolver`'s `inheritedDirectPublicEntries`). Kychon's prior release used `mode: "explicit"` with many declared paths including `.well-known/kychon.json`; the slice's new (minimal) site dir didn't carry those exact assets, triggering `site.public_paths.inherited./.well-known/kychon.json` missing-asset rejections. Fix: default `site.public_paths: { mode: "implicit" }` — opts the new release out of the explicit-mode carry-forward. Astro's natural build shape is filename-URL congruent, so implicit reachability is semantically correct. The gateway emits a `PUBLIC_PATH_MODE_WIDENS_TO_IMPLICIT` warning (severity: warn, requires_confirmation: true) so the transition is auditable. Callers who need explicit per-path control pass `cacheClass`, which still produces a fully-declared explicit map. Verification: - 243 astro unit tests pass. - 30 SSR-astro e2e assertions pass. - End-to-end smoke against api.run402.com on Kychon's actual project (`prj_1776162950442_0031`) reached plan.diff with class:'ssr' accepted and only expected destructive-removal + mode-widen confirmation warnings. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent fbdf977 commit 7e20f7e

3 files changed

Lines changed: 134 additions & 5 deletions

File tree

astro/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@
6565
},
6666
"peerDependencies": {
6767
"@run402/functions": "^2.7.0",
68-
"@run402/sdk": ">=2.3",
68+
"@run402/sdk": ">=2.18.0",
6969
"astro": ">=5 <7",
7070
"react": ">=18.0.0",
7171
"react-dom": ">=18.0.0"

astro/src/release-slice.test.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -247,15 +247,24 @@ describe("buildAstroReleaseSlice — explicit cacheClass option", () => {
247247
});
248248
});
249249

250-
it("does not emit public_paths when cacheClass is not provided", async () => {
250+
it("defaults to public_paths { mode: 'implicit' } so the release opts out of inherited explicit paths", async () => {
251251
const { distDir } = writeFixture(root, {
252252
routes: [{ pattern: "/about", pathname: "/about", prerender: true, type: "page" }],
253253
});
254254
const slice = await buildAstroReleaseSlice(distDir);
255+
const publicPaths = (slice.site as { public_paths?: unknown }).public_paths as {
256+
mode: string;
257+
replace?: unknown;
258+
};
259+
assert.equal(
260+
publicPaths?.mode,
261+
"implicit",
262+
"default mode must be 'implicit' to avoid carry-forward of prior explicit public_paths",
263+
);
255264
assert.equal(
256-
(slice.site as { public_paths?: unknown }).public_paths,
265+
publicPaths.replace,
257266
undefined,
258-
"public_paths must remain implicit unless the caller asked for cacheClass overrides",
267+
"implicit mode must NOT carry a replace map",
259268
);
260269
});
261270

astro/src/release-slice.ts

Lines changed: 121 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,13 @@ const ADAPTER_MANIFEST_RELATIVE = path.join("run402", "adapter.json");
4343
const CLIENT_DIR_RELATIVE = path.join("run402", "client");
4444
const SERVER_DIR_RELATIVE = path.join("run402", "server");
4545
const DEFAULT_ENTRYPOINT_REL = "entry.mjs";
46+
/** Minimum `@run402/sdk` version where `FunctionSpec.class` is in the
47+
* SDK's local strict-fields allowlist (`FUNCTION_SPEC_FIELDS` in
48+
* `sdk/src/namespaces/deploy.ts`). Earlier SDKs reject `class: 'ssr'`
49+
* LOCALLY via `validateKnownFields` before the spec ever reaches the
50+
* gateway — the rejection looks like
51+
* `Unknown ReleaseSpec field: functions.replace.<name>.class`. */
52+
const MIN_SDK_VERSION_FOR_CLASS_FIELD = "2.18.0";
4653

4754
/**
4855
* Public-facing error envelope for `@run402/astro/release-slice` failures.
@@ -78,6 +85,95 @@ export class Run402AstroAdapterManifestError extends Error {
7885
}
7986
}
8087

88+
/**
89+
* Thrown when the installed `@run402/sdk` is older than the version that
90+
* accepts `FunctionSpec.class: 'ssr'` in its local validator (≥2.18.0).
91+
* The release-slice helper always emits `class: 'ssr'` for the SSR
92+
* function — without that field, the gateway's v1.52 auto-fallback
93+
* doesn't route unmatched paths to the function, and hybrid mode breaks.
94+
* Older SDKs reject the field locally with `Unknown ReleaseSpec field:
95+
* functions.replace.<name>.class` before any network call.
96+
*
97+
* The remedy is always the same: upgrade `@run402/sdk` (and typically
98+
* `run402` CLI alongside, since the CLI bundles its own SDK).
99+
*/
100+
export class Run402AstroSdkVersionError extends Error {
101+
readonly code = "R402_ASTRO_SDK_VERSION_TOO_OLD" as const;
102+
readonly installedVersion: string;
103+
readonly requiredVersion: string;
104+
readonly suggestedFix: string;
105+
readonly docs: string;
106+
107+
constructor(opts: { installedVersion: string; requiredVersion: string }) {
108+
super(
109+
`@run402/astro/release-slice produces specs with FunctionSpec.class: 'ssr' (gateway ` +
110+
`v1.52+ feature). The installed @run402/sdk version (${opts.installedVersion}) ` +
111+
`rejects this field locally before the spec reaches the gateway. Required: ` +
112+
`>=${opts.requiredVersion}.`,
113+
);
114+
this.name = "Run402AstroSdkVersionError";
115+
this.installedVersion = opts.installedVersion;
116+
this.requiredVersion = opts.requiredVersion;
117+
this.suggestedFix =
118+
"Upgrade with `npm install @run402/sdk@latest run402@latest`. " +
119+
"The CLI bundles its own SDK, so both packages need to be on a matching version.";
120+
this.docs = "https://docs.run402.com/errors#R402_ASTRO_SDK_VERSION_TOO_OLD";
121+
}
122+
}
123+
124+
/**
125+
* Read the installed `@run402/sdk` package.json `version` field. Returns
126+
* `null` when the package isn't resolvable from the helper's import
127+
* context — e.g. some bundled test runners shim require() and the resolve
128+
* fails. A null return falls through to "skip the version check" rather
129+
* than throwing, so the helper never blocks on a self-inflicted lookup
130+
* failure. The downstream gateway / SDK validator still catches a real
131+
* mismatch.
132+
*/
133+
function readInstalledSdkVersion(): string | null {
134+
try {
135+
// Use createRequire to resolve relative to this module. The Vite /
136+
// esbuild build of @run402/astro keeps this as a runtime require —
137+
// package.json reads aren't statically inlined.
138+
const { createRequire } = require("node:module") as typeof import("node:module");
139+
const req = createRequire(import.meta.url);
140+
const pkg = req("@run402/sdk/package.json") as { version?: unknown };
141+
if (pkg && typeof pkg.version === "string") return pkg.version;
142+
} catch {
143+
// fall through
144+
}
145+
return null;
146+
}
147+
148+
/** Lowest-common-denominator semver compare. Handles `MAJOR.MINOR.PATCH`
149+
* and ignores pre-release / build suffixes (treats `2.18.0-alpha.1` as
150+
* `2.18.0` — strictly less than `2.18.0` is the correct comparison since
151+
* alphas are pre-releases of the final). Returns negative when a < b,
152+
* positive when a > b, 0 on equal. */
153+
function compareSemver(a: string, b: string): number {
154+
const parse = (v: string) =>
155+
v
156+
.split("-")[0]!
157+
.split(".")
158+
.map((n) => Number.parseInt(n, 10) || 0);
159+
const [aa, ab, ac] = parse(a);
160+
const [ba, bb, bc] = parse(b);
161+
if ((aa ?? 0) !== (ba ?? 0)) return (aa ?? 0) - (ba ?? 0);
162+
if ((ab ?? 0) !== (bb ?? 0)) return (ab ?? 0) - (bb ?? 0);
163+
return (ac ?? 0) - (bc ?? 0);
164+
}
165+
166+
function assertSdkSupportsClassField(): void {
167+
const installed = readInstalledSdkVersion();
168+
if (installed === null) return; // lookup failed → defer to runtime
169+
if (compareSemver(installed, MIN_SDK_VERSION_FOR_CLASS_FIELD) < 0) {
170+
throw new Run402AstroSdkVersionError({
171+
installedVersion: installed,
172+
requiredVersion: MIN_SDK_VERSION_FOR_CLASS_FIELD,
173+
});
174+
}
175+
}
176+
81177
export interface BuildAstroReleaseSliceOptions {
82178
/** Materialized function name for the SSR Lambda. Defaults to `"ssr"`. */
83179
functionName?: string;
@@ -232,6 +328,14 @@ export async function buildAstroReleaseSlice(
232328
distDir: string,
233329
opts: BuildAstroReleaseSliceOptions = {},
234330
): Promise<AstroReleaseSlice> {
331+
// Fail fast on stale `@run402/sdk` installs. The helper always emits
332+
// `class: 'ssr'` on the SSR function; SDKs older than 2.18.0 reject
333+
// that field LOCALLY (in `validateKnownFields`) before the spec ever
334+
// reaches the gateway. Detecting this here means consumers see a
335+
// precise "upgrade @run402/sdk" message instead of a misleading
336+
// "Unknown ReleaseSpec field" error after the deploy script's
337+
// boilerplate runs.
338+
assertSdkSupportsClassField();
235339
const manifest = await loadAstroAdapterManifest(distDir);
236340
const functionName = opts.functionName ?? DEFAULT_FUNCTION_NAME;
237341
const cacheClass = opts.cacheClass ?? DEFAULT_HTML_CACHE_CLASS;
@@ -242,7 +346,23 @@ export async function buildAstroReleaseSlice(
242346
path: clientDirAbs,
243347
};
244348

245-
const site: SiteSpec = { replace: siteDir };
349+
// Default public_paths mode: "implicit" — filename-derived reachability
350+
// from the new client dir. Critically, this OPTS OUT of the gateway's
351+
// base-release carry-forward semantics: when a prior release used
352+
// `mode: "explicit"` with declared paths (e.g., `.well-known/kychon.json`),
353+
// the gateway would otherwise inherit those paths into the new release
354+
// and reject if the asset isn't present in the new site dir, surfacing
355+
// as `site.public_paths.inherited.<path>` missing-asset errors.
356+
//
357+
// The Astro build's natural shape is filename-URL congruent (`/about` →
358+
// `<client>/about/index.html`; assets in `public/` → top-level paths in
359+
// the client dir), so implicit mode is semantically correct for an Astro
360+
// release. Consumers who need explicit per-path overrides pass `cacheClass`
361+
// and the helper switches to `mode: "explicit"` with a fully-declared map.
362+
const site: SiteSpec = {
363+
replace: siteDir,
364+
public_paths: { mode: "implicit" },
365+
};
246366

247367
// Bundle the Astro server output into a single ESM source. The
248368
// gateway's `validateFunctionSpec` rejects multi-file function specs

0 commit comments

Comments
 (0)