跳到內容

ch25-astro

對應 25. Astro on Workers

取得並執行

這個範例可以獨立 clone 執行,不依賴其他章節。

npx degit mrsuner/cloudflare-serverless-platform-manuals/examples/ch25-astro
cd ch25-astro
npm install

可用指令

npm run dev	# astro dev
npm run deploy	# astro build && wrangler deploy
npm run build	# astro build
npm run check	# astro check
npm run preview	# astro preview

說明

A working Astro 7 + @astrojs/cloudflare 14 site, plus probes for the v13 breaking changes and one thing no documentation covers at all.

Verified with astro 7.1.6, @astrojs/cloudflare 14.1.7, wrangler 4.118.0, node 22.22.2.

Terminal window
npm install
npx astro build
npx astro preview --port 9027 # runs in workerd

astro dev and astro preview both run under workerd since adapter v13, so request.cf is populated and cloudflare:workers imports resolve.

PageWhat it shows
/The current binding surface: import { env } from "cloudflare:workers", Astro.request.cf, Astro.locals.cfContext, the global caches. Also dumps Object.keys(env) — note ASSETS, IMAGES and SESSION are auto-injected.
/legacyAll four removed Astro.locals.runtime.* accessors, each caught.
/contentContent Collections called from an on-demand rendered page. Undocumented territory — see below.
/sessionZero-config Astro Sessions on the auto-provisioned SESSION KV binding.

The breaking change landed in @astrojs/cloudflare v13.0.0 / Astro 6 (2026-03-10), not v14 / Astro 7. The adapter’s v14.0.0 changelog has exactly one Major Change: “Upgrade to Vite v8.” The runtime error strings say so themselves:

Terminal window
curl -s localhost:9027/legacy
{
"runtimeIsUndefined": false,
"runtimeTypeof": "object",
"runtimeEnumerable": false,
"env": { "threw": "Astro.locals.runtime.env has been removed in Astro v6. Use 'import { env } from \"cloudflare:workers\"' instead." },
"cf": { "threw": "Astro.locals.runtime.cf has been removed in Astro v6. Use 'Astro.request.cf' instead." },
"caches": { "threw": "Astro.locals.runtime.caches has been removed in Astro v6. Use the global 'caches' object instead." },
"ctx": { "threw": "Astro.locals.runtime.ctx has been removed in Astro v6. Use 'Astro.locals.cfContext' instead." }
}

runtime is not undefined — it is a non-enumerable object whose getters throw. So if (Astro.locals.runtime) passes and the next line explodes.

Content Collections at runtime — no source documents this

Section titled “Content Collections at runtime — no source documents this”

/content calls getCollection, getEntry and render from a page with prerender = false, so they execute inside the Worker.

Terminal window
curl -s localhost:9027/content
# all: 2 entries, one: { title: "First post" }, rendered: { hasContentComponent: true }

They all work. Verified with 202 entries too.

The cost is that the whole content store is bundled into the Worker script:

Terminal window
ls -l dist/server/chunks/_astro_data-layer-content_*.mjs

Measured with 202 posts of ~1.6 KB of unique body text each:

src/content/posts/ markdown812 KB
_astro_data-layer-content_*.mjs836 KB
dist/server gzipped385 KiB

So markdown lands in the bundle roughly 1:1 uncompressed, ~45% gzipped. Against the 3 MB (Free) / 10 MB (Paid) script limits, that means on-demand content collections stop being viable somewhere around 6 MB / 20 MB of source markdown. Beyond that, prerender — then the store never enters the Worker.

(If you measure this yourself, use unique body text. The serialisation format shares references, so duplicated bodies dedupe and flatter the number: my first run with identical bodies reported 109 KB instead of 836 KB.)

dist/
client/ _headers .assetsignore _astro/…
server/ entry.mjs chunks/ wrangler.json
.wrangler/deploy/config.json

There is no dist/_worker.js/ — that layout predates v13, yet both Cloudflare’s Astro guide and Astro’s own deploy guide still print "main": "./dist/_worker.js/index.js".

In your hand-written wrangler.jsonc, main is a package export, not a path:

"main": "@astrojs/cloudflare/entrypoints/server"

Deploy with a bare npx wrangler deploy.wrangler/deploy/config.json points it at the generated config.

