Skip to content

Commit 3b1d081

Browse files
authored
asset-worker support for SPA mode (#8443)
* Apply not_found_handling = single-page-application when single_page_application = true * Invoke asset-worker when single_page_application = true and incoming request looks SPA-like * Move over to using a compatibility flag and supporting 404-page
1 parent c4fa349 commit 3b1d081

File tree

11 files changed

+373
-40
lines changed

11 files changed

+373
-40
lines changed

.changeset/all-mugs-give.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@cloudflare/workers-shared": minor
3+
---
4+
5+
Requests with a `Sec-Fetch-Mode: navigate` header, made to a project with `sec_fetch_mode_navigate_header_prefers_asset_serving` compatibility flag, will be routed to the asset-worker rather than a user Worker when no exact asset match is found.
6+
7+
Requests without that header will continue to be routed to the user Worker when no exact asset match is found.

fixtures/asset-config/html-handling.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { SELF } from "cloudflare:test";
22
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3-
import { applyConfigurationDefaults } from "../../packages/workers-shared/asset-worker/src/configuration";
3+
import { normalizeConfiguration } from "../../packages/workers-shared/asset-worker/src/configuration";
44
import Worker from "../../packages/workers-shared/asset-worker/src/index";
55
import { getAssetWithMetadataFromKV } from "../../packages/workers-shared/asset-worker/src/utils/kv";
66
import { encodingTestCases } from "./test-cases/encoding-test-cases";
@@ -65,8 +65,8 @@ describe.each(testSuites)("$title", ({ title, suite }) => {
6565
await vi.importActual<
6666
typeof import("../../packages/workers-shared/asset-worker/src/configuration")
6767
>("../../packages/workers-shared/asset-worker/src/configuration")
68-
).applyConfigurationDefaults;
69-
vi.mocked(applyConfigurationDefaults).mockImplementation(() => ({
68+
).normalizeConfiguration;
69+
vi.mocked(normalizeConfiguration).mockImplementation(() => ({
7070
...originalApplyConfigurationDefaults({}),
7171
html_handling,
7272
not_found_handling: "none",

fixtures/asset-config/redirects.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { SELF } from "cloudflare:test";
22
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3-
import { applyConfigurationDefaults } from "../../packages/workers-shared/asset-worker/src/configuration";
3+
import { normalizeConfiguration } from "../../packages/workers-shared/asset-worker/src/configuration";
44
import Worker from "../../packages/workers-shared/asset-worker/src/index";
55
import { getAssetWithMetadataFromKV } from "../../packages/workers-shared/asset-worker/src/utils/kv";
66
import type { AssetMetadata } from "../../packages/workers-shared/asset-worker/src/utils/kv";
@@ -42,8 +42,8 @@ describe("[Asset Worker] `test location rewrite`", () => {
4242
await vi.importActual<
4343
typeof import("../../packages/workers-shared/asset-worker/src/configuration")
4444
>("../../packages/workers-shared/asset-worker/src/configuration")
45-
).applyConfigurationDefaults;
46-
vi.mocked(applyConfigurationDefaults).mockImplementation(() => ({
45+
).normalizeConfiguration;
46+
vi.mocked(normalizeConfiguration).mockImplementation(() => ({
4747
...originalApplyConfigurationDefaults({}),
4848
html_handling: "none",
4949
not_found_handling: "none",

fixtures/asset-config/url-normalization.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { SELF } from "cloudflare:test";
22
import { afterEach, beforeEach, describe, expect, it, Mock, vi } from "vitest";
3-
import { applyConfigurationDefaults } from "../../packages/workers-shared/asset-worker/src/configuration";
3+
import { normalizeConfiguration } from "../../packages/workers-shared/asset-worker/src/configuration";
44
import Worker from "../../packages/workers-shared/asset-worker/src/index";
55
import { getAssetWithMetadataFromKV } from "../../packages/workers-shared/asset-worker/src/utils/kv";
66
import type { AssetMetadata } from "../../packages/workers-shared/asset-worker/src/utils/kv";
@@ -42,8 +42,8 @@ describe("[Asset Worker] `test slash normalization`", () => {
4242
await vi.importActual<
4343
typeof import("../../packages/workers-shared/asset-worker/src/configuration")
4444
>("../../packages/workers-shared/asset-worker/src/configuration")
45-
).applyConfigurationDefaults;
46-
vi.mocked(applyConfigurationDefaults).mockImplementation(() => ({
45+
).normalizeConfiguration;
46+
vi.mocked(normalizeConfiguration).mockImplementation(() => ({
4747
...originalApplyConfigurationDefaults({}),
4848
html_handling: "none",
4949
not_found_handling: "none",

packages/workers-shared/asset-worker/src/analytics.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { ENABLEMENT_COMPATIBILITY_FLAGS } from "./compatibility-flags";
12
import type { ReadyAnalytics } from "./types";
23

34
// This will allow us to make breaking changes to the analytic schema
@@ -20,6 +21,8 @@ type Data = {
2021
coloTier?: number;
2122
// double5 - Response status code
2223
status?: number;
24+
// double6 - Compatibility flags
25+
compatibilityFlags?: string[]; // converted into a bitmask
2326

2427
// -- Blobs --
2528
// blob1 - Hostname of the request
@@ -40,6 +43,14 @@ type Data = {
4043
cacheStatus?: string;
4144
};
4245

46+
const COMPATIBILITY_FLAG_MASKS: Record<ENABLEMENT_COMPATIBILITY_FLAGS, number> =
47+
{
48+
assets_navigation_prefers_asset_serving: 1 << 0,
49+
// next_one: 1 << 1
50+
// one_after_that: 1 << 2
51+
// etc: 1 << 3
52+
};
53+
4354
export class Analytics {
4455
private data: Data = {};
4556
private readyAnalytics?: ReadyAnalytics;
@@ -61,6 +72,17 @@ export class Analytics {
6172
return;
6273
}
6374

75+
let compatibilityFlagsBitmask = 0;
76+
for (const compatibilityFlag of this.data.compatibilityFlags || []) {
77+
const mask =
78+
COMPATIBILITY_FLAG_MASKS[
79+
compatibilityFlag as ENABLEMENT_COMPATIBILITY_FLAGS
80+
];
81+
if (mask) {
82+
compatibilityFlagsBitmask += mask;
83+
}
84+
}
85+
6486
this.readyAnalytics.logEvent({
6587
version: VERSION,
6688
accountId: this.data.accountId,
@@ -71,6 +93,7 @@ export class Analytics {
7193
this.data.metalId ?? -1, // double3
7294
this.data.coloTier ?? -1, // double4
7395
this.data.status ?? -1, // double5
96+
compatibilityFlagsBitmask, // double6
7497
],
7598
blobs: [
7699
this.data.hostname?.substring(0, 256), // blob1 - trim to 256 bytes
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import type { AssetConfig } from "../../utils/types";
2+
3+
interface CompatibilityFlag {
4+
enable: `assets_${string}`;
5+
disable: `assets_${string}`;
6+
onByDefaultAfter?: string;
7+
}
8+
9+
export const SEC_FETCH_MODE_NAVIGATE_HEADER_PREFERS_ASSET_SERVING = {
10+
enable: "assets_navigation_prefers_asset_serving",
11+
disable: "assets_navigation_has_no_effect",
12+
onByDefaultAfter: "2025-04-01",
13+
} satisfies CompatibilityFlag;
14+
15+
const COMPATIBILITY_FLAGS = [
16+
SEC_FETCH_MODE_NAVIGATE_HEADER_PREFERS_ASSET_SERVING,
17+
] as const;
18+
19+
export type ENABLEMENT_COMPATIBILITY_FLAGS =
20+
(typeof COMPATIBILITY_FLAGS)[number]["enable"];
21+
22+
export const resolveCompatibilityOptions = (configuration?: AssetConfig) => {
23+
const compatibilityDate = configuration?.compatibility_date ?? "2021-11-02";
24+
const compatibilityFlags = configuration?.compatibility_flags ?? [];
25+
26+
const resolvedCompatibilityFlags = compatibilityFlags;
27+
for (const compatibilityFlag of COMPATIBILITY_FLAGS) {
28+
if (
29+
compatibilityFlag.onByDefaultAfter &&
30+
compatibilityDate >= compatibilityFlag.onByDefaultAfter &&
31+
!resolvedCompatibilityFlags.find(
32+
(flag) => flag === compatibilityFlag.disable
33+
) &&
34+
!resolvedCompatibilityFlags.find(
35+
(flag) => flag === compatibilityFlag.enable
36+
)
37+
) {
38+
resolvedCompatibilityFlags.push(compatibilityFlag.enable);
39+
}
40+
}
41+
42+
return {
43+
compatibilityDate,
44+
compatibilityFlags: resolvedCompatibilityFlags,
45+
};
46+
};
47+
48+
export const flagIsEnabled = (
49+
configuration: Required<AssetConfig>,
50+
compatibilityFlag: (typeof COMPATIBILITY_FLAGS)[number]
51+
) => {
52+
return !!configuration.compatibility_flags.find(
53+
(flag) => flag === compatibilityFlag.enable
54+
);
55+
};

packages/workers-shared/asset-worker/src/configuration.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
1+
import { resolveCompatibilityOptions } from "./compatibility-flags";
12
import type { AssetConfig } from "../../utils/types";
23

3-
export const applyConfigurationDefaults = (
4+
export const normalizeConfiguration = (
45
configuration?: AssetConfig
56
): Required<AssetConfig> => {
7+
const compatibilityOptions = resolveCompatibilityOptions(configuration);
8+
69
return {
7-
compatibility_date: configuration?.compatibility_date ?? "2021-11-02",
8-
compatibility_flags: configuration?.compatibility_flags ?? [],
10+
compatibility_date: compatibilityOptions.compatibilityDate,
11+
compatibility_flags: compatibilityOptions.compatibilityFlags,
912
html_handling: configuration?.html_handling ?? "auto-trailing-slash",
1013
not_found_handling: configuration?.not_found_handling ?? "none",
1114
redirects: configuration?.redirects ?? {

packages/workers-shared/asset-worker/src/handler.ts

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ import {
1111
SeeOtherResponse,
1212
TemporaryRedirectResponse,
1313
} from "../../utils/responses";
14+
import {
15+
flagIsEnabled,
16+
SEC_FETCH_MODE_NAVIGATE_HEADER_PREFERS_ASSET_SERVING,
17+
} from "./compatibility-flags";
1418
import { attachCustomHeaders, getAssetHeaders } from "./utils/headers";
1519
import { generateRulesMatcher, replacer } from "./utils/rules-engine";
1620
import type { AssetConfig } from "../../utils/types";
@@ -238,13 +242,24 @@ export const canFetch = async (
238242
configuration: Required<AssetConfig>,
239243
exists: typeof EntrypointType.prototype.unstable_exists
240244
): Promise<boolean> => {
245+
if (
246+
!(
247+
flagIsEnabled(
248+
configuration,
249+
SEC_FETCH_MODE_NAVIGATE_HEADER_PREFERS_ASSET_SERVING
250+
) && request.headers.get("Sec-Fetch-Mode") === "navigate"
251+
)
252+
) {
253+
configuration = {
254+
...configuration,
255+
not_found_handling: "none",
256+
};
257+
}
258+
241259
const responseOrAssetIntent = await getResponseOrAssetIntent(
242260
request,
243261
env,
244-
{
245-
...configuration,
246-
not_found_handling: "none",
247-
},
262+
configuration,
248263
exists
249264
);
250265

packages/workers-shared/asset-worker/src/index.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { setupSentry } from "../../utils/sentry";
44
import { mockJaegerBinding } from "../../utils/tracing";
55
import { Analytics } from "./analytics";
66
import { AssetsManifest } from "./assets-manifest";
7-
import { applyConfigurationDefaults } from "./configuration";
7+
import { normalizeConfiguration } from "./configuration";
88
import { ExperimentAnalytics } from "./experiment-analytics";
99
import { canFetch, handleRequest } from "./handler";
1010
import { handleError, submitMetrics } from "./utils/final-operations";
@@ -80,7 +80,12 @@ export default class extends WorkerEntrypoint<Env> {
8080
this.env.CONFIG?.script_id
8181
);
8282

83-
const config = applyConfigurationDefaults(this.env.CONFIG);
83+
const config = normalizeConfiguration(this.env.CONFIG);
84+
sentry?.setContext("compatibilityOptions", {
85+
compatibilityDate: config.compatibility_date,
86+
compatibilityFlags: config.compatibility_flags,
87+
originalCompatibilityFlags: this.env.CONFIG.compatibility_flags,
88+
});
8489
const userAgent = request.headers.get("user-agent") ?? "UA UNKNOWN";
8590

8691
const url = new URL(request.url);
@@ -102,6 +107,7 @@ export default class extends WorkerEntrypoint<Env> {
102107
hostname: url.hostname,
103108
htmlHandling: config.html_handling,
104109
notFoundHandling: config.not_found_handling,
110+
compatibilityFlags: config.compatibility_flags,
105111
userAgent: userAgent,
106112
});
107113
}
@@ -142,7 +148,7 @@ export default class extends WorkerEntrypoint<Env> {
142148
return canFetch(
143149
request,
144150
this.env,
145-
applyConfigurationDefaults(this.env.CONFIG),
151+
normalizeConfiguration(this.env.CONFIG),
146152
this.unstable_exists.bind(this)
147153
);
148154
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { resolveCompatibilityOptions } from "../src/compatibility-flags";
2+
3+
describe("resolveCompatibilityOptions", () => {
4+
test("it does not interfere with existing flags", () => {
5+
expect(resolveCompatibilityOptions({ compatibility_flags: ["foo", "bar"] }))
6+
.toMatchInlineSnapshot(`
7+
{
8+
"compatibilityDate": "2021-11-02",
9+
"compatibilityFlags": [
10+
"foo",
11+
"bar",
12+
],
13+
}
14+
`);
15+
});
16+
17+
test("it opts you into new flags if you have a future date", () => {
18+
const { compatibilityDate, compatibilityFlags } =
19+
resolveCompatibilityOptions({
20+
compatibility_flags: ["foo", "bar"],
21+
compatibility_date: "2099-12-12",
22+
});
23+
24+
expect(compatibilityDate).toEqual("2099-12-12");
25+
expect(compatibilityFlags).toContain("foo");
26+
expect(compatibilityFlags).toContain("bar");
27+
expect(compatibilityFlags).toContain(
28+
"assets_navigation_prefers_asset_serving"
29+
);
30+
});
31+
32+
test("it doesn't double you up if you've already opted into new flags and have a future date", () => {
33+
const { compatibilityDate, compatibilityFlags } =
34+
resolveCompatibilityOptions({
35+
compatibility_flags: [
36+
"foo",
37+
"bar",
38+
"assets_navigation_prefers_asset_serving",
39+
],
40+
compatibility_date: "2099-12-12",
41+
});
42+
43+
expect(compatibilityDate).toEqual("2099-12-12");
44+
expect(compatibilityFlags).toContain("foo");
45+
expect(compatibilityFlags).toContain("bar");
46+
expect(
47+
compatibilityFlags.filter(
48+
(flag) => flag === "assets_navigation_prefers_asset_serving"
49+
).length
50+
).toBe(1);
51+
});
52+
53+
test("it doesn't opt you into new flags if you have a future date and have disabled it", () => {
54+
const { compatibilityDate, compatibilityFlags } =
55+
resolveCompatibilityOptions({
56+
compatibility_flags: ["foo", "bar", "assets_navigation_has_no_effect"],
57+
compatibility_date: "2099-12-12",
58+
});
59+
60+
expect(compatibilityDate).toEqual("2099-12-12");
61+
expect(compatibilityFlags).toContain("foo");
62+
expect(compatibilityFlags).toContain("bar");
63+
expect(compatibilityFlags).not.toContain(
64+
"assets_navigation_prefers_asset_serving"
65+
);
66+
});
67+
});

0 commit comments

Comments
 (0)