@astrojs/cloudflare 14.0.0-alpha.0 → 14.0.0-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -34,5 +34,5 @@ Copyright (c) 2023–present [Astro][astro]
34
34
  [coc]: https://github.com/withastro/.github/blob/main/CODE_OF_CONDUCT.md
35
35
  [community]: https://github.com/withastro/.github/blob/main/COMMUNITY_GUIDE.md
36
36
  [discord]: https://astro.build/chat/
37
- [issues]: https://github.com/withastro/adapter/issues
37
+ [issues]: https://github.com/withastro/astro/issues
38
38
  [astro-integration]: https://docs.astro.build/en/guides/integrations/
@@ -2,6 +2,7 @@ import { imageConfig } from "astro:assets";
2
2
  import { isRemotePath } from "@astrojs/internal-helpers/path";
3
3
  import { isRemoteAllowed } from "@astrojs/internal-helpers/remote";
4
4
  import { env } from "cloudflare:workers";
5
+ import { fetchWithRedirects } from "astro/assets";
5
6
  const prerender = false;
6
7
  const GET = async ({ request }) => {
7
8
  try {
@@ -14,7 +15,10 @@ const GET = async ({ request }) => {
14
15
  if (!isRemoteAllowed(href, imageConfig)) {
15
16
  return new Response("Forbidden", { status: 403 });
16
17
  }
17
- response = await fetch(href, { redirect: "manual" });
18
+ response = await fetchWithRedirects({
19
+ url: href,
20
+ imageConfig
21
+ });
18
22
  } else {
19
23
  const sourceUrl = new URL(href, url.origin);
20
24
  if (sourceUrl.origin !== url.origin) {
@@ -41,7 +41,7 @@ const createPreviewServer = async ({
41
41
  allowedHosts
42
42
  },
43
43
  plugins: [
44
- cfVitePlugin({ ...globalThis.astroCloudflareOptions, viteEnvironment: { name: "ssr" } })
44
+ cfVitePlugin({ ...globalThis.astroCloudflareConfig, viteEnvironment: { name: "ssr" } })
45
45
  ]
46
46
  });
47
47
  } catch (err) {
@@ -88,7 +88,7 @@ function serverStart({
88
88
  host,
89
89
  base
90
90
  }) {
91
- const version = "14.0.0-alpha.0";
91
+ const version = "14.0.0-beta.2";
92
92
  const localPrefix = `${colors.dim("\u2503")} Local `;
93
93
  const networkPrefix = `${colors.dim("\u2503")} Network `;
94
94
  const emptyPrefix = " ".repeat(11);
@@ -1,15 +1,22 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
  const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---/;
3
+ const RETURN_REPLACE_RE = /(\/\/[^\n]*|\/\*[\s\S]*?\*\/|`(?:[^`\\]|\\.)*`|"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*')|(?<!\.)\breturn(\s*;|\b)/g;
4
+ function replaceTopLevelReturns(code) {
5
+ return code.replace(RETURN_REPLACE_RE, (_match, skip, tail) => {
6
+ if (skip !== void 0) return skip;
7
+ return tail.trim() === ";" ? "throw 0;" : "throw ";
8
+ });
9
+ }
3
10
  function astroFrontmatterScanPlugin() {
4
11
  return {
5
12
  name: "astro-frontmatter-scan",
6
13
  setup(build) {
7
- build.onLoad({ filter: /\.astro$/ }, async (args) => {
14
+ build.onLoad({ filter: /\.astro$/, namespace: "file" }, async (args) => {
8
15
  try {
9
16
  const code = await readFile(args.path, "utf-8");
10
17
  const frontmatterMatch = FRONTMATTER_RE.exec(code);
11
18
  if (frontmatterMatch) {
12
- const contents = frontmatterMatch[1].replace(/\breturn\s*;/g, "throw 0;").replace(/\breturn\b/g, "throw ");
19
+ const contents = replaceTopLevelReturns(frontmatterMatch[1]);
13
20
  return {
14
21
  contents: contents + "\nexport default {}",
15
22
  loader: "ts"
@@ -0,0 +1,12 @@
1
+ import type { FetchState } from 'astro/fetch';
2
+ /**
3
+ * Applies Cloudflare-specific setup to a `FetchState`:
4
+ * - Injects the SESSION KV binding
5
+ * - Serves static assets via the ASSETS binding
6
+ * - Sets `locals.cfContext`, client address, `waitUntil`, and error page fetch
7
+ *
8
+ * Returns a `Response` if the request was handled by the ASSETS binding
9
+ * (static file hit). Returns `undefined` when the caller should continue
10
+ * to Astro rendering.
11
+ */
12
+ export declare function cf(state: FetchState, env: Env, ctx: ExecutionContext): Promise<Response | undefined>;
package/dist/fetch.js ADDED
@@ -0,0 +1,37 @@
1
+ import { env as globalEnv } from "cloudflare:workers";
2
+ import { createApp } from "astro/app/entrypoint";
3
+ import { setGetEnv } from "astro/env/setup";
4
+ import { createGetEnv } from "./utils/env.js";
5
+ import {
6
+ injectSessionBinding,
7
+ matchStaticAsset,
8
+ fallbackToAssets,
9
+ createErrorPageFetch,
10
+ createLocals,
11
+ getClientAddress
12
+ } from "./utils/cf.js";
13
+ let app;
14
+ function ensureInitialized() {
15
+ if (!app) {
16
+ setGetEnv(createGetEnv(globalEnv));
17
+ app = createApp();
18
+ }
19
+ }
20
+ async function cf(state, env, ctx) {
21
+ ensureInitialized();
22
+ injectSessionBinding(app.manifest, env);
23
+ const staticAsset = matchStaticAsset(app.manifest, state.request.url, env);
24
+ if (staticAsset) return staticAsset;
25
+ if (!state.routeData) {
26
+ const asset = await fallbackToAssets(state.request.url, env);
27
+ if (asset) return asset;
28
+ }
29
+ Object.assign(state.locals, createLocals(ctx));
30
+ state.clientAddress = getClientAddress(state.request);
31
+ state.renderOptions.waitUntil = ctx.waitUntil.bind(ctx);
32
+ state.renderOptions.prerenderedErrorPageFetch = createErrorPageFetch(env);
33
+ return void 0;
34
+ }
35
+ export {
36
+ cf
37
+ };
package/dist/hono.d.ts ADDED
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Duck-typed Hono context — matches Hono's `Context` shape for
3
+ * Cloudflare Workers without importing from `hono` at runtime.
4
+ */
5
+ type HonoCloudflareContextLike = {
6
+ req: {
7
+ raw: Request;
8
+ };
9
+ env: Env;
10
+ executionCtx: ExecutionContext;
11
+ get?: (key: string) => unknown;
12
+ set?: (key: string, value: unknown) => void;
13
+ };
14
+ type HonoMiddlewareHandler = (context: HonoCloudflareContextLike, next: () => Promise<void>) => Promise<Response | void>;
15
+ /**
16
+ * Hono middleware that applies Cloudflare-specific setup.
17
+ *
18
+ * Reads `env` and `executionCtx` from the Hono context (provided
19
+ * automatically by Hono on Cloudflare Workers). Handles static assets
20
+ * via the ASSETS binding, injects the SESSION KV binding, and sets
21
+ * `locals.cfContext`, client address, `waitUntil`, and error page fetch.
22
+ *
23
+ * If the request matches a static asset, returns the asset response
24
+ * directly. Otherwise calls `next()` to continue the middleware chain.
25
+ */
26
+ export declare function cf(): HonoMiddlewareHandler;
27
+ export {};
package/dist/hono.js ADDED
@@ -0,0 +1,21 @@
1
+ import { FetchState } from "astro/fetch";
2
+ import { cf as cfFetch } from "./fetch.js";
3
+ const FETCH_STATE_KEY = "fetchState";
4
+ function getFetchState(context) {
5
+ const state = context.get?.(FETCH_STATE_KEY);
6
+ if (state) return state;
7
+ const nextState = new FetchState(context.req.raw);
8
+ context.set?.(FETCH_STATE_KEY, nextState);
9
+ return nextState;
10
+ }
11
+ function cf() {
12
+ return async (context, next) => {
13
+ const state = getFetchState(context);
14
+ const asset = await cfFetch(state, context.env, context.executionCtx);
15
+ if (asset) return asset;
16
+ await next();
17
+ };
18
+ }
19
+ export {
20
+ cf
21
+ };
package/dist/index.js CHANGED
@@ -1,11 +1,18 @@
1
1
  import { createReadStream, existsSync, readFileSync } from "node:fs";
2
- import { appendFile, stat } from "node:fs/promises";
2
+ import { appendFile, readFile, rename, stat, unlink, writeFile } from "node:fs/promises";
3
+ import { relative } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { normalizePath } from "vite";
3
6
  import { createInterface } from "node:readline/promises";
4
- import { removeLeadingForwardSlash } from "@astrojs/internal-helpers/path";
7
+ import {
8
+ removeLeadingForwardSlash,
9
+ removeTrailingForwardSlash
10
+ } from "@astrojs/internal-helpers/path";
5
11
  import { createRedirectsFromAstroRoutes, printAsRedirects } from "@astrojs/underscore-redirects";
6
12
  import { cloudflare as cfVitePlugin } from "@cloudflare/vite-plugin";
7
13
  import { astroFrontmatterScanPlugin } from "./esbuild-plugin-astro-frontmatter.js";
8
14
  import { getParts } from "./utils/generate-routes-json.js";
15
+ import { buildAssetsHeadersContent } from "./utils/headers.js";
9
16
  import {
10
17
  normalizeImageServiceConfig,
11
18
  setImageConfig
@@ -20,6 +27,7 @@ import {
20
27
  import { parseEnv } from "node:util";
21
28
  import { sessionDrivers } from "astro/config";
22
29
  import { createCloudflarePrerenderer } from "./prerenderer.js";
30
+ import cfPrismPlugin from "./vite-plugin-prism.js";
23
31
  const CLOUDFLARE_KV_SESSION_DRIVER_ENTRYPOINT = sessionDrivers.cloudflareKVBinding().entrypoint;
24
32
  function usesCloudflareKVSessionDriver(session) {
25
33
  const driver = session?.driver;
@@ -57,15 +65,16 @@ function createIntegration({
57
65
  ...cloudflareOptions
58
66
  } = {}) {
59
67
  let _config;
68
+ let _buildOutput;
69
+ let _originalClientDir;
60
70
  let _routes;
61
- let _isFullyStatic = false;
62
71
  let cfPluginConfig;
63
72
  const { buildService, runtimeService } = normalizeImageServiceConfig(imageService);
64
73
  const needsImagesBinding = runtimeService === "cloudflare-binding";
65
74
  return {
66
75
  name: "@astrojs/cloudflare",
67
76
  hooks: {
68
- "astro:config:setup": ({ command, config, updateConfig, logger, addWatchFile }) => {
77
+ "astro:config:setup": async ({ command, config, updateConfig, logger, addWatchFile }) => {
69
78
  if (!!process.versions.webcontainer) {
70
79
  throw new Error("`workerd` does not run on Stackblitz.");
71
80
  }
@@ -96,7 +105,7 @@ function createIntegration({
96
105
  const needsImagesBindingForDev = isCompile && command === "dev";
97
106
  const usesContentCollections = hasContentCollectionsConfig(config.srcDir);
98
107
  const prebundleContentRuntime = command === "dev" && usesContentCollections;
99
- cfPluginConfig = {
108
+ const adapterPluginConfig = {
100
109
  config: cloudflareConfigCustomizer({
101
110
  needsSessionKVBinding,
102
111
  sessionKVBindingName,
@@ -122,9 +131,17 @@ function createIntegration({
122
131
  }
123
132
  }
124
133
  };
134
+ cfPluginConfig = { ...cloudflareOptions, ...adapterPluginConfig };
125
135
  if (command === "preview") {
126
- globalThis.astroCloudflareOptions = cfPluginConfig;
136
+ globalThis.astroCloudflareConfig = cfPluginConfig;
127
137
  }
138
+ const prismFiles = [
139
+ "@astrojs/prism > prismjs",
140
+ "@astrojs/prism > prismjs/components.js",
141
+ "@astrojs/prism > prismjs/dependencies.js"
142
+ ];
143
+ const isAstroPrismPackageInstalled = await getIsAstroPrismInstalled(config.root);
144
+ const userOptimizeDeps = config.vite?.optimizeDeps;
128
145
  updateConfig({
129
146
  build: {
130
147
  redirects: false
@@ -134,9 +151,9 @@ function createIntegration({
134
151
  plugins: [
135
152
  ...prerenderEnvironment === "node" && command === "dev" ? [createNodePrerenderPlugin()] : [],
136
153
  cfVitePlugin({
137
- ...cloudflareOptions,
138
154
  ...cfPluginConfig,
139
- viteEnvironment: { name: "ssr" }
155
+ viteEnvironment: { name: "ssr" },
156
+ assetsOnly: () => _buildOutput === "static"
140
157
  }),
141
158
  {
142
159
  name: "@astrojs/cloudflare:cf-imports",
@@ -177,6 +194,9 @@ function createIntegration({
177
194
  "astro > piccolore",
178
195
  "astro > picomatch",
179
196
  "astro/app",
197
+ "astro/app/fetch/default-handler",
198
+ "astro/fetch",
199
+ "astro/hono",
180
200
  "astro/assets",
181
201
  "astro/assets/runtime",
182
202
  "astro/assets/utils/inferRemoteSize.js",
@@ -185,7 +205,9 @@ function createIntegration({
185
205
  "astro/compiler-runtime",
186
206
  "astro/jsx-runtime",
187
207
  "astro/app/entrypoint/dev",
188
- "astro/virtual-modules/middleware.js"
208
+ "astro/virtual-modules/middleware.js",
209
+ ...isAstroPrismPackageInstalled ? prismFiles : [],
210
+ ...Array.isArray(userOptimizeDeps?.include) ? userOptimizeDeps.include : []
189
211
  ],
190
212
  exclude: [
191
213
  "unstorage/drivers/cloudflare-kv-binding",
@@ -193,7 +215,8 @@ function createIntegration({
193
215
  "virtual:astro:*",
194
216
  "virtual:astro-cloudflare:*",
195
217
  "virtual:@astrojs/*",
196
- "@astrojs/starlight"
218
+ "@astrojs/starlight",
219
+ ...Array.isArray(userOptimizeDeps?.exclude) ? userOptimizeDeps.exclude : []
197
220
  ],
198
221
  esbuildOptions: {
199
222
  // Suppress Vite's `createRequire(import.meta.url)` banner to work around
@@ -201,7 +224,8 @@ function createIntegration({
201
224
  // incorrectly rewrites identifiers inside `import.meta` when an imported
202
225
  // binding shares the same name (e.g. zod v4 exports `meta`).
203
226
  banner: { js: "" },
204
- plugins: [astroFrontmatterScanPlugin()]
227
+ plugins: [astroFrontmatterScanPlugin()],
228
+ ...userOptimizeDeps?.esbuildOptions?.loader ? { loader: userOptimizeDeps.esbuildOptions.loader } : {}
205
229
  }
206
230
  }
207
231
  };
@@ -237,7 +261,8 @@ function createIntegration({
237
261
  imageServiceEntrypoint: "@astrojs/cloudflare/image-service-workerd",
238
262
  buildAssets: config.build.assets ?? "_astro"
239
263
  } : null
240
- })
264
+ }),
265
+ cfPrismPlugin()
241
266
  ]
242
267
  },
243
268
  image: setImageConfig(imageService, config.image, command, logger)
@@ -251,11 +276,14 @@ function createIntegration({
251
276
  },
252
277
  "astro:routes:resolved": ({ routes }) => {
253
278
  _routes = routes;
254
- const nonInternalRoutes = routes.filter((route) => route.origin !== "internal");
255
- _isFullyStatic = nonInternalRoutes.length > 0 && nonInternalRoutes.every((route) => route.isPrerendered);
256
279
  },
257
- "astro:config:done": ({ setAdapter, config, injectTypes, logger }) => {
280
+ "astro:config:done": ({ setAdapter, config, injectTypes, logger, buildOutput }) => {
258
281
  _config = config;
282
+ _buildOutput = buildOutput;
283
+ _originalClientDir = new URL(config.build.client.href);
284
+ if (config.base !== "/") {
285
+ config.build.client = new URL("." + config.base + "/", config.build.client);
286
+ }
259
287
  injectTypes({
260
288
  filename: "cloudflare.d.ts",
261
289
  content: '/// <reference types="@astrojs/cloudflare/types.d.ts" />'
@@ -263,9 +291,10 @@ function createIntegration({
263
291
  setAdapter({
264
292
  name: "@astrojs/cloudflare",
265
293
  adapterFeatures: {
266
- buildOutput: "server",
294
+ buildOutput,
267
295
  middlewareMode: "classic",
268
- preserveBuildClientDir: true
296
+ preserveBuildClientDir: true,
297
+ preserveBuildServerDir: true
269
298
  },
270
299
  entrypointResolution: "auto",
271
300
  previewEntrypoint: "@astrojs/cloudflare/entrypoints/preview",
@@ -330,9 +359,65 @@ function createIntegration({
330
359
  }
331
360
  },
332
361
  "astro:build:done": async ({ dir, logger, assets }) => {
362
+ if (_config.base !== "/") {
363
+ for (const file of [".assetsignore", "_headers"]) {
364
+ try {
365
+ await rename(
366
+ new URL(`./${file}`, _config.build.client),
367
+ new URL(`./${file}`, _originalClientDir)
368
+ );
369
+ } catch {
370
+ }
371
+ }
372
+ try {
373
+ const wranglerJsonUrl = new URL("./wrangler.json", _config.build.server);
374
+ const raw = await readFile(wranglerJsonUrl, "utf-8");
375
+ const wranglerConfig = JSON.parse(raw);
376
+ if (wranglerConfig.assets?.directory) {
377
+ wranglerConfig.assets.directory = normalizePath(
378
+ relative(fileURLToPath(_config.build.server), fileURLToPath(_originalClientDir))
379
+ );
380
+ await writeFile(wranglerJsonUrl, JSON.stringify(wranglerConfig));
381
+ }
382
+ } catch {
383
+ }
384
+ }
385
+ if (_config.build.assetsPrefix) {
386
+ logger.debug(
387
+ "Skipping Cache-Control injection for assets \u2014 `build.assetsPrefix` is set, so assets are served from a different origin."
388
+ );
389
+ } else {
390
+ const headersPath = new URL("./_headers", _originalClientDir);
391
+ const result = await buildAssetsHeadersContent(
392
+ {
393
+ assetsDir: _config.build.assets,
394
+ basePrefix: removeTrailingForwardSlash(_config.base),
395
+ headersPath
396
+ },
397
+ (path) => readFile(path, "utf-8")
398
+ );
399
+ if (result === null) {
400
+ logger.debug(
401
+ `Skipping Cache-Control injection \u2014 _headers already sets Cache-Control on a matching rule.`
402
+ );
403
+ } else {
404
+ const tempPath = new URL("./_headers.tmp", _originalClientDir);
405
+ try {
406
+ await writeFile(tempPath, result.content);
407
+ await rename(tempPath, headersPath);
408
+ } catch (err) {
409
+ await unlink(tempPath).catch(() => {
410
+ });
411
+ throw err;
412
+ }
413
+ logger.info(
414
+ `Injected immutable Cache-Control for ${result.assetsPattern} into _headers.`
415
+ );
416
+ }
417
+ }
333
418
  let redirectsExists = false;
334
419
  try {
335
- const redirectsStat = await stat(new URL("./_redirects", _config.build.client));
420
+ const redirectsStat = await stat(new URL("./_redirects", _originalClientDir));
336
421
  if (redirectsStat.isFile()) {
337
422
  redirectsExists = true;
338
423
  }
@@ -342,7 +427,7 @@ function createIntegration({
342
427
  const redirects = [];
343
428
  if (redirectsExists) {
344
429
  const rl = createInterface({
345
- input: createReadStream(new URL("./_redirects", _config.build.client)),
430
+ input: createReadStream(new URL("./_redirects", _originalClientDir)),
346
431
  crlfDelay: Number.POSITIVE_INFINITY
347
432
  });
348
433
  for await (const line of rl) {
@@ -364,13 +449,13 @@ function createIntegration({
364
449
  )
365
450
  ),
366
451
  dir,
367
- buildOutput: _isFullyStatic ? "static" : "server",
452
+ buildOutput: _buildOutput,
368
453
  assets
369
454
  });
370
455
  if (!trueRedirects.empty()) {
371
456
  try {
372
457
  await appendFile(
373
- new URL("./_redirects", _config.build.client),
458
+ new URL("./_redirects", _originalClientDir),
374
459
  printAsRedirects(trueRedirects)
375
460
  );
376
461
  } catch (_error) {
@@ -382,6 +467,16 @@ function createIntegration({
382
467
  }
383
468
  };
384
469
  }
470
+ async function getIsAstroPrismInstalled(rootURL) {
471
+ try {
472
+ const pkgURL = new URL("./package.json", rootURL);
473
+ const input = await readFile(pkgURL, { encoding: "utf-8" });
474
+ const pkgJson = JSON.parse(input);
475
+ return Object.hasOwn(pkgJson["dependencies"], "@astrojs/prism");
476
+ } catch {
477
+ return false;
478
+ }
479
+ }
385
480
  export {
386
481
  createIntegration as default
387
482
  };
@@ -0,0 +1,33 @@
1
+ export interface Runtime {
2
+ cfContext: ExecutionContext;
3
+ }
4
+ /** Minimal manifest shape needed by the Cloudflare helpers. */
5
+ export interface ManifestLike {
6
+ assets: Set<string>;
7
+ sessionConfig?: {
8
+ options?: Record<string, unknown>;
9
+ } | undefined;
10
+ }
11
+ /**
12
+ * Returns a `Response` from the ASSETS binding if the request pathname
13
+ * is a known static asset. Returns `undefined` otherwise.
14
+ */
15
+ export declare function matchStaticAsset(manifest: ManifestLike, requestUrl: string, env: Env): Response | undefined;
16
+ /**
17
+ * Tries the ASSETS binding as a fallback for an unmatched route.
18
+ * Returns the asset `Response` if found (non-404), `undefined` otherwise.
19
+ */
20
+ export declare function fallbackToAssets(requestUrl: string, env: Env): Promise<Response | undefined>;
21
+ /**
22
+ * Creates a fetch function for prerendered error pages via the ASSETS binding.
23
+ */
24
+ export declare function createErrorPageFetch(env: Env): (url: string) => Promise<Response>;
25
+ /**
26
+ * Creates the Cloudflare-specific locals object with `cfContext`
27
+ * and deprecated `runtime` property getters.
28
+ */
29
+ export declare function createLocals(ctx: ExecutionContext): Runtime;
30
+ /**
31
+ * Extracts the client IP address from the `cf-connecting-ip` header.
32
+ */
33
+ export declare function getClientAddress(request: Request): string | undefined;
@@ -0,0 +1,63 @@
1
+ import { getValidatedIpFromHeader } from "@astrojs/internal-helpers/request";
2
+ function matchStaticAsset(manifest, requestUrl, env) {
3
+ const { pathname } = new URL(requestUrl);
4
+ if (manifest.assets.has(pathname)) {
5
+ return env.ASSETS.fetch(requestUrl.replace(/\.html$/, ""));
6
+ }
7
+ return void 0;
8
+ }
9
+ async function fallbackToAssets(requestUrl, env) {
10
+ const asset = await env.ASSETS.fetch(
11
+ requestUrl.replace(/index.html$/, "").replace(/\.html$/, "")
12
+ );
13
+ if (asset.status !== 404) {
14
+ return asset;
15
+ }
16
+ return void 0;
17
+ }
18
+ function createErrorPageFetch(env) {
19
+ return async (url) => {
20
+ return env.ASSETS.fetch(url.replace(/\.html$/, ""));
21
+ };
22
+ }
23
+ function createLocals(ctx) {
24
+ const locals = {
25
+ cfContext: ctx
26
+ };
27
+ Object.defineProperty(locals, "runtime", {
28
+ enumerable: false,
29
+ value: {
30
+ get env() {
31
+ throw new Error(
32
+ `Astro.locals.runtime.env has been removed in Astro v6. Use 'import { env } from "cloudflare:workers"' instead.`
33
+ );
34
+ },
35
+ get cf() {
36
+ throw new Error(
37
+ `Astro.locals.runtime.cf has been removed in Astro v6. Use 'Astro.request.cf' instead.`
38
+ );
39
+ },
40
+ get caches() {
41
+ throw new Error(
42
+ `Astro.locals.runtime.caches has been removed in Astro v6. Use the global 'caches' object instead.`
43
+ );
44
+ },
45
+ get ctx() {
46
+ throw new Error(
47
+ `Astro.locals.runtime.ctx has been removed in Astro v6. Use 'Astro.locals.cfContext' instead.`
48
+ );
49
+ }
50
+ }
51
+ });
52
+ return locals;
53
+ }
54
+ function getClientAddress(request) {
55
+ return getValidatedIpFromHeader(request.headers.get("cf-connecting-ip"));
56
+ }
57
+ export {
58
+ createErrorPageFetch,
59
+ createLocals,
60
+ fallbackToAssets,
61
+ getClientAddress,
62
+ matchStaticAsset
63
+ };
@@ -0,0 +1,8 @@
1
+ import type { ManifestLike } from './cf-helpers.js';
2
+ export type { Runtime, ManifestLike } from './cf-helpers.js';
3
+ export { matchStaticAsset, fallbackToAssets, createErrorPageFetch, createLocals, getClientAddress, } from './cf-helpers.js';
4
+ /**
5
+ * Injects the SESSION KV binding into the app manifest's session config.
6
+ * Idempotent — safe to call on every request.
7
+ */
8
+ export declare function injectSessionBinding(manifest: ManifestLike, env: Env): void;
@@ -0,0 +1,24 @@
1
+ import { sessionKVBindingName } from "virtual:astro-cloudflare:config";
2
+ import {
3
+ matchStaticAsset,
4
+ fallbackToAssets,
5
+ createErrorPageFetch,
6
+ createLocals,
7
+ getClientAddress
8
+ } from "./cf-helpers.js";
9
+ function injectSessionBinding(manifest, env) {
10
+ if (env[sessionKVBindingName]) {
11
+ const sessionConfigOptions = manifest.sessionConfig?.options ?? {};
12
+ Object.assign(sessionConfigOptions, {
13
+ binding: env[sessionKVBindingName]
14
+ });
15
+ }
16
+ }
17
+ export {
18
+ createErrorPageFetch,
19
+ createLocals,
20
+ fallbackToAssets,
21
+ getClientAddress,
22
+ injectSessionBinding,
23
+ matchStaticAsset
24
+ };
@@ -1,9 +1,7 @@
1
- export interface Runtime {
2
- cfContext: ExecutionContext;
3
- }
1
+ import { type Runtime } from './cf.js';
2
+ export type { Runtime };
4
3
  declare global {
5
4
  var __ASTRO_IMAGES_BINDING_NAME: string;
6
5
  }
7
6
  type CfResponse = Awaited<ReturnType<Required<ExportedHandler<Env>>['fetch']>>;
8
7
  export declare function handle(request: Request, env: Env, context: ExecutionContext): Promise<CfResponse>;
9
- export {};
@@ -1,9 +1,5 @@
1
1
  import { env as globalEnv } from "cloudflare:workers";
2
- import {
3
- sessionKVBindingName,
4
- compileImageConfig,
5
- isPrerender
6
- } from "virtual:astro-cloudflare:config";
2
+ import { compileImageConfig, isPrerender } from "virtual:astro-cloudflare:config";
7
3
  import { createApp } from "astro/app/entrypoint";
8
4
  import { setGetEnv } from "astro/env/setup";
9
5
  import { createGetEnv } from "../utils/env.js";
@@ -15,7 +11,14 @@ import {
15
11
  isStaticImagesRequest,
16
12
  handleStaticImagesRequest
17
13
  } from "./prerender.js";
18
- import { getValidatedIpFromHeader } from "@astrojs/internal-helpers/request";
14
+ import {
15
+ injectSessionBinding,
16
+ matchStaticAsset,
17
+ fallbackToAssets,
18
+ createErrorPageFetch,
19
+ createLocals,
20
+ getClientAddress
21
+ } from "./cf.js";
19
22
  setGetEnv(createGetEnv(globalEnv));
20
23
  const app = createApp();
21
24
  async function handle(request, env, context) {
@@ -34,16 +37,9 @@ async function handle(request, env, context) {
34
37
  return handleStaticImagesRequest();
35
38
  }
36
39
  }
37
- const { pathname: requestPathname } = new URL(request.url);
38
- if (env[sessionKVBindingName]) {
39
- const sessionConfigOptions = app.manifest.sessionConfig?.options ?? {};
40
- Object.assign(sessionConfigOptions, {
41
- binding: env[sessionKVBindingName]
42
- });
43
- }
44
- if (app.manifest.assets.has(requestPathname)) {
45
- return env.ASSETS.fetch(request.url.replace(/\.html$/, ""));
46
- }
40
+ injectSessionBinding(app.manifest, env);
41
+ const staticAsset = matchStaticAsset(app.manifest, request.url, env);
42
+ if (staticAsset) return staticAsset;
47
43
  let routeData = void 0;
48
44
  if (app.isDev()) {
49
45
  const result = await app.devMatch(app.getPathnameFromRequest(request));
@@ -54,50 +50,17 @@ async function handle(request, env, context) {
54
50
  routeData = app.match(request);
55
51
  }
56
52
  if (!routeData) {
57
- const asset = await env.ASSETS.fetch(
58
- request.url.replace(/index.html$/, "").replace(/\.html$/, "")
59
- );
60
- if (asset.status !== 404) {
61
- return asset;
62
- }
53
+ const asset = await fallbackToAssets(request.url, env);
54
+ if (asset) return asset;
63
55
  }
64
- const locals = {
65
- cfContext: context
66
- };
67
- Object.defineProperty(locals, "runtime", {
68
- enumerable: false,
69
- value: {
70
- get env() {
71
- throw new Error(
72
- `Astro.locals.runtime.env has been removed in Astro v6. Use 'import { env } from "cloudflare:workers"' instead.`
73
- );
74
- },
75
- get cf() {
76
- throw new Error(
77
- `Astro.locals.runtime.cf has been removed in Astro v6. Use 'Astro.request.cf' instead.`
78
- );
79
- },
80
- get caches() {
81
- throw new Error(
82
- `Astro.locals.runtime.caches has been removed in Astro v6. Use the global 'caches' object instead.`
83
- );
84
- },
85
- get ctx() {
86
- throw new Error(
87
- `Astro.locals.runtime.ctx has been removed in Astro v6. Use 'Astro.locals.cfContext' instead.`
88
- );
89
- }
90
- }
91
- });
56
+ const locals = createLocals(context);
92
57
  const waitUntil = context.waitUntil.bind(context);
93
58
  const response = await app.render(request, {
94
59
  routeData,
95
60
  locals,
96
61
  waitUntil,
97
- prerenderedErrorPageFetch: async (url) => {
98
- return env.ASSETS.fetch(url.replace(/\.html$/, ""));
99
- },
100
- clientAddress: getValidatedIpFromHeader(request.headers.get("cf-connecting-ip"))
62
+ prerenderedErrorPageFetch: createErrorPageFetch(env),
63
+ clientAddress: getClientAddress(request)
101
64
  });
102
65
  if (app.setCookieHeaders) {
103
66
  for (const setCookieHeader of app.setCookieHeaders(response)) {
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Returns true if the given `_headers` content already declares (or detaches)
3
+ * a `Cache-Control` directive on any rule whose URL pattern matches `path`.
4
+ *
5
+ * Used to avoid emitting a second `Cache-Control` rule for hashed assets when
6
+ * the user already has one — Cloudflare merges duplicate header values across
7
+ * matching rules with a comma, which produces contradictory cache directives.
8
+ */
9
+ export declare function headersFileHasCacheControlForPath(content: string, path: string): boolean;
10
+ /**
11
+ * Computes the content to write to `_headers` to inject an immutable
12
+ * Cache-Control rule for the hashed assets directory.
13
+ *
14
+ * Returns `null` when injection should be skipped because the existing
15
+ * `_headers` already declares `Cache-Control` on a rule matching the assets
16
+ * path — Cloudflare merges duplicate header values with a comma, which would
17
+ * produce contradictory directives.
18
+ */
19
+ export declare function buildAssetsHeadersContent(opts: {
20
+ assetsDir: string;
21
+ basePrefix: string;
22
+ headersPath: URL;
23
+ }, readFile: (path: URL) => Promise<string>): Promise<{
24
+ content: string;
25
+ assetsPattern: string;
26
+ } | null>;
@@ -0,0 +1,63 @@
1
+ function cfHeadersPatternToRegex(pattern) {
2
+ let regexStr = "";
3
+ let i = 0;
4
+ while (i < pattern.length) {
5
+ const ch = pattern[i];
6
+ if (ch === "*") {
7
+ regexStr += ".*";
8
+ i++;
9
+ } else if (ch === ":" && /[A-Za-z]/.test(pattern[i + 1] ?? "")) {
10
+ i++;
11
+ while (i < pattern.length && /\w/.test(pattern[i])) i++;
12
+ regexStr += "[^/]+";
13
+ } else {
14
+ regexStr += ch.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
15
+ i++;
16
+ }
17
+ }
18
+ return new RegExp(`^${regexStr}$`);
19
+ }
20
+ function headersFileHasCacheControlForPath(content, path) {
21
+ let matchesCurrentSection = false;
22
+ for (const rawLine of content.split("\n")) {
23
+ const trimmed = rawLine.trim();
24
+ if (!trimmed || trimmed.startsWith("#")) continue;
25
+ const isSectionHeader = !/^\s/.test(rawLine);
26
+ if (isSectionHeader) {
27
+ const pathOnly = trimmed.replace(/^https?:\/\/[^/]+/, "");
28
+ try {
29
+ matchesCurrentSection = cfHeadersPatternToRegex(pathOnly).test(path);
30
+ } catch {
31
+ matchesCurrentSection = false;
32
+ }
33
+ } else if (matchesCurrentSection && // Either `Cache-Control: value` (set) or `! Cache-Control` (detach).
34
+ /^\s+(?:!\s+cache-control\s*$|cache-control\s*:)/i.test(rawLine)) {
35
+ return true;
36
+ }
37
+ }
38
+ return false;
39
+ }
40
+ async function buildAssetsHeadersContent(opts, readFile) {
41
+ const { assetsDir, basePrefix, headersPath } = opts;
42
+ const assetsPattern = `${basePrefix}/${assetsDir}/*`;
43
+ const probePath = `${basePrefix}/${assetsDir}/probe`;
44
+ let existingHeaders = "";
45
+ try {
46
+ existingHeaders = await readFile(headersPath);
47
+ } catch {
48
+ }
49
+ if (headersFileHasCacheControlForPath(existingHeaders, probePath)) {
50
+ return null;
51
+ }
52
+ const cacheBlock = `${assetsPattern}
53
+ Cache-Control: public, max-age=31536000, immutable
54
+ `;
55
+ const normalizedExisting = existingHeaders && !existingHeaders.endsWith("\n") ? existingHeaders + "\n" : existingHeaders;
56
+ const content = normalizedExisting ? `${cacheBlock}
57
+ ${normalizedExisting}` : cacheBlock;
58
+ return { content, assetsPattern };
59
+ }
60
+ export {
61
+ buildAssetsHeadersContent,
62
+ headersFileHasCacheControlForPath
63
+ };
@@ -1,6 +1,7 @@
1
1
  import { imageConfig } from "astro:assets";
2
2
  import { isRemotePath } from "@astrojs/internal-helpers/path";
3
3
  import { isRemoteAllowed } from "@astrojs/internal-helpers/remote";
4
+ import { fetchWithRedirects } from "astro/assets";
4
5
  const qualityTable = {
5
6
  low: 25,
6
7
  mid: 50,
@@ -14,9 +15,21 @@ async function transform(rawUrl, images, assets) {
14
15
  return new Response("Forbidden", { status: 403 });
15
16
  }
16
17
  const imageSrc = new URL(href, url.origin);
17
- const content = await (isRemotePath(href) ? fetch(imageSrc, { redirect: "manual" }) : assets.fetch(imageSrc));
18
- if (content.status >= 300 && content.status < 400) {
19
- return new Response("Not Found", { status: 404 });
18
+ let content;
19
+ if (isRemotePath(href)) {
20
+ try {
21
+ content = await fetchWithRedirects({
22
+ url: imageSrc,
23
+ imageConfig
24
+ });
25
+ if (!isRemoteAllowed(content.url, imageConfig)) {
26
+ return new Response("Forbidden", { status: 403 });
27
+ }
28
+ } catch {
29
+ return new Response("Not Found", { status: 404 });
30
+ }
31
+ } else {
32
+ content = await assets.fetch(imageSrc);
20
33
  }
21
34
  if (!content.body) {
22
35
  return new Response(null, { status: 404 });
@@ -16,6 +16,7 @@ export declare function setImageConfig(service: ImageServiceConfig | undefined,
16
16
  endpoint: {
17
17
  entrypoint: string;
18
18
  };
19
+ dangerouslyProcessSVG: boolean;
19
20
  domains: string[];
20
21
  remotePatterns: {
21
22
  protocol?: string | undefined;
@@ -34,6 +35,7 @@ export declare function setImageConfig(service: ImageServiceConfig | undefined,
34
35
  route: string;
35
36
  entrypoint?: string | undefined;
36
37
  };
38
+ dangerouslyProcessSVG: boolean;
37
39
  domains: string[];
38
40
  remotePatterns: {
39
41
  protocol?: string | undefined;
@@ -0,0 +1,2 @@
1
+ import type { Plugin } from 'vite';
2
+ export default function cfPrismPlugin(): Plugin;
@@ -0,0 +1,56 @@
1
+ import { fileURLToPath } from "node:url";
2
+ import components from "prismjs/components.js";
3
+ const MODULE_ID = "virtual:astro-cloudflare:prism";
4
+ const RESOLVED_MODULE_ID = "\0" + MODULE_ID;
5
+ const languages = Object.keys(components.languages).filter((l) => l !== "meta");
6
+ function cfPrismPlugin() {
7
+ return {
8
+ name: "@astrojs/cloudflare:prism",
9
+ configEnvironment(environmentName) {
10
+ if (environmentName === "ssr") {
11
+ return {
12
+ // Because this virtual module adds a large number of dynamic import statements,
13
+ // Vite’s logs will consequently display the message “new dependencies optimized” for all languages.
14
+ // To avoid this, we explicitly specify that the module should be optimized in advance.
15
+ optimizeDeps: {
16
+ include: ["prismjs/components/prism-*.js"]
17
+ }
18
+ };
19
+ }
20
+ },
21
+ resolveId: {
22
+ filter: {
23
+ id: new RegExp(`^${MODULE_ID}$`)
24
+ },
25
+ handler() {
26
+ return RESOLVED_MODULE_ID;
27
+ }
28
+ },
29
+ load: {
30
+ filter: {
31
+ id: new RegExp(`^${RESOLVED_MODULE_ID}$`)
32
+ },
33
+ async handler() {
34
+ const importerPath = fileURLToPath(import.meta.url);
35
+ const resolvedModules = await Promise.all(
36
+ languages.map(async (lang) => {
37
+ const resolvedId = await this.resolve(
38
+ `prismjs/components/prism-${lang}.js`,
39
+ importerPath
40
+ );
41
+ return { resolvedId: resolvedId?.id, lang };
42
+ })
43
+ );
44
+ const prismBundledLanguages = resolvedModules.filter(({ resolvedId }) => resolvedId !== void 0).map(
45
+ ({ resolvedId, lang }) => `${JSON.stringify(lang)}: () => import(${JSON.stringify(resolvedId)})`
46
+ );
47
+ return `
48
+ export const bundledLanguages = { ${prismBundledLanguages.join(",")} };
49
+ `;
50
+ }
51
+ }
52
+ };
53
+ }
54
+ export {
55
+ cfPrismPlugin as default
56
+ };
package/dist/wrangler.js CHANGED
@@ -13,11 +13,7 @@ function cloudflareConfigCustomizer(options) {
13
13
  );
14
14
  const hasImagesBinding = nonInheritableConfig?.images?.binding !== void 0;
15
15
  return {
16
- kv_namespaces: !needsSessionKVBinding || hasSessionBinding ? void 0 : [
17
- {
18
- binding: sessionKVBindingName
19
- }
20
- ],
16
+ kv_namespaces: !needsSessionKVBinding || hasSessionBinding ? void 0 : [{ binding: sessionKVBindingName }],
21
17
  images: hasImagesBinding || !imagesBindingName ? void 0 : {
22
18
  binding: imagesBindingName
23
19
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@astrojs/cloudflare",
3
3
  "description": "Deploy your site to Cloudflare Workers",
4
- "version": "14.0.0-alpha.0",
4
+ "version": "14.0.0-beta.2",
5
5
  "type": "module",
6
6
  "types": "./dist/index.d.ts",
7
7
  "author": "withastro",
@@ -27,6 +27,8 @@
27
27
  "./image-passthrough-endpoint": "./dist/entrypoints/image-passthrough-endpoint.js",
28
28
  "./image-service-workerd": "./dist/entrypoints/image-service-workerd.js",
29
29
  "./handler": "./dist/utils/handler.js",
30
+ "./fetch": "./dist/fetch.js",
31
+ "./hono": "./dist/hono.js",
30
32
  "./types.d.ts": "./types.d.ts",
31
33
  "./package.json": "./package.json"
32
34
  },
@@ -35,23 +37,25 @@
35
37
  "types.d.ts"
36
38
  ],
37
39
  "dependencies": {
38
- "@cloudflare/vite-plugin": "^1.32.3",
40
+ "@cloudflare/vite-plugin": "^1.39.0",
39
41
  "piccolore": "^0.1.3",
40
42
  "tinyglobby": "^0.2.15",
41
- "vite": "^8.0.8",
42
- "@astrojs/internal-helpers": "0.9.0",
43
+ "vite": "^8.0.13",
44
+ "@astrojs/internal-helpers": "0.10.0",
43
45
  "@astrojs/underscore-redirects": "1.0.3"
44
46
  },
45
47
  "peerDependencies": {
46
- "astro": "^7.0.0-alpha.0",
48
+ "astro": "^7.0.0-alpha.2",
47
49
  "wrangler": "^4.83.0"
48
50
  },
49
51
  "devDependencies": {
50
- "@cloudflare/workers-types": "^4.20260228.0",
51
- "@types/node": "^25.2.2",
52
+ "@cloudflare/workers-types": "^4.20260526.1",
53
+ "@types/node": "^22.10.6",
54
+ "@types/prismjs": "1.26.6",
52
55
  "cheerio": "1.2.0",
53
- "devalue": "^5.6.3",
54
- "astro": "7.0.0-alpha.0",
56
+ "devalue": "^5.8.1",
57
+ "prismjs": "^1.30.0",
58
+ "astro": "7.0.0-beta.3",
55
59
  "astro-scripts": "0.0.14"
56
60
  },
57
61
  "publishConfig": {
@@ -59,9 +63,8 @@
59
63
  },
60
64
  "scripts": {
61
65
  "dev": "astro-scripts dev \"src/**/*.ts\"",
62
- "build": "astro-scripts build \"src/**/*.ts\" --clean-dts && tsc",
66
+ "build": "astro-scripts build \"src/**/*.ts\" --clean-dts && tsc -b",
63
67
  "build:ci": "astro-scripts build \"src/**/*.ts\"",
64
- "test": "astro-scripts test --force-exit \"test/**/*.test.ts\"",
65
- "typecheck:tests": "tsc --build tsconfig.test.json"
68
+ "test": "astro-scripts test --force-exit \"test/**/*.test.ts\""
66
69
  }
67
70
  }