Skip to content

Commit

Permalink
implement getCloudflareContext for production (#31)
Browse files Browse the repository at this point in the history
* implement `getCloudflareContext` for production

---------

Co-authored-by: Victor Berchet <[email protected]>
  • Loading branch information
dario-piotrowicz and vicb authored Sep 25, 2024
1 parent 9758666 commit af15fd1
Show file tree
Hide file tree
Showing 44 changed files with 181 additions and 59 deletions.
15 changes: 12 additions & 3 deletions examples/api/app/api/hello/route.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,19 @@
import { headers } from "next/headers";

import { getCloudflareContext } from "@opennextjs/cloudflare";

export async function GET() {
// Note: we use headers just so that the route is not built as a static one
const headersList = headers();
const sayHi = !!headersList.get("should-say-hi");
return new Response(sayHi ? "Hi World!" : "Hello World!");

const fromCloudflareContext = headersList.has("from-cloudflare-context");

if (!fromCloudflareContext) {
return new Response("Hello World!");
}

// Retrieve the bindings defined in wrangler.toml
const { env } = await getCloudflareContext();
return new Response(env.hello);
}

export async function POST(request: Request) {
Expand Down
10 changes: 10 additions & 0 deletions examples/api/e2e-tests/base.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,16 @@ test("the hello-world api GET route works as intended", async ({ page }) => {
expect(await res.text()).toEqual("Hello World!");
});

test("returns a hello world string from the cloudflare context env", async ({ page }) => {
const res = await page.request.get("/api/hello", {
headers: {
"from-cloudflare-context": "true",
},
});
expect(res.headers()["content-type"]).toContain("text/plain");
expect(await res.text()).toEqual("Hello World from the cloudflare context!");
});

test("the hello-world api POST route works as intended", async ({ page }) => {
const res = await page.request.post("/api/hello", { data: "some body" });
expect(res.headers()["content-type"]).toContain("text/plain");
Expand Down
3 changes: 2 additions & 1 deletion examples/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
"build:worker": "pnpm cloudflare",
"dev:worker": "wrangler dev --port 8770",
"preview:worker": "pnpm build:worker && pnpm dev:worker",
"e2e": "playwright test"
"e2e": "playwright test",
"cf-typegen": "wrangler types --env-interface CloudflareEnv"
},
"dependencies": {
"next": "catalog:",
Expand Down
2 changes: 1 addition & 1 deletion examples/api/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,6 @@
}
]
},
"include": ["next-env.d.ts", ".next/types/**/*.ts", "**/*.ts", "**/*.tsx"],
"include": ["next-env.d.ts", ".next/types/**/*.ts", "**/*.ts", "**/*.tsx", "worker-configuration.d.ts"],
"exclude": ["node_modules"]
}
5 changes: 5 additions & 0 deletions examples/api/worker-configuration.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// Generated by Wrangler by running `wrangler types --env-interface CloudflareEnv`

interface CloudflareEnv {
hello: "Hello World from the cloudflare context!";
}
3 changes: 3 additions & 0 deletions examples/api/wrangler.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,6 @@ compatibility_date = "2024-09-16"
compatibility_flags = ["nodejs_compat_v2"]

experimental_assets = { directory = ".worker-next/assets", binding = "ASSETS" }

