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
5 changes: 5 additions & 0 deletions .changeset/angry-lizards-add.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@opennextjs/cloudflare": minor
---

Add an asset resolver to support `run_worker_first=true`
5 changes: 4 additions & 1 deletion examples/playground14/e2e/cloudflare.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,10 @@ test.describe("playground/cloudflare", () => {

test("400 when fetching an image disallowed by remotePatterns", async ({ page }) => {
const res = await page.request.get("/_next/image?url=https://avatars.githubusercontent.com/u/248817");
expect(res.status()).toBe(400);
// The request should be blocked by either the remote patterns or the asset worker
const isBlockedByRemotePattern = res.status() === 400;
const isBlockedByAssetWorker = res.status() === 403;
expect(isBlockedByRemotePattern || isBlockedByAssetWorker).toBe(true);
});
});

Expand Down
3 changes: 2 additions & 1 deletion examples/playground15/wrangler.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
"compatibility_flags": ["nodejs_compat", "global_fetch_strictly_public"],
"assets": {
"directory": ".open-next/assets",
"binding": "ASSETS"
"binding": "ASSETS",
"run_worker_first": true
},
"kv_namespaces": [
{
Expand Down
3 changes: 3 additions & 0 deletions packages/cloudflare/src/api/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import type {
TagCache,
} from "@opennextjs/aws/types/overrides";

import assetResolver from "./overrides/asset-resolver/index.js";

export type Override<T extends BaseOverride> = "dummy" | T | LazyLoadedOverride<T>;

/**
Expand Down Expand Up @@ -102,6 +104,7 @@ export function defineCloudflareConfig(config: CloudflareOverrides = {}): OpenNe
tagCache: resolveTagCache(tagCache),
queue: resolveQueue(queue),
},
assetResolver: () => assetResolver,
},
};
}
Expand Down
47 changes: 47 additions & 0 deletions packages/cloudflare/src/api/overrides/asset-resolver/index.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { describe, expect, test } from "vitest";

import { isUserWorkerFirst } from "./index.js";

describe("isUserWorkerFirst", () => {
test("run_worker_first = false", () => {
expect(isUserWorkerFirst(false, "/test")).toBe(false);
expect(isUserWorkerFirst(false, "/")).toBe(false);
});

test("run_worker_first is undefined", () => {
expect(isUserWorkerFirst(undefined, "/test")).toBe(false);
expect(isUserWorkerFirst(undefined, "/")).toBe(false);
});

test("run_worker_first = true", () => {
expect(isUserWorkerFirst(true, "/test")).toBe(true);
expect(isUserWorkerFirst(true, "/")).toBe(true);
});

describe("run_worker_first is an array", () => {
test("positive string match", () => {
expect(isUserWorkerFirst(["/test.ext"], "/test.ext")).toBe(true);
expect(isUserWorkerFirst(["/a", "/b", "/test.ext"], "/test.ext")).toBe(true);
expect(isUserWorkerFirst(["/a", "/b", "/test.ext"], "/test")).toBe(false);
expect(isUserWorkerFirst(["/before/test.ext"], "/test.ext")).toBe(false);
expect(isUserWorkerFirst(["/test.ext/after"], "/test.ext")).toBe(false);
});

test("negative string match", () => {
expect(isUserWorkerFirst(["!/test.ext"], "/test.ext")).toBe(false);
expect(isUserWorkerFirst(["!/a", "!/b", "!/test.ext"], "/test.ext")).toBe(false);
});

test("positive patterns", () => {
expect(isUserWorkerFirst(["/images/*"], "/images/pic.jpg")).toBe(true);
expect(isUserWorkerFirst(["/images/*"], "/other/pic.jpg")).toBe(false);
});

test("negative patterns", () => {
expect(isUserWorkerFirst(["/*", "!/images/*"], "/images/pic.jpg")).toBe(false);
expect(isUserWorkerFirst(["/*", "!/images/*"], "/index.html")).toBe(true);
expect(isUserWorkerFirst(["!/images/*", "/*"], "/images/pic.jpg")).toBe(false);
expect(isUserWorkerFirst(["!/images/*", "/*"], "/index.html")).toBe(true);
});
});
});
96 changes: 96 additions & 0 deletions packages/cloudflare/src/api/overrides/asset-resolver/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import type { InternalEvent, InternalResult } from "@opennextjs/aws/types/open-next";
import type { AssetResolver } from "@opennextjs/aws/types/overrides";

import { getCloudflareContext } from "../../cloudflare-context.js";

/**
* Serves assets when `run_worker_first` is set to true.
*
* When `run_worker_first` is `false`, the assets are served directly bypassing Next routing.
*
* When it is `true`, assets are served from the routing layer. It should be used when assets
* should be behind the middleware or when skew protection is enabled.
*
* See https://developers.cloudflare.com/workers/static-assets/binding/#run_worker_first
*/
const resolver: AssetResolver = {
name: "cloudflare-asset-resolver",
async maybeGetAssetResult(event: InternalEvent) {
const { ASSETS } = getCloudflareContext().env;

if (!ASSETS || !isUserWorkerFirst(globalThis.__ASSETS_RUN_WORKER_FIRST__, event.rawPath)) {
// Only handle assets when the user worker runs first for the path
return undefined;
}

const { method, headers } = event;

if (method !== "GET" && method != "HEAD") {
return undefined;
}

const url = new URL(event.rawPath, "https://assets.local");
const response = await ASSETS.fetch(url, {
headers,
method,
});

if (response.status === 404) {
return undefined;
}

return {
type: "core",
statusCode: response.status,
headers: Object.fromEntries(response.headers.entries()),
// Workers and Node types differ.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
body: response.body || (new ReadableStream() as any),
isBase64Encoded: false,
} satisfies InternalResult;
},
};

/**
* @param runWorkerFirst `run_worker_first` config
* @param pathname pathname of the request
* @returns Whether the user worker runs first
*/
export function isUserWorkerFirst(runWorkerFirst: boolean | string[] | undefined, pathname: string): boolean {
if (!Array.isArray(runWorkerFirst)) {
return runWorkerFirst ?? false;
}

let hasPositiveMatch = false;

for (let rule of runWorkerFirst) {
let isPositiveRule = true;

if (rule.startsWith("!")) {
rule = rule.slice(1);
isPositiveRule = false;
} else if (hasPositiveMatch) {
// Do not look for more positive rules once we have a match
continue;
}

// - Escapes special characters
// - Replaces * with .*
const match = new RegExp(`^${rule.replace(/([[\]().*+?^$|{}\\])/g, "\\$1").replace("\\*", ".*")}$`).test(
pathname
);

if (match) {
if (isPositiveRule) {
hasPositiveMatch = true;
} else {
// Exit early when there is a negative match
return false;
}
}
}

return hasPositiveMatch;
}

export default resolver;
7 changes: 4 additions & 3 deletions packages/cloudflare/src/cli/build/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { createMiddleware } from "@opennextjs/aws/build/createMiddleware.js";
import * as buildHelper from "@opennextjs/aws/build/helper.js";
import { printHeader } from "@opennextjs/aws/build/utils.js";
import logger from "@opennextjs/aws/logger.js";
import type { Unstable_Config } from "wrangler";

import { OpenNextConfig } from "../../api/config.js";
import type { ProjectOptions } from "../project-options.js";
Expand All @@ -30,10 +31,10 @@ import { getVersion } from "./utils/version.js";
export async function build(
options: buildHelper.BuildOptions,
config: OpenNextConfig,
projectOpts: ProjectOptions
projectOpts: ProjectOptions,
wranglerConfig: Unstable_Config
): Promise<void> {
// Do not minify the code so that we can apply string replacement patch.
// Note that wrangler will still minify the bundle.
options.minify = false;

// Pre-build validation
Expand Down Expand Up @@ -65,7 +66,7 @@ export async function build(
compileEnvFiles(options);

// Compile workerd init
compileInit(options);
compileInit(options, wranglerConfig);

// Compile image helpers
compileImages(options);
Expand Down
4 changes: 3 additions & 1 deletion packages/cloudflare/src/cli/build/open-next/compile-init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,12 @@ import { fileURLToPath } from "node:url";
import { loadConfig } from "@opennextjs/aws/adapters/config/util.js";
import type { BuildOptions } from "@opennextjs/aws/build/helper.js";
import { build } from "esbuild";
import type { Unstable_Config } from "wrangler";

/**
* Compiles the initialization code for the workerd runtime
*/
export async function compileInit(options: BuildOptions) {
export async function compileInit(options: BuildOptions, wranglerConfig: Unstable_Config) {
const currentDir = path.join(path.dirname(fileURLToPath(import.meta.url)));
const templatesDir = path.join(currentDir, "../../templates");
const initPath = path.join(templatesDir, "init.js");
Expand All @@ -27,6 +28,7 @@ export async function compileInit(options: BuildOptions) {
define: {
__BUILD_TIMESTAMP_MS__: JSON.stringify(Date.now()),
__NEXT_BASE_PATH__: JSON.stringify(basePath),
__ASSETS_RUN_WORKER_FIRST__: JSON.stringify(wranglerConfig.assets?.run_worker_first ?? false),
},
});
}
12 changes: 10 additions & 2 deletions packages/cloudflare/src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { compileOpenNextConfig } from "@opennextjs/aws/build/compileConfig.js";
import { normalizeOptions } from "@opennextjs/aws/build/helper.js";
import { printHeader, showWarningOnWindows } from "@opennextjs/aws/build/utils.js";
import logger from "@opennextjs/aws/logger.js";
import { unstable_readConfig } from "wrangler";

import { Arguments, getArgs } from "./args.js";
import { build } from "./build/build.js";
Expand All @@ -14,6 +15,7 @@ import { deploy } from "./commands/deploy.js";
import { populateCache } from "./commands/populate-cache.js";
import { preview } from "./commands/preview.js";
import { upload } from "./commands/upload.js";
import { getWranglerConfigFlag, getWranglerEnvironmentFlag } from "./utils/run-wrangler.js";

const nextAppDir = process.cwd();

Expand All @@ -38,8 +40,14 @@ async function runCommand(args: Arguments) {
logger.setLevel(options.debug ? "debug" : "info");

switch (args.command) {
case "build":
return build(options, config, { ...args, sourceDir: baseDir });
case "build": {
const argv = process.argv.slice(2);
const wranglerEnv = getWranglerEnvironmentFlag(argv);
const wranglerConfigFile = getWranglerConfigFlag(argv);
const wranglerConfig = unstable_readConfig({ env: wranglerEnv, config: wranglerConfigFile });

return build(options, config, { ...args, sourceDir: baseDir }, wranglerConfig);
}
case "preview":
return preview(options, config, args);
case "deploy":
Expand Down
7 changes: 5 additions & 2 deletions packages/cloudflare/src/cli/templates/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,8 +94,9 @@ function initRuntime() {

Object.assign(globalThis, {
Request: CustomRequest,
__BUILD_TIMESTAMP_MS__: __BUILD_TIMESTAMP_MS__,
__NEXT_BASE_PATH__: __NEXT_BASE_PATH__,
__BUILD_TIMESTAMP_MS__,
__NEXT_BASE_PATH__,
__ASSETS_RUN_WORKER_FIRST__,
// The external middleware will use the convertTo function of the `edge` converter
// by default it will try to fetch the request, but since we are running everything in the same worker
// we need to use the request as is.
Expand Down Expand Up @@ -146,5 +147,7 @@ declare global {
var __BUILD_TIMESTAMP_MS__: number;
// Next basePath
var __NEXT_BASE_PATH__: string;
// Value of `run_worker_first` for the asset binding
var __ASSETS_RUN_WORKER_FIRST__: boolean | string[] | undefined;
}
/* eslint-enable no-var */
54 changes: 54 additions & 0 deletions packages/cloudflare/src/cli/utils/run-wrangler.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { describe, expect, test } from "vitest";

import { getFlagValue, getWranglerConfigFlag, getWranglerEnvironmentFlag } from "./run-wrangler.js";

describe("getFlagValue", () => {
test("long", () => {
expect(getFlagValue(["--flag", "value"], "--flag", "-f")).toEqual("value");
expect(getFlagValue(["--flag=value"], "--flag", "-f")).toEqual("value");
});

test("short", () => {
expect(getFlagValue(["-f", "value"], "--flag", "-f")).toEqual("value");
expect(getFlagValue(["-f=value"], "--flag", "-f")).toEqual("value");
});

test("not found", () => {
expect(getFlagValue(["--some", "value"], "--other", "-o")).toBeUndefined();
expect(getFlagValue(["--some=value"], "--other", "-o")).toBeUndefined();
});
});

describe("getWranglerEnvironmentFlag", () => {
test("long", () => {
expect(getWranglerEnvironmentFlag(["--env", "value"])).toEqual("value");
expect(getWranglerEnvironmentFlag(["--env=value"])).toEqual("value");
});

test("short", () => {
expect(getWranglerEnvironmentFlag(["-e", "value"])).toEqual("value");
expect(getWranglerEnvironmentFlag(["-e=value"])).toEqual("value");
});

test("not found", () => {
expect(getWranglerEnvironmentFlag(["--some", "value"])).toBeUndefined();
expect(getWranglerEnvironmentFlag(["--some=value"])).toBeUndefined();
});
});

describe("getWranglerConfigFlag", () => {
test("long", () => {
expect(getWranglerConfigFlag(["--config", "path/to/wrangler.jsonc"])).toEqual("path/to/wrangler.jsonc");
expect(getWranglerConfigFlag(["--config=path/to/wrangler.jsonc"])).toEqual("path/to/wrangler.jsonc");
});

test("short", () => {
expect(getWranglerConfigFlag(["-c", "path/to/wrangler.jsonc"])).toEqual("path/to/wrangler.jsonc");
expect(getWranglerConfigFlag(["-c=path/to/wrangler.jsonc"])).toEqual("path/to/wrangler.jsonc");
});

test("not found", () => {
expect(getWranglerConfigFlag(["--some", "value"])).toBeUndefined();
expect(getWranglerConfigFlag(["--some=value"])).toBeUndefined();
});
});
Loading