wrangler 2.0.9 → 2.0.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -277,12 +277,21 @@ export const isObjectWith =
277
277
  !properties.every((prop) => prop in value))
278
278
  ) {
279
279
  diagnostics.errors.push(
280
- `Expected "${field}" to be of type object, containing properties ${properties}, but got ${JSON.stringify(
280
+ `Expected "${field}" to be of type object, containing only properties ${properties}, but got ${JSON.stringify(
281
281
  value
282
282
  )}.`
283
283
  );
284
284
  return false;
285
285
  }
286
+ // it's an object with the field as desired,
287
+ // but let's also check for unexpected fields
288
+ if (value !== undefined) {
289
+ const restFields = Object.keys(value).filter(
290
+ (key) => !properties.includes(key)
291
+ );
292
+ validateAdditionalProperties(diagnostics, field, restFields, []);
293
+ }
294
+
286
295
  return true;
287
296
  };
288
297
 
package/src/pages.tsx CHANGED
@@ -821,6 +821,299 @@ interface CreateDeploymentArgs {
821
821
  commitDirty?: boolean;
822
822
  }
823
823
 
824
+ const upload = async ({
825
+ directory,
826
+ accountId,
827
+ projectName,
828
+ }: {
829
+ directory: string;
830
+ accountId: string;
831
+ projectName: string;
832
+ }) => {
833
+ type FileContainer = {
834
+ content: string;
835
+ contentType: string;
836
+ sizeInBytes: number;
837
+ hash: string;
838
+ };
839
+
840
+ const IGNORE_LIST = [
841
+ "_worker.js",
842
+ "_redirects",
843
+ "_headers",
844
+ ".DS_Store",
845
+ "node_modules",
846
+ ".git",
847
+ ];
848
+
849
+ const walk = async (
850
+ dir: string,
851
+ fileMap: Map<string, FileContainer> = new Map(),
852
+ depth = 0
853
+ ) => {
854
+ const files = await readdir(dir);
855
+
856
+ await Promise.all(
857
+ files.map(async (file) => {
858
+ const filepath = join(dir, file);
859
+ const filestat = await stat(filepath);
860
+
861
+ if (IGNORE_LIST.includes(file)) {
862
+ return;
863
+ }
864
+
865
+ if (filestat.isSymbolicLink()) {
866
+ return;
867
+ }
868
+
869
+ if (filestat.isDirectory()) {
870
+ fileMap = await walk(filepath, fileMap, depth + 1);
871
+ } else {
872
+ let name;
873
+ if (depth) {
874
+ name = filepath.split(sep).slice(1).join("/");
875
+ } else {
876
+ name = file;
877
+ }
878
+
879
+ // TODO: Move this to later so we don't hold as much in memory
880
+ const fileContent = await readFile(filepath);
881
+
882
+ const base64Content = fileContent.toString("base64");
883
+ const extension = extname(basename(name)).substring(1);
884
+
885
+ if (filestat.size > 25 * 1024 * 1024) {
886
+ throw new Error(
887
+ `Error: Pages only supports files up to ${prettyBytes(
888
+ 25 * 1024 * 1024
889
+ )} in size\n${name} is ${prettyBytes(filestat.size)} in size`
890
+ );
891
+ }
892
+
893
+ fileMap.set(name, {
894
+ content: base64Content,
895
+ contentType: getType(name) || "application/octet-stream",
896
+ sizeInBytes: filestat.size,
897
+ hash: hash(base64Content + extension)
898
+ .toString("hex")
899
+ .slice(0, 32),
900
+ });
901
+ }
902
+ })
903
+ );
904
+
905
+ return fileMap;
906
+ };
907
+
908
+ const fileMap = await walk(directory);
909
+
910
+ if (fileMap.size > 20000) {
911
+ throw new FatalError(
912
+ `Error: Pages only supports up to 20,000 files in a deployment. Ensure you have specified your build output directory correctly.`,
913
+ 1
914
+ );
915
+ }
916
+
917
+ const files = [...fileMap.values()];
918
+
919
+ async function fetchJwt(): Promise<string> {
920
+ return (
921
+ await fetchResult<{ jwt: string }>(
922
+ `/accounts/${accountId}/pages/projects/${projectName}/upload-token`
923
+ )
924
+ ).jwt;
925
+ }
926
+
927
+ let jwt = await fetchJwt();
928
+
929
+ const start = Date.now();
930
+
931
+ const missingHashes = await fetchResult<string[]>(
932
+ `/pages/assets/check-missing`,
933
+ {
934
+ method: "POST",
935
+ headers: {
936
+ "Content-Type": "application/json",
937
+ Authorization: `Bearer ${jwt}`,
938
+ },
939
+ body: JSON.stringify({
940
+ hashes: files.map(({ hash }) => hash),
941
+ }),
942
+ }
943
+ );
944
+
945
+ const sortedFiles = files
946
+ .filter((file) => missingHashes.includes(file.hash))
947
+ .sort((a, b) => b.sizeInBytes - a.sizeInBytes);
948
+
949
+ // Start with a few buckets so small projects still get
950
+ // the benefit of multiple upload streams
951
+ const buckets: {
952
+ files: FileContainer[];
953
+ remainingSize: number;
954
+ }[] = new Array(BULK_UPLOAD_CONCURRENCY).fill(null).map(() => ({
955
+ files: [],
956
+ remainingSize: MAX_BUCKET_SIZE,
957
+ }));
958
+
959
+ let bucketOffset = 0;
960
+ for (const file of sortedFiles) {
961
+ let inserted = false;
962
+
963
+ for (let i = 0; i < buckets.length; i++) {
964
+ // Start at a different bucket for each new file
965
+ const bucket = buckets[(i + bucketOffset) % buckets.length];
966
+ if (
967
+ bucket.remainingSize >= file.sizeInBytes &&
968
+ bucket.files.length < MAX_BUCKET_FILE_COUNT
969
+ ) {
970
+ bucket.files.push(file);
971
+ bucket.remainingSize -= file.sizeInBytes;
972
+ inserted = true;
973
+ break;
974
+ }
975
+ }
976
+
977
+ if (!inserted) {
978
+ buckets.push({
979
+ files: [file],
980
+ remainingSize: MAX_BUCKET_SIZE - file.sizeInBytes,
981
+ });
982
+ }
983
+ bucketOffset++;
984
+ }
985
+
986
+ let counter = fileMap.size - sortedFiles.length;
987
+ const { rerender, unmount } = render(
988
+ <Progress done={counter} total={fileMap.size} />
989
+ );
990
+
991
+ const queue = new PQueue({ concurrency: BULK_UPLOAD_CONCURRENCY });
992
+
993
+ for (const bucket of buckets) {
994
+ // Don't upload empty buckets (can happen for tiny projects)
995
+ if (bucket.files.length === 0) continue;
996
+
997
+ const payload: UploadPayloadFile[] = bucket.files.map((file) => ({
998
+ key: file.hash,
999
+ value: file.content,
1000
+ metadata: {
1001
+ contentType: file.contentType,
1002
+ },
1003
+ base64: true,
1004
+ }));
1005
+
1006
+ let attempts = 0;
1007
+ const doUpload = async (): Promise<void> => {
1008
+ try {
1009
+ return await fetchResult(`/pages/assets/upload`, {
1010
+ method: "POST",
1011
+ headers: {
1012
+ "Content-Type": "application/json",
1013
+ Authorization: `Bearer ${jwt}`,
1014
+ },
1015
+ body: JSON.stringify(payload),
1016
+ });
1017
+ } catch (e) {
1018
+ if (attempts < MAX_UPLOAD_ATTEMPTS) {
1019
+ // Linear backoff, 0 second first time, then 1 second etc.
1020
+ await new Promise((resolve) =>
1021
+ setTimeout(resolve, attempts++ * 1000)
1022
+ );
1023
+
1024
+ if ((e as { code: number }).code === 8000013) {
1025
+ // Looks like the JWT expired, fetch another one
1026
+ jwt = await fetchJwt();
1027
+ }
1028
+ return doUpload();
1029
+ } else {
1030
+ throw e;
1031
+ }
1032
+ }
1033
+ };
1034
+
1035
+ queue.add(() =>
1036
+ doUpload().then(
1037
+ () => {
1038
+ counter += bucket.files.length;
1039
+ rerender(<Progress done={counter} total={fileMap.size} />);
1040
+ },
1041
+ (error) => {
1042
+ return Promise.reject(
1043
+ new FatalError(
1044
+ "Failed to upload files. Please try again.",
1045
+ error.code || 1
1046
+ )
1047
+ );
1048
+ }
1049
+ )
1050
+ );
1051
+ }
1052
+
1053
+ await queue.onIdle();
1054
+
1055
+ unmount();
1056
+
1057
+ const uploadMs = Date.now() - start;
1058
+
1059
+ const skipped = fileMap.size - missingHashes.length;
1060
+ const skippedMessage = skipped > 0 ? `(${skipped} already uploaded) ` : "";
1061
+
1062
+ logger.log(
1063
+ `✨ Success! Uploaded ${
1064
+ sortedFiles.length
1065
+ } files ${skippedMessage}${formatTime(uploadMs)}\n`
1066
+ );
1067
+
1068
+ const doUpsertHashes = async (): Promise<void> => {
1069
+ try {
1070
+ return await fetchResult(`/pages/assets/upsert-hashes`, {
1071
+ method: "POST",
1072
+ headers: {
1073
+ "Content-Type": "application/json",
1074
+ Authorization: `Bearer ${jwt}`,
1075
+ },
1076
+ body: JSON.stringify({
1077
+ hashes: files.map(({ hash }) => hash),
1078
+ }),
1079
+ });
1080
+ } catch (e) {
1081
+ await new Promise((resolve) => setTimeout(resolve, 1000));
1082
+
1083
+ if ((e as { code: number }).code === 8000013) {
1084
+ // Looks like the JWT expired, fetch another one
1085
+ jwt = await fetchJwt();
1086
+ }
1087
+
1088
+ return await fetchResult(`/pages/assets/upsert-hashes`, {
1089
+ method: "POST",
1090
+ headers: {
1091
+ "Content-Type": "application/json",
1092
+ Authorization: `Bearer ${jwt}`,
1093
+ },
1094
+ body: JSON.stringify({
1095
+ hashes: files.map(({ hash }) => hash),
1096
+ }),
1097
+ });
1098
+ }
1099
+ };
1100
+
1101
+ try {
1102
+ await doUpsertHashes();
1103
+ } catch {
1104
+ logger.warn(
1105
+ "Failed to update file hashes. Every upload appeared to succeed for this deployment, but you might need to re-upload for future deployments. This shouldn't have any impact other than slowing the upload speed of your next deployment."
1106
+ );
1107
+ }
1108
+
1109
+ return Object.fromEntries(
1110
+ [...fileMap.entries()].map(([fileName, file]) => [
1111
+ `/${fileName}`,
1112
+ file.hash,
1113
+ ])
1114
+ );
1115
+ };
1116
+
824
1117
  const createDeployment: CommandModule<
825
1118
  CreateDeploymentArgs,
826
1119
  CreateDeploymentArgs
@@ -1053,249 +1346,11 @@ const createDeployment: CommandModule<
1053
1346
  builtFunctions = readFileSync(outfile, "utf-8");
1054
1347
  }
1055
1348
 
1056
- type FileContainer = {
1057
- content: string;
1058
- contentType: string;
1059
- sizeInBytes: number;
1060
- hash: string;
1061
- };
1062
-
1063
- const IGNORE_LIST = [
1064
- "_worker.js",
1065
- "_redirects",
1066
- "_headers",
1067
- ".DS_Store",
1068
- "node_modules",
1069
- ".git",
1070
- ];
1071
-
1072
- const walk = async (
1073
- dir: string,
1074
- fileMap: Map<string, FileContainer> = new Map(),
1075
- depth = 0
1076
- ) => {
1077
- const files = await readdir(dir);
1078
-
1079
- await Promise.all(
1080
- files.map(async (file) => {
1081
- const filepath = join(dir, file);
1082
- const filestat = await stat(filepath);
1083
-
1084
- if (IGNORE_LIST.includes(file)) {
1085
- return;
1086
- }
1087
-
1088
- if (filestat.isSymbolicLink()) {
1089
- return;
1090
- }
1091
-
1092
- if (filestat.isDirectory()) {
1093
- fileMap = await walk(filepath, fileMap, depth + 1);
1094
- } else {
1095
- let name;
1096
- if (depth) {
1097
- name = filepath.split(sep).slice(1).join("/");
1098
- } else {
1099
- name = file;
1100
- }
1101
-
1102
- // TODO: Move this to later so we don't hold as much in memory
1103
- const fileContent = await readFile(filepath);
1104
-
1105
- const base64Content = fileContent.toString("base64");
1106
- const extension = extname(basename(name)).substring(1);
1107
-
1108
- if (filestat.size > 25 * 1024 * 1024) {
1109
- throw new Error(
1110
- `Error: Pages only supports files up to ${prettyBytes(
1111
- 25 * 1024 * 1024
1112
- )} in size\n${name} is ${prettyBytes(filestat.size)} in size`
1113
- );
1114
- }
1115
-
1116
- fileMap.set(name, {
1117
- content: base64Content,
1118
- contentType: getType(name) || "application/octet-stream",
1119
- sizeInBytes: filestat.size,
1120
- hash: hash(base64Content + extension)
1121
- .toString("hex")
1122
- .slice(0, 32),
1123
- });
1124
- }
1125
- })
1126
- );
1127
-
1128
- return fileMap;
1129
- };
1130
-
1131
- const fileMap = await walk(directory);
1132
-
1133
- if (fileMap.size > 20000) {
1134
- throw new FatalError(
1135
- `Error: Pages only supports up to 20,000 files in a deployment. Ensure you have specified your build output directory correctly.`,
1136
- 1
1137
- );
1138
- }
1139
-
1140
- const files = [...fileMap.values()];
1141
-
1142
- async function fetchJwt(): Promise<string> {
1143
- return (
1144
- await fetchResult<{ jwt: string }>(
1145
- `/accounts/${accountId}/pages/projects/${projectName}/upload-token`
1146
- )
1147
- ).jwt;
1148
- }
1149
-
1150
- let jwt = await fetchJwt();
1151
-
1152
- const start = Date.now();
1153
-
1154
- const missingHashes = await fetchResult<string[]>(
1155
- `/pages/assets/check-missing`,
1156
- {
1157
- method: "POST",
1158
- headers: {
1159
- "Content-Type": "application/json",
1160
- Authorization: `Bearer ${jwt}`,
1161
- },
1162
- body: JSON.stringify({
1163
- hashes: files.map(({ hash }) => hash),
1164
- }),
1165
- }
1166
- );
1167
-
1168
- const sortedFiles = files
1169
- .filter((file) => missingHashes.includes(file.hash))
1170
- .sort((a, b) => b.sizeInBytes - a.sizeInBytes);
1171
-
1172
- // Start with a few buckets so small projects still get
1173
- // the benefit of multiple upload streams
1174
- const buckets: {
1175
- files: FileContainer[];
1176
- remainingSize: number;
1177
- }[] = new Array(BULK_UPLOAD_CONCURRENCY).fill(null).map(() => ({
1178
- files: [],
1179
- remainingSize: MAX_BUCKET_SIZE,
1180
- }));
1181
-
1182
- let bucketOffset = 0;
1183
- for (const file of sortedFiles) {
1184
- let inserted = false;
1185
-
1186
- for (let i = 0; i < buckets.length; i++) {
1187
- // Start at a different bucket for each new file
1188
- const bucket = buckets[(i + bucketOffset) % buckets.length];
1189
- if (
1190
- bucket.remainingSize >= file.sizeInBytes &&
1191
- bucket.files.length < MAX_BUCKET_FILE_COUNT
1192
- ) {
1193
- bucket.files.push(file);
1194
- bucket.remainingSize -= file.sizeInBytes;
1195
- inserted = true;
1196
- break;
1197
- }
1198
- }
1199
-
1200
- if (!inserted) {
1201
- buckets.push({
1202
- files: [file],
1203
- remainingSize: MAX_BUCKET_SIZE - file.sizeInBytes,
1204
- });
1205
- }
1206
- bucketOffset++;
1207
- }
1208
-
1209
- let counter = fileMap.size - sortedFiles.length;
1210
- const { rerender, unmount } = render(
1211
- <Progress done={counter} total={fileMap.size} />
1212
- );
1213
-
1214
- const queue = new PQueue({ concurrency: BULK_UPLOAD_CONCURRENCY });
1215
-
1216
- for (const bucket of buckets) {
1217
- // Don't upload empty buckets (can happen for tiny projects)
1218
- if (bucket.files.length === 0) continue;
1219
-
1220
- const payload: UploadPayloadFile[] = bucket.files.map((file) => ({
1221
- key: file.hash,
1222
- value: file.content,
1223
- metadata: {
1224
- contentType: file.contentType,
1225
- },
1226
- base64: true,
1227
- }));
1228
-
1229
- let attempts = 0;
1230
- const doUpload = async (): Promise<void> => {
1231
- try {
1232
- return await fetchResult(`/pages/assets/upload`, {
1233
- method: "POST",
1234
- headers: {
1235
- "Content-Type": "application/json",
1236
- Authorization: `Bearer ${jwt}`,
1237
- },
1238
- body: JSON.stringify(payload),
1239
- });
1240
- } catch (e) {
1241
- if (attempts < MAX_UPLOAD_ATTEMPTS) {
1242
- // Linear backoff, 0 second first time, then 1 second etc.
1243
- await new Promise((resolve) =>
1244
- setTimeout(resolve, attempts++ * 1000)
1245
- );
1246
-
1247
- if ((e as { code: number }).code === 8000013) {
1248
- // Looks like the JWT expired, fetch another one
1249
- jwt = await fetchJwt();
1250
- }
1251
- return doUpload();
1252
- } else {
1253
- throw e;
1254
- }
1255
- }
1256
- };
1257
-
1258
- queue.add(() =>
1259
- doUpload().then(
1260
- () => {
1261
- counter += bucket.files.length;
1262
- rerender(<Progress done={counter} total={fileMap.size} />);
1263
- },
1264
- (error) => {
1265
- return Promise.reject(
1266
- new FatalError(
1267
- "Failed to upload files. Please try again.",
1268
- error.code || 1
1269
- )
1270
- );
1271
- }
1272
- )
1273
- );
1274
- }
1275
-
1276
- await queue.onIdle();
1277
-
1278
- unmount();
1279
-
1280
- const uploadMs = Date.now() - start;
1281
-
1282
- logger.log(
1283
- `✨ Success! Uploaded ${fileMap.size} files ${formatTime(uploadMs)}\n`
1284
- );
1349
+ const manifest = await upload({ directory, accountId, projectName });
1285
1350
 
1286
1351
  const formData = new FormData();
1287
1352
 
1288
- formData.append(
1289
- "manifest",
1290
- JSON.stringify(
1291
- Object.fromEntries(
1292
- [...fileMap.entries()].map(([fileName, file]) => [
1293
- `/${fileName}`,
1294
- file.hash,
1295
- ])
1296
- )
1297
- )
1298
- );
1353
+ formData.append("manifest", JSON.stringify(manifest));
1299
1354
 
1300
1355
  if (branch) {
1301
1356
  formData.append("branch", branch);
package/src/sites.tsx CHANGED
@@ -137,7 +137,13 @@ export async function syncAssets(
137
137
  const namespaceKeys = new Set(namespaceKeysResponse.map((x) => x.name));
138
138
 
139
139
  const manifest: Record<string, string> = {};
140
- const toUpload: KeyValue[] = [];
140
+
141
+ // A batch of uploads where each bucket has to be less than 100mb
142
+ const uploadBuckets: KeyValue[][] = [];
143
+ // The "live" bucket that we'll keep filling until it's just below 100mb
144
+ let uploadBucket: KeyValue[] = [];
145
+ // A size counter for the live bucket
146
+ let uploadBucketSize = 0;
141
147
 
142
148
  const include = createPatternMatcher(siteAssets.includePatterns, false);
143
149
  const exclude = createPatternMatcher(siteAssets.excludePatterns, true);
@@ -148,7 +154,7 @@ export async function syncAssets(
148
154
  siteAssets.assetDirectory
149
155
  );
150
156
  for await (const absAssetFile of getFilesInFolder(assetDirectory)) {
151
- const assetFile = path.relative(siteAssets.baseDirectory, absAssetFile);
157
+ const assetFile = path.relative(assetDirectory, absAssetFile);
152
158
  if (!include(assetFile)) {
153
159
  continue;
154
160
  }
@@ -156,17 +162,32 @@ export async function syncAssets(
156
162
  continue;
157
163
  }
158
164
 
159
- await validateAssetSize(absAssetFile, assetFile);
160
165
  logger.log(`Reading ${assetFile}...`);
161
166
  const content = await readFile(absAssetFile, "base64");
162
-
167
+ await validateAssetSize(absAssetFile, assetFile);
168
+ // while KV accepts files that are 25 MiB **before** b64 encoding
169
+ // the overall bucket size must be below 100 MiB **after** b64 encoding
170
+ const assetSize = Buffer.from(content).length;
163
171
  const assetKey = hashAsset(hasher, assetFile, content);
164
172
  validateAssetKey(assetKey);
165
173
 
166
174
  // now put each of the files into kv
167
175
  if (!namespaceKeys.has(assetKey)) {
168
176
  logger.log(`Uploading as ${assetKey}...`);
169
- toUpload.push({
177
+
178
+ // Check if adding this asset to the bucket would
179
+ // push it over the 100 MiB limit KV bulk API limit
180
+ if (uploadBucketSize + assetSize > 100 * 1024 * 1024) {
181
+ // If so, move the current bucket into the batch,
182
+ // and reset the counter/bucket
183
+ uploadBuckets.push(uploadBucket);
184
+ uploadBucketSize = 0;
185
+ uploadBucket = [];
186
+ }
187
+
188
+ // Update the bucket and the size counter
189
+ uploadBucketSize += assetSize;
190
+ uploadBucket.push({
170
191
  key: assetKey,
171
192
  value: content,
172
193
  base64: true,
@@ -175,27 +196,33 @@ export async function syncAssets(
175
196
  logger.log(`Skipping - already uploaded.`);
176
197
  }
177
198
 
178
- // remove the key from the set so we know what we've already uploaded
199
+ // Remove the key from the set so we know what we've already uploaded
179
200
  namespaceKeys.delete(assetKey);
180
201
 
181
- // prevent causing different manifest keys on windows
182
- const maifestKey = urlSafe(
202
+ // Prevent different manifest keys on windows
203
+ const manifestKey = urlSafe(
183
204
  path.relative(siteAssets.assetDirectory, absAssetFile)
184
205
  );
185
- manifest[maifestKey] = assetKey;
206
+ manifest[manifestKey] = assetKey;
186
207
  }
187
208
 
209
+ // Add the last (potentially only) bucket to the batch
210
+ uploadBuckets.push(uploadBucket);
211
+
188
212
  // keys now contains all the files we're deleting
189
213
  for (const key of namespaceKeys) {
190
214
  logger.log(`Deleting ${key} from the asset store...`);
191
215
  }
192
216
 
193
- await Promise.all([
194
- // upload all the new assets
195
- putKVBulkKeyValue(accountId, namespace, toUpload),
196
- // delete all the unused assets
197
- deleteKVBulkKeyValue(accountId, namespace, Array.from(namespaceKeys)),
198
- ]);
217
+ // upload each bucket in parallel
218
+ const bucketsToPut = [];
219
+ for (const bucket of uploadBuckets) {
220
+ bucketsToPut.push(putKVBulkKeyValue(accountId, namespace, bucket));
221
+ }
222
+ await Promise.all(bucketsToPut);
223
+
224
+ // then delete all the assets that aren't used anymore
225
+ await deleteKVBulkKeyValue(accountId, namespace, Array.from(namespaceKeys));
199
226
 
200
227
  logger.log("↗️ Done syncing assets");
201
228
 
@@ -214,10 +241,16 @@ function createPatternMatcher(
214
241
  }
215
242
  }
216
243
 
244
+ /**
245
+ * validate that the passed-in file is below 25 MiB
246
+ * **PRIOR** to base64 encoding. 25 MiB is a KV limit
247
+ * @param absFilePath
248
+ * @param relativeFilePath
249
+ */
217
250
  async function validateAssetSize(
218
251
  absFilePath: string,
219
252
  relativeFilePath: string
220
- ) {
253
+ ): Promise<void> {
221
254
  const { size } = await stat(absFilePath);
222
255
  if (size > 25 * 1024 * 1024) {
223
256
  throw new Error(