From 4cb3ac6aaf7ef4f0b187aa24cd96083328ea2c5c Mon Sep 17 00:00:00 2001 From: Madraka <8898640+Madraka@users.noreply.github.com> Date: Sun, 15 Nov 2026 09:56:08 +0300 Subject: [PATCH 1/2] fix(astro-renderer): no dataDir in the cloudflare target's config (#239) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RFC-0046 §11b classes cloudhal.json dataDir as runtime: the Site Worker reads D1 and R2 through its bindings (runtime-workerd.ts ignores the field), but the integration still baked the resolved value, the build machine's absolute path, into virtual:cloudhal/config, so a release bundle built in CI carried it to every deployment. The cloudflare target now gets the config without dataDir (virtualConfig); Node targets keep it. - astro-renderer test: the module has no dataDir on cloudflare, whatever cloudhal.json says, and keeps it on node. - cli workerd test: two starter builds that differ only in dataDir are the same bytes (Astro's per-build key pinned with ASTRO_KEY), neither carries the path, and the built Worker serves the site with no data directory on disk. Without the fix the first two fail. - RFC-0046 §11b cites the guard. Closes #239 Co-Authored-By: Claude Opus 5.5 Signed-off-by: Madraka <8898640+Madraka@users.noreply.github.com> --- .changeset/datadir-guard-239.md | 5 + docs/rfcs/0046-release-bundles.md | 4 +- packages/astro-renderer/src/integration.ts | 17 ++- .../test/virtual-config.test.ts | 54 +++++++ packages/cli/test/cloudflare-datadir.test.ts | 135 ++++++++++++++++++ 5 files changed, 212 insertions(+), 3 deletions(-) create mode 100644 .changeset/datadir-guard-239.md create mode 100644 packages/astro-renderer/test/virtual-config.test.ts create mode 100644 packages/cli/test/cloudflare-datadir.test.ts diff --git a/.changeset/datadir-guard-239.md b/.changeset/datadir-guard-239.md new file mode 100644 index 00000000..6bf4a100 --- /dev/null +++ b/.changeset/datadir-guard-239.md @@ -0,0 +1,5 @@ +--- +"@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 value was the build machine's absolute path, which a release bundle built in CI would have carried to every deployment. Node targets (`cloudhal dev`, static builds) keep it. diff --git a/docs/rfcs/0046-release-bundles.md b/docs/rfcs/0046-release-bundles.md index 3139e353..c3be1d65 100644 --- a/docs/rfcs/0046-release-bundles.md +++ b/docs/rfcs/0046-release-bundles.md @@ -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 CI path reaches a bundle. - `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. @@ -432,7 +432,7 @@ 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.` 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. - **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. diff --git a/packages/astro-renderer/src/integration.ts b/packages/astro-renderer/src/integration.ts index 67f60267..6bed6d10 100644 --- a/packages/astro-renderer/src/integration.ts +++ b/packages/astro-renderer/src/integration.ts @@ -72,6 +72,21 @@ const THEME_HOOKS_ID = "virtual:cloudhal/theme-hooks"; const RESOLVED_THEME_HOOKS_ID = `\0${THEME_HOOKS_ID}`; const THEME_MODULE = "src/theme.ts"; +/** + * 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, +): Omit & { dataDir?: string } { + 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 = { @@ -182,7 +197,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), }), diff --git a/packages/astro-renderer/test/virtual-config.test.ts b/packages/astro-renderer/test/virtual-config.test.ts new file mode 100644 index 00000000..ef3d2a01 --- /dev/null +++ b/packages/astro-renderer/test/virtual-config.test.ts @@ -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 { + const integration = cloudhal({ siteId: "site_abc123", dataDir, target }); + const plugins: Plugin[] = []; + const setup = integration.hooks["astro:config:setup"] as unknown as (o: object) => Promise; + 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" }); + }); +}); diff --git a/packages/cli/test/cloudflare-datadir.test.ts b/packages/cli/test/cloudflare-datadir.test.ts new file mode 100644 index 00000000..64012230 --- /dev/null +++ b/packages/cli/test/cloudflare-datadir.test.ts @@ -0,0 +1,135 @@ +/** + * #239 (RFC-0046 §11b): the Site Worker never reads `cloudhal.json` `dataDir`. A release bundle is + * built in CI, so a dataDir in the build would be that machine's absolute path. Two builds of the + * starter that differ only in `dataDir` are the same bytes, neither carries the path, and the built + * Worker serves the site on real workerd (wrangler's test harness, local D1 and R2) with no data + * directory left on disk at all. + */ +import { createHash } from "node:crypto"; +import { mkdtemp, readdir, readFile, rename, rm, symlink, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { copySite } from "@cloudhal/core-ts"; +import { openLocalStore } from "@cloudhal/core-ts/local"; +import { migrate, migrations } from "@cloudhal/storage"; +import { D1Driver } from "@cloudhal/storage/d1"; +import { R2BlobStore } from "@cloudhal/storage/r2"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { createTestHarness } from "wrangler"; +import { buildSiteWorker } from "../src/cloudflare/build.ts"; +import { siteConfigPath, siteWorkerConfig, writeWranglerConfig } from "../src/cloudflare/config.ts"; +import { init, snapshot } from "../src/index.ts"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const STARTER_NODE_MODULES = path.resolve(here, "../../../starters/blog/node_modules"); +/** + * Astro writes a random per-build `key` (server islands, actions) into the manifest unless + * `ASTRO_KEY` sets it; fixed here so the two builds can be compared byte for byte. With the key + * fixed, the build is deterministic. + */ +const ASTRO_KEY = Buffer.alloc(32, 7).toString("base64"); +/** A dataDir no machine has: the build must not need it, nor carry it. */ +const ELSEWHERE = "/nonexistent/cloudhal-ci-runner/data"; + +/** SHA-256 of every file the build wrote, by path. */ +async function digests(dir: string, rel = ""): Promise> { + const out: Record = {}; + for (const e of await readdir(path.join(dir, rel), { withFileTypes: true })) { + const r = rel ? `${rel}/${e.name}` : e.name; + if (e.isDirectory()) Object.assign(out, await digests(dir, r)); + else + out[r] = createHash("sha256") + .update(await readFile(path.join(dir, r))) + .digest("hex"); + } + return out; +} + +/** Whether any built file contains `needle`. */ +async function anyFileContains(dir: string, needle: string, rel = ""): Promise { + for (const e of await readdir(path.join(dir, rel), { withFileTypes: true })) { + const r = rel ? `${rel}/${e.name}` : e.name; + if (e.isDirectory()) { + const found = await anyFileContains(dir, needle, r); + if (found) return found; + } else if ((await readFile(path.join(dir, r), "latin1")).includes(needle)) return r; + } + return undefined; +} + +describe("the Site Worker ignores dataDir (#239)", () => { + let tmp: string; + let projectDir: string; + let siteId: string; + let first: Record; + let second: Record; + let harness: ReturnType; + + const setDataDir = async (dataDir: string) => { + const file = path.join(projectDir, "cloudhal.json"); + const cfg = JSON.parse(await readFile(file, "utf8")) as Record; + await writeFile(file, JSON.stringify({ ...cfg, dataDir }, null, 2)); + }; + + const savedKey = process.env.ASTRO_KEY; + beforeAll(async () => { + process.env.ASTRO_KEY = ASTRO_KEY; + tmp = await mkdtemp(path.join(os.tmpdir(), "cloudhal-datadir-")); + projectDir = path.join(tmp, "site"); + siteId = (await init({ dir: projectDir, name: "Datadir Site" })).siteId; + await symlink(STARTER_NODE_MODULES, path.join(projectDir, "node_modules"), "dir"); + await snapshot({ projectDir, label: "v1", publish: true }); + await writeWranglerConfig( + siteConfigPath(projectDir), + siteWorkerConfig({ + name: "datadir-test", + siteId, + databaseId: "00000000-0000-0000-0000-00000000da7a", + }), + "test", + ); + await buildSiteWorker(projectDir); + first = await digests(path.join(projectDir, "dist")); + await setDataDir(ELSEWHERE); + const config = await buildSiteWorker(projectDir); + second = await digests(path.join(projectDir, "dist")); + + harness = createTestHarness({ root: projectDir, workers: [{ configPath: config }] }); + await harness.listen(); + const env = (await harness.getWorker().getEnv()) as { DB: D1Database; BLOBS: R2Bucket }; + const target = { sql: new D1Driver(env.DB), blobs: new R2BlobStore(env.BLOBS) }; + await migrate(target.sql, migrations); + const local = await openLocalStore(path.join(projectDir, ".cloudhal")); + await copySite(local, target, siteId); + await local.close(); + // No data directory anywhere now: neither the built one nor the configured one exists. + await rename(path.join(projectDir, ".cloudhal"), path.join(tmp, "moved-away")); + }, 600_000); + + afterAll(async () => { + if (savedKey === undefined) delete process.env.ASTRO_KEY; + else process.env.ASTRO_KEY = savedKey; + await harness?.close(); + await rm(tmp, { recursive: true, force: true }); + }); + + it("two builds that differ only in dataDir are the same bytes", () => { + expect(Object.keys(second).length).toBeGreaterThan(10); + expect(second).toEqual(first); + }); + + it("no built file carries either data directory", async () => { + const dist = path.join(projectDir, "dist"); + expect(await anyFileContains(dist, ELSEWHERE)).toBeUndefined(); + expect(await anyFileContains(dist, path.join(projectDir, ".cloudhal"))).toBeUndefined(); + }); + + it("the built Worker serves the site from its bindings with no data directory on disk", async () => { + const res = await harness.fetch("https://datadir.example/"); + const html = await res.text(); + expect(res.status, html.slice(0, 500)).toBe(200); + expect(html).toMatch(/]*>Datadir Site<\/h1>/); + expect((await harness.fetch("https://datadir.example/posts/hello-world")).status).toBe(200); + }); +}); From 756f4ee1813e0c59c40a1fd4ea186f2ce2448439 Mon Sep 17 00:00:00 2001 From: Madraka <8898640+Madraka@users.noreply.github.com> Date: Sun, 15 Nov 2026 10:08:03 +0300 Subject: [PATCH 2/2] fix(astro-renderer): PR #240 review: virtual config type, wording (#239) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - virtual:cloudhal/config is typed VirtualCloudhalConfig (dataDir optional, exported) in astro-renderer's astro-env.d.ts and the starter's env.d.ts; getSource and peerEditions take it, and the Node paths that need dataDir say so when it is missing (nodeDataDir, the comments middleware). - RFC-0046 §11b and the changeset say no dataDir path reaches a bundle (Astro's own manifest still carries its root, src and out URLs), and that bundles are not byte-reproducible by design: Astro's random per-build key stays unpinned in the release build, as a fixed one would be published. - cloudflare-datadir.test scans the first build for its own path before the second build replaces dist. Refs #239 Co-Authored-By: Claude Opus 5.5 Signed-off-by: Madraka <8898640+Madraka@users.noreply.github.com> --- .changeset/datadir-guard-239.md | 4 +++- docs/rfcs/0046-release-bundles.md | 3 ++- packages/astro-renderer/src/astro-env.d.ts | 3 ++- .../astro-renderer/src/comments-middleware.ts | 6 +++++- packages/astro-renderer/src/index.ts | 6 +++++- packages/astro-renderer/src/integration.ts | 10 +++++++++- .../astro-renderer/src/runtime-workerd.ts | 6 +++--- packages/astro-renderer/src/runtime.ts | 19 ++++++++++++++----- packages/cli/test/cloudflare-datadir.test.ts | 9 +++++++++ starters/blog/src/env.d.ts | 3 ++- 10 files changed, 54 insertions(+), 15 deletions(-) diff --git a/.changeset/datadir-guard-239.md b/.changeset/datadir-guard-239.md index 6bf4a100..4bcdbf6b 100644 --- a/.changeset/datadir-guard-239.md +++ b/.changeset/datadir-guard-239.md @@ -2,4 +2,6 @@ "@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 value was the build machine's absolute path, which a release bundle built in CI would have carried to every deployment. Node targets (`cloudhal dev`, static builds) keep it. +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. diff --git a/docs/rfcs/0046-release-bundles.md b/docs/rfcs/0046-release-bundles.md index c3be1d65..3207cfbc 100644 --- a/docs/rfcs/0046-release-bundles.md +++ b/docs/rfcs/0046-release-bundles.md @@ -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, and since #239 does not bake it in either (`virtual:cloudhal/config` has no `dataDir` on that target), so no CI path reaches a bundle. + - `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. @@ -433,6 +433,7 @@ Today `cloudhal deploy` builds the site with Astro and the admin Worker with wra - **Site.** A repository test scans `starters/blog/astro.config.mjs` for every `project.` 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`; - 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. diff --git a/packages/astro-renderer/src/astro-env.d.ts b/packages/astro-renderer/src/astro-env.d.ts index 6be51bd9..eba59892 100644 --- a/packages/astro-renderer/src/astro-env.d.ts +++ b/packages/astro-renderer/src/astro-env.d.ts @@ -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; } diff --git a/packages/astro-renderer/src/comments-middleware.ts b/packages/astro-renderer/src/comments-middleware.ts index bbdac582..63a442d7 100644 --- a/packages/astro-renderer/src/comments-middleware.ts +++ b/packages/astro-renderer/src/comments-middleware.ts @@ -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), }); diff --git a/packages/astro-renderer/src/index.ts b/packages/astro-renderer/src/index.ts index af29bf63..7710eaa5 100644 --- a/packages/astro-renderer/src/index.ts +++ b/packages/astro-renderer/src/index.ts @@ -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"; diff --git a/packages/astro-renderer/src/integration.ts b/packages/astro-renderer/src/integration.ts index 6bed6d10..3484144b 100644 --- a/packages/astro-renderer/src/integration.ts +++ b/packages/astro-renderer/src/integration.ts @@ -72,6 +72,14 @@ 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 & { + 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), @@ -81,7 +89,7 @@ const THEME_MODULE = "src/theme.ts"; export function virtualConfig( resolved: CloudhalRendererConfig, cloudflare: boolean, -): Omit & { dataDir?: string } { +): VirtualCloudhalConfig { if (!cloudflare) return resolved; const { dataDir: _buildMachinePath, ...rest } = resolved; return rest; diff --git a/packages/astro-renderer/src/runtime-workerd.ts b/packages/astro-renderer/src/runtime-workerd.ts index b64caa0c..426e03f9 100644 --- a/packages/astro-renderer/src/runtime-workerd.ts +++ b/packages/astro-renderer/src/runtime-workerd.ts @@ -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"; @@ -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 { const siteId = siteOf(config.siteId); @@ -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 { if (!astro) throw new Error("on Cloudflare, pass the Astro global: getSource(config, Astro)"); diff --git a/packages/astro-renderer/src/runtime.ts b/packages/astro-renderer/src/runtime.ts index 1b28ea7d..dfdc805c 100644 --- a/packages/astro-renderer/src/runtime.ts +++ b/packages/astro-renderer/src/runtime.ts @@ -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"; @@ -16,13 +16,22 @@ let cached: { key: string; source: Promise } | undefined; * selector. `CLOUDHAL_SOURCE=live | snapshot:` 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): 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 { 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; } @@ -35,9 +44,9 @@ function localStore(dataDir: string): Promise { return local.store; } -function open(config: CloudhalRendererConfig, selector: string): Promise { +function open(config: VirtualCloudhalConfig, selector: string): Promise { 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)); diff --git a/packages/cli/test/cloudflare-datadir.test.ts b/packages/cli/test/cloudflare-datadir.test.ts index 64012230..469f73eb 100644 --- a/packages/cli/test/cloudflare-datadir.test.ts +++ b/packages/cli/test/cloudflare-datadir.test.ts @@ -64,6 +64,7 @@ describe("the Site Worker ignores dataDir (#239)", () => { let siteId: string; let first: Record; let second: Record; + let firstCarries: string | undefined; let harness: ReturnType; const setDataDir = async (dataDir: string) => { @@ -91,6 +92,11 @@ describe("the Site Worker ignores dataDir (#239)", () => { ); await buildSiteWorker(projectDir); first = await digests(path.join(projectDir, "dist")); + // Scanned now, before the second build replaces dist: the first build and its own data path. + firstCarries = await anyFileContains( + path.join(projectDir, "dist"), + path.join(projectDir, ".cloudhal"), + ); await setDataDir(ELSEWHERE); const config = await buildSiteWorker(projectDir); second = await digests(path.join(projectDir, "dist")); @@ -120,6 +126,9 @@ describe("the Site Worker ignores dataDir (#239)", () => { }); it("no built file carries either data directory", async () => { + // The first build (dataDir .cloudhal), scanned before it was replaced. + expect(firstCarries).toBeUndefined(); + // The second build (the absolute ELSEWHERE), and neither path in it. const dist = path.join(projectDir, "dist"); expect(await anyFileContains(dist, ELSEWHERE)).toBeUndefined(); expect(await anyFileContains(dist, path.join(projectDir, ".cloudhal"))).toBeUndefined(); diff --git a/starters/blog/src/env.d.ts b/starters/blog/src/env.d.ts index 61e0b265..af9a71c5 100644 --- a/starters/blog/src/env.d.ts +++ b/starters/blog/src/env.d.ts @@ -1,5 +1,6 @@ /// declare module "virtual:cloudhal/config" { - const config: import("@cloudhal/astro-renderer").CloudhalRendererConfig; + /** No `dataDir` on the Cloudflare Site Worker; `cloudhal dev` and static builds have it. */ + const config: import("@cloudhal/astro-renderer").VirtualCloudhalConfig; export default config; }