Terminal window
CLOUDFLARE_ENV=staging npx astro build && npx wrangler deploy

wrangler deploy -e staging is a no-op after the build. Verified:

default buildCLOUDFLARE_ENV=staging
generated namech25-astroch25-astro-staging
generated vars.STAGEproductionstaging

The environment is baked into the artifact, so CI must build once per environment.

npm create cloudflare -- --framework=astro overlays a src/env.d.ts containing import("@astrojs/cloudflare").Runtime<Env>, which is error TS2315: Type 'Runtime' is not generic — and is redundant, because the adapter injects the correct non-generic declaration itself. It’s masked by Astro’s skipLibCheck: true, so it won’t break your build; it will surface the day you touch tsconfig. Its public/.assetsignore also still lists _worker.js and _routes.json, neither of which exists any more.

Use instead:

Terminal window
npm create astro@latest
npx astro add cloudflare

platformProxy, cloudflareModules, workerEntryPoint, _worker.js, _routes.json0 occurrences each. The full Options surface today is imageService, sessionKVBindingName, imagesBindingName, prerenderEnvironment, experimental, plus five options passed straight through to @cloudflare/vite-plugin.

nodejs_compat is not required — this project runs with only global_fetch_strictly_public. Add it only if you import node:*.

原始碼

wrangler.jsonc

{
  "$schema": "./node_modules/wrangler/config-schema.json",
  "name": "ch25-astro",
  // The virtual entrypoint. NOT "./dist/_worker.js/index.js" -- that path has
  // not existed since adapter v13.
  "main": "@astrojs/cloudflare/entrypoints/server",
  "compatibility_date": "2026-07-24",
  "compatibility_flags": ["global_fetch_strictly_public"],
  "assets": { "directory": "./dist", "binding": "ASSETS" },
  "observability": { "enabled": true },
  "vars": { "STAGE": "production", "GREETING": "hello from wrangler.jsonc" },
  "kv_namespaces": [{ "binding": "CACHE", "id": "ch25-local-cache" }],
  "env": {
    "staging": {
      "name": "ch25-astro-staging",
      "vars": { "STAGE": "staging", "GREETING": "hello from the staging env" },
      "assets": { "directory": "./dist", "binding": "ASSETS" },
      "kv_namespaces": [{ "binding": "CACHE", "id": "ch25-local-cache" }],
      "observability": { "enabled": true }
    }
  }
}

package.json

{
  "name": "ch25-astro",
  "type": "module",
  "private": true,
  "scripts": {
    "dev": "astro dev",
    "build": "astro build",
    "preview": "astro preview",
    "check": "astro check",
    "deploy": "astro build && wrangler deploy"
  },
  "dependencies": {
    "@astrojs/cloudflare": "^14.1.7",
    "astro": "^7.1.6"
  },
  "devDependencies": {
    "wrangler": "^4.118.0",
    "typescript": "^5.9.2"
  }
}

src/content.config.ts

import { defineCollection, z } from "astro:content";
import { glob } from "astro/loaders";

// Content Layer with the glob loader. Whether this survives at RUNTIME under
// workerd (as opposed to build time) is not documented anywhere -- src/pages/
// content.astro tests it.
const posts = defineCollection({
  loader: glob({ pattern: "**/*.md", base: "./src/content/posts" }),
  schema: z.object({
    title: z.string(),
    date: z.coerce.date(),
  }),
});

export const collections = { posts };

src/content/posts/first.md

---
title: "First post"
date: 2026-07-01
---

Body of the first post.

src/content/posts/second.md

---
title: "Second post"
date: 2026-07-15
---

Body of the second post.

src/pages/content.astro

---
// UNDOCUMENTED TERRITORY: no official source says whether the content layer
// works at runtime under workerd, or is build-time only. This page is an
// on-demand rendered page (output: "server"), so getCollection() here runs
// inside the Worker.
export const prerender = false;

import { getCollection, getEntry, render } from "astro:content";

const t = async (fn: () => unknown) => {
  try { return { ok: await fn() }; }
  catch (e) { return { threw: String(e).slice(0, 240) }; }
};

