Skip to content

Commit c9cee82

Browse files
WC-4185 Support 100k assets when jwt claim more_files is present
As noted in the comments, normally we'd need to validate this jwt, but since any uploads depend on a valid jwt (and are validated later) it's fine to just decode the jwt and check for this feature
1 parent 8672321 commit c9cee82

File tree

6 files changed

+119
-3
lines changed

6 files changed

+119
-3
lines changed

.changeset/two-comics-win.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"wrangler": minor
3+
---
4+
5+
Support more files in an upload for non-free users

packages/wrangler/src/__tests__/pages/project-upload.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import { mkdirSync, writeFileSync } from "node:fs";
33
import { http, HttpResponse } from "msw";
44
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5+
import { isMoreFilesEnabled } from "../../pages/upload";
56
import { endEventLoop } from "../helpers/end-event-loop";
67
import { mockAccountId, mockApiToken } from "../helpers/mock-account-id";
78
import { mockConsoleMethods } from "../helpers/mock-console";
@@ -665,3 +666,44 @@ describe("pages project upload", () => {
665666
expect(std.err).toMatchInlineSnapshot(`""`);
666667
});
667668
});
669+
670+
describe("isMoreFilesEnabled", () => {
671+
it("should return true when JWT has more_files feature in claims", () => {
672+
// JWT payload: {"more_files": true}
673+
const jwt =
674+
"header." +
675+
Buffer.from(
676+
JSON.stringify({ features: ["files", "more_files"] })
677+
).toString("base64") +
678+
".signature";
679+
expect(isMoreFilesEnabled(jwt)).toBe(true);
680+
});
681+
682+
it("should return false when JWT does not have more_files feature in claims", () => {
683+
// JWT payload: {"more_files": false}
684+
const jwt =
685+
"header." +
686+
Buffer.from(JSON.stringify({ features: ["files"] })).toString("base64") +
687+
".signature";
688+
expect(isMoreFilesEnabled(jwt)).toBe(false);
689+
});
690+
691+
it("should return false when JWT does not have features claim", () => {
692+
// JWT payload: {"sub": "user"}
693+
const jwt =
694+
"header." +
695+
Buffer.from(JSON.stringify({ sub: "user" })).toString("base64") +
696+
".signature";
697+
expect(isMoreFilesEnabled(jwt)).toBe(false);
698+
});
699+
700+
it("should return false for test tokens without parsing", () => {
701+
expect(isMoreFilesEnabled("<<funfetti-auth-jwt>>")).toBe(false);
702+
expect(isMoreFilesEnabled("<<funfetti-auth-jwt2>>")).toBe(false);
703+
expect(isMoreFilesEnabled("<<aus-completion-token>>")).toBe(false);
704+
});
705+
706+
it("should throw error for invalid JWT format", () => {
707+
expect(() => isMoreFilesEnabled("invalid-jwt")).toThrow("Invalid token:");
708+
});
709+
});

packages/wrangler/src/__tests__/pages/project-validate.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { endEventLoop } from "../helpers/end-event-loop";
55
import { mockConsoleMethods } from "../helpers/mock-console";
66
import { runInTempDir } from "../helpers/run-in-tmp";
77
import { runWrangler } from "../helpers/run-wrangler";
8+
import { validate } from "../../pages/validate";
89

910
vi.mock("../../pages/constants", async (importActual) => ({
1011
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
@@ -57,4 +58,29 @@ describe("pages project validate", () => {
5758
`[Error: Error: Pages only supports up to 10 files in a deployment. Ensure you have specified your build output directory correctly.]`
5859
);
5960
});
61+
62+
it("should succeed with custom fileCountLimit even when exceeding default limit", async () => {
63+
// Create 11 files, which exceeds the mocked MAX_ASSET_COUNT of 10
64+
for (let i = 0; i < 11; i++) {
65+
writeFileSync(`logo${i}.png`, Buffer.alloc(1));
66+
}
67+
68+
// Should succeed when passing a custom fileCountLimit of 20
69+
const fileMap = await validate({ directory: ".", fileCountLimit: 20 });
70+
expect(fileMap.size).toBe(11);
71+
});
72+
73+
it("should error with custom fileCountLimit when exceeding custom limit", async () => {
74+
// Create 6 files
75+
for (let i = 0; i < 6; i++) {
76+
writeFileSync(`logo${i}.png`, Buffer.alloc(1));
77+
}
78+
79+
// Should fail when passing a custom fileCountLimit of 5
80+
await expect(() =>
81+
validate({ directory: ".", fileCountLimit: 5 })
82+
).rejects.toThrowError(
83+
"Error: Pages only supports up to 5 files in a deployment. Ensure you have specified your build output directory correctly."
84+
);
85+
});
6086
});

