Skip to content

Commit 40f7e72

Browse files
authored
feat: add support for serving assets from the routing layer (#768)
1 parent 2797c3f commit 40f7e72

File tree

14 files changed

+1079
-681
lines changed

14 files changed

+1079
-681
lines changed

.changeset/angry-lizards-add.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@opennextjs/cloudflare": minor
3+
---
4+
5+
Add an asset resolver to support `run_worker_first=true`

examples/playground14/e2e/cloudflare.spec.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,10 @@ test.describe("playground/cloudflare", () => {
2828

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

examples/playground15/wrangler.jsonc

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@
66
"compatibility_flags": ["nodejs_compat", "global_fetch_strictly_public"],
77
"assets": {
88
"directory": ".open-next/assets",
9-
"binding": "ASSETS"
9+
"binding": "ASSETS",
10+
"run_worker_first": true
1011
},
1112
"kv_namespaces": [
1213
{

packages/cloudflare/src/api/config.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import type {
1212
TagCache,
1313
} from "@opennextjs/aws/types/overrides";
1414

15+
import assetResolver from "./overrides/asset-resolver/index.js";
16+
1517
export type Override<T extends BaseOverride> = "dummy" | T | LazyLoadedOverride<T>;
1618

1719
/**
@@ -102,6 +104,7 @@ export function defineCloudflareConfig(config: CloudflareOverrides = {}): OpenNe
102104
tagCache: resolveTagCache(tagCache),
103105
queue: resolveQueue(queue),
104106
},
107+
assetResolver: () => assetResolver,
105108
},
106109
};
107110
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { describe, expect, test } from "vitest";
2+
3+
import { isUserWorkerFirst } from "./index.js";
4+
5+
describe("isUserWorkerFirst", () => {
6+
test("run_worker_first = false", () => {
7+
expect(isUserWorkerFirst(false, "/test")).toBe(false);
8+
expect(isUserWorkerFirst(false, "/")).toBe(false);
9+
});
10+
11+
test("run_worker_first is undefined", () => {
12+
expect(isUserWorkerFirst(undefined, "/test")).toBe(false);
13+
expect(isUserWorkerFirst(undefined, "/")).toBe(false);
14+
});
15+
16+
test("run_worker_first = true", () => {
17+
expect(isUserWorkerFirst(true, "/test")).toBe(true);
18+
expect(isUserWorkerFirst(true, "/")).toBe(true);
19+
});
20+
21+
describe("run_worker_first is an array", () => {
22+
test("positive string match", () => {
23+
expect(isUserWorkerFirst(["/test.ext"], "/test.ext")).toBe(true);
24+
expect(isUserWorkerFirst(["/a", "/b", "/test.ext"], "/test.ext")).toBe(true);
25+
expect(isUserWorkerFirst(["/a", "/b", "/test.ext"], "/test")).toBe(false);
26+
expect(isUserWorkerFirst(["/before/test.ext"], "/test.ext")).toBe(false);
27+
expect(isUserWorkerFirst(["/test.ext/after"], "/test.ext")).toBe(false);
28+
});
29+
30+
test("negative string match", () => {
31+
expect(isUserWorkerFirst(["!/test.ext"], "/test.ext")).toBe(false);
32+
expect(isUserWorkerFirst(["!/a", "!/b", "!/test.ext"], "/test.ext")).toBe(false);
33+
});
34+
35+
test("positive patterns", () => {
36+
expect(isUserWorkerFirst(["/images/*"], "/images/pic.jpg")).toBe(true);
37+
expect(isUserWorkerFirst(["/images/*"], "/other/pic.jpg")).toBe(false);
38+
});
39+
40+
test("negative patterns", () => {
41+
expect(isUserWorkerFirst(["/*", "!/images/*"], "/images/pic.jpg")).toBe(false);
42+
expect(isUserWorkerFirst(["/*", "!/images/*"], "/index.html")).toBe(true);
43+
expect(isUserWorkerFirst(["!/images/*", "/*"], "/images/pic.jpg")).toBe(false);
44+
expect(isUserWorkerFirst(["!/images/*", "/*"], "/index.html")).toBe(true);
45+
});
46+
});
47+
});
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import type { InternalEvent, InternalResult } from "@opennextjs/aws/types/open-next";
2+
import type { AssetResolver } from "@opennextjs/aws/types/overrides";
3+
4+
import { getCloudflareContext } from "../../cloudflare-context.js";
5+
6+
/**
7+
* Serves assets when `run_worker_first` is set to true.
8+
*
9+
* When `run_worker_first` is `false`, the assets are served directly bypassing Next routing.
10+
*
11+
* When it is `true`, assets are served from the routing layer. It should be used when assets
12+
* should be behind the middleware or when skew protection is enabled.
13+
*
14+
* See https://developers.cloudflare.com/workers/static-assets/binding/#run_worker_first
15+
*/
16+
const resolver: AssetResolver = {
17+
name: "cloudflare-asset-resolver",
18+
async maybeGetAssetResult(event: InternalEvent) {
19+
const { ASSETS } = getCloudflareContext().env;
20+
21+
if (!ASSETS || !isUserWorkerFirst(globalThis.__ASSETS_RUN_WORKER_FIRST__, event.rawPath)) {
22+
// Only handle assets when the user worker runs first for the path
23+
return undefined;
24+
}
25+
26+
const { method, headers } = event;
27+
28+
if (method !== "GET" && method != "HEAD") {
29+
return undefined;
30+
}
31+
32+
const url = new URL(event.rawPath, "https://assets.local");
33+
const response = await ASSETS.fetch(url, {
34+
headers,
35+
method,
36+
});
37+
38+
if (response.status === 404) {
39+
return undefined;
40+
}
41+
42+
return {
43+
type: "core",
44+
statusCode: response.status,
45+
headers: Object.fromEntries(response.headers.entries()),
46+
// Workers and Node types differ.
47+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
48+
body: response.body || (new ReadableStream() as any),
49+
isBase64Encoded: false,
50+
} satisfies InternalResult;
51+
},
52+
};
53+
54+
/**
55+
* @param runWorkerFirst `run_worker_first` config
56+
* @param pathname pathname of the request
57+
* @returns Whether the user worker runs first
58+
*/
59+
export function isUserWorkerFirst(runWorkerFirst: boolean | string[] | undefined, pathname: string): boolean {
60+
if (!Array.isArray(runWorkerFirst)) {
61+
return runWorkerFirst ?? false;
62+
}
63+
64+
let hasPositiveMatch = false;
65+
66+
for (let rule of runWorkerFirst) {
67+
let isPositiveRule = true;
68+
69+
if (rule.startsWith("!")) {
70+
rule = rule.slice(1);
71+
isPositiveRule = false;
72+
} else if (hasPositiveMatch) {
73+
// Do not look for more positive rules once we have a match
74+
continue;
75+
}
76+
77+
// - Escapes special characters
78+
// - Replaces * with .*
79+
const match = new RegExp(`^${rule.replace(/([[\]().*+?^$|{}\\])/g, "\\$1").replace("\\*", ".*")}$`).test(
80+
pathname
81+
);
82+
83+
if (match) {
84+
if (isPositiveRule) {
85+
hasPositiveMatch = true;
86+
} else {
87+
// Exit early when there is a negative match
88+
return false;
89+
}
90+
}
91+
}
92+
93+
return hasPositiveMatch;
94+
}
95+
96+
export default resolver;

packages/cloudflare/src/cli/build/build.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { createMiddleware } from "@opennextjs/aws/build/createMiddleware.js";
55
import * as buildHelper from "@opennextjs/aws/build/helper.js";
66
import { printHeader } from "@opennextjs/aws/build/utils.js";
77
import logger from "@opennextjs/aws/logger.js";
8+
import type { Unstable_Config } from "wrangler";
89

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

3940
// Pre-build validation
@@ -65,7 +66,7 @@ export async function build(
6566
compileEnvFiles(options);
6667

6768
// Compile workerd init
68-
compileInit(options);
69+
compileInit(options, wranglerConfig);
6970

7071
// Compile image helpers
7172
compileImages(options);

packages/cloudflare/src/cli/build/open-next/compile-init.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,12 @@ import { fileURLToPath } from "node:url";
44
import { loadConfig } from "@opennextjs/aws/adapters/config/util.js";
55
import type { BuildOptions } from "@opennextjs/aws/build/helper.js";
66
import { build } from "esbuild";
7+
import type { Unstable_Config } from "wrangler";
78

89
/**
910
* Compiles the initialization code for the workerd runtime
1011
*/
11-
export async function compileInit(options: BuildOptions) {
12+
export async function compileInit(options: BuildOptions, wranglerConfig: Unstable_Config) {
1213
const currentDir = path.join(path.dirname(fileURLToPath(import.meta.url)));
1314
const templatesDir = path.join(currentDir, "../../templates");
1415
const initPath = path.join(templatesDir, "init.js");
@@ -27,6 +28,7 @@ export async function compileInit(options: BuildOptions) {
2728
define: {
2829
__BUILD_TIMESTAMP_MS__: JSON.stringify(Date.now()),
2930
__NEXT_BASE_PATH__: JSON.stringify(basePath),
31+
__ASSETS_RUN_WORKER_FIRST__: JSON.stringify(wranglerConfig.assets?.run_worker_first ?? false),
3032
},
3133
});
3234
}

packages/cloudflare/src/cli/index.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { compileOpenNextConfig } from "@opennextjs/aws/build/compileConfig.js";
66
import { normalizeOptions } from "@opennextjs/aws/build/helper.js";
77
import { printHeader, showWarningOnWindows } from "@opennextjs/aws/build/utils.js";
88
import logger from "@opennextjs/aws/logger.js";
9+
import { unstable_readConfig } from "wrangler";
910

1011
import { Arguments, getArgs } from "./args.js";
1112
import { build } from "./build/build.js";
@@ -14,6 +15,7 @@ import { deploy } from "./commands/deploy.js";
1415
import { populateCache } from "./commands/populate-cache.js";
1516
import { preview } from "./commands/preview.js";
1617
import { upload } from "./commands/upload.js";
18+
import { getWranglerConfigFlag, getWranglerEnvironmentFlag } from "./utils/run-wrangler.js";
1719

1820
const nextAppDir = process.cwd();
1921

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

4042
switch (args.command) {
41-
case "build":
42-
return build(options, config, { ...args, sourceDir: baseDir });
43+
case "build": {
44+
const argv = process.argv.slice(2);
45+
const wranglerEnv = getWranglerEnvironmentFlag(argv);
46+
const wranglerConfigFile = getWranglerConfigFlag(argv);
47+
const wranglerConfig = unstable_readConfig({ env: wranglerEnv, config: wranglerConfigFile });
48+
49+
return build(options, config, { ...args, sourceDir: baseDir }, wranglerConfig);
50+
}
4351
case "preview":
4452
return preview(options, config, args);
4553
case "deploy":

packages/cloudflare/src/cli/templates/init.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,8 +94,9 @@ function initRuntime() {
9494

9595
Object.assign(globalThis, {
9696
Request: CustomRequest,
97-
__BUILD_TIMESTAMP_MS__: __BUILD_TIMESTAMP_MS__,
98-
__NEXT_BASE_PATH__: __NEXT_BASE_PATH__,
97+
__BUILD_TIMESTAMP_MS__,
98+
__NEXT_BASE_PATH__,
99+
__ASSETS_RUN_WORKER_FIRST__,
99100
// The external middleware will use the convertTo function of the `edge` converter
100101
// by default it will try to fetch the request, but since we are running everything in the same worker
101102
// we need to use the request as is.
@@ -146,5 +147,7 @@ declare global {
146147
var __BUILD_TIMESTAMP_MS__: number;
147148
// Next basePath
148149
var __NEXT_BASE_PATH__: string;
150+
// Value of `run_worker_first` for the asset binding
151+
var __ASSETS_RUN_WORKER_FIRST__: boolean | string[] | undefined;
149152
}
150153
/* eslint-enable no-var */

0 commit comments

Comments
 (0)