Skip to content

Commit fab558c

Browse files
committed
Add support for serving assets from the routing
1 parent 2797c3f commit fab558c

File tree

14 files changed

+1075
-656
lines changed

14 files changed

+1075
-656
lines changed

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: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
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 `tun_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(__CF_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;
97+
98+
/* eslint-disable no-var */
99+
declare global {
100+
// The configuration of `run_worker_first` for the assets.
101+
// Replaced at built time.
102+
var __CF_ASSETS_RUN_WORKER_FIRST__: boolean | string[] | undefined;
103+
}
104+
/* eslint-enable no-var */

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
@@ -87,7 +88,7 @@ export async function build(
8788

8889
await compileDurableObjects(options);
8990

90-
await bundleServer(options);
91+
await bundleServer(options, wranglerConfig);
9192

9293
if (!projectOpts.skipWranglerConfigCheck) {
9394
await createWranglerConfigIfNotExistent(projectOpts);

packages/cloudflare/src/cli/build/bundle-server.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { fileURLToPath } from "node:url";
66
import { type BuildOptions, getPackagePath } from "@opennextjs/aws/build/helper.js";
77
import { ContentUpdater } from "@opennextjs/aws/plugins/content-updater.js";
88
import { build, type Plugin } from "esbuild";
9+
import type { Unstable_Config } from "wrangler";
910

1011
import { getOpenNextConfig } from "../../api/config.js";
1112
import { patchVercelOgLibrary } from "./patches/ast/patch-vercel-og-library.js";
@@ -44,7 +45,7 @@ const optionalDependencies = [
4445
/**
4546
* Bundle the Open Next server.
4647
*/
47-
export async function bundleServer(buildOpts: BuildOptions): Promise<void> {
48+
export async function bundleServer(buildOpts: BuildOptions, wranglerConfig: Unstable_Config): Promise<void> {
4849
copyPackageCliFiles(packageDistDir, buildOpts);
4950

5051
const { appPath, outputDir, monorepoRoot, debug } = buildOpts;
@@ -146,6 +147,8 @@ export async function bundleServer(buildOpts: BuildOptions): Promise<void> {
146147
"process.env.TURBOPACK": "false",
147148
// This define should be safe to use for Next 14.2+, earlier versions (13.5 and less) will cause trouble
148149
"process.env.__NEXT_EXPERIMENTAL_REACT": `${needsExperimentalReact(nextConfig)}`,
150+
// Pass `assets.run_worker_first` to the asset resolver
151+
__CF_ASSETS_RUN_WORKER_FIRST__: JSON.stringify(wranglerConfig.assets?.run_worker_first ?? false),
149152
},
150153
banner: {
151154
// We need to import them here, assigning them to `globalThis` does not work because node:timers use `globalThis` and thus create an infinite loop
Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,25 @@
11
import { BuildOptions } from "@opennextjs/aws/build/helper.js";
2+
import { type Unstable_Config } from "wrangler";
23

34
import type { OpenNextConfig } from "../../api/config.js";
4-
import { getWranglerEnvironmentFlag, runWrangler } from "../utils/run-wrangler.js";
5+
import { runWrangler } from "../utils/run-wrangler.js";
56
import { populateCache } from "./populate-cache.js";
67

78
export async function deploy(
89
options: BuildOptions,
910
config: OpenNextConfig,
10-
deployOptions: { passthroughArgs: string[]; cacheChunkSize?: number }
11+
deployOptions: { passthroughArgs: string[]; cacheChunkSize?: number },
12+
wranglerConfig: Unstable_Config
1113
) {
12-
await populateCache(options, config, {
13-
target: "remote",
14-
environment: getWranglerEnvironmentFlag(deployOptions.passthroughArgs),
15-
cacheChunkSize: deployOptions.cacheChunkSize,
16-
});
14+
await populateCache(
15+
options,
16+
config,
17+
{
18+
target: "remote",
19+
cacheChunkSize: deployOptions.cacheChunkSize,
20+
},
21+
wranglerConfig
22+
);
1723

1824
runWrangler(options, ["deploy", ...deployOptions.passthroughArgs], { logging: "all" });
1925
}

packages/cloudflare/src/cli/commands/populate-cache.ts

Lines changed: 15 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import type {
1212
import type { IncrementalCache, TagCache } from "@opennextjs/aws/types/overrides.js";
1313
import { globSync } from "glob";
1414
import { tqdm } from "ts-tqdm";
15-
import { getPlatformProxy, type GetPlatformProxyOptions, unstable_readConfig } from "wrangler";
15+
import { getPlatformProxy, type GetPlatformProxyOptions, type Unstable_Config } from "wrangler";
1616

1717
import {
1818
BINDING_NAME as KV_CACHE_BINDING_NAME,
@@ -102,13 +102,12 @@ async function getPlatformProxyEnv<T extends keyof CloudflareEnv>(options: GetPl
102102

103103
async function populateR2IncrementalCache(
104104
options: BuildOptions,
105-
populateCacheOptions: { target: WranglerTarget; environment?: string }
105+
populateCacheOptions: { target: WranglerTarget; environment?: string },
106+
wranglerConfig: Unstable_Config
106107
) {
107108
logger.info("\nPopulating R2 incremental cache...");
108109

109-
const config = unstable_readConfig({ env: populateCacheOptions.environment });
110-
111-
const binding = config.r2_buckets.find(({ binding }) => binding === R2_CACHE_BINDING_NAME);
110+
const binding = wranglerConfig.r2_buckets.find(({ binding }) => binding === R2_CACHE_BINDING_NAME);
112111
if (!binding) {
113112
throw new Error(`No R2 binding ${JSON.stringify(R2_CACHE_BINDING_NAME)} found!`);
114113
}
@@ -145,13 +144,12 @@ async function populateR2IncrementalCache(
145144

146145
async function populateKVIncrementalCache(
147146
options: BuildOptions,
148-
populateCacheOptions: { target: WranglerTarget; environment?: string; cacheChunkSize?: number }
147+
populateCacheOptions: { target: WranglerTarget; environment?: string; cacheChunkSize?: number },
148+
wranglerConfig: Unstable_Config
149149
) {
150150
logger.info("\nPopulating KV incremental cache...");
151151

152-
const config = unstable_readConfig({ env: populateCacheOptions.environment });
153-
154-
const binding = config.kv_namespaces.find(({ binding }) => binding === KV_CACHE_BINDING_NAME);
152+
const binding = wranglerConfig.kv_namespaces.find(({ binding }) => binding === KV_CACHE_BINDING_NAME);
155153
if (!binding) {
156154
throw new Error(`No KV binding ${JSON.stringify(KV_CACHE_BINDING_NAME)} found!`);
157155
}
@@ -194,13 +192,12 @@ async function populateKVIncrementalCache(
194192

195193
function populateD1TagCache(
196194
options: BuildOptions,
197-
populateCacheOptions: { target: WranglerTarget; environment?: string }
195+
populateCacheOptions: { target: WranglerTarget; environment?: string },
196+
wranglerConfig: Unstable_Config
198197
) {
199198
logger.info("\nCreating D1 table if necessary...");
200199

201-
const config = unstable_readConfig({ env: populateCacheOptions.environment });
202-
203-
const binding = config.d1_databases.find(({ binding }) => binding === D1_TAG_BINDING_NAME);
200+
const binding = wranglerConfig.d1_databases.find(({ binding }) => binding === D1_TAG_BINDING_NAME);
204201
if (!binding) {
205202
throw new Error(`No D1 binding ${JSON.stringify(D1_TAG_BINDING_NAME)} found!`);
206203
}
@@ -233,7 +230,8 @@ function populateStaticAssetsIncrementalCache(options: BuildOptions) {
233230
export async function populateCache(
234231
options: BuildOptions,
235232
config: OpenNextConfig,
236-
populateCacheOptions: { target: WranglerTarget; environment?: string; cacheChunkSize?: number }
233+
populateCacheOptions: { target: WranglerTarget; cacheChunkSize?: number },
234+
wranglerConfig: Unstable_Config
237235
) {
238236
const { incrementalCache, tagCache } = config.default.override ?? {};
239237

@@ -246,10 +244,10 @@ export async function populateCache(
246244
const name = await resolveCacheName(incrementalCache);
247245
switch (name) {
248246
case R2_CACHE_NAME:
249-
await populateR2IncrementalCache(options, populateCacheOptions);
247+
await populateR2IncrementalCache(options, populateCacheOptions, wranglerConfig);
250248
break;
251249
case KV_CACHE_NAME:
252-
await populateKVIncrementalCache(options, populateCacheOptions);
250+
await populateKVIncrementalCache(options, populateCacheOptions, wranglerConfig);
253251
break;
254252
case STATIC_ASSETS_CACHE_NAME:
255253
populateStaticAssetsIncrementalCache(options);
@@ -263,7 +261,7 @@ export async function populateCache(
263261
const name = await resolveCacheName(tagCache);
264262
switch (name) {
265263
case D1_TAG_NAME:
266-
populateD1TagCache(options, populateCacheOptions);
264+
populateD1TagCache(options, populateCacheOptions, wranglerConfig);
267265
break;
268266
default:
269267
logger.info("Tag cache does not need populating");
Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,25 @@
11
import { BuildOptions } from "@opennextjs/aws/build/helper.js";
2+
import { type Unstable_Config } from "wrangler";
23

34
import type { OpenNextConfig } from "../../api/config.js";
4-
import { getWranglerEnvironmentFlag, runWrangler } from "../utils/run-wrangler.js";
5+
import { runWrangler } from "../utils/run-wrangler.js";
56
import { populateCache } from "./populate-cache.js";
67

78
export async function preview(
89
options: BuildOptions,
910
config: OpenNextConfig,
10-
previewOptions: { passthroughArgs: string[]; cacheChunkSize?: number }
11+
previewOptions: { passthroughArgs: string[]; cacheChunkSize?: number },
12+
wranglerConfig: Unstable_Config
1113
) {
12-
await populateCache(options, config, {
13-
target: "local",
14-
environment: getWranglerEnvironmentFlag(previewOptions.passthroughArgs),
15-
cacheChunkSize: previewOptions.cacheChunkSize,
16-
});
14+
await populateCache(
15+
options,
16+
config,
17+
{
18+
target: "local",
19+
cacheChunkSize: previewOptions.cacheChunkSize,
20+
},
21+
wranglerConfig
22+
);
1723

1824
runWrangler(options, ["dev", ...previewOptions.passthroughArgs], { logging: "all" });
1925
}

0 commit comments

Comments
 (0)