Skip to content

Commit b4169a3

Browse files
MajorTalclaude
andcommitted
feat(astro): bundle SSR with esbuild + drop /* catchall + colocate manifest
Three coordinated fixes that align @run402/astro/release-slice with the gateway's actual contract (Kychon's adoption of v1.1.0 surfaced the gaps): 1) SSR bundling (gateway rejects multi-file specs). The helper previously emitted functions.replace.<name>.{files,entrypoint} pointing at the multi-file Astro server output. The gateway hard-rejects that shape with "multi-file function spec (files + entrypoint) is not yet supported by the gateway; bundle locally with esbuild and pass `source` instead". New src/ssr-bundler.ts wraps esbuild with the right defaults (target node22, ESM, externalize Node built-ins + @run402/functions) and returns a bundled ESM string. release-slice emits source: <bundled string> instead. 2) Drop /* catchall + drop static aliases (gateway auto-fallback). The gateway's v1.52 ssr-fallback (run402-private commit 22bf0059) routes every unmatched-path request to the project's single class:'ssr' function automatically. The helper no longer needs to declare a /* catchall (route validator rejected it as "prefix wildcard must include a path segment before /*") and no longer emits static aliases for prerendered routes (the implicit public-paths mode handles them via the static manifest, and dual declarations were conflicting at /). 3) _assets-manifest.json colocated with the resolved client dir. The integration's vite plugin wrote dist/_assets-manifest.json at top-level, but the adapter relocates build.client to dist/run402/client/. The release slice's site.replace dir then didn't carry the manifest, triggering site.public_paths.inherited./_assets-manifest.json missing-asset rejections from the gateway. Fix: astro:config:done hook reads the final build.client and writes the manifest inside it. Universally correct — without an adapter, build.client defaults to dist/. Plus dep: esbuild as a direct dependency (regular dep, not peer, since bundling is a hard requirement for the helper to produce gateway-valid specs). Plus tests: 11 unit tests rewritten for the v1.2 contract (source-only function shape, empty routes, cacheClass=>explicit public_paths). 244 astro tests / 11 release-slice tests all pass. End-to-end smoke against api.run402.com confirms the gateway accepts the new slice shape (gets past validation to plan phase). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent d129043 commit b4169a3

6 files changed

Lines changed: 765 additions & 140 deletions

File tree

astro/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,9 @@
8787
"optional": true
8888
}
8989
},
90+
"dependencies": {
91+
"esbuild": "^0.24.0"
92+
},
9093
"devDependencies": {
9194
"@types/node": "^22.0.0",
9295
"@types/react": "^19.2.14",

astro/src/index.ts

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,16 @@ type AstroIntegration = {
6666
command: "dev" | "build" | "preview";
6767
logger?: { info: (m: string) => void; warn: (m: string) => void; error: (m: string) => void };
6868
}) => void | Promise<void>;
69+
"astro:config:done"?: (params: {
70+
config: {
71+
root: URL | string;
72+
// The build triple is fully resolved by config:done time. Both
73+
// `client` and `server` are URLs in Astro 5+; the integration only
74+
// reads `build.client` to colocate `_assets-manifest.json` with
75+
// the deploy slice's client dir.
76+
build: { client: URL | string; server: URL | string; serverEntry: string };
77+
};
78+
}) => void | Promise<void>;
6979
"astro:build:setup"?: (params: unknown) => void | Promise<void>;
7080
"astro:server:setup"?: (params: unknown) => void | Promise<void>;
7181
};
@@ -121,6 +131,13 @@ export function run402Image(options: Run402AstroOptions = {}): AstroIntegration
121131
const dryRun = options.dryRun ?? false;
122132
const prefix = options.assetPrefix ?? DEFAULT_PREFIX;
123133

134+
// Shared across hooks. `astro:config:setup` runs first and creates the
135+
// state + registers the Vite plugin; `astro:config:done` runs after
136+
// every adapter / integration has mutated config (notably build.client),
137+
// so it's the right moment to finalize manifestPath against the actual
138+
// client output dir.
139+
let state: VitePluginState | null = null;
140+
124141
return {
125142
name: "@run402/astro",
126143
hooks: {
@@ -131,7 +148,7 @@ export function run402Image(options: Run402AstroOptions = {}): AstroIntegration
131148

132149
const projectRoot = configRootToPath(config.root);
133150

134-
const state: VitePluginState = {
151+
state = {
135152
projectRoot,
136153
aliases: loadAliasConfig(projectRoot),
137154
client: null,
@@ -145,6 +162,12 @@ export function run402Image(options: Run402AstroOptions = {}): AstroIntegration
145162
// v0.2 data-driven path. Resolve assetsDir(s) to absolute paths
146163
// against the project root so the walker doesn't depend on cwd.
147164
assetsDirs: resolveAssetsDirs(projectRoot, options.assetsDir),
165+
// Provisional manifest path — replaced in `astro:config:done`
166+
// unless the user explicitly set `options.manifestPath`. The
167+
// provisional value is what older releases shipped (defaulting
168+
// to `<projectRoot>/dist/_assets-manifest.json`), so a build
169+
// that for some reason never reaches `astro:config:done` still
170+
// writes a manifest to a sane path.
148171
manifestPath: resolveManifestPath(projectRoot, options.manifestPath),
149172
assetExtensions: options.assetExtensions ?? DEFAULT_ASSET_EXTENSIONS,
150173
manifestKeyByAbsPath: new Map(),
@@ -164,6 +187,28 @@ export function run402Image(options: Run402AstroOptions = {}): AstroIntegration
164187
},
165188
});
166189
},
190+
191+
// Capability `astro-ssr-runtime` coordination (v1.1+). When the
192+
// Run402 SSR adapter is also active, it relocates `build.client`
193+
// from the default `dist/` to `dist/run402/client/`. Writing the
194+
// manifest to `<projectRoot>/dist/_assets-manifest.json` would
195+
// then leave the manifest OUTSIDE the deploy slice's client dir,
196+
// and the gateway's release validator would reject the missing
197+
// file ("site.public_paths.inherited./_assets-manifest.json").
198+
//
199+
// Fix: write the manifest INTO the resolved `build.client` dir.
200+
// This is universally correct:
201+
// - With the Run402 SSR adapter: dist/run402/client/_assets-manifest.json
202+
// - With no adapter (static-only): dist/_assets-manifest.json
203+
// - With a different SSR adapter: <their client dir>/_assets-manifest.json
204+
//
205+
// Users who explicitly set `options.manifestPath` keep their
206+
// override (escape hatch for non-standard layouts).
207+
"astro:config:done": ({ config }) => {
208+
if (!state || options.manifestPath) return;
209+
const clientDir = configRootToPath(config.build.client);
210+
state.manifestPath = path.join(clientDir, "_assets-manifest.json");
211+
},
167212
},
168213
};
169214
}

astro/src/release-slice.test.ts

Lines changed: 60 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,13 @@ function writeFixture(
2121
mkdirSync(serverDir, { recursive: true });
2222
mkdirSync(clientDir, { recursive: true });
2323
const entryAbs = join(serverDir, "entry.mjs");
24-
writeFileSync(entryAbs, "export const handler = async () => new Response('ok');\n");
24+
// esbuild bundles this; the source must be valid ESM. The Astro
25+
// server entry in production exports `handler` + `default` — match
26+
// that shape so the bundled output looks right.
27+
writeFileSync(
28+
entryAbs,
29+
"export const handler = async () => new Response('ok');\nexport default handler;\n",
30+
);
2531
writeFileSync(join(clientDir, "index.html"), "<!doctype html><title>home</title>");
2632

2733
if (manifest !== null) {
@@ -122,7 +128,7 @@ describe("loadAstroAdapterManifest", () => {
122128
});
123129
});
124130

125-
describe("buildAstroReleaseSlice — happy path + prerender truth", () => {
131+
describe("buildAstroReleaseSlice — happy path", () => {
126132
let root: string;
127133
beforeEach(() => {
128134
root = mkdtempSync(join(tmpdir(), "r402-release-slice-build-"));
@@ -131,7 +137,7 @@ describe("buildAstroReleaseSlice — happy path + prerender truth", () => {
131137
rmSync(root, { recursive: true, force: true });
132138
});
133139

134-
it("returns site/functions/routes from a hybrid build", async () => {
140+
it("emits site (LocalDirRef), functions (bundled source), empty routes", async () => {
135141
const { distDir } = writeFixture(root, {
136142
output: "server",
137143
routes: [
@@ -142,107 +148,55 @@ describe("buildAstroReleaseSlice — happy path + prerender truth", () => {
142148

143149
const slice = await buildAstroReleaseSlice(distDir);
144150

145-
// site
151+
// site — LocalDirRef pointing at dist/run402/client/
146152
const siteReplace = (slice.site as { replace: unknown }).replace as {
147153
__source: string;
148154
path: string;
149155
};
150156
assert.equal(siteReplace.__source, "local-dir");
151157
assert.match(siteReplace.path, /dist[/\\]run402[/\\]client$/);
152158

153-
// functions
159+
// functions — single ssr function with bundled source (string)
154160
assert.deepEqual(Object.keys(slice.functions.replace), ["ssr"]);
155161
const fn = slice.functions.replace.ssr;
156162
assert.equal(fn.runtime, "node22");
157163
assert.equal((fn as { class?: string }).class, "ssr");
158-
assert.equal(fn.entrypoint, "entry.mjs");
159-
assert.ok(
160-
fn.files && typeof fn.files === "object",
161-
"functions.replace.ssr.files must be a FileSet",
164+
assert.equal(
165+
typeof fn.source,
166+
"string",
167+
"functions.replace.ssr.source must be a string (bundled ESM)",
162168
);
163169
assert.ok(
164-
Object.prototype.hasOwnProperty.call(fn.files, "entry.mjs"),
165-
"FileSet must contain the entry.mjs key",
170+
typeof fn.source === "string" && fn.source.includes("handler"),
171+
"bundled source should reference the entrypoint's `handler` export",
172+
);
173+
assert.equal(
174+
fn.files,
175+
undefined,
176+
"v1.2 slice must NOT emit files+entrypoint — the gateway rejects multi-file specs",
177+
);
178+
assert.equal(
179+
fn.entrypoint,
180+
undefined,
181+
"v1.2 slice must NOT emit entrypoint — the bundle is self-contained",
166182
);
167183

168-
// routes — prerendered /about gets a static alias, /[slug] falls through
169-
// to the catchall (no per-route entry), then catchall last. Static aliases
170-
// are constrained to GET/HEAD per the SDK's static-route validator.
171-
assert.deepEqual(slice.routes.replace, [
172-
{
173-
pattern: "/about",
174-
methods: ["GET", "HEAD"],
175-
target: { type: "static", file: "about/index.html" },
176-
},
177-
{ pattern: "/*", target: { type: "function", name: "ssr" } },
178-
]);
179-
});
180-
181-
it("maps / → index.html and routes ending in .html keep the extension", async () => {
182-
const { distDir } = writeFixture(root, {
183-
routes: [
184-
{ pattern: "/", pathname: "/", prerender: true, type: "page" },
185-
{ pattern: "/sitemap.xml", pathname: "/sitemap.xml", prerender: true, type: "endpoint" },
186-
{ pattern: "/legacy.html", pathname: "/legacy.html", prerender: true, type: "page" },
187-
],
188-
});
189-
190-
const slice = await buildAstroReleaseSlice(distDir);
191-
const aliases = slice.routes.replace.filter((r) => r.target.type === "static");
192-
assert.deepEqual(aliases, [
193-
{ pattern: "/", methods: ["GET", "HEAD"], target: { type: "static", file: "index.html" } },
194-
{
195-
pattern: "/sitemap.xml",
196-
methods: ["GET", "HEAD"],
197-
target: { type: "static", file: "sitemap.xml/index.html" },
198-
},
199-
{
200-
pattern: "/legacy.html",
201-
methods: ["GET", "HEAD"],
202-
target: { type: "static", file: "legacy.html" },
203-
},
204-
]);
205-
});
206-
207-
it("skips redirect and fallback route types", async () => {
208-
const { distDir } = writeFixture(root, {
209-
routes: [
210-
{ pattern: "/old", prerender: true, type: "redirect" },
211-
{ pattern: "/404", prerender: true, type: "fallback" },
212-
{ pattern: "/keep", pathname: "/keep", prerender: true, type: "page" },
213-
],
214-
});
215-
const slice = await buildAstroReleaseSlice(distDir);
216-
const aliases = slice.routes.replace.filter((r) => r.target.type === "static");
217-
assert.deepEqual(aliases, [
218-
{
219-
pattern: "/keep",
220-
methods: ["GET", "HEAD"],
221-
target: { type: "static", file: "keep/index.html" },
222-
},
223-
]);
224-
});
225-
});
226-
227-
describe("buildAstroReleaseSlice — option surface", () => {
228-
let root: string;
229-
beforeEach(() => {
230-
root = mkdtempSync(join(tmpdir(), "r402-release-slice-opts-"));
231-
});
232-
afterEach(() => {
233-
rmSync(root, { recursive: true, force: true });
184+
// routes — empty by default. The gateway's class:'ssr' auto-fallback
185+
// routes unmatched paths to the ssr function automatically. Prerendered
186+
// routes are reachable via the static manifest's implicit public-paths
187+
// mode — no explicit aliases needed (and emitting them would conflict
188+
// with the implicit declaration for the same path).
189+
assert.deepEqual(slice.routes.replace, []);
234190
});
235191

236-
it("respects functionName override across functions + routes", async () => {
192+
it("functionName option flows through to functions key", async () => {
237193
const { distDir } = writeFixture(root, { routes: [] });
238194
const slice = await buildAstroReleaseSlice(distDir, { functionName: "render" });
239195
assert.deepEqual(Object.keys(slice.functions.replace), ["render"]);
240-
assert.deepEqual(slice.routes.replace, [
241-
{ pattern: "/*", target: { type: "function", name: "render" } },
242-
]);
196+
assert.deepEqual(slice.routes.replace, []);
243197
});
244198

245-
it("passes requireAuth + requireRole through verbatim", async () => {
199+
it("passes requireAuth + requireRole through verbatim onto the ssr function", async () => {
246200
const { distDir } = writeFixture(root, { routes: [] });
247201
const slice = await buildAstroReleaseSlice(distDir, {
248202
requireAuth: true,
@@ -264,6 +218,16 @@ describe("buildAstroReleaseSlice — option surface", () => {
264218
cacheTtl: 30,
265219
});
266220
});
221+
});
222+
223+
describe("buildAstroReleaseSlice — explicit cacheClass option", () => {
224+
let root: string;
225+
beforeEach(() => {
226+
root = mkdtempSync(join(tmpdir(), "r402-release-slice-cache-"));
227+
});
228+
afterEach(() => {
229+
rmSync(root, { recursive: true, force: true });
230+
});
267231

268232
it("emits explicit public_paths for prerendered routes when cacheClass is set", async () => {
269233
const { distDir } = writeFixture(root, {
@@ -294,4 +258,18 @@ describe("buildAstroReleaseSlice — option surface", () => {
294258
"public_paths must remain implicit unless the caller asked for cacheClass overrides",
295259
);
296260
});
261+
262+
it("normalizes Astro's empty-string root pathname to / in explicit public_paths", async () => {
263+
const { distDir } = writeFixture(root, {
264+
routes: [{ pattern: "", pathname: "", prerender: true, type: "page" }],
265+
});
266+
const slice = await buildAstroReleaseSlice(distDir, { cacheClass: "html" });
267+
const publicPaths = (slice.site as { public_paths?: unknown }).public_paths as {
268+
mode: string;
269+
replace: Record<string, { asset: string; cache_class?: string }>;
270+
};
271+
assert.deepEqual(publicPaths.replace, {
272+
"/": { asset: "index.html", cache_class: "html" },
273+
});
274+
});
297275
});

0 commit comments

Comments
 (0)