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.
@@ -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 };
@@ -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,3 @@
1
+ export * from "./s3-uploader.js";
2
+ export * from "./cloudfront-invalidator.js";
3
+ export * from "./cdn-migrator.js";
@@ -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
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": { "outDir": "./dist", "rootDir": "./src" },
4
+ "include": ["src/**/*"],
5
+ "exclude": ["node_modules", "dist"]
6
+ }