packages/wrangler/src/pages/constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { version as wranglerVersion } from "../../package.json";
33
const isWindows = process.platform === "win32";
44

55
export const MAX_ASSET_COUNT = 20_000;
6+
export const MAX_ASSET_COUNT_MORE_FILES = 100_000;
67
export const MAX_ASSET_SIZE = 25 * 1024 * 1024;
78
export const PAGES_CONFIG_CACHE_FILENAME = "pages.json";
89
export const MAX_BUCKET_SIZE = 40 * 1024 * 1024;

packages/wrangler/src/pages/upload.ts

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ import isInteractive from "../is-interactive";
1313
import { logger } from "../logger";
1414
import {
1515
BULK_UPLOAD_CONCURRENCY,
16+
MAX_ASSET_COUNT,
17+
MAX_ASSET_COUNT_MORE_FILES,
1618
MAX_BUCKET_FILE_COUNT,
1719
MAX_BUCKET_SIZE,
1820
MAX_CHECK_MISSING_ATTEMPTS,
@@ -59,7 +61,12 @@ export const pagesProjectUploadCommand = createCommand({
5961
throw new FatalError("No JWT given.", 1);
6062
}
6163

62-
const fileMap = await validate({ directory });
64+
const fileMap = await validate({
65+
directory,
66+
fileCountLimit: isMoreFilesEnabled(process.env.CF_PAGES_UPLOAD_JWT)
67+
? MAX_ASSET_COUNT_MORE_FILES
68+
: MAX_ASSET_COUNT,
69+
});
6370

6471
const manifest = await upload({
6572
fileMap,
@@ -400,6 +407,38 @@ export const isJwtExpired = (token: string): boolean | undefined => {
400407
}
401408
};
402409

410+
export const isMoreFilesEnabled = (token: string): boolean => {
411+
// During testing we don't use valid JWTs, so don't try and parse them
412+
if (
413+
typeof vitest !== "undefined" &&
414+
(token === "<<funfetti-auth-jwt>>" ||
415+
token === "<<funfetti-auth-jwt2>>" ||
416+
token === "<<aus-completion-token>>")
417+
) {
418+
return false;
419+
}
420+
try {
421+
// Not validating the JWT here, which ordinarily would be a big red flag.
422+
// However, if the JWT is invalid, no uploads (calls to /pages/assets/upload)
423+
// will succeed.
424+
const decodedJwt = JSON.parse(
425+
Buffer.from(token.split(".")[1], "base64").toString()
426+
);
427+
428+
const features = decodedJwt.features as Array<string>;
429+
if (!features || !Array.isArray(features)) {
430+
return false;
431+
}
432+
433+
return features.some((feat) => feat === "more_files");
434+
} catch (e) {
435+
if (e instanceof Error) {
436+
throw new Error(`Invalid token: ${e.message}`);
437+
}
438+
return false;
439+
}
440+
};
441+
403442
function formatTime(duration: number) {
404443
return `(${(duration / 1000).toFixed(2)} sec)`;
405444
}

packages/wrangler/src/pages/validate.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ export type FileContainer = {
4646

4747
export const validate = async (args: {
4848
directory: string;
49+
fileCountLimit?: number;
4950
}): Promise<Map<string, FileContainer>> => {
5051
const IGNORE_LIST = [
5152
"_worker.js",
@@ -68,6 +69,8 @@ export const validate = async (args: {
6869
// maxMemory = (parsed['max-old-space-size'] ? parsed['max-old-space-size'] : parsed['max_old_space_size']) * 1000 * 1000; // Turn MB into bytes
6970
// }
7071

72+
const fileCountLimit = args.fileCountLimit ?? MAX_ASSET_COUNT;
73+
7174
const walk = async (
7275
dir: string,
7376
fileMap: Map<string, FileContainer> = new Map(),
@@ -124,9 +127,9 @@ export const validate = async (args: {
124127

125128
const fileMap = await walk(directory);
126129

127-
if (fileMap.size > MAX_ASSET_COUNT) {
130+
if (fileMap.size > fileCountLimit) {
128131
throw new FatalError(
129-
`Error: Pages only supports up to ${MAX_ASSET_COUNT.toLocaleString()} files in a deployment. Ensure you have specified your build output directory correctly.`,
132+
`Error: Pages only supports up to ${fileCountLimit.toLocaleString()} files in a deployment. Ensure you have specified your build output directory correctly.`,
130133
1
131134
);
132135
}

0 commit comments

Comments
 (0)