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

Adds new configuration options to allow you to set a custom `workerEntryPoint` for Cloudflare Workers. This is useful if you want to use features that require handlers (e.g. Durable Objects, Cloudflare Queues, Scheduled Invocations) not supported by the basic generic entry file.

This feature is not supported when running the Astro dev server. However, you can run `astro build` followed by either `wrangler deploy` (to deploy it) or `wrangler dev` to preview it.

The following example configures a custom entry file that registers a Durable Object and a queue handler:

```ts
// astro.config.ts
import cloudflare from '@astrojs/cloudflare';
import { defineConfig } from 'astro/config';

export default defineConfig({
adapter: cloudflare({
workerEntryPoint: {
path: 'src/worker.ts',
namedExports: ['MyDurableObject']
}
}),
});
```

```ts
// src/worker.ts
import type { SSRManifest } from 'astro';

import { App } from 'astro/app';
import { handle } from '@astrojs/cloudflare/handler'
import { DurableObject } from 'cloudflare:workers';

class MyDurableObject extends DurableObject<Env> {
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env)
}
}

export function createExports(manifest: SSRManifest) {
const app = new App(manifest);
return {
default: {
async fetch(request, env, ctx) {
await env.MY_QUEUE.send("log");
return handle(manifest, app, request, env, ctx);
},
async queue(batch, _env) {
let messages = JSON.stringify(batch.messages);
console.log(`consumed from our queue: ${messages}`);
}
} satisfies ExportedHandler<Env>,
MyDurableObject,
}
}
```
1 change: 0 additions & 1 deletion packages/integrations/cloudflare/env.d.ts

This file was deleted.

