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
45 changes: 45 additions & 0 deletions .changeset/silly-coins-wash.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
---
'@astrojs/cloudflare': minor
---

Adds `@astrojs/cloudflare/fetch` and `@astrojs/cloudflare/hono` exports for composing Cloudflare-specific setup with Astro's advanced routing handlers.

#### `@astrojs/cloudflare/fetch`

For use with `astro/fetch` in a custom fetch handler:

```ts
import { astro, FetchState } from 'astro/fetch';
import { cf } from '@astrojs/cloudflare/fetch';

export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext) {
const state = new FetchState(request);
const asset = await cf(state, env, ctx);
if (asset) return asset;
return astro(state);
}
}
```

#### `@astrojs/cloudflare/hono`

For use with `astro/hono` as Hono middleware:

```ts
import { Hono } from 'hono';
import { actions, middleware, pages, i18n } from 'astro/hono';
import { cf } from '@astrojs/cloudflare/hono';

const app = new Hono<{ Bindings: Env }>();

app.use(cf());
app.use(actions());
app.use(middleware());
app.use(pages());
app.use(i18n());

export default app;
```

Both handlers configure SESSION KV bindings, static asset serving via the ASSETS binding, `locals.cfContext`, client address, `waitUntil`, and prerendered error page fetch.
2 changes: 2 additions & 0 deletions packages/integrations/cloudflare/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@
"./image-passthrough-endpoint": "./dist/entrypoints/image-passthrough-endpoint.js",
"./image-service-workerd": "./dist/entrypoints/image-service-workerd.js",
"./handler": "./dist/utils/handler.js",
"./fetch": "./dist/fetch.js",
"./hono": "./dist/hono.js",
"./types.d.ts": "./types.d.ts",
"./package.json": "./package.json"
},
Expand Down
69 changes: 69 additions & 0 deletions packages/integrations/cloudflare/src/fetch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
/**
* Cloudflare handler for use with `astro/fetch`.
*
* Usage in `src/app.ts`:
*
* ```ts
* import { astro, FetchState } from 'astro/fetch';
* import { cf } from '@astrojs/cloudflare/fetch';
*
* export default {
* async fetch(request: Request) {
* const state = new FetchState(request);
* const asset = await cf(state, env, ctx);
* if (asset) return asset;
* return astro(state);
* }
* }
* ```
*/
import { env as globalEnv } from 'cloudflare:workers';
import type { FetchState } from 'astro/fetch';
import { createApp } from 'astro/app/entrypoint';
import { setGetEnv } from 'astro/env/setup';
import { createGetEnv } from './utils/env.js';
import {
injectSessionBinding,
matchStaticAsset,
fallbackToAssets,
createErrorPageFetch,
createLocals,
getClientAddress,
} from './utils/cf.js';

setGetEnv(createGetEnv(globalEnv));

const app = createApp();

/**
* Applies Cloudflare-specific setup to a `FetchState`:
* - Injects the SESSION KV binding
* - Serves static assets via the ASSETS binding
* - Sets `locals.cfContext`, client address, `waitUntil`, and error page fetch
*
* Returns a `Response` if the request was handled by the ASSETS binding
* (static file hit). Returns `undefined` when the caller should continue
* to Astro rendering.
*/
export async function cf(
state: FetchState,
env: Env,
ctx: ExecutionContext,
): Promise<Response | undefined> {
injectSessionBinding(app.manifest, env);

const staticAsset = matchStaticAsset(app.manifest, state.request.url, env);
if (staticAsset) return staticAsset;

if (!state.routeData) {
const asset = await fallbackToAssets(state.request.url, env);
if (asset) return asset;
}

Object.assign(state.locals, createLocals(ctx));
state.clientAddress = getClientAddress(state.request);
state.renderOptions.waitUntil = ctx.waitUntil.bind(ctx);
state.renderOptions.prerenderedErrorPageFetch = createErrorPageFetch(env);

return undefined;
}
73 changes: 73 additions & 0 deletions packages/integrations/cloudflare/src/hono.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/**
* Cloudflare middleware for use with `astro/hono`.
*
* Usage in `src/app.ts`:
*
* ```ts
* import { Hono } from 'hono';
* import { actions, middleware, pages, i18n } from 'astro/hono';
* import { cf } from '@astrojs/cloudflare/hono';
*
* const app = new Hono<{ Bindings: Env }>();
*
* app.use(cf());
* app.use(actions());
* app.use(middleware());
* app.use(pages());
* app.use(i18n());
*
* export default app;
* ```
*/
import { FetchState } from 'astro/fetch';
import { cf as cfFetch } from './fetch.js';

const FETCH_STATE_KEY = 'fetchState';

/**
* Duck-typed Hono context — matches Hono's `Context` shape for
* Cloudflare Workers without importing from `hono` at runtime.
*/
type HonoCloudflareContextLike = {
req: {
raw: Request;
};
env: Env;
executionCtx: ExecutionContext;
get?: (key: string) => unknown;
set?: (key: string, value: unknown) => void;
};

type HonoMiddlewareHandler = (
context: HonoCloudflareContextLike,
next: () => Promise<void>,
) => Promise<Response | void>;

