Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/datadir-guard-239.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@cloudhal/astro-renderer": patch
---

The `cloudflare` target no longer bakes `dataDir` into `virtual:cloudhal/config` (#239, RFC-0046 §11b). The Site Worker reads D1 and R2 through its bindings and never read the field. The baked value was the build machine's absolute data path, so a release bundle built in CI would have carried that path to every deployment. Node targets (`cloudhal dev`, static builds) keep it.

The virtual module is now typed as `VirtualCloudhalConfig`, with `dataDir` optional; the type is exported, and the starter's `env.d.ts` uses it. Theme code that reads `dataDir` must handle its absence on the Cloudflare Site Worker.
5 changes: 3 additions & 2 deletions docs/rfcs/0046-release-bundles.md
Original file line number Diff line number Diff line change
Expand Up @@ -419,7 +419,7 @@ Today `cloudhal deploy` builds the site with Astro and the admin Worker with wra
- `analytics.queryBudget`, `analytics.rawRetentionDays` and `analytics.rateLimit`.
- `runtime`: a build reads the key, but the deployed Worker overrides it.
- `siteId`: the Site Worker's `CLOUDHAL_SITE_ID`/`CLOUDHAL_SITE_IDS` vars override the baked id.
- `dataDir`: the `cloudflare` target never reads it. The baked value is the CI build's absolute path.
- `dataDir`: the `cloudflare` target never reads it, and since #239 does not bake it in either (`virtual:cloudhal/config` has no `dataDir` on that target), so no `dataDir` path reaches a bundle. Astro's own serialized manifest still carries the build's root, source and output directories as absolute `file://` URLs; those are Astro's, not `cloudhal.json` keys.
- `analytics.enabled`, `analytics.uniqueVisitors` and `analytics.vitalsOneIn`: the Site Worker reads its vars instead.
- `analytics.dev`: only `cloudhal dev` reads it.
- `build`: a build reads the key and nothing overrides it.
Expand All @@ -432,7 +432,8 @@ Today `cloudhal deploy` builds the site with Astro and the admin Worker with wra
- that every sub-key the `cloudhal()` integration's option types declare for `media`, `analytics` and `comments` is classed.
- **Site.** A repository test scans `starters/blog/astro.config.mjs` for every `project.<key>` it reads. It fails unless every such key is in the table. For `media`, `analytics` and `comments`, which the file passes to the integration whole, the sub-key check above applies. The theme check guarantees that a project deployed with `--release` carries this exact file. Two further tests hold the `runtime` overrides:
- a Site Worker test serves a site whose id differs from the baked one, through `CLOUDHAL_SITE_ID`;
- an astro-renderer test shows that the `cloudflare` target ignores `dataDir`. If it does not, the integration stops emitting `dataDir` for that target.
- the `cloudflare` target ignores `dataDir` (#239). The integration emits no `dataDir` for it. `astro-renderer/test/virtual-config.test.ts` checks the module. `cli/test/cloudflare-datadir.test.ts` builds the starter twice with different `dataDir`s and checks that the two outputs are the same bytes and that neither path appears in them. It then serves the site on workerd with no data directory on disk.
- **Bundles are not byte-reproducible, by design.** Astro writes a random per-build `key` into its manifest; it encrypts server-island and action props. The test pins it with `ASTRO_KEY` only to compare its two builds. The release build must not pin it: a fixed key would be published in every tarball. Two bundles built from one commit therefore differ in that key, and trust rests on the signature and the build attestation, never on rebuilding to the same bytes.
- **Admin.** A test runs `prepareAdminWorker` twice, with different `mail`, `ai`, `browser`, `observability`, `placement`, `domains.studio` and `siteId`. The generated entry, the skills module and the theme-design module must be byte-identical; only the wrangler config may differ. The code the admin build bakes in reads only files (block types, theme settings, `src/DESIGN.md`, skills), and the theme check covers those files.
- **Other refusals.** The CLI also refuses when the project is attached to another installation (RFC-0034 `installation`), serves several sites (`sites`), or runs in Workers Builds.
- **Wrangler.** `--release` needs a wrangler at least as new as the one the bundle was built with (`manifest.toolchain.wrangler`), because the generated configs use its asset and module settings. The CLI reads the version of the wrangler it will run, resolved the way `deploy` resolves it today, from that wrangler's own `package.json`. An older wrangler is refused, and the refusal names the required version. The starter only declares a range (`wrangler ^4.158.0`), so the check trusts the installed version, never the range.
Expand Down
3 changes: 2 additions & 1 deletion packages/astro-renderer/src/astro-env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ declare module "virtual:cloudhal/analytics-client" {
}

declare module "virtual:cloudhal/config" {
const config: import("./integration.ts").CloudhalRendererConfig;
/** No `dataDir` on the `cloudflare` target (`virtualConfig`). */
const config: import("./integration.ts").VirtualCloudhalConfig;
export default config;
}

Expand Down
6 changes: 5 additions & 1 deletion packages/astro-renderer/src/comments-middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,11 @@ const handler = createNodeComments({
// the same local store, site row and published snapshots analytics read
backend: analyticsBackend(config.siteId, config) as NodeCommentsBackend,
client: config.comments?.available === false ? null : client,
secret: () => localCommentsKey(config.dataDir),
// `cloudhal dev` only (a Node target), where the integration always passes dataDir.
secret: () => {
if (!config.dataDir) throw new Error("the comments middleware needs the renderer's dataDir");
return localCommentsKey(config.dataDir);
},
onError: (m, e) => console.warn(`cloudhal: ${m}`, e),
});

Expand Down
6 changes: 5 additions & 1 deletion packages/astro-renderer/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,11 @@ export * from "./collection-feeds.ts";
export * from "./entry-query.ts";
export * from "./fields.ts";
export * from "./gates.ts";
export { type CloudhalRendererConfig, cloudhal } from "./integration.ts";
export {
type CloudhalRendererConfig,
cloudhal,
type VirtualCloudhalConfig,
} from "./integration.ts";
export { renderBody, renderEntryArticle, renderMarkdown, safeHref } from "./markdown.ts";
export * from "./navigation.ts";
export * from "./preview.ts";
Expand Down
25 changes: 24 additions & 1 deletion packages/astro-renderer/src/integration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,29 @@ const THEME_HOOKS_ID = "virtual:cloudhal/theme-hooks";
const RESOLVED_THEME_HOOKS_ID = `\0${THEME_HOOKS_ID}`;
const THEME_MODULE = "src/theme.ts";

/**
* The type of `virtual:cloudhal/config`: the integration's config, with `dataDir` absent on the
* `cloudflare` target (`virtualConfig`). Theme code that reads `dataDir` must handle that.
*/
export type VirtualCloudhalConfig = Omit<CloudhalRendererConfig, "dataDir"> & {
readonly dataDir?: string;
};

/**
* What pages see as `virtual:cloudhal/config`. On the `cloudflare` target there is no `dataDir`:
* the Site Worker reads D1 and R2 through its bindings (`runtime-workerd.ts` ignores the field),
* and the resolved value is the build machine's absolute path, which a release bundle built in CI
* would otherwise carry to every deployment (RFC-0046 §11b, #239).
*/
export function virtualConfig(
resolved: CloudhalRendererConfig,
cloudflare: boolean,
): VirtualCloudhalConfig {
if (!cloudflare) return resolved;
const { dataDir: _buildMachinePath, ...rest } = resolved;
return rest;
}

/** The virtual modules: the project config, and in dev the built analytics and comments clients. */
function configPlugin(modules: { config: unknown; client: unknown; comments: unknown }) {
const ids: Record<string, string> = {
Expand Down Expand Up @@ -182,7 +205,7 @@ export function cloudhal(config: CloudhalRendererConfig): AstroIntegration {
vite: {
plugins: [
configPlugin({
config: resolved,
config: virtualConfig(resolved, cloudflare),
client: cloudflare ? null : (client ?? null),
comments: cloudflare ? null : (comments ?? null),
}),
Expand Down
6 changes: 3 additions & 3 deletions packages/astro-renderer/src/runtime-workerd.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import { D1Driver } from "@cloudhal/storage/d1";
import { R2BlobStore } from "@cloudhal/storage/r2";
import { siteCacheTag } from "./cache-tags.ts";
import { usableCommentsKey } from "./comments.ts";
import type { CloudhalRendererConfig } from "./integration.ts";
import type { CloudhalRendererConfig, VirtualCloudhalConfig } from "./integration.ts";
import type { PeerEdition } from "./locales.ts";
import { matchMediaRoute, serveMediaRequest } from "./media-route.ts";
import { PREVIEW_PARAM, type Resolution, SiteResolver } from "./resolver.ts";
Expand Down Expand Up @@ -263,7 +263,7 @@ export { CACHE_TAG_PREFIX } from "./cache-tags.ts";
* left out (there is no address to point to).
*/
export async function peerEditions(
config: CloudhalRendererConfig,
config: VirtualCloudhalConfig,
node: { id: string; translation?: { group: string } | undefined; path?: string },
): Promise<PeerEdition[]> {
const siteId = siteOf(config.siteId);
Expand Down Expand Up @@ -314,7 +314,7 @@ export async function commentsBackend(
* Returns `undefined` when the site has never been published (the page should answer 503).
*/
export async function getSource(
config: CloudhalRendererConfig,
config: VirtualCloudhalConfig,
astro?: AstroLike,
): Promise<GraphSource | undefined> {
if (!astro) throw new Error("on Cloudflare, pass the Astro global: getSource(config, Astro)");
Expand Down
19 changes: 14 additions & 5 deletions packages/astro-renderer/src/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type { Site } from "@cloudhal/core-contracts";
import type { SiteGraphStore } from "@cloudhal/core-ts";
import { type LocalStore, openLocalStore } from "@cloudhal/core-ts/local";
import type { SqlDriver } from "@cloudhal/storage";
import type { CloudhalRendererConfig } from "./integration.ts";
import type { CloudhalRendererConfig, VirtualCloudhalConfig } from "./integration.ts";
import type { PeerEdition } from "./locales.ts";
import { matchMediaRoute, serveMediaRequest } from "./media-route.ts";
import { SiteResolver } from "./resolver.ts";
Expand All @@ -16,13 +16,22 @@ let cached: { key: string; source: Promise<GraphSource> } | undefined;
* selector. `CLOUDHAL_SOURCE=live | snapshot:<id>` overrides the integration config (used by
* `cloudhal build`); a later build in the same process with another selector gets a new source.
*/
/** On Node targets the integration always passes `dataDir`; only the `cloudflare` target omits it. */
export function nodeDataDir(config: Pick<VirtualCloudhalConfig, "dataDir">): string {
if (!config.dataDir)
throw new Error(
"the renderer config has no dataDir: Node targets read the local store from it",
);
return config.dataDir;
}

export function getSource(
config: CloudhalRendererConfig,
config: VirtualCloudhalConfig,
/** Ignored on Node (one source per process); the `workerd` build resolves per request. */
_astro?: { request: Request; response: { headers: Headers } },
): Promise<GraphSource> {
const selector = process.env.CLOUDHAL_SOURCE ?? config.source ?? "live";
const key = `${config.dataDir}\n${config.siteId}\n${selector}`;
const key = `${nodeDataDir(config)}\n${config.siteId}\n${selector}`;
if (cached?.key !== key) cached = { key, source: open(config, selector) };
return cached.source;
}
Expand All @@ -35,9 +44,9 @@ function localStore(dataDir: string): Promise<LocalStore> {
return local.store;
}

function open(config: CloudhalRendererConfig, selector: string): Promise<GraphSource> {
function open(config: VirtualCloudhalConfig, selector: string): Promise<GraphSource> {
return (async () => {
const { store } = await localStore(config.dataDir);
const { store } = await localStore(nodeDataDir(config));
if (selector === "live") return new LiveSource(store, config.siteId);
if (selector.startsWith("snapshot:"))
return SnapshotSource.open(store, selector.slice("snapshot:".length));
Expand Down
54 changes: 54 additions & 0 deletions packages/astro-renderer/test/virtual-config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/**
* `virtual:cloudhal/config` (RFC-0046 §11b, #239): the `cloudflare` target bakes in no `dataDir`.
* The Site Worker reads D1 and R2 through its bindings, and the resolved value would be the build
* machine's absolute path, which a release bundle built in CI would carry to every deployment.
* Node targets keep it (`cloudhal dev` and static builds read the local store from it).
*/
import { describe, expect, it } from "vitest";
import { cloudhal, virtualConfig } from "../src/integration.ts";

type Plugin = { name: string; load: (id: string) => string | undefined };

/** The virtual config module's source after `astro:config:setup` (no build, no dev server). */
async function configModule(target: "node" | "cloudflare", dataDir: string): Promise<string> {
const integration = cloudhal({ siteId: "site_abc123", dataDir, target });
const plugins: Plugin[] = [];
const setup = integration.hooks["astro:config:setup"] as unknown as (o: object) => Promise<void>;
await setup({
command: "sync",
config: { root: new URL("file:///ci/runner/work/project/"), vite: {} },
addMiddleware: () => {},
updateConfig: (c: { vite?: { plugins?: Plugin[] } }) =>
plugins.push(...(c.vite?.plugins ?? [])),
});
const plugin = plugins.find((p) => p.name === "cloudhal-config");
const body = plugin?.load("\0virtual:cloudhal/config");
if (!body) throw new Error("no virtual config module");
return body;
}

describe("virtual:cloudhal/config", () => {
it("carries no dataDir on the cloudflare target, whatever cloudhal.json says", async () => {
for (const dataDir of [".cloudhal", "/ci/runner/work/data", "other/data"]) {
const body = await configModule("cloudflare", dataDir);
expect(body).not.toContain("dataDir");
expect(body).not.toContain("/ci/runner");
expect(body).toContain('"siteId":"site_abc123"');
}
// So the module is the same bytes for every dataDir.
expect(await configModule("cloudflare", ".cloudhal")).toBe(
await configModule("cloudflare", "/elsewhere/data"),
);
});

it("keeps the resolved dataDir on Node targets", async () => {
expect(await configModule("node", ".cloudhal")).toContain(
'"dataDir":"/ci/runner/work/project/.cloudhal"',
);
expect(virtualConfig({ siteId: "s", dataDir: "/d" }, false)).toEqual({
siteId: "s",
dataDir: "/d",
});
expect(virtualConfig({ siteId: "s", dataDir: "/d" }, true)).toEqual({ siteId: "s" });
});
});
Loading
Loading