Skip to content

Commit 87c23d6

Browse files
MajorTalclaude
andcommitted
feat(astro)!: omit routes from buildAstroReleaseSlice (gh#411)
buildAstroReleaseSlice used to return `routes: { replace: [] }`. An empty replace CLEARS the release route table, which (a) clobbers caller-declared base routes like a separately-mounted `/api/*` function and (b) trips CI route-scope enforcement (a CI OIDC session without route scopes is rejected for setting `routes` at all) - part of why consumers hand-rolled their own CI deploy specs and drifted into the #411 footgun. The slice now OMITS `routes` entirely (the field is optional on AstroReleaseSlice). An Astro hybrid release needs no explicit route table: prerendered pages resolve through the static manifest and every other path falls through to the implicit `class: "ssr"` fallback. Omitting `routes` carries base routes forward and keeps the slice CI-safe. BREAKING CHANGE: AstroReleaseSlice.routes is now optional and is undefined by default. Consumers reading `slice.routes.replace` must use `slice.routes?.replace ?? []`. Also fixes the stale doc comment (it described a `/*` catchall + per-page static aliases the helper has not emitted since v1.2) and adds a "Deploying with the SDK directly (and from CI)" README section with the do/don't. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 212e9e5 commit 87c23d6

3 files changed

Lines changed: 67 additions & 28 deletions

File tree

astro/README.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -430,6 +430,32 @@ run402 logs --request-id req_xyz123 --json # debug a failed render
430430

431431
`run402 init astro` creates a working project with `package.json` (dev/deploy scripts), `astro.config.mjs` (one-line preset), sample `[slug].astro` with the full DB-fetch + cache pattern, an admin save endpoint demonstrating cache invalidation, and `.env.example`. See `run402 init astro --help`.
432432

433+
### Deploying with the SDK directly (and from CI)
434+
435+
Most projects deploy with `run402 deploy` (above) or `run402 deploy apply --dir dist`. If you write your own deploy script with `@run402/sdk` — e.g. a CI job that assembles a custom `ReleaseSpec` — turn the build into a deploy slice with the one canonical helper. **Do not hand-roll `site` / `public_paths`.**
436+
437+
```ts
438+
import { run402 } from "@run402/sdk";
439+
import { buildAstroReleaseSlice } from "@run402/astro/release-slice";
440+
441+
const slice = await buildAstroReleaseSlice("dist"); // point at the BUILD ROOT, not dist/run402/client
442+
await run402().project(projectId).apply({
443+
database: { migrations }, // your own cross-cutting slices
444+
...slice, // site + functions (routes intentionally omitted)
445+
});
446+
```
447+
448+
`buildAstroReleaseSlice` is the only supported way to map an Astro build to a `ReleaseSpec`. It:
449+
450+
- roots the site at `dist/run402/client/` (the served output) — **not** `dist/`;
451+
- sets `site.public_paths: { mode: "implicit" }` so prerendered pages are reachable by filename;
452+
- bundles the SSR entry into a single `class: "ssr"` function;
453+
- **omits `routes`** (it is not in the returned object) so base-release routes — e.g. a separately-declared `/api/*` function — carry forward instead of being cleared, and the slice stays safe to submit from a CI OIDC session that has no route scopes.
454+
455+
**Anti-pattern (the cause of [kychee-com/run402#411](https://github.com/kychee-com/run402/issues/411)):** do not point `fileSetFromDir`/`dir()` at `dist/` and build `public_paths` by hand. That ships the adapter build tree (`run402/adapter.json`, `run402/server/**`) as static assets and lands every page under a `run402/client/` path prefix — so the static manifest has no reachable pages, every URL falls through to the SSR catchall and 404s, and the SSR bundle becomes publicly downloadable. The SDK now rejects this locally with `ASTRO_ADAPTER_TREE_IN_SITE` before any upload, and the gateway warns (`SITE_NO_REACHABLE_HTML`) when a release ships HTML that isn't reachable at any public path.
456+
457+
**Full vs CI/patch deploys use the same slice.** CI sessions are content-only (no subdomains/routes/i18n) — the slice already omits `routes`, so just don't add `routes`/`subdomains`/`i18n` to the spec in CI. The CAS substrate dedupes unchanged bytes, so a `site.replace` from the slice uploads only what actually changed; you don't need a hand-rolled `site.patch` diff to get incremental uploads.
458+
433459
## R402_* error codes
434460

435461
Build / deploy / runtime / cache failures all return a structured envelope:

astro/src/release-slice.test.ts

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,7 @@ describe("buildAstroReleaseSlice — happy path", () => {
137137
rmSync(root, { recursive: true, force: true });
138138
});
139139

140-
it("emits site (LocalDirRef), functions (bundled source), empty routes", async () => {
140+
it("emits site (LocalDirRef), functions (bundled source), and omits routes", async () => {
141141
const { distDir } = writeFixture(root, {
142142
output: "server",
143143
routes: [
@@ -181,19 +181,23 @@ describe("buildAstroReleaseSlice — happy path", () => {
181181
"v1.2 slice must NOT emit entrypoint — the bundle is self-contained",
182182
);
183183

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, []);
184+
// routes — OMITTED (not `{ replace: [] }`). The gateway's class:'ssr'
185+
// auto-fallback routes unmatched paths to the ssr function automatically,
186+
// and prerendered pages resolve via the static manifest's implicit
187+
// public-paths mode. Omitting `routes` carries forward any base-release
188+
// routes instead of clearing them, and keeps the slice CI-safe.
189+
assert.equal(
190+
slice.routes,
191+
undefined,
192+
"slice must omit routes so base routes carry forward and CI sessions aren't rejected",
193+
);
190194
});
191195

192196
it("functionName option flows through to functions key", async () => {
193197
const { distDir } = writeFixture(root, { routes: [] });
194198
const slice = await buildAstroReleaseSlice(distDir, { functionName: "render" });
195199
assert.deepEqual(Object.keys(slice.functions.replace), ["render"]);
196-
assert.deepEqual(slice.routes.replace, []);
200+
assert.equal(slice.routes, undefined);
197201
});
198202

199203
it("passes requireAuth + requireRole through verbatim onto the ssr function", async () => {

astro/src/release-slice.ts

Lines changed: 29 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -211,7 +211,17 @@ export interface BuildAstroReleaseSliceOptions {
211211
export interface AstroReleaseSlice {
212212
site: SiteSpec;
213213
functions: { replace: Record<string, FunctionSpec> };
214-
routes: { replace: RouteSpec[] };
214+
/**
215+
* Intentionally **omitted** by {@link buildAstroReleaseSlice} (the field is
216+
* optional, not `{ replace: [] }`). An Astro hybrid release needs no explicit
217+
* route table: prerendered pages resolve through the static manifest and
218+
* every other path falls through to the gateway's implicit `class: "ssr"`
219+
* fallback. Omitting `routes` carries forward any base-release routes (e.g. a
220+
* separately-declared `/api/*` function) instead of clearing them, and keeps
221+
* the slice safe to submit from a CI OIDC session that lacks route scopes.
222+
* Callers who need explicit routes declare them on top of the slice.
223+
*/
224+
routes?: { replace: RouteSpec[] };
215225
}
216226

217227
/**
@@ -320,9 +330,14 @@ export async function loadAstroAdapterManifest(
320330
* - `functions.replace[functionName]` describes the SSR Lambda entry from
321331
* `manifest.serverEntrypoint` and carries `class: "ssr"` (gateway v1.52+)
322332
* so the gateway enables SnapStart + ISR-cache routing.
323-
* - `routes.replace` emits one `{ type: "static", file }` alias per
324-
* prerendered route, followed by a final wildcard `/* → function:ssr`
325-
* catchall. Order matters at the gateway: explicit > prefix > catchall.
333+
* - `routes` is intentionally **omitted** from the returned slice (not
334+
* `{ replace: [] }`). An Astro hybrid release needs no explicit route table:
335+
* prerendered pages resolve through the static manifest (implicit
336+
* `public_paths`), and every unmatched path falls through to the gateway's
337+
* implicit `class: "ssr"` fallback (v1.52+). Omitting `routes` carries
338+
* forward any base-release routes (e.g. a separately-declared `/api/*`
339+
* function) instead of clearing them, and keeps the slice CI-safe — a CI
340+
* OIDC session without route scopes is rejected if the spec sets `routes`.
326341
*/
327342
export async function buildAstroReleaseSlice(
328343
distDir: string,
@@ -396,21 +411,15 @@ export async function buildAstroReleaseSlice(
396411
replace: { [functionName]: functionSpec },
397412
};
398413

399-
// No explicit routes are emitted by default. The gateway (v1.52+,
400-
// capability `astro-ssr-runtime`) routes every unmatched-path request
401-
// to the project's single class:'ssr' function automatically — so
402-
// the helper does not need to declare a /* catchall (which the route
403-
// validator rejects anyway: "prefix wildcard must include a path
404-
// segment before /*"). Prerendered routes are reachable via the
405-
// gateway's implicit public-paths mode against the static manifest;
406-
// we do not emit static-route aliases, which previously conflicted
407-
// with the implicit-mode declaration for the same path.
408-
//
409-
// Callers who want explicit prefix routes (e.g. `/api/*` → a dedicated
410-
// function) can still declare them on top of the slice; the slice's
411-
// own `routes.replace: []` is intentionally empty so it doesn't
412-
// clobber that pattern.
413-
const routes: RouteSpec[] = [];
414+
// `routes` is intentionally OMITTED from the returned slice (see the doc
415+
// comment above) — not emitted as `{ replace: [] }`. The gateway (v1.52+,
416+
// capability `astro-ssr-runtime`) routes every unmatched-path request to the
417+
// project's single `class: "ssr"` function automatically, and prerendered
418+
// pages resolve through `site.public_paths`. Omitting `routes` (rather than
419+
// sending an empty replace, which CLEARS the route table) carries forward any
420+
// caller-declared base routes (e.g. `/api/*` → a dedicated function) and
421+
// keeps the slice safe to submit from a CI OIDC session without route scopes.
422+
// Callers who need explicit routes declare them on top of the slice.
414423

415424
// `cacheClass` is plumbed into `site.public_paths` for prerendered routes
416425
// when a caller cares about overriding the default html cache class. The
@@ -436,7 +445,7 @@ export async function buildAstroReleaseSlice(
436445
};
437446
}
438447

439-
return { site, functions, routes: { replace: routes } };
448+
return { site, functions };
440449
}
441450

442451
/**

0 commit comments

Comments
 (0)