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.
- package/package.json +1 -1
- package/src/__tests__/configuration.test.ts +34 -0
- package/src/__tests__/publish.test.ts +244 -131
- package/src/config/validation-helpers.ts +10 -1
- package/src/pages.tsx +295 -240
- package/src/sites.tsx +49 -16
- package/wrangler-dist/cli.js +227 -167
|
@@ -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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
//
|
|
199
|
+
// Remove the key from the set so we know what we've already uploaded
|
|
179
200
|
namespaceKeys.delete(assetKey);
|
|
180
201
|
|
|
181
|
-
//
|
|
182
|
-
const
|
|
202
|
+
// Prevent different manifest keys on windows
|
|
203
|
+
const manifestKey = urlSafe(
|
|
183
204
|
path.relative(siteAssets.assetDirectory, absAssetFile)
|
|
184
205
|
);
|
|
185
|
-
manifest[
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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(
|