ai-localize-aws-cloudfront 1.0.0
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/dist/index.d.mts +64 -0
- package/dist/index.d.ts +64 -0
- package/dist/index.js +203 -0
- package/dist/index.mjs +164 -0
- package/package.json +40 -0
- package/src/cdn-migrator.ts +81 -0
- package/src/cloudfront-invalidator.ts +37 -0
- package/src/index.ts +3 -0
- package/src/s3-uploader.ts +110 -0
- package/tsconfig.json +6 -0
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { AwsConfig, CloudFrontAsset, LegacyCdnUrl } from '@ai-localize/shared';
|
|
2
|
+
|
|
3
|
+
interface UploadOptions {
|
|
4
|
+
assetsDir: string;
|
|
5
|
+
force?: boolean;
|
|
6
|
+
onProgress?: (asset: string, done: number, total: number) => void;
|
|
7
|
+
}
|
|
8
|
+
interface UploadResult {
|
|
9
|
+
uploaded: CloudFrontAsset[];
|
|
10
|
+
skipped: string[];
|
|
11
|
+
failed: string[];
|
|
12
|
+
duration: number;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Uploads static assets to S3 with content-hash in filename for cache-busting.
|
|
16
|
+
*/
|
|
17
|
+
declare class S3Uploader {
|
|
18
|
+
private client;
|
|
19
|
+
private config;
|
|
20
|
+
constructor(config: AwsConfig);
|
|
21
|
+
uploadDirectory(options: UploadOptions): Promise<UploadResult>;
|
|
22
|
+
uploadFile(filePath: string, baseDir: string, force?: boolean): Promise<CloudFrontAsset | null>;
|
|
23
|
+
private objectExists;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface InvalidationResult {
|
|
27
|
+
invalidationId: string;
|
|
28
|
+
paths: string[];
|
|
29
|
+
status: string;
|
|
30
|
+
}
|
|
31
|
+
declare class CloudFrontInvalidator {
|
|
32
|
+
private client;
|
|
33
|
+
private distributionId;
|
|
34
|
+
constructor(config: AwsConfig);
|
|
35
|
+
invalidate(paths?: string[]): Promise<InvalidationResult>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface MigrationOptions {
|
|
39
|
+
sourceDir: string;
|
|
40
|
+
assetsDir: string;
|
|
41
|
+
legacyCdnUrls: LegacyCdnUrl[];
|
|
42
|
+
dryRun?: boolean;
|
|
43
|
+
invalidateCache?: boolean;
|
|
44
|
+
onProgress?: (step: string) => void;
|
|
45
|
+
}
|
|
46
|
+
interface MigrationResult {
|
|
47
|
+
uploadedAssets: CloudFrontAsset[];
|
|
48
|
+
replacedUrls: number;
|
|
49
|
+
invalidationId?: string;
|
|
50
|
+
duration: number;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Orchestrates the full CDN migration:
|
|
54
|
+
* 1. Upload assets to S3
|
|
55
|
+
* 2. Replace legacy CDN URLs in source files
|
|
56
|
+
* 3. Invalidate CloudFront cache
|
|
57
|
+
*/
|
|
58
|
+
declare class CdnMigrator {
|
|
59
|
+
private config;
|
|
60
|
+
constructor(config: AwsConfig);
|
|
61
|
+
migrate(options: MigrationOptions): Promise<MigrationResult>;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export { CdnMigrator, CloudFrontInvalidator, type InvalidationResult, type MigrationOptions, type MigrationResult, S3Uploader, type UploadOptions, type UploadResult };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { AwsConfig, CloudFrontAsset, LegacyCdnUrl } from '@ai-localize/shared';
|
|
2
|
+
|
|
3
|
+
interface UploadOptions {
|
|
4
|
+
assetsDir: string;
|
|
5
|
+
force?: boolean;
|
|
6
|
+
onProgress?: (asset: string, done: number, total: number) => void;
|
|
7
|
+
}
|
|
8
|
+
interface UploadResult {
|
|
9
|
+
uploaded: CloudFrontAsset[];
|
|
10
|
+
skipped: string[];
|
|
11
|
+
failed: string[];
|
|
12
|
+
duration: number;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Uploads static assets to S3 with content-hash in filename for cache-busting.
|
|
16
|
+
*/
|
|
17
|
+
declare class S3Uploader {
|
|
18
|
+
private client;
|
|
19
|
+
private config;
|
|
20
|
+
constructor(config: AwsConfig);
|
|
21
|
+
uploadDirectory(options: UploadOptions): Promise<UploadResult>;
|
|
22
|
+
uploadFile(filePath: string, baseDir: string, force?: boolean): Promise<CloudFrontAsset | null>;
|
|
23
|
+
private objectExists;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface InvalidationResult {
|
|
27
|
+
invalidationId: string;
|
|
28
|
+
paths: string[];
|
|
29
|
+
status: string;
|
|
30
|
+
}
|
|
31
|
+
declare class CloudFrontInvalidator {
|
|
32
|
+
private client;
|
|
33
|
+
private distributionId;
|
|
34
|
+
constructor(config: AwsConfig);
|
|
35
|
+
invalidate(paths?: string[]): Promise<InvalidationResult>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface MigrationOptions {
|
|
39
|
+
sourceDir: string;
|
|
40
|
+
assetsDir: string;
|
|
41
|
+
legacyCdnUrls: LegacyCdnUrl[];
|
|
42
|
+
dryRun?: boolean;
|
|
43
|
+
invalidateCache?: boolean;
|
|
44
|
+
onProgress?: (step: string) => void;
|
|
45
|
+
}
|
|
46
|
+
interface MigrationResult {
|
|
47
|
+
uploadedAssets: CloudFrontAsset[];
|
|
48
|
+
replacedUrls: number;
|
|
49
|
+
invalidationId?: string;
|
|
50
|
+
duration: number;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Orchestrates the full CDN migration:
|
|
54
|
+
* 1. Upload assets to S3
|
|
55
|
+
* 2. Replace legacy CDN URLs in source files
|
|
56
|
+
* 3. Invalidate CloudFront cache
|
|
57
|
+
*/
|
|
58
|
+
declare class CdnMigrator {
|
|
59
|
+
private config;
|
|
60
|
+
constructor(config: AwsConfig);
|
|
61
|
+
migrate(options: MigrationOptions): Promise<MigrationResult>;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export { CdnMigrator, CloudFrontInvalidator, type InvalidationResult, type MigrationOptions, type MigrationResult, S3Uploader, type UploadOptions, type UploadResult };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __create = Object.create;
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
7
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
var __export = (target, all) => {
|
|
9
|
+
for (var name in all)
|
|
10
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
11
|
+
};
|
|
12
|
+
var __copyProps = (to, from, except, desc) => {
|
|
13
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
14
|
+
for (let key of __getOwnPropNames(from))
|
|
15
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
16
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
17
|
+
}
|
|
18
|
+
return to;
|
|
19
|
+
};
|
|
20
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
21
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
22
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
23
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
24
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
25
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
26
|
+
mod
|
|
27
|
+
));
|
|
28
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
29
|
+
|
|
30
|
+
// src/index.ts
|
|
31
|
+
var index_exports = {};
|
|
32
|
+
__export(index_exports, {
|
|
33
|
+
CdnMigrator: () => CdnMigrator,
|
|
34
|
+
CloudFrontInvalidator: () => CloudFrontInvalidator,
|
|
35
|
+
S3Uploader: () => S3Uploader
|
|
36
|
+
});
|
|
37
|
+
module.exports = __toCommonJS(index_exports);
|
|
38
|
+
|
|
39
|
+
// src/s3-uploader.ts
|
|
40
|
+
var fs = __toESM(require("fs"));
|
|
41
|
+
var path = __toESM(require("path"));
|
|
42
|
+
var crypto = __toESM(require("crypto"));
|
|
43
|
+
var import_client_s3 = require("@aws-sdk/client-s3");
|
|
44
|
+
var mime = __toESM(require("mime-types"));
|
|
45
|
+
var import_shared = require("@ai-localize/shared");
|
|
46
|
+
var S3Uploader = class {
|
|
47
|
+
client;
|
|
48
|
+
config;
|
|
49
|
+
constructor(config) {
|
|
50
|
+
this.config = config;
|
|
51
|
+
this.client = new import_client_s3.S3Client({
|
|
52
|
+
region: config.region || "us-east-1",
|
|
53
|
+
...config.profile ? { credentials: { accessKeyId: "", secretAccessKey: "" } } : {}
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
async uploadDirectory(options) {
|
|
57
|
+
const startTime = Date.now();
|
|
58
|
+
const assetFiles = (0, import_shared.collectFiles)(options.assetsDir, import_shared.ASSET_EXTENSIONS, ["node_modules"]);
|
|
59
|
+
const uploaded = [];
|
|
60
|
+
const skipped = [];
|
|
61
|
+
const failed = [];
|
|
62
|
+
let done = 0;
|
|
63
|
+
for (const filePath of assetFiles) {
|
|
64
|
+
try {
|
|
65
|
+
const asset = await this.uploadFile(filePath, options.assetsDir, options.force);
|
|
66
|
+
if (asset) {
|
|
67
|
+
uploaded.push(asset);
|
|
68
|
+
} else {
|
|
69
|
+
skipped.push(filePath);
|
|
70
|
+
}
|
|
71
|
+
} catch (err) {
|
|
72
|
+
failed.push(filePath);
|
|
73
|
+
console.error(`Failed to upload ${filePath}:`, err);
|
|
74
|
+
}
|
|
75
|
+
done++;
|
|
76
|
+
options.onProgress?.(filePath, done, assetFiles.length);
|
|
77
|
+
}
|
|
78
|
+
return { uploaded, skipped, failed, duration: Date.now() - startTime };
|
|
79
|
+
}
|
|
80
|
+
async uploadFile(filePath, baseDir, force = false) {
|
|
81
|
+
const content = fs.readFileSync(filePath);
|
|
82
|
+
const hash = crypto.createHash("md5").update(content).digest("hex").slice(0, 8);
|
|
83
|
+
const ext = path.extname(filePath);
|
|
84
|
+
const basename3 = path.basename(filePath, ext);
|
|
85
|
+
const hashedName = `${basename3}.${hash}${ext}`;
|
|
86
|
+
const relativePath = path.relative(baseDir, filePath);
|
|
87
|
+
const s3Key = path.join(this.config.assetsPrefix || "assets", path.dirname(relativePath), hashedName).replace(/\\/g, "/");
|
|
88
|
+
if (!force) {
|
|
89
|
+
const exists = await this.objectExists(s3Key);
|
|
90
|
+
if (exists) return null;
|
|
91
|
+
}
|
|
92
|
+
const contentType = mime.lookup(filePath) || "application/octet-stream";
|
|
93
|
+
await this.client.send(
|
|
94
|
+
new import_client_s3.PutObjectCommand({
|
|
95
|
+
Bucket: this.config.bucket,
|
|
96
|
+
Key: s3Key,
|
|
97
|
+
Body: content,
|
|
98
|
+
ContentType: contentType,
|
|
99
|
+
CacheControl: "public, max-age=31536000, immutable"
|
|
100
|
+
})
|
|
101
|
+
);
|
|
102
|
+
const cdnBase = this.config.cdnBaseUrl || `https://${this.config.distributionId}.cloudfront.net`;
|
|
103
|
+
return {
|
|
104
|
+
localPath: filePath,
|
|
105
|
+
s3Key,
|
|
106
|
+
hash,
|
|
107
|
+
cloudfrontUrl: `${cdnBase}/${s3Key}`,
|
|
108
|
+
contentType,
|
|
109
|
+
size: content.length
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
async objectExists(key) {
|
|
113
|
+
try {
|
|
114
|
+
await this.client.send(new import_client_s3.HeadObjectCommand({ Bucket: this.config.bucket, Key: key }));
|
|
115
|
+
return true;
|
|
116
|
+
} catch {
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
// src/cloudfront-invalidator.ts
|
|
123
|
+
var import_client_cloudfront = require("@aws-sdk/client-cloudfront");
|
|
124
|
+
var CloudFrontInvalidator = class {
|
|
125
|
+
client;
|
|
126
|
+
distributionId;
|
|
127
|
+
constructor(config) {
|
|
128
|
+
this.distributionId = config.distributionId;
|
|
129
|
+
this.client = new import_client_cloudfront.CloudFrontClient({ region: config.region || "us-east-1" });
|
|
130
|
+
}
|
|
131
|
+
async invalidate(paths = ["/*"]) {
|
|
132
|
+
const callerReference = `ai-localize-${Date.now()}`;
|
|
133
|
+
const response = await this.client.send(
|
|
134
|
+
new import_client_cloudfront.CreateInvalidationCommand({
|
|
135
|
+
DistributionId: this.distributionId,
|
|
136
|
+
InvalidationBatch: {
|
|
137
|
+
CallerReference: callerReference,
|
|
138
|
+
Paths: { Quantity: paths.length, Items: paths }
|
|
139
|
+
}
|
|
140
|
+
})
|
|
141
|
+
);
|
|
142
|
+
return {
|
|
143
|
+
invalidationId: response.Invalidation?.Id || "",
|
|
144
|
+
paths,
|
|
145
|
+
status: response.Invalidation?.Status || "InProgress"
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
// src/cdn-migrator.ts
|
|
151
|
+
var path2 = __toESM(require("path"));
|
|
152
|
+
var CdnMigrator = class {
|
|
153
|
+
config;
|
|
154
|
+
constructor(config) {
|
|
155
|
+
this.config = config;
|
|
156
|
+
}
|
|
157
|
+
async migrate(options) {
|
|
158
|
+
const startTime = Date.now();
|
|
159
|
+
const { dryRun = false, onProgress } = options;
|
|
160
|
+
onProgress?.("Uploading assets to S3...");
|
|
161
|
+
let uploadedAssets = [];
|
|
162
|
+
if (!dryRun) {
|
|
163
|
+
const uploader = new S3Uploader(this.config);
|
|
164
|
+
const uploadResult = await uploader.uploadDirectory({
|
|
165
|
+
assetsDir: options.assetsDir,
|
|
166
|
+
onProgress: (asset, done, total) => onProgress?.(`Uploading ${done}/${total}: ${path2.basename(asset)}`)
|
|
167
|
+
});
|
|
168
|
+
uploadedAssets = uploadResult.uploaded;
|
|
169
|
+
}
|
|
170
|
+
onProgress?.("Replacing legacy CDN URLs...");
|
|
171
|
+
let replacedUrls = 0;
|
|
172
|
+
if (!dryRun && uploadedAssets.length > 0) {
|
|
173
|
+
const { batchReplaceCdnUrls } = await import("@ai-localize/codemods");
|
|
174
|
+
const fileUrlMap = /* @__PURE__ */ new Map();
|
|
175
|
+
for (const url of options.legacyCdnUrls) {
|
|
176
|
+
const existing = fileUrlMap.get(url.filePath) || [];
|
|
177
|
+
existing.push(url);
|
|
178
|
+
fileUrlMap.set(url.filePath, existing);
|
|
179
|
+
}
|
|
180
|
+
const results = await batchReplaceCdnUrls(fileUrlMap, uploadedAssets, dryRun);
|
|
181
|
+
replacedUrls = results.reduce((sum, r) => sum + r.replacedCount, 0);
|
|
182
|
+
}
|
|
183
|
+
let invalidationId;
|
|
184
|
+
if (!dryRun && options.invalidateCache && uploadedAssets.length > 0) {
|
|
185
|
+
onProgress?.("Invalidating CloudFront cache...");
|
|
186
|
+
const invalidator = new CloudFrontInvalidator(this.config);
|
|
187
|
+
const inv = await invalidator.invalidate(["/*"]);
|
|
188
|
+
invalidationId = inv.invalidationId;
|
|
189
|
+
}
|
|
190
|
+
return {
|
|
191
|
+
uploadedAssets,
|
|
192
|
+
replacedUrls,
|
|
193
|
+
invalidationId,
|
|
194
|
+
duration: Date.now() - startTime
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
199
|
+
0 && (module.exports = {
|
|
200
|
+
CdnMigrator,
|
|
201
|
+
CloudFrontInvalidator,
|
|
202
|
+
S3Uploader
|
|
203
|
+
});
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// src/s3-uploader.ts
|
|
2
|
+
import * as fs from "fs";
|
|
3
|
+
import * as path from "path";
|
|
4
|
+
import * as crypto from "crypto";
|
|
5
|
+
import { S3Client, PutObjectCommand, HeadObjectCommand } from "@aws-sdk/client-s3";
|
|
6
|
+
import * as mime from "mime-types";
|
|
7
|
+
import { collectFiles, ASSET_EXTENSIONS } from "@ai-localize/shared";
|
|
8
|
+
var S3Uploader = class {
|
|
9
|
+
client;
|
|
10
|
+
config;
|
|
11
|
+
constructor(config) {
|
|
12
|
+
this.config = config;
|
|
13
|
+
this.client = new S3Client({
|
|
14
|
+
region: config.region || "us-east-1",
|
|
15
|
+
...config.profile ? { credentials: { accessKeyId: "", secretAccessKey: "" } } : {}
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
async uploadDirectory(options) {
|
|
19
|
+
const startTime = Date.now();
|
|
20
|
+
const assetFiles = collectFiles(options.assetsDir, ASSET_EXTENSIONS, ["node_modules"]);
|
|
21
|
+
const uploaded = [];
|
|
22
|
+
const skipped = [];
|
|
23
|
+
const failed = [];
|
|
24
|
+
let done = 0;
|
|
25
|
+
for (const filePath of assetFiles) {
|
|
26
|
+
try {
|
|
27
|
+
const asset = await this.uploadFile(filePath, options.assetsDir, options.force);
|
|
28
|
+
if (asset) {
|
|
29
|
+
uploaded.push(asset);
|
|
30
|
+
} else {
|
|
31
|
+
skipped.push(filePath);
|
|
32
|
+
}
|
|
33
|
+
} catch (err) {
|
|
34
|
+
failed.push(filePath);
|
|
35
|
+
console.error(`Failed to upload ${filePath}:`, err);
|
|
36
|
+
}
|
|
37
|
+
done++;
|
|
38
|
+
options.onProgress?.(filePath, done, assetFiles.length);
|
|
39
|
+
}
|
|
40
|
+
return { uploaded, skipped, failed, duration: Date.now() - startTime };
|
|
41
|
+
}
|
|
42
|
+
async uploadFile(filePath, baseDir, force = false) {
|
|
43
|
+
const content = fs.readFileSync(filePath);
|
|
44
|
+
const hash = crypto.createHash("md5").update(content).digest("hex").slice(0, 8);
|
|
45
|
+
const ext = path.extname(filePath);
|
|
46
|
+
const basename3 = path.basename(filePath, ext);
|
|
47
|
+
const hashedName = `${basename3}.${hash}${ext}`;
|
|
48
|
+
const relativePath = path.relative(baseDir, filePath);
|
|
49
|
+
const s3Key = path.join(this.config.assetsPrefix || "assets", path.dirname(relativePath), hashedName).replace(/\\/g, "/");
|
|
50
|
+
if (!force) {
|
|
51
|
+
const exists = await this.objectExists(s3Key);
|
|
52
|
+
if (exists) return null;
|
|
53
|
+
}
|
|
54
|
+
const contentType = mime.lookup(filePath) || "application/octet-stream";
|
|
55
|
+
await this.client.send(
|
|
56
|
+
new PutObjectCommand({
|
|
57
|
+
Bucket: this.config.bucket,
|
|
58
|
+
Key: s3Key,
|
|
59
|
+
Body: content,
|
|
60
|
+
ContentType: contentType,
|
|
61
|
+
CacheControl: "public, max-age=31536000, immutable"
|
|
62
|
+
})
|
|
63
|
+
);
|
|
64
|
+
const cdnBase = this.config.cdnBaseUrl || `https://${this.config.distributionId}.cloudfront.net`;
|
|
65
|
+
return {
|
|
66
|
+
localPath: filePath,
|
|
67
|
+
s3Key,
|
|
68
|
+
hash,
|
|
69
|
+
cloudfrontUrl: `${cdnBase}/${s3Key}`,
|
|
70
|
+
contentType,
|
|
71
|
+
size: content.length
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
async objectExists(key) {
|
|
75
|
+
try {
|
|
76
|
+
await this.client.send(new HeadObjectCommand({ Bucket: this.config.bucket, Key: key }));
|
|
77
|
+
return true;
|
|
78
|
+
} catch {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
// src/cloudfront-invalidator.ts
|
|
85
|
+
import { CloudFrontClient, CreateInvalidationCommand } from "@aws-sdk/client-cloudfront";
|
|
86
|
+
var CloudFrontInvalidator = class {
|
|
87
|
+
client;
|
|
88
|
+
distributionId;
|
|
89
|
+
constructor(config) {
|
|
90
|
+
this.distributionId = config.distributionId;
|
|
91
|
+
this.client = new CloudFrontClient({ region: config.region || "us-east-1" });
|
|
92
|
+
}
|
|
93
|
+
async invalidate(paths = ["/*"]) {
|
|
94
|
+
const callerReference = `ai-localize-${Date.now()}`;
|
|
95
|
+
const response = await this.client.send(
|
|
96
|
+
new CreateInvalidationCommand({
|
|
97
|
+
DistributionId: this.distributionId,
|
|
98
|
+
InvalidationBatch: {
|
|
99
|
+
CallerReference: callerReference,
|
|
100
|
+
Paths: { Quantity: paths.length, Items: paths }
|
|
101
|
+
}
|
|
102
|
+
})
|
|
103
|
+
);
|
|
104
|
+
return {
|
|
105
|
+
invalidationId: response.Invalidation?.Id || "",
|
|
106
|
+
paths,
|
|
107
|
+
status: response.Invalidation?.Status || "InProgress"
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
// src/cdn-migrator.ts
|
|
113
|
+
import * as path2 from "path";
|
|
114
|
+
var CdnMigrator = class {
|
|
115
|
+
config;
|
|
116
|
+
constructor(config) {
|
|
117
|
+
this.config = config;
|
|
118
|
+
}
|
|
119
|
+
async migrate(options) {
|
|
120
|
+
const startTime = Date.now();
|
|
121
|
+
const { dryRun = false, onProgress } = options;
|
|
122
|
+
onProgress?.("Uploading assets to S3...");
|
|
123
|
+
let uploadedAssets = [];
|
|
124
|
+
if (!dryRun) {
|
|
125
|
+
const uploader = new S3Uploader(this.config);
|
|
126
|
+
const uploadResult = await uploader.uploadDirectory({
|
|
127
|
+
assetsDir: options.assetsDir,
|
|
128
|
+
onProgress: (asset, done, total) => onProgress?.(`Uploading ${done}/${total}: ${path2.basename(asset)}`)
|
|
129
|
+
});
|
|
130
|
+
uploadedAssets = uploadResult.uploaded;
|
|
131
|
+
}
|
|
132
|
+
onProgress?.("Replacing legacy CDN URLs...");
|
|
133
|
+
let replacedUrls = 0;
|
|
134
|
+
if (!dryRun && uploadedAssets.length > 0) {
|
|
135
|
+
const { batchReplaceCdnUrls } = await import("@ai-localize/codemods");
|
|
136
|
+
const fileUrlMap = /* @__PURE__ */ new Map();
|
|
137
|
+
for (const url of options.legacyCdnUrls) {
|
|
138
|
+
const existing = fileUrlMap.get(url.filePath) || [];
|
|
139
|
+
existing.push(url);
|
|
140
|
+
fileUrlMap.set(url.filePath, existing);
|
|
141
|
+
}
|
|
142
|
+
const results = await batchReplaceCdnUrls(fileUrlMap, uploadedAssets, dryRun);
|
|
143
|
+
replacedUrls = results.reduce((sum, r) => sum + r.replacedCount, 0);
|
|
144
|
+
}
|
|
145
|
+
let invalidationId;
|
|
146
|
+
if (!dryRun && options.invalidateCache && uploadedAssets.length > 0) {
|
|
147
|
+
onProgress?.("Invalidating CloudFront cache...");
|
|
148
|
+
const invalidator = new CloudFrontInvalidator(this.config);
|
|
149
|
+
const inv = await invalidator.invalidate(["/*"]);
|
|
150
|
+
invalidationId = inv.invalidationId;
|
|
151
|
+
}
|
|
152
|
+
return {
|
|
153
|
+
uploadedAssets,
|
|
154
|
+
replacedUrls,
|
|
155
|
+
invalidationId,
|
|
156
|
+
duration: Date.now() - startTime
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
export {
|
|
161
|
+
CdnMigrator,
|
|
162
|
+
CloudFrontInvalidator,
|
|
163
|
+
S3Uploader
|
|
164
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "ai-localize-aws-cloudfront",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "S3 upload, CloudFront invalidation, CDN URL generation",
|
|
5
|
+
"main": "./dist/index.js",
|
|
6
|
+
"module": "./dist/index.mjs",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.mjs",
|
|
12
|
+
"require": "./dist/index.js"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"@aws-sdk/client-s3": "^3.511.0",
|
|
17
|
+
"@aws-sdk/client-cloudfront": "^3.511.0",
|
|
18
|
+
"@aws-sdk/lib-storage": "^3.511.0",
|
|
19
|
+
"mime-types": "^2.1.35",
|
|
20
|
+
"ai-localize-shared": "1.0.0",
|
|
21
|
+
"ai-localize-codemods": "1.0.0"
|
|
22
|
+
},
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"@types/mime-types": "^2.1.4",
|
|
25
|
+
"tsup": "^8.0.1",
|
|
26
|
+
"typescript": "^5.3.3",
|
|
27
|
+
"vitest": "^1.2.1"
|
|
28
|
+
},
|
|
29
|
+
"license": "MIT",
|
|
30
|
+
"publishConfig": {
|
|
31
|
+
"access": "public"
|
|
32
|
+
},
|
|
33
|
+
"scripts": {
|
|
34
|
+
"build": "tsup src/index.ts --format cjs,esm --dts",
|
|
35
|
+
"dev": "tsup src/index.ts --format cjs,esm --dts --watch",
|
|
36
|
+
"typecheck": "tsc --noEmit",
|
|
37
|
+
"test": "vitest run",
|
|
38
|
+
"lint": "eslint src --ext .ts"
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import * as path from "path";
|
|
2
|
+
import type { AwsConfig, CloudFrontAsset, LegacyCdnUrl } from "@ai-localize/shared";
|
|
3
|
+
import { S3Uploader } from "./s3-uploader.js";
|
|
4
|
+
import { CloudFrontInvalidator } from "./cloudfront-invalidator.js";
|
|
5
|
+
|
|
6
|
+
export interface MigrationOptions {
|
|
7
|
+
sourceDir: string;
|
|
8
|
+
assetsDir: string;
|
|
9
|
+
legacyCdnUrls: LegacyCdnUrl[];
|
|
10
|
+
dryRun?: boolean;
|
|
11
|
+
invalidateCache?: boolean;
|
|
12
|
+
onProgress?: (step: string) => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface MigrationResult {
|
|
16
|
+
uploadedAssets: CloudFrontAsset[];
|
|
17
|
+
replacedUrls: number;
|
|
18
|
+
invalidationId?: string;
|
|
19
|
+
duration: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Orchestrates the full CDN migration:
|
|
24
|
+
* 1. Upload assets to S3
|
|
25
|
+
* 2. Replace legacy CDN URLs in source files
|
|
26
|
+
* 3. Invalidate CloudFront cache
|
|
27
|
+
*/
|
|
28
|
+
export class CdnMigrator {
|
|
29
|
+
private config: AwsConfig;
|
|
30
|
+
|
|
31
|
+
constructor(config: AwsConfig) {
|
|
32
|
+
this.config = config;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async migrate(options: MigrationOptions): Promise<MigrationResult> {
|
|
36
|
+
const startTime = Date.now();
|
|
37
|
+
const { dryRun = false, onProgress } = options;
|
|
38
|
+
|
|
39
|
+
onProgress?.("Uploading assets to S3...");
|
|
40
|
+
let uploadedAssets: CloudFrontAsset[] = [];
|
|
41
|
+
|
|
42
|
+
if (!dryRun) {
|
|
43
|
+
const uploader = new S3Uploader(this.config);
|
|
44
|
+
const uploadResult = await uploader.uploadDirectory({
|
|
45
|
+
assetsDir: options.assetsDir,
|
|
46
|
+
onProgress: (asset, done, total) => onProgress?.(`Uploading ${done}/${total}: ${path.basename(asset)}`),
|
|
47
|
+
});
|
|
48
|
+
uploadedAssets = uploadResult.uploaded;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
onProgress?.("Replacing legacy CDN URLs...");
|
|
52
|
+
let replacedUrls = 0;
|
|
53
|
+
|
|
54
|
+
if (!dryRun && uploadedAssets.length > 0) {
|
|
55
|
+
const { batchReplaceCdnUrls } = await import("@ai-localize/codemods");
|
|
56
|
+
const fileUrlMap = new Map<string, LegacyCdnUrl[]>();
|
|
57
|
+
for (const url of options.legacyCdnUrls) {
|
|
58
|
+
const existing = fileUrlMap.get(url.filePath) || [];
|
|
59
|
+
existing.push(url);
|
|
60
|
+
fileUrlMap.set(url.filePath, existing);
|
|
61
|
+
}
|
|
62
|
+
const results = await batchReplaceCdnUrls(fileUrlMap, uploadedAssets, dryRun);
|
|
63
|
+
replacedUrls = results.reduce((sum, r) => sum + r.replacedCount, 0);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
let invalidationId: string | undefined;
|
|
67
|
+
if (!dryRun && options.invalidateCache && uploadedAssets.length > 0) {
|
|
68
|
+
onProgress?.("Invalidating CloudFront cache...");
|
|
69
|
+
const invalidator = new CloudFrontInvalidator(this.config);
|
|
70
|
+
const inv = await invalidator.invalidate(["/*"]);
|
|
71
|
+
invalidationId = inv.invalidationId;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
uploadedAssets,
|
|
76
|
+
replacedUrls,
|
|
77
|
+
invalidationId,
|
|
78
|
+
duration: Date.now() - startTime,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { CloudFrontClient, CreateInvalidationCommand } from "@aws-sdk/client-cloudfront";
|
|
2
|
+
import type { AwsConfig } from "@ai-localize/shared";
|
|
3
|
+
|
|
4
|
+
export interface InvalidationResult {
|
|
5
|
+
invalidationId: string;
|
|
6
|
+
paths: string[];
|
|
7
|
+
status: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export class CloudFrontInvalidator {
|
|
11
|
+
private client: CloudFrontClient;
|
|
12
|
+
private distributionId: string;
|
|
13
|
+
|
|
14
|
+
constructor(config: AwsConfig) {
|
|
15
|
+
this.distributionId = config.distributionId;
|
|
16
|
+
this.client = new CloudFrontClient({ region: config.region || "us-east-1" });
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async invalidate(paths: string[] = ["/*"]): Promise<InvalidationResult> {
|
|
20
|
+
const callerReference = `ai-localize-${Date.now()}`;
|
|
21
|
+
const response = await this.client.send(
|
|
22
|
+
new CreateInvalidationCommand({
|
|
23
|
+
DistributionId: this.distributionId,
|
|
24
|
+
InvalidationBatch: {
|
|
25
|
+
CallerReference: callerReference,
|
|
26
|
+
Paths: { Quantity: paths.length, Items: paths },
|
|
27
|
+
},
|
|
28
|
+
})
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
invalidationId: response.Invalidation?.Id || "",
|
|
33
|
+
paths,
|
|
34
|
+
status: response.Invalidation?.Status || "InProgress",
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
import * as crypto from "crypto";
|
|
4
|
+
import { S3Client, PutObjectCommand, HeadObjectCommand } from "@aws-sdk/client-s3";
|
|
5
|
+
import * as mime from "mime-types";
|
|
6
|
+
|
|
7
|
+
import type { AwsConfig, CloudFrontAsset } from "@ai-localize/shared";
|
|
8
|
+
import { collectFiles, ASSET_EXTENSIONS } from "@ai-localize/shared";
|
|
9
|
+
|
|
10
|
+
export interface UploadOptions {
|
|
11
|
+
assetsDir: string;
|
|
12
|
+
force?: boolean;
|
|
13
|
+
onProgress?: (asset: string, done: number, total: number) => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface UploadResult {
|
|
17
|
+
uploaded: CloudFrontAsset[];
|
|
18
|
+
skipped: string[];
|
|
19
|
+
failed: string[];
|
|
20
|
+
duration: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Uploads static assets to S3 with content-hash in filename for cache-busting.
|
|
25
|
+
*/
|
|
26
|
+
export class S3Uploader {
|
|
27
|
+
private client: S3Client;
|
|
28
|
+
private config: AwsConfig;
|
|
29
|
+
|
|
30
|
+
constructor(config: AwsConfig) {
|
|
31
|
+
this.config = config;
|
|
32
|
+
this.client = new S3Client({
|
|
33
|
+
region: config.region || "us-east-1",
|
|
34
|
+
...(config.profile ? { credentials: { accessKeyId: "", secretAccessKey: "" } } : {}),
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async uploadDirectory(options: UploadOptions): Promise<UploadResult> {
|
|
39
|
+
const startTime = Date.now();
|
|
40
|
+
const assetFiles = collectFiles(options.assetsDir, ASSET_EXTENSIONS, ["node_modules"]);
|
|
41
|
+
const uploaded: CloudFrontAsset[] = [];
|
|
42
|
+
const skipped: string[] = [];
|
|
43
|
+
const failed: string[] = [];
|
|
44
|
+
let done = 0;
|
|
45
|
+
|
|
46
|
+
for (const filePath of assetFiles) {
|
|
47
|
+
try {
|
|
48
|
+
const asset = await this.uploadFile(filePath, options.assetsDir, options.force);
|
|
49
|
+
if (asset) {
|
|
50
|
+
uploaded.push(asset);
|
|
51
|
+
} else {
|
|
52
|
+
skipped.push(filePath);
|
|
53
|
+
}
|
|
54
|
+
} catch (err) {
|
|
55
|
+
failed.push(filePath);
|
|
56
|
+
console.error(`Failed to upload ${filePath}:`, err);
|
|
57
|
+
}
|
|
58
|
+
done++;
|
|
59
|
+
options.onProgress?.(filePath, done, assetFiles.length);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return { uploaded, skipped, failed, duration: Date.now() - startTime };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async uploadFile(filePath: string, baseDir: string, force = false): Promise<CloudFrontAsset | null> {
|
|
66
|
+
const content = fs.readFileSync(filePath);
|
|
67
|
+
const hash = crypto.createHash("md5").update(content).digest("hex").slice(0, 8);
|
|
68
|
+
const ext = path.extname(filePath);
|
|
69
|
+
const basename = path.basename(filePath, ext);
|
|
70
|
+
const hashedName = `${basename}.${hash}${ext}`;
|
|
71
|
+
const relativePath = path.relative(baseDir, filePath);
|
|
72
|
+
const s3Key = path.join(this.config.assetsPrefix || "assets", path.dirname(relativePath), hashedName).replace(/\\/g, "/");
|
|
73
|
+
|
|
74
|
+
// Check if already uploaded
|
|
75
|
+
if (!force) {
|
|
76
|
+
const exists = await this.objectExists(s3Key);
|
|
77
|
+
if (exists) return null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const contentType = mime.lookup(filePath) || "application/octet-stream";
|
|
81
|
+
await this.client.send(
|
|
82
|
+
new PutObjectCommand({
|
|
83
|
+
Bucket: this.config.bucket,
|
|
84
|
+
Key: s3Key,
|
|
85
|
+
Body: content,
|
|
86
|
+
ContentType: contentType,
|
|
87
|
+
CacheControl: "public, max-age=31536000, immutable",
|
|
88
|
+
})
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
const cdnBase = this.config.cdnBaseUrl || `https://${this.config.distributionId}.cloudfront.net`;
|
|
92
|
+
return {
|
|
93
|
+
localPath: filePath,
|
|
94
|
+
s3Key,
|
|
95
|
+
hash,
|
|
96
|
+
cloudfrontUrl: `${cdnBase}/${s3Key}`,
|
|
97
|
+
contentType,
|
|
98
|
+
size: content.length,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
private async objectExists(key: string): Promise<boolean> {
|
|
103
|
+
try {
|
|
104
|
+
await this.client.send(new HeadObjectCommand({ Bucket: this.config.bucket, Key: key }));
|
|
105
|
+
return true;
|
|
106
|
+
} catch {
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|