1 change: 1 addition & 0 deletions packages/integrations/cloudflare/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
"./entrypoints/middleware.js": "./dist/entrypoints/middleware.js",
"./image-service": "./dist/entrypoints/image-service.js",
"./image-endpoint": "./dist/entrypoints/image-endpoint.js",
"./handler": "./dist/utils/handler.js",
"./package.json": "./package.json"
},
"files": [
Expand Down
103 changes: 5 additions & 98 deletions packages/integrations/cloudflare/src/entrypoints/server.ts
Original file line number Diff line number Diff line change
@@ -1,117 +1,24 @@
import { env as globalEnv } from 'cloudflare:workers';
import type {
CacheStorage as CLOUDFLARE_CACHESTORAGE,
Request as CLOUDFLARE_REQUEST,
ExecutionContext,
} from '@cloudflare/workers-types';
import type { ExecutionContext, ExportedHandlerFetchHandler } from '@cloudflare/workers-types';
import type { SSRManifest } from 'astro';
import { App } from 'astro/app';
import { setGetEnv } from 'astro/env/setup';
import { createGetEnv } from '../utils/env.js';

setGetEnv(createGetEnv(globalEnv as Env));
import { App } from 'astro/app';
import { handle } from '../utils/handler.js';

type Env = {
[key: string]: unknown;
ASSETS: { fetch: (req: Request | string) => Promise<Response> };
ASTRO_STUDIO_APP_TOKEN?: string;
};

export interface Runtime<T extends object = object> {
runtime: {
env: Env & T;
cf: CLOUDFLARE_REQUEST['cf'];
caches: CLOUDFLARE_CACHESTORAGE;
ctx: ExecutionContext;
};
}

declare global {
// This is not a real global, but is injected using Vite define to allow us to specify the session binding name in the config.
// eslint-disable-next-line no-var
var __ASTRO_SESSION_BINDING_NAME: string;

// Just used to pass the KV binding to unstorage.
// eslint-disable-next-line no-var
var __env__: Partial<Env>;
}

export function createExports(manifest: SSRManifest) {
const app = new App(manifest);

const fetch = async (
request: Request & CLOUDFLARE_REQUEST,
request: Parameters<ExportedHandlerFetchHandler>[0],
env: Env,
context: ExecutionContext,
) => {
const { pathname } = new URL(request.url);
const bindingName = globalThis.__ASTRO_SESSION_BINDING_NAME;
// Assigning the KV binding to globalThis allows unstorage to access it for session storage.
// unstorage checks in globalThis and globalThis.__env__ for the binding.
globalThis.__env__ ??= {};
globalThis.__env__[bindingName] = env[bindingName];

// static assets fallback, in case default _routes.json is not used
if (manifest.assets.has(pathname)) {
return env.ASSETS.fetch(request.url.replace(/\.html$/, ''));
}

const routeData = app.match(request);
if (!routeData) {
// https://developers.cloudflare.com/pages/functions/api-reference/#envassetsfetch
const asset = await env.ASSETS.fetch(
request.url.replace(/index.html$/, '').replace(/\.html$/, ''),
);
if (asset.status !== 404) {
return asset;
}
}

Reflect.set(
request,
Symbol.for('astro.clientAddress'),
request.headers.get('cf-connecting-ip'),
);

process.env.ASTRO_STUDIO_APP_TOKEN ??= (() => {
if (typeof env.ASTRO_STUDIO_APP_TOKEN === 'string') {
return env.ASTRO_STUDIO_APP_TOKEN;
}
})();

const locals: Runtime = {
runtime: {
env: env,
cf: request.cf,
caches: caches as unknown as CLOUDFLARE_CACHESTORAGE,
ctx: {
waitUntil: (promise: Promise<any>) => context.waitUntil(promise),
// Currently not available: https://developers.cloudflare.com/pages/platform/known-issues/#pages-functions
passThroughOnException: () => {
throw new Error(
'`passThroughOnException` is currently not available in Cloudflare Pages. See https://developers.cloudflare.com/pages/platform/known-issues/#pages-functions.',
);
},
props: {},
},
},
};

const response = await app.render(request, {
routeData,
locals,
prerenderedErrorPageFetch: async (url) => {
return env.ASSETS.fetch(url.replace(/\.html$/, ''));
},
});

if (app.setCookieHeaders) {
for (const setCookieHeader of app.setCookieHeaders(response)) {
response.headers.append('Set-Cookie', setCookieHeader);
}
}

return response;
return await handle(manifest, app, request, env, context);
};

return { default: { fetch } };
Expand Down
43 changes: 38 additions & 5 deletions packages/integrations/cloudflare/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ import type { PluginOption } from 'vite';
import { createReadStream } from 'node:fs';
import { appendFile, stat } from 'node:fs/promises';
import { createInterface } from 'node:readline/promises';
import { fileURLToPath } from 'node:url';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { createRequire } from 'node:module';
import {
appendForwardSlash,
prependForwardSlash,
Expand All @@ -27,7 +28,7 @@ import { createGetEnv } from './utils/env.js';
import { createRoutesFile, getParts } from './utils/generate-routes-json.js';
import { type ImageService, setImageConfig } from './utils/image-config.js';

export type { Runtime } from './entrypoints/server.js';
export type { Runtime } from './utils/handler.js';

export type Options = {
/** Options for handling images. */
Expand Down Expand Up @@ -98,8 +99,28 @@ export type Options = {
* See https://developers.cloudflare.com/kv/concepts/kv-namespaces/ for more details on using KV namespaces.
*
*/

sessionKVBindingName?: string;

/**
* This configuration option allows you to specify a custom entryPoint for your Cloudflare Worker.
* The entry point is the file that will be executed when your Worker is invoked.
* By default, this is set to `@astrojs/cloudflare/entrypoints/server.js` and `['default']`.
* @docs https://docs.astro.build/en/guides/integrations-guide/cloudflare/#workerEntryPoint
*/
workerEntryPoint?: {
/**
* The path to the entry file. This should be a relative path from the root of your Astro project.
* @example`'src/worker.ts'`
* @docs https://docs.astro.build/en/guides/integrations-guide/cloudflare/#workerentrypointpath
*/
path: string | URL;
/**
* Additional named exports to use for the entry file. Astro always includes the default export (`['default']`). If you need to have other top level named exports use this option.
* @example ['MyDurableObject', 'namedExport']
* @docs https://docs.astro.build/en/guides/integrations-guide/cloudflare/#workerentrypointnamedexports
*/
namedExports?: string[];
};
};

function wrapWithSlashes(path: string): string {
Expand Down Expand Up @@ -229,10 +250,22 @@ export default function createIntegration(args?: Options): AstroIntegration {
_config = config;
finalBuildOutput = buildOutput;

let customWorkerEntryPoint: URL | undefined;
if (args?.workerEntryPoint && typeof args.workerEntryPoint.path === 'string') {
const require = createRequire(config.root);
try {
customWorkerEntryPoint = pathToFileURL(require.resolve(args.workerEntryPoint.path));
} catch {
customWorkerEntryPoint = new URL(args.workerEntryPoint.path, config.root);
}
}

setAdapter({
name: '@astrojs/cloudflare',
serverEntrypoint: '@astrojs/cloudflare/entrypoints/server.js',
exports: ['default'],
serverEntrypoint: customWorkerEntryPoint ?? '@astrojs/cloudflare/entrypoints/server.js',
exports: args?.workerEntryPoint?.namedExports
? ['default', ...args.workerEntryPoint.namedExports]
: ['default'],
adapterFeatures: {
edgeMiddleware: false,
buildOutput: 'server',
Expand Down
114 changes: 114 additions & 0 deletions packages/integrations/cloudflare/src/utils/handler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
// @ts-expect-error - It is safe to expect the error here.
import { env as globalEnv } from 'cloudflare:workers';
import type { App } from 'astro/app';
import type { SSRManifest } from 'astro';
import type {
CacheStorage as CloudflareCacheStorage,
ExecutionContext,
ExportedHandlerFetchHandler,
} from '@cloudflare/workers-types';
import { setGetEnv } from 'astro/env/setup';
import { createGetEnv } from '../utils/env.js';

type Env = {
[key: string]: unknown;
ASSETS: { fetch: (req: Request | string) => Promise<Response> };
ASTRO_STUDIO_APP_TOKEN?: string;
};

setGetEnv(createGetEnv(globalEnv as Env));

export interface Runtime<T extends object = object> {
runtime: {
env: Env & T;
cf: Parameters<ExportedHandlerFetchHandler>[0]['cf'];
caches: CloudflareCacheStorage;
ctx: ExecutionContext;
};
}

declare global {
// This is not a real global, but is injected using Vite define to allow us to specify the session binding name in the config.
// eslint-disable-next-line no-var
var __ASTRO_SESSION_BINDING_NAME: string;

// Just used to pass the KV binding to unstorage.
// eslint-disable-next-line no-var
var __env__: Partial<Env>;
}

export async function handle(
manifest: SSRManifest,
app: App,
request: Parameters<ExportedHandlerFetchHandler>[0],
env: Env,
context: ExecutionContext,
) {
const { pathname } = new URL(request.url);
const bindingName = globalThis.__ASTRO_SESSION_BINDING_NAME;
// Assigning the KV binding to globalThis allows unstorage to access it for session storage.
// unstorage checks in globalThis and globalThis.__env__ for the binding.
globalThis.__env__ ??= {};
globalThis.__env__[bindingName] = env[bindingName];

// static assets fallback, in case default _routes.json is not used
if (manifest.assets.has(pathname)) {
return env.ASSETS.fetch(request.url.replace(/\.html$/, ''));
}

const routeData = app.match(request as Request & Parameters<ExportedHandlerFetchHandler>[0]);
if (!routeData) {
// https://developers.cloudflare.com/pages/functions/api-reference/#envassetsfetch
const asset = await env.ASSETS.fetch(
request.url.replace(/index.html$/, '').replace(/\.html$/, ''),
);
if (asset.status !== 404) {
return asset;
}
}

Reflect.set(request, Symbol.for('astro.clientAddress'), request.headers.get('cf-connecting-ip'));

process.env.ASTRO_STUDIO_APP_TOKEN ??= (() => {
if (typeof env.ASTRO_STUDIO_APP_TOKEN === 'string') {
return env.ASTRO_STUDIO_APP_TOKEN;
}
})();

const locals: Runtime = {
runtime: {
env: env,
cf: request.cf,
caches: caches as unknown as CloudflareCacheStorage,
ctx: {
waitUntil: (promise: Promise<any>) => context.waitUntil(promise),
// Currently not available: https://developers.cloudflare.com/pages/platform/known-issues/#pages-functions
passThroughOnException: () => {
throw new Error(
'`passThroughOnException` is currently not available in Cloudflare Pages. See https://developers.cloudflare.com/pages/platform/known-issues/#pages-functions.',
);
},
props: {},
},
},
};

const response = await app.render(
request as Request & Parameters<ExportedHandlerFetchHandler>[0],
{
routeData,
locals,
prerenderedErrorPageFetch: async (url) => {
return env.ASSETS.fetch(url.replace(/\.html$/, ''));
},
},
);

if (app.setCookieHeaders) {
for (const setCookieHeader of app.setCookieHeaders(response)) {
response.headers.append('Set-Cookie', setCookieHeader);
}
}

return response;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
worker-configuration.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import cloudflare from '@astrojs/cloudflare';
import { defineConfig } from 'astro/config';


export default defineConfig({
adapter: cloudflare({
workerEntryPoint: {
path: 'src/worker.ts',
namedExports: ['MyDurableObject']
}
}),
});
Loading