function getFetchState(context: HonoCloudflareContextLike): FetchState {
const state = context.get?.(FETCH_STATE_KEY) as FetchState | undefined;
if (state) return state;

const nextState = new FetchState(context.req.raw);
context.set?.(FETCH_STATE_KEY, nextState);
return nextState;
}

/**
* Hono middleware that applies Cloudflare-specific setup.
*
* Reads `env` and `executionCtx` from the Hono context (provided
* automatically by Hono on Cloudflare Workers). Handles static assets
* via the ASSETS binding, injects the SESSION KV binding, and sets
* `locals.cfContext`, client address, `waitUntil`, and error page fetch.
*
* If the request matches a static asset, returns the asset response
* directly. Otherwise calls `next()` to continue the middleware chain.
*/
export function cf(): HonoMiddlewareHandler {
return async (context, next) => {
const state = getFetchState(context);
const asset = await cfFetch(state, context.env, context.executionCtx);
if (asset) return asset;
await next();
};
}
98 changes: 98 additions & 0 deletions packages/integrations/cloudflare/src/utils/cf-helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
/**
* Pure Cloudflare helpers with no virtual module dependencies.
* Shared by `handler.ts`, `@astrojs/cloudflare/fetch`, and `@astrojs/cloudflare/hono`.
* Testable without a Vite build context.
*/
import { getValidatedIpFromHeader } from '@astrojs/internal-helpers/request';

export interface Runtime {
cfContext: ExecutionContext;
}

/** Minimal manifest shape needed by the Cloudflare helpers. */
export interface ManifestLike {
assets: Set<string>;
sessionConfig?: { options?: Record<string, unknown> } | undefined;
}

/**
* Returns a `Response` from the ASSETS binding if the request pathname
* is a known static asset. Returns `undefined` otherwise.
*/
export function matchStaticAsset(
manifest: ManifestLike,
requestUrl: string,
env: Env,
): Response | undefined {
const { pathname } = new URL(requestUrl);
if (manifest.assets.has(pathname)) {
return env.ASSETS.fetch(requestUrl.replace(/\.html$/, '')) as unknown as Response;
}
return undefined;
}

/**
* Tries the ASSETS binding as a fallback for an unmatched route.
* Returns the asset `Response` if found (non-404), `undefined` otherwise.
*/
export async function fallbackToAssets(requestUrl: string, env: Env): Promise<Response | undefined> {
const asset = await env.ASSETS.fetch(
requestUrl.replace(/index.html$/, '').replace(/\.html$/, ''),
);
if (asset.status !== 404) {
return asset as unknown as Response;
}
return undefined;
}

/**
* Creates a fetch function for prerendered error pages via the ASSETS binding.
*/
export function createErrorPageFetch(env: Env): (url: string) => Promise<Response> {
return async (url: string) => {
return env.ASSETS.fetch(url.replace(/\.html$/, '')) as unknown as Response;
};
}

/**
* Creates the Cloudflare-specific locals object with `cfContext`
* and deprecated `runtime` property getters.
*/
export function createLocals(ctx: ExecutionContext): Runtime {
const locals: Runtime = {
cfContext: ctx,
};
Object.defineProperty(locals, 'runtime', {
enumerable: false,
value: {
get env(): never {
throw new Error(
`Astro.locals.runtime.env has been removed in Astro v6. Use 'import { env } from "cloudflare:workers"' instead.`,
);
},
get cf(): never {
throw new Error(
`Astro.locals.runtime.cf has been removed in Astro v6. Use 'Astro.request.cf' instead.`,
);
},
get caches(): never {
throw new Error(
`Astro.locals.runtime.caches has been removed in Astro v6. Use the global 'caches' object instead.`,
);
},
get ctx(): never {
throw new Error(
`Astro.locals.runtime.ctx has been removed in Astro v6. Use 'Astro.locals.cfContext' instead.`,
);
},
},
});
return locals;
}

/**
* Extracts the client IP address from the `cf-connecting-ip` header.
*/
export function getClientAddress(request: Request): string | undefined {
return getValidatedIpFromHeader(request.headers.get('cf-connecting-ip'));
}
31 changes: 31 additions & 0 deletions packages/integrations/cloudflare/src/utils/cf.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/**
* Shared Cloudflare helpers used by `handler.ts` (the `handle()` entrypoint),
* `@astrojs/cloudflare/fetch`, and `@astrojs/cloudflare/hono`.
*
* Re-exports all pure helpers from `cf-helpers.ts` and adds
* `injectSessionBinding` which depends on the virtual module.
*/
import { sessionKVBindingName } from 'virtual:astro-cloudflare:config';
import type { ManifestLike } from './cf-helpers.js';

export type { Runtime, ManifestLike } from './cf-helpers.js';
export {
matchStaticAsset,
fallbackToAssets,
createErrorPageFetch,
createLocals,
getClientAddress,
} from './cf-helpers.js';

/**
* Injects the SESSION KV binding into the app manifest's session config.
* Idempotent — safe to call on every request.
*/
export function injectSessionBinding(manifest: ManifestLike, env: Env): void {
if (env[sessionKVBindingName]) {
const sessionConfigOptions = manifest.sessionConfig?.options ?? {};
Object.assign(sessionConfigOptions, {
binding: env[sessionKVBindingName],
});
}
}
Loading
Loading