const all = await t(async () => {
  const posts = await getCollection("posts");
  return posts.map((p) => ({ id: p.id, title: p.data.title, date: p.data.date }));
});

const one = await t(async () => {
  const e = await getEntry("posts", "first");
  return e ? { id: e.id, title: e.data.title, bodyLength: e.body?.length ?? null } : null;
});

// Rendering markdown to HTML at runtime is the heavier test.
const rendered = await t(async () => {
  const e = await getEntry("posts", "second");
  if (!e) return null;
  const { Content } = await render(e);
  return { hasContentComponent: typeof Content === "function" };
});
---
<html><body><pre>{JSON.stringify({ all, one, rendered }, null, 2)}</pre></body></html>

src/pages/index.astro

---
// The v13+ way to reach bindings: a module import, no Astro.locals.runtime.
import { env } from "cloudflare:workers";

const cf = Astro.request.cf;
const cfContext = Astro.locals.cfContext;

// waitUntil now lives on Astro.locals.cfContext, not on locals.runtime.
cfContext.waitUntil(Promise.resolve());

// `caches` is the plain global -- no adapter-specific accessor.
const hasCaches = typeof caches !== "undefined" && "default" in caches;

const kvWrite = await (async () => {
  try {
    await env.CACHE.put("ch25", String(Date.now()));
    return { ok: await env.CACHE.get("ch25") };
  } catch (e) {
    return { threw: String(e).slice(0, 160) };
  }
})();

const info = {
  greeting: env.GREETING,
  stage: env.STAGE,
  envKeys: Object.keys(env).sort(),
  localsKeys: Object.keys(Astro.locals),
  hasCfContext: typeof cfContext?.waitUntil === "function",
  cfContextKeys: cfContext ? Object.getOwnPropertyNames(Object.getPrototypeOf(cfContext)) : null,
  cfCountry: cf?.country ?? null,
  cfColo: cf?.colo ?? null,
  hasCaches,
  kvWrite,
};
---

<html lang="en">
  <head><title>ch25 — Astro on Workers</title></head>
  <body>
    <h1>ch25 — Astro on Workers</h1>
    <pre id="info">{JSON.stringify(info, null, 2)}</pre>
  </body>
</html>

src/pages/legacy.astro

---
// Every removed accessor, probed. These are what every 2025-era tutorial
// (and Cloudflare's own Pages guide, still) tells you to use.
const t = (fn: () => unknown) => {
  try { return { ok: String(fn()).slice(0, 80) }; }
  catch (e) { return { threw: (e as Error).message }; }
};

const runtime = (Astro.locals as Record<string, any>).runtime;

const probes = {
  runtimeIsUndefined: runtime === undefined,
  runtimeTypeof: typeof runtime,
  // Is it enumerable? (i.e. does Object.keys see it?)
  runtimeEnumerable: Object.keys(Astro.locals).includes("runtime"),
  env: t(() => runtime?.env),
  cf: t(() => runtime?.cf),
  caches: t(() => runtime?.caches),
  ctx: t(() => runtime?.ctx),
};
---
<html><body><pre>{JSON.stringify(probes, null, 2)}</pre></body></html>

src/pages/session.astro

---
// Sessions with zero configuration -- the adapter injects a SESSION KV binding.
export const prerender = false;

const n = ((await Astro.session?.get<number>("n")) ?? 0) + 1;
await Astro.session?.set("n", n);
---
<html><body><pre>{JSON.stringify({ sessionAvailable: Astro.session !== undefined, n })}</pre></body></html>

astro.config.mjs

// @ts-check
import { defineConfig } from "astro/config";
import cloudflare from "@astrojs/cloudflare";

export default defineConfig({
  // Zero-config. The adapter auto-injects the SESSION (KV), IMAGES and ASSETS
  // bindings, generates the Worker config, and injects the App.Locals types.
  adapter: cloudflare(),
  output: "server",
  vite: {
    // Readable stack traces from workerd.
    build: { minify: false },
  },
});

.gitignore

node_modules/
dist/
.astro/
.wrangler/
worker-configuration.d.ts

tsconfig.json

{
  "extends": "astro/tsconfigs/strict",
  "include": [".astro/types.d.ts", "**/*", "worker-configuration.d.ts"],
  "exclude": ["dist"]
}