[vars]
hello = 'Hello World from the cloudflare context!'
10 changes: 9 additions & 1 deletion packages/cloudflare/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,15 @@
"test": "vitest --run",
"test:watch": "vitest"
},
"bin": "dist/index.mjs",
"bin": "dist/cli/index.mjs",
"main": "./dist/api/index.mjs",
"types": "./dist/api/index.d.mts",
"exports": {
".": {
"import": "./dist/api/index.mjs",
"types": "./dist/api/index.d.mts"
}
},
"files": [
"README.md",
"dist"
Expand Down
53 changes: 53 additions & 0 deletions packages/cloudflare/src/api/get-cloudflare-context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import "server-only";

declare global {
// eslint-disable-next-line @typescript-eslint/no-empty-interface
interface CloudflareEnv {}
}

export type CloudflareContext<
CfProperties extends Record<string, unknown> = IncomingRequestCfProperties,
Context = ExecutionContext,
> = {
/**
* the worker's [bindings](https://developers.cloudflare.com/workers/runtime-apis/bindings/)
*/
env: CloudflareEnv;
/**
* the request's [cf properties](https://developers.cloudflare.com/workers/runtime-apis/request/#the-cf-property-requestinitcfproperties)
*/
cf: CfProperties;
/**
* the current [execution context](https://developers.cloudflare.com/workers/runtime-apis/context)
*/
ctx: Context;
};

// Note: this symbol needs to be kept in sync with the one used in `src/cli/templates/worker.ts`
const cloudflareContextSymbol = Symbol.for("__cloudflare-context__");

/**
* Utility to get the current Cloudflare context
*
* Throws an error if the context could not be retrieved
*
* @returns the cloudflare context
*/
export async function getCloudflareContext<
CfProperties extends Record<string, unknown> = IncomingRequestCfProperties,
Context = ExecutionContext,
>(): Promise<CloudflareContext<CfProperties, Context>> {
const cloudflareContext = (
globalThis as unknown as {
[cloudflareContextSymbol]: CloudflareContext<CfProperties, Context> | undefined;
}
)[cloudflareContextSymbol];

if (!cloudflareContext) {
// TODO: cloudflareContext should always be present in production/preview, if not it means that this
// is running under `next dev`, in this case use `getPlatformProxy` to return local proxies
throw new Error("Cloudflare context is not defined!");
}

return cloudflareContext;
}
1 change: 1 addition & 0 deletions packages/cloudflare/src/api/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./get-cloudflare-context";
11 changes: 0 additions & 11 deletions packages/cloudflare/src/build/patches/investigated/copy-package.ts

This file was deleted.

File renamed without changes.
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { Config } from "../config";
import { build, Plugin } from "esbuild";
import { existsSync, readFileSync, cpSync } from "node:fs";
import { existsSync, readFileSync } from "node:fs";
import { cp, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";

import { patchRequire } from "./patches/investigated/patch-require";
import { copyPackage } from "./patches/investigated/copy-package";
import { copyPackageCliFiles } from "./patches/investigated/copy-package-cli-files";

import { patchReadFile } from "./patches/to-investigate/patch-read-file";
import { patchFindDir } from "./patches/to-investigate/patch-find-dir";
Expand All @@ -16,8 +16,8 @@ import { patchWranglerDeps } from "./patches/to-investigate/wrangler-deps";
import { updateWebpackChunksFile } from "./patches/investigated/update-webpack-chunks-file";
import { patchCache } from "./patches/investigated/patch-cache";

/** The directory containing the Cloudflare template files. */
const packageDir = path.dirname(fileURLToPath(import.meta.url));
/** The dist directory of the Cloudflare adapter package */
const packageDistDir = path.join(path.dirname(fileURLToPath(import.meta.url)), "..");

/**
* Using the Next.js build output in the `.next` directory builds a workerd compatible output
Expand Down Expand Up @@ -45,9 +45,9 @@ export async function buildWorker(config: Config): Promise<void> {
});
}

copyPackage(packageDir, config);
copyPackageCliFiles(packageDistDir, config);

const templateDir = path.join(config.paths.internalPackage, "templates");
const templateDir = path.join(config.paths.internalPackage, "cli", "templates");

const workerEntrypoint = path.join(templateDir, "worker.ts");
const workerOutputFile = path.join(config.paths.builderOutput, "index.mjs");
Expand Down
File renamed without changes.
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { Config } from "../../../config";
import { cpSync } from "node:fs";
import path from "node:path";

/**
* Copies the template files present in the cloudflare adapter package into the standalone node_modules folder
*/
export function copyPackageCliFiles(packageDistDir: string, config: Config) {
console.log("# copyPackageTemplateFiles");
const sourceDir = path.join(packageDistDir, "cli");
const destinationDir = path.join(config.paths.internalPackage, "cli");

cpSync(sourceDir, destinationDir, { recursive: true });
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { Config } from "../../../config";
export function patchCache(code: string, config: Config): string {
console.log("# patchCached");

const cacheHandler = path.join(config.paths.internalPackage, "cache-handler.mjs");
const cacheHandler = path.join(config.paths.internalPackage, "cli", "cache-handler.mjs");

const patchedCode = code.replace(
"const { cacheHandler } = this.nextConfig;",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { readdirSync, readFileSync, writeFileSync } from "node:fs";
import path from "node:path";

import { Config } from "../../../../config";
import { Config } from "../../../../cli/config";
import { getUpdatedWebpackChunksFileContent } from "./get-updated-webpack-chunks-file-content";

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { globSync } from "glob";
import path from "node:path";
import { Config } from "../../../config";
import { Config } from "../../../cli/config";

/**
* `evalManifest` relies on readFileSync so we need to patch the function so that it instead returns the content of the manifest files
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { readFileSync, existsSync } from "node:fs";
import { Config } from "../../../config";
import { Config } from "../../../cli/config";
import path from "node:path";

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import path from "node:path";
import { Config } from "../../../config";
import { Config } from "../../../cli/config";
import { existsSync } from "node:fs";

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { readFileSync } from "node:fs";
import { globSync } from "glob";
import { Config } from "../../../config";
import { Config } from "../../../cli/config";
import path from "node:path";

export function patchReadFile(code: string, config: Config): string {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import path from "node:path";
import fs, { writeFileSync } from "node:fs";
import { Config } from "../../../config";
import { Config } from "../../../cli/config";

export function patchWranglerDeps(config: Config) {
console.log("# patchWranglerDeps");
Expand Down
File renamed without changes.
File renamed without changes.
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import { resolve } from "node:path";
import { getArgs } from "./args";
import { existsSync } from "node:fs";
import { build } from "./build/build";
import { build } from "./build";

const nextAppDir = resolve(".");

Expand Down
Original file line number Diff line number Diff line change
@@ -1,46 +1,64 @@
import { AsyncLocalStorage } from "node:async_hooks";
import Stream from "node:stream";
import type { NextConfig } from "next";
import { NodeNextRequest, NodeNextResponse } from "next/dist/server/base-http/node";
import { MockedResponse } from "next/dist/server/lib/mock-request";
import NextNodeServer, { NodeRequestHandler } from "next/dist/server/next-server";
import type { IncomingMessage } from "node:http";
import { type CloudflareContext } from "../../api";

const NON_BODY_RESPONSES = new Set([101, 204, 205, 304]);

const cloudflareContextALS = new AsyncLocalStorage<CloudflareContext>();

// Note: this symbol needs to be kept in sync with the one defined in `src/api/get-cloudflare-context.ts`
(globalThis as any)[Symbol.for("__cloudflare-context__")] = new Proxy(
{},
{
ownKeys: () => Reflect.ownKeys(cloudflareContextALS.getStore()!),
getOwnPropertyDescriptor: (_, ...args) =>
Reflect.getOwnPropertyDescriptor(cloudflareContextALS.getStore()!, ...args),
get: (_, property) => Reflect.get(cloudflareContextALS.getStore()!, property),
set: (_, property, value) => Reflect.set(cloudflareContextALS.getStore()!, property, value),
}
);

// Injected at build time
const nextConfig: NextConfig = JSON.parse(process.env.__NEXT_PRIVATE_STANDALONE_CONFIG ?? "{}");

let requestHandler: NodeRequestHandler | null = null;

export default {
async fetch(request: Request, env: any, ctx: any) {
if (requestHandler == null) {
globalThis.process.env = { ...globalThis.process.env, ...env };
requestHandler = new NextNodeServer({
conf: { ...nextConfig, env },
customServer: false,
dev: false,
dir: "",
minimalMode: false,
}).getRequestHandler();
}

const url = new URL(request.url);

if (url.pathname === "/_next/image") {
let imageUrl =
url.searchParams.get("url") ?? "https://developers.cloudflare.com/_astro/logo.BU9hiExz.svg";
if (imageUrl.startsWith("/")) {
return env.ASSETS.fetch(new URL(imageUrl, request.url));
async fetch(request: Request & { cf: IncomingRequestCfProperties }, env: any, ctx: any) {
return cloudflareContextALS.run({ env, ctx, cf: request.cf }, async () => {
if (requestHandler == null) {
globalThis.process.env = { ...globalThis.process.env, ...env };
requestHandler = new NextNodeServer({
conf: { ...nextConfig, env },
customServer: false,
dev: false,
dir: "",
minimalMode: false,
}).getRequestHandler();
}

const url = new URL(request.url);

if (url.pathname === "/_next/image") {
let imageUrl =
url.searchParams.get("url") ?? "https://developers.cloudflare.com/_astro/logo.BU9hiExz.svg";
if (imageUrl.startsWith("/")) {
return env.ASSETS.fetch(new URL(imageUrl, request.url));
}
return fetch(imageUrl, { cf: { cacheEverything: true } } as any);
}
return fetch(imageUrl, { cf: { cacheEverything: true } } as any);
}

const { req, res, webResponse } = getWrappedStreams(request, ctx);
const { req, res, webResponse } = getWrappedStreams(request, ctx);

ctx.waitUntil(requestHandler(new NodeNextRequest(req), new NodeNextResponse(res)));
ctx.waitUntil(requestHandler(new NodeNextRequest(req), new NodeNextResponse(res)));

return await webResponse();
return await webResponse();
});
},
};

Expand Down
19 changes: 15 additions & 4 deletions packages/cloudflare/tsup.config.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,27 @@
import { cp } from "fs/promises";
import { defineConfig } from "tsup";

export default defineConfig({
entry: ["src/index.ts", "src/cache-handler.ts"],
outDir: "dist",
const cliConfig = defineConfig({
entry: ["src/cli/index.ts", "src/cli/cache-handler.ts"],
outDir: "dist/cli",
dts: false,
format: ["esm"],
platform: "node",
external: ["esbuild"],
onSuccess: async () => {
await cp(`${__dirname}/src/templates`, `${__dirname}/dist/templates`, {
await cp(`${__dirname}/src/cli/templates`, `${__dirname}/dist/cli/templates`, {
recursive: true,
});
},
});

const apiConfig = defineConfig({
entry: ["src/api"],
outDir: "dist/api",
dts: true,
format: ["esm"],
platform: "node",
external: ["server-only"],
});

export default [cliConfig, apiConfig];

0 comments on commit af15fd1

Please sign in to comment.