Skip to content

Commit 5fb4f51

Browse files
WC-4185 Support 100k assets based on claims from upstream
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 5fb4f51

File tree

6 files changed

+126
-6
lines changed

6 files changed

+126
-6
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: 48 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 { maxFileCountAllowedFromClaims } 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,50 @@ describe("pages project upload", () => {
665666
expect(std.err).toMatchInlineSnapshot(`""`);
666667
});
667668
});
669+
670+
describe("maxFileCountAllowedFromClaims", () => {
671+
it("should return the value from max_file_count_allowed claim when present", () => {
672+
// JWT payload: {"max_file_count_allowed": 100000}
673+
const jwt =
674+
"header." +
675+
Buffer.from(JSON.stringify({ max_file_count_allowed: 100000 })).toString(
676+
"base64"
677+
) +
678+
".signature";
679+
expect(maxFileCountAllowedFromClaims(jwt)).toBe(100000);
680+
});
681+
682+
it("should return default value when max_file_count_allowed is not a number", () => {
683+
// JWT payload: {"max_file_count_allowed": "invalid"}
684+
const jwt =
685+
"header." +
686+
Buffer.from(
687+
JSON.stringify({ max_file_count_allowed: "invalid" })
688+
).toString("base64") +
689+
".signature";
690+
expect(maxFileCountAllowedFromClaims(jwt)).toBe(20000);
691+
});
692+
693+
it("should return default value when JWT does not have max_file_count_allowed claim", () => {
694+
// JWT payload: {"sub": "user"}
695+
const jwt =
696+
"header." +
697+
Buffer.from(JSON.stringify({ sub: "user" })).toString("base64") +
698+
".signature";
699+
expect(maxFileCountAllowedFromClaims(jwt)).toBe(20000);
700+
});
701+
702+
it("should return default value for test tokens without parsing", () => {
703+
expect(maxFileCountAllowedFromClaims("<<funfetti-auth-jwt>>")).toBe(20000);
704+
expect(maxFileCountAllowedFromClaims("<<funfetti-auth-jwt2>>")).toBe(20000);
705+
expect(maxFileCountAllowedFromClaims("<<aus-completion-token>>")).toBe(
706+
20000
707+
);
708+
});
709+
710+
it("should throw error for invalid JWT format", () => {
711+
expect(() => maxFileCountAllowedFromClaims("invalid-jwt")).toThrow(
712+
"Invalid token:"
713+
);
714+
});
715+
});

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

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// /* eslint-disable no-shadow */
22
import { writeFileSync } from "node:fs";
33
import { afterEach, describe, expect, it, vi } from "vitest";
4+
import { validate } from "../../pages/validate";
45
import { endEventLoop } from "../helpers/end-event-loop";
56
import { mockConsoleMethods } from "../helpers/mock-console";
67
import { runInTempDir } from "../helpers/run-in-tmp";
@@ -54,7 +55,32 @@ describe("pages project validate", () => {
5455
await expect(() =>
5556
runWrangler("pages project validate .")
5657
).rejects.toThrowErrorMatchingInlineSnapshot(
57-
`[Error: Error: Pages only supports up to 10 files in a deployment. Ensure you have specified your build output directory correctly.]`
58+
`[Error: Error: Pages only supports up to 10 files in a deployment for your current plan. Ensure you have specified your build output directory correctly.]`
59+
);
60+
});
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 for your current plan. Ensure you have specified your build output directory correctly."
5884
);
5985
});
6086
});

packages/wrangler/src/pages/constants.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { version as wranglerVersion } from "../../package.json";
22

33
const isWindows = process.platform === "win32";
44

5-
export const MAX_ASSET_COUNT = 20_000;
5+
export const MAX_ASSET_COUNT_DEFAULT = 20_000;
66
export const MAX_ASSET_SIZE = 25 * 1024 * 1024;
77
export const PAGES_CONFIG_CACHE_FILENAME = "pages.json";
88
export const MAX_BUCKET_SIZE = 40 * 1024 * 1024;

packages/wrangler/src/pages/upload.ts

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import isInteractive from "../is-interactive";
1313
import { logger } from "../logger";
1414
import {
1515
BULK_UPLOAD_CONCURRENCY,
16+
MAX_ASSET_COUNT_DEFAULT,
1617
MAX_BUCKET_FILE_COUNT,
1718
MAX_BUCKET_SIZE,
1819
MAX_CHECK_MISSING_ATTEMPTS,
@@ -59,7 +60,12 @@ export const pagesProjectUploadCommand = createCommand({
5960
throw new FatalError("No JWT given.", 1);
6061
}
6162

62-
const fileMap = await validate({ directory });
63+
const fileMap = await validate({
64+
directory,
65+
fileCountLimit: maxFileCountAllowedFromClaims(
66+
process.env.CF_PAGES_UPLOAD_JWT
67+
),
68+
});
6369

6470
const manifest = await upload({
6571
fileMap,
@@ -400,6 +406,38 @@ export const isJwtExpired = (token: string): boolean | undefined => {
400406
}
401407
};
402408

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

packages/wrangler/src/pages/validate.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { getType } from "mime";
55
import { Minimatch } from "minimatch";
66
import prettyBytes from "pretty-bytes";
77
import { createCommand } from "../core/create-command";
8-
import { MAX_ASSET_COUNT, MAX_ASSET_SIZE } from "./constants";
8+
import { MAX_ASSET_COUNT_DEFAULT, MAX_ASSET_SIZE } from "./constants";
99
import { hashFile } from "./hash";
1010

1111
export const pagesProjectValidateCommand = createCommand({
@@ -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_DEFAULT;
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 for your current plan. Ensure you have specified your build output directory correctly.`,
130133
1
131134
);
132135
}

0 commit comments

Comments
 (0)