Skip to content

Commit 4f05ce3

Browse files
MajorTalclaude
andcommitted
feat(astro,sdk,cli): @run402/astro/release-slice helper + --dir flag
- astro: new buildAstroReleaseSlice + loadAstroAdapterManifest at @run402/astro/release-slice - astro: ssr-adapter writes per-route prerender truth (Astro 5+ routes arg) + real astroVersion - sdk: FunctionSpec.class?: 'ssr' | 'standard' (v1.52+ field type-declared) - sdk: mapSite passes through LocalDirRef so CLI --dir flow doesn't trip file-set walker - cli: run402 deploy apply --dir <build-output> reads adapter.json + merges slice - 12 new unit tests; 244 astro / 1003 unit / 562 e2e / 32 sync all green Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent ef0c2e7 commit 4f05ce3

8 files changed

Lines changed: 840 additions & 24 deletions

File tree

astro/package.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,10 @@
3131
"types": "./dist/ssr-detectors.d.ts",
3232
"import": "./dist/ssr-detectors.js"
3333
},
34+
"./release-slice": {
35+
"types": "./dist/release-slice.d.ts",
36+
"import": "./dist/release-slice.js"
37+
},
3438
"./runtime/server": {
3539
"types": "./dist/runtime/server.d.ts",
3640
"import": "./dist/runtime/server.js"
@@ -54,7 +58,7 @@
5458
],
5559
"scripts": {
5660
"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/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"
61+
"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/release-slice.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"
5862
},
5963
"engines": {
6064
"node": ">=18"

astro/src/release-slice.test.ts

Lines changed: 297 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,297 @@
1+
import assert from "node:assert/strict";
2+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3+
import { tmpdir } from "node:os";
4+
import { join } from "node:path";
5+
import { afterEach, beforeEach, describe, it } from "node:test";
6+
7+
import {
8+
buildAstroReleaseSlice,
9+
loadAstroAdapterManifest,
10+
Run402AstroAdapterManifestError,
11+
} from "./release-slice.js";
12+
import type { Run402AdapterManifest } from "./ssr-adapter.js";
13+
14+
function writeFixture(
15+
root: string,
16+
manifest: Partial<Run402AdapterManifest> | string | null,
17+
): { distDir: string; entryAbs: string } {
18+
const distDir = join(root, "dist");
19+
const serverDir = join(distDir, "run402", "server");
20+
const clientDir = join(distDir, "run402", "client");
21+
mkdirSync(serverDir, { recursive: true });
22+
mkdirSync(clientDir, { recursive: true });
23+
const entryAbs = join(serverDir, "entry.mjs");
24+
writeFileSync(entryAbs, "export const handler = async () => new Response('ok');\n");
25+
writeFileSync(join(clientDir, "index.html"), "<!doctype html><title>home</title>");
26+
27+
if (manifest !== null) {
28+
const adapterPath = join(distDir, "run402", "adapter.json");
29+
const body =
30+
typeof manifest === "string"
31+
? manifest
32+
: JSON.stringify({
33+
version: "1.0",
34+
astroVersion: "6.1.3",
35+
output: "server",
36+
serverEntrypoint: entryAbs,
37+
clientDir,
38+
routes: [],
39+
features: { middleware: true, serverIslands: false, sessions: false, mdx: true },
40+
...manifest,
41+
});
42+
writeFileSync(adapterPath, body);
43+
}
44+
45+
return { distDir, entryAbs };
46+
}
47+
48+
describe("loadAstroAdapterManifest", () => {
49+
let root: string;
50+
beforeEach(() => {
51+
root = mkdtempSync(join(tmpdir(), "r402-release-slice-load-"));
52+
});
53+
afterEach(() => {
54+
rmSync(root, { recursive: true, force: true });
55+
});
56+
57+
it("returns parsed manifest on the happy path", async () => {
58+
const { distDir, entryAbs } = writeFixture(root, {});
59+
const m = await loadAstroAdapterManifest(distDir);
60+
assert.equal(m.version, "1.0");
61+
assert.equal(m.serverEntrypoint, entryAbs);
62+
assert.equal(m.output, "server");
63+
});
64+
65+
it("throws R402_ASTRO_ADAPTER_MANIFEST_MISSING when adapter.json is absent", async () => {
66+
const { distDir } = writeFixture(root, null);
67+
await assert.rejects(
68+
() => loadAstroAdapterManifest(distDir),
69+
(err: unknown) => {
70+
assert.ok(err instanceof Run402AstroAdapterManifestError);
71+
assert.equal(err.code, "R402_ASTRO_ADAPTER_MANIFEST_MISSING");
72+
assert.match(err.file, /dist[/\\]run402[/\\]adapter\.json$/);
73+
assert.equal(
74+
err.docs,
75+
"https://docs.run402.com/errors#R402_ASTRO_ADAPTER_MANIFEST_MISSING",
76+
);
77+
assert.ok(typeof err.suggestedFix === "string" && err.suggestedFix.length > 0);
78+
return true;
79+
},
80+
);
81+
});
82+
83+
it("throws R402_ASTRO_ADAPTER_MANIFEST_MISSING when adapter.json is not valid JSON", async () => {
84+
const { distDir } = writeFixture(root, "this { is not json");
85+
await assert.rejects(
86+
() => loadAstroAdapterManifest(distDir),
87+
(err: unknown) => {
88+
assert.ok(err instanceof Run402AstroAdapterManifestError);
89+
assert.equal(err.code, "R402_ASTRO_ADAPTER_MANIFEST_MISSING");
90+
return true;
91+
},
92+
);
93+
});
94+
95+
it("throws R402_ASTRO_ADAPTER_MANIFEST_MISSING when adapter.json is not an object", async () => {
96+
const { distDir } = writeFixture(root, "[1,2,3]");
97+
await assert.rejects(
98+
() => loadAstroAdapterManifest(distDir),
99+
(err: unknown) => {
100+
assert.ok(err instanceof Run402AstroAdapterManifestError);
101+
assert.equal(err.code, "R402_ASTRO_ADAPTER_MANIFEST_MISSING");
102+
return true;
103+
},
104+
);
105+
});
106+
107+
it("throws R402_ASTRO_ADAPTER_MANIFEST_VERSION_UNSUPPORTED when version is outside the supported set", async () => {
108+
const { distDir } = writeFixture(root, { version: "2.0" } as unknown as Partial<Run402AdapterManifest>);
109+
await assert.rejects(
110+
() => loadAstroAdapterManifest(distDir),
111+
(err: unknown) => {
112+
assert.ok(err instanceof Run402AstroAdapterManifestError);
113+
assert.equal(err.code, "R402_ASTRO_ADAPTER_MANIFEST_VERSION_UNSUPPORTED");
114+
assert.equal(err.observedVersion, "2.0");
115+
assert.equal(
116+
err.docs,
117+
"https://docs.run402.com/errors#R402_ASTRO_ADAPTER_MANIFEST_VERSION_UNSUPPORTED",
118+
);
119+
return true;
120+
},
121+
);
122+
});
123+
});
124+
125+
describe("buildAstroReleaseSlice — happy path + prerender truth", () => {
126+
let root: string;
127+
beforeEach(() => {
128+
root = mkdtempSync(join(tmpdir(), "r402-release-slice-build-"));
129+
});
130+
afterEach(() => {
131+
rmSync(root, { recursive: true, force: true });
132+
});
133+
134+
it("returns site/functions/routes from a hybrid build", async () => {
135+
const { distDir } = writeFixture(root, {
136+
output: "server",
137+
routes: [
138+
{ pattern: "/about", pathname: "/about", prerender: true, type: "page" },
139+
{ pattern: "/[slug]", prerender: false, type: "page" },
140+
],
141+
});
142+
143+
const slice = await buildAstroReleaseSlice(distDir);
144+
145+
// site
146+
const siteReplace = (slice.site as { replace: unknown }).replace as {
147+
__source: string;
148+
path: string;
149+
};
150+
assert.equal(siteReplace.__source, "local-dir");
151+
assert.match(siteReplace.path, /dist[/\\]run402[/\\]client$/);
152+
153+
// functions
154+
assert.deepEqual(Object.keys(slice.functions.replace), ["ssr"]);
155+
const fn = slice.functions.replace.ssr;
156+
assert.equal(fn.runtime, "node22");
157+
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",
162+
);
163+
assert.ok(
164+
Object.prototype.hasOwnProperty.call(fn.files, "entry.mjs"),
165+
"FileSet must contain the entry.mjs key",
166+
);
167+
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 });
234+
});
235+
236+
it("respects functionName override across functions + routes", async () => {
237+
const { distDir } = writeFixture(root, { routes: [] });
238+
const slice = await buildAstroReleaseSlice(distDir, { functionName: "render" });
239+
assert.deepEqual(Object.keys(slice.functions.replace), ["render"]);
240+
assert.deepEqual(slice.routes.replace, [
241+
{ pattern: "/*", target: { type: "function", name: "render" } },
242+
]);
243+
});
244+
245+
it("passes requireAuth + requireRole through verbatim", async () => {
246+
const { distDir } = writeFixture(root, { routes: [] });
247+
const slice = await buildAstroReleaseSlice(distDir, {
248+
requireAuth: true,
249+
requireRole: {
250+
table: "users",
251+
idColumn: "id",
252+
roleColumn: "role",
253+
allowed: ["admin", "editor"],
254+
cacheTtl: 30,
255+
},
256+
});
257+
const fn = slice.functions.replace.ssr;
258+
assert.equal(fn.requireAuth, true);
259+
assert.deepEqual(fn.requireRole, {
260+
table: "users",
261+
idColumn: "id",
262+
roleColumn: "role",
263+
allowed: ["admin", "editor"],
264+
cacheTtl: 30,
265+
});
266+
});
267+
268+
it("emits explicit public_paths for prerendered routes when cacheClass is set", async () => {
269+
const { distDir } = writeFixture(root, {
270+
routes: [
271+
{ pattern: "/about", pathname: "/about", prerender: true, type: "page" },
272+
{ pattern: "/[slug]", prerender: false, type: "page" },
273+
],
274+
});
275+
const slice = await buildAstroReleaseSlice(distDir, { cacheClass: "revalidating_asset" });
276+
const publicPaths = (slice.site as { public_paths?: unknown }).public_paths as {
277+
mode: string;
278+
replace: Record<string, { asset: string; cache_class?: string }>;
279+
};
280+
assert.equal(publicPaths.mode, "explicit");
281+
assert.deepEqual(publicPaths.replace, {
282+
"/about": { asset: "about/index.html", cache_class: "revalidating_asset" },
283+
});
284+
});
285+
286+
it("does not emit public_paths when cacheClass is not provided", async () => {
287+
const { distDir } = writeFixture(root, {
288+
routes: [{ pattern: "/about", pathname: "/about", prerender: true, type: "page" }],
289+
});
290+
const slice = await buildAstroReleaseSlice(distDir);
291+
assert.equal(
292+
(slice.site as { public_paths?: unknown }).public_paths,
293+
undefined,
294+
"public_paths must remain implicit unless the caller asked for cacheClass overrides",
295+
);
296+
});
297+
});

0 commit comments

Comments
 (0)