s3-ship 0.1.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/LICENSE +21 -0
- package/README.md +196 -0
- package/dist/aws/cloudfront-client.d.ts +10 -0
- package/dist/aws/cloudfront-client.d.ts.map +1 -0
- package/dist/aws/s3-client.d.ts +11 -0
- package/dist/aws/s3-client.d.ts.map +1 -0
- package/dist/cli/init.d.ts +11 -0
- package/dist/cli/init.d.ts.map +1 -0
- package/dist/cli/run.d.ts +6 -0
- package/dist/cli/run.d.ts.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +1000 -0
- package/dist/config/define.d.ts +3 -0
- package/dist/config/define.d.ts.map +1 -0
- package/dist/config/load.d.ts +10 -0
- package/dist/config/load.d.ts.map +1 -0
- package/dist/config/merge.d.ts +28 -0
- package/dist/config/merge.d.ts.map +1 -0
- package/dist/config/schema.d.ts +348 -0
- package/dist/config/schema.d.ts.map +1 -0
- package/dist/deploy.d.ts +32 -0
- package/dist/deploy.d.ts.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +707 -0
- package/dist/reporter.d.ts +8 -0
- package/dist/reporter.d.ts.map +1 -0
- package/dist/stages/delete-stale.d.ts +10 -0
- package/dist/stages/delete-stale.d.ts.map +1 -0
- package/dist/stages/diff.d.ts +34 -0
- package/dist/stages/diff.d.ts.map +1 -0
- package/dist/stages/execute.d.ts +28 -0
- package/dist/stages/execute.d.ts.map +1 -0
- package/dist/stages/invalidate.d.ts +8 -0
- package/dist/stages/invalidate.d.ts.map +1 -0
- package/dist/stages/list-remote.d.ts +13 -0
- package/dist/stages/list-remote.d.ts.map +1 -0
- package/dist/stages/redirects.d.ts +11 -0
- package/dist/stages/redirects.d.ts.map +1 -0
- package/dist/stages/scan.d.ts +12 -0
- package/dist/stages/scan.d.ts.map +1 -0
- package/dist/stages/upload.d.ts +12 -0
- package/dist/stages/upload.d.ts.map +1 -0
- package/dist/util/content-type.d.ts +2 -0
- package/dist/util/content-type.d.ts.map +1 -0
- package/dist/util/glob-match.d.ts +3 -0
- package/dist/util/glob-match.d.ts.map +1 -0
- package/dist/util/parallel-limit.d.ts +10 -0
- package/dist/util/parallel-limit.d.ts.map +1 -0
- package/dist/util/retry.d.ts +6 -0
- package/dist/util/retry.d.ts.map +1 -0
- package/package.json +66 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,707 @@
|
|
|
1
|
+
// src/config/define.ts
|
|
2
|
+
function defineConfig(config) {
|
|
3
|
+
return config;
|
|
4
|
+
}
|
|
5
|
+
// src/aws/cloudfront-client.ts
|
|
6
|
+
import { CloudFrontClient } from "@aws-sdk/client-cloudfront";
|
|
7
|
+
import { fromIni } from "@aws-sdk/credential-providers";
|
|
8
|
+
function createCloudFrontClient(options) {
|
|
9
|
+
return new CloudFrontClient({
|
|
10
|
+
region: options.region ?? "us-east-1",
|
|
11
|
+
credentials: options.profile ? fromIni({ profile: options.profile }) : undefined
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// src/aws/s3-client.ts
|
|
16
|
+
import { S3Client } from "@aws-sdk/client-s3";
|
|
17
|
+
import { fromIni as fromIni2 } from "@aws-sdk/credential-providers";
|
|
18
|
+
function createS3Client(options) {
|
|
19
|
+
return new S3Client({
|
|
20
|
+
region: options.region,
|
|
21
|
+
credentials: options.profile ? fromIni2({ profile: options.profile }) : undefined
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
function normalizeTargetPrefix(target) {
|
|
25
|
+
if (!target)
|
|
26
|
+
return "";
|
|
27
|
+
return target.endsWith("/") ? target : `${target}/`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// src/config/load.ts
|
|
31
|
+
import { existsSync } from "node:fs";
|
|
32
|
+
import { readFile } from "node:fs/promises";
|
|
33
|
+
import { extname, isAbsolute, resolve } from "node:path";
|
|
34
|
+
import { pathToFileURL } from "node:url";
|
|
35
|
+
import YAML from "yaml";
|
|
36
|
+
var SEARCH_ORDER = [
|
|
37
|
+
"s3-ship.config.ts",
|
|
38
|
+
"s3-ship.config.js",
|
|
39
|
+
"s3-ship.config.mjs",
|
|
40
|
+
"s3-ship.config.cjs",
|
|
41
|
+
"s3-ship.config.json",
|
|
42
|
+
"s3-ship.config.yml",
|
|
43
|
+
"s3-ship.config.yaml"
|
|
44
|
+
];
|
|
45
|
+
async function loadConfigFile(options) {
|
|
46
|
+
const sourcePath = options.configPath ? resolveExplicitPath(options.configPath, options.cwd) : findConfigInCwd(options.cwd);
|
|
47
|
+
if (!sourcePath) {
|
|
48
|
+
throw new Error(`[config] No s3-ship config file found in ${options.cwd}. Expected one of: ${SEARCH_ORDER.join(", ")}`);
|
|
49
|
+
}
|
|
50
|
+
const config = await readByExtension(sourcePath);
|
|
51
|
+
return { config, sourcePath };
|
|
52
|
+
}
|
|
53
|
+
function resolveExplicitPath(configPath, cwd) {
|
|
54
|
+
const abs = isAbsolute(configPath) ? configPath : resolve(cwd, configPath);
|
|
55
|
+
if (!existsSync(abs)) {
|
|
56
|
+
throw new Error(`[config] Config file not found: ${abs}`);
|
|
57
|
+
}
|
|
58
|
+
return abs;
|
|
59
|
+
}
|
|
60
|
+
function findConfigInCwd(cwd) {
|
|
61
|
+
for (const name of SEARCH_ORDER) {
|
|
62
|
+
const candidate = resolve(cwd, name);
|
|
63
|
+
if (existsSync(candidate)) {
|
|
64
|
+
return candidate;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
async function readByExtension(path) {
|
|
70
|
+
const ext = extname(path).toLowerCase();
|
|
71
|
+
switch (ext) {
|
|
72
|
+
case ".json":
|
|
73
|
+
return parseJson(path);
|
|
74
|
+
case ".yml":
|
|
75
|
+
case ".yaml":
|
|
76
|
+
return parseYaml(path);
|
|
77
|
+
case ".ts":
|
|
78
|
+
case ".js":
|
|
79
|
+
case ".mjs":
|
|
80
|
+
case ".cjs":
|
|
81
|
+
return importModule(path);
|
|
82
|
+
default:
|
|
83
|
+
throw new Error(`[config] Unsupported config file extension: ${ext}`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
async function parseJson(path) {
|
|
87
|
+
const raw = await readFile(path, "utf8");
|
|
88
|
+
try {
|
|
89
|
+
return JSON.parse(raw);
|
|
90
|
+
} catch (err) {
|
|
91
|
+
throw new Error(`[config] Failed to parse JSON config at ${path}: ${err.message}`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
async function parseYaml(path) {
|
|
95
|
+
const raw = await readFile(path, "utf8");
|
|
96
|
+
try {
|
|
97
|
+
const data = YAML.parse(raw);
|
|
98
|
+
if (!data || typeof data !== "object" || Array.isArray(data)) {
|
|
99
|
+
throw new Error("YAML root must be a mapping");
|
|
100
|
+
}
|
|
101
|
+
return data;
|
|
102
|
+
} catch (err) {
|
|
103
|
+
throw new Error(`[config] Failed to parse YAML config at ${path}: ${err.message}`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
async function importModule(path) {
|
|
107
|
+
const url = `${pathToFileURL(path).href}?t=${Date.now()}`;
|
|
108
|
+
const mod = await import(url);
|
|
109
|
+
if (mod.default == null) {
|
|
110
|
+
throw new Error(`[config] ${path} has no default export. Add: export default { ... } or module.exports = { ... }`);
|
|
111
|
+
}
|
|
112
|
+
if (typeof mod.default !== "object" || Array.isArray(mod.default)) {
|
|
113
|
+
throw new Error(`[config] ${path} default export must be a config object.`);
|
|
114
|
+
}
|
|
115
|
+
return mod.default;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// src/config/merge.ts
|
|
119
|
+
var DEFAULTS = {
|
|
120
|
+
source: "dist",
|
|
121
|
+
target: "",
|
|
122
|
+
syncDelete: false,
|
|
123
|
+
invalidationPaths: ["/*"]
|
|
124
|
+
};
|
|
125
|
+
function pickEnvironment(config, envName) {
|
|
126
|
+
if (!envName)
|
|
127
|
+
return;
|
|
128
|
+
const env = config.environments?.[envName];
|
|
129
|
+
if (!env) {
|
|
130
|
+
const available = Object.keys(config.environments ?? {}).join(", ") || "(none)";
|
|
131
|
+
throw new Error(`[config] environment "${envName}" not found. Available environments: ${available}`);
|
|
132
|
+
}
|
|
133
|
+
return env;
|
|
134
|
+
}
|
|
135
|
+
function resolveConfig(config, overrides) {
|
|
136
|
+
const env = pickEnvironment(config, overrides.env);
|
|
137
|
+
const envVars = overrides.envVars ?? {};
|
|
138
|
+
const layered = {
|
|
139
|
+
...config,
|
|
140
|
+
...env ?? {}
|
|
141
|
+
};
|
|
142
|
+
if (env?.cloudfront || config.cloudfront) {
|
|
143
|
+
layered.cloudfront = {
|
|
144
|
+
...config.cloudfront ?? {},
|
|
145
|
+
...env?.cloudfront ?? {}
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
const bucket = overrides.bucket ?? layered.bucket;
|
|
149
|
+
if (!bucket) {
|
|
150
|
+
throw new Error("[config] bucket is required (top-level, environment, or --bucket flag)");
|
|
151
|
+
}
|
|
152
|
+
const region = layered.region ?? envVars.AWS_REGION;
|
|
153
|
+
const profile = overrides.profile ?? layered.profile ?? envVars.AWS_PROFILE;
|
|
154
|
+
const cloudfront = layered.cloudfront ? {
|
|
155
|
+
distributionId: layered.cloudfront.distributionId,
|
|
156
|
+
invalidationPaths: layered.cloudfront.invalidationPaths ?? DEFAULTS.invalidationPaths
|
|
157
|
+
} : undefined;
|
|
158
|
+
const syncDelete = overrides.syncDelete ?? layered.syncDelete ?? DEFAULTS.syncDelete;
|
|
159
|
+
return {
|
|
160
|
+
source: overrides.source ?? layered.source ?? DEFAULTS.source,
|
|
161
|
+
target: overrides.target ?? layered.target ?? DEFAULTS.target,
|
|
162
|
+
bucket,
|
|
163
|
+
region,
|
|
164
|
+
profile,
|
|
165
|
+
cloudfront,
|
|
166
|
+
syncDelete,
|
|
167
|
+
ignore: layered.ignore ?? [],
|
|
168
|
+
redirects: layered.redirects ?? [],
|
|
169
|
+
cacheControl: layered.cacheControl ?? [],
|
|
170
|
+
environment: overrides.env
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// src/config/schema.ts
|
|
175
|
+
import { z } from "zod";
|
|
176
|
+
var redirectRuleSchema = z.object({
|
|
177
|
+
from: z.string().min(1, "redirect.from is required"),
|
|
178
|
+
to: z.string().min(1, "redirect.to is required"),
|
|
179
|
+
statusCode: z.union([z.literal(301), z.literal(302)]).optional()
|
|
180
|
+
});
|
|
181
|
+
var cacheRuleSchema = z.object({
|
|
182
|
+
match: z.string().min(1),
|
|
183
|
+
cacheControl: z.string().min(1)
|
|
184
|
+
});
|
|
185
|
+
var cloudfrontConfigSchema = z.object({
|
|
186
|
+
distributionId: z.string().min(1, "cloudfront.distributionId is required"),
|
|
187
|
+
invalidationPaths: z.array(z.string().min(1)).optional()
|
|
188
|
+
});
|
|
189
|
+
var baseConfigShape = {
|
|
190
|
+
source: z.string().min(1).optional(),
|
|
191
|
+
target: z.string().optional(),
|
|
192
|
+
bucket: z.string().min(1).optional(),
|
|
193
|
+
region: z.string().min(1).optional(),
|
|
194
|
+
profile: z.string().min(1).optional(),
|
|
195
|
+
cloudfront: cloudfrontConfigSchema.optional(),
|
|
196
|
+
syncDelete: z.boolean().optional(),
|
|
197
|
+
ignore: z.array(z.string()).optional(),
|
|
198
|
+
redirects: z.array(redirectRuleSchema).optional(),
|
|
199
|
+
cacheControl: z.array(cacheRuleSchema).optional()
|
|
200
|
+
};
|
|
201
|
+
var environmentConfigSchema = z.object(baseConfigShape).strict();
|
|
202
|
+
var configSchema = z.object({
|
|
203
|
+
...baseConfigShape,
|
|
204
|
+
environments: z.record(z.string().min(1), environmentConfigSchema).optional()
|
|
205
|
+
}).strict();
|
|
206
|
+
function zodIssuesToErrors(issues) {
|
|
207
|
+
return issues.map((issue) => ({
|
|
208
|
+
path: issue.path.join("."),
|
|
209
|
+
message: issue.message
|
|
210
|
+
}));
|
|
211
|
+
}
|
|
212
|
+
function validateConfig(input) {
|
|
213
|
+
const parsed = configSchema.safeParse(input);
|
|
214
|
+
if (!parsed.success) {
|
|
215
|
+
return { ok: false, errors: zodIssuesToErrors(parsed.error.issues) };
|
|
216
|
+
}
|
|
217
|
+
const value = parsed.data;
|
|
218
|
+
const errors = [];
|
|
219
|
+
const envs = value.environments ?? {};
|
|
220
|
+
const envEntries = Object.entries(envs);
|
|
221
|
+
const topHasBucket = typeof value.bucket === "string" && value.bucket.length > 0;
|
|
222
|
+
if (!topHasBucket) {
|
|
223
|
+
if (envEntries.length === 0) {
|
|
224
|
+
errors.push({
|
|
225
|
+
path: "bucket",
|
|
226
|
+
message: "bucket is required at the top level or inside every environment"
|
|
227
|
+
});
|
|
228
|
+
} else {
|
|
229
|
+
for (const [name, env] of envEntries) {
|
|
230
|
+
const envHasBucket = typeof env.bucket === "string" && env.bucket.length > 0;
|
|
231
|
+
if (!envHasBucket) {
|
|
232
|
+
errors.push({
|
|
233
|
+
path: `environments.${name}.bucket`,
|
|
234
|
+
message: `bucket is required in environment "${name}" because no top-level bucket is set`
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
if (errors.length > 0) {
|
|
241
|
+
return { ok: false, errors };
|
|
242
|
+
}
|
|
243
|
+
return { ok: true, value };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// src/stages/diff.ts
|
|
247
|
+
function prefixed(prefix, key) {
|
|
248
|
+
return prefix ? `${prefix}${key}` : key;
|
|
249
|
+
}
|
|
250
|
+
function sortByKey(items) {
|
|
251
|
+
return [...items].sort((a, b) => a.key < b.key ? -1 : a.key > b.key ? 1 : 0);
|
|
252
|
+
}
|
|
253
|
+
function computeDiff(input) {
|
|
254
|
+
const prefix = normalizeTargetPrefix(input.target);
|
|
255
|
+
const plannedRedirects = input.redirects.map((r) => ({
|
|
256
|
+
...r,
|
|
257
|
+
fullKey: prefixed(prefix, r.from)
|
|
258
|
+
}));
|
|
259
|
+
const redirectKeys = new Set(plannedRedirects.map((r) => r.fullKey));
|
|
260
|
+
const localFull = input.localFiles.map((f) => ({ ...f, key: prefixed(prefix, f.key) })).filter((f) => !redirectKeys.has(f.key));
|
|
261
|
+
const localKeys = new Set(localFull.map((f) => f.key));
|
|
262
|
+
const remoteByKey = new Map(input.remoteObjects.map((r) => [r.key, r]));
|
|
263
|
+
const toUpload = [];
|
|
264
|
+
const toUpdate = [];
|
|
265
|
+
const toSkip = [];
|
|
266
|
+
for (const file of localFull) {
|
|
267
|
+
const remote = remoteByKey.get(file.key);
|
|
268
|
+
if (!remote) {
|
|
269
|
+
toUpload.push(file);
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
if (remote.etag.includes("-")) {
|
|
273
|
+
toUpdate.push(file);
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
if (remote.etag === file.hash) {
|
|
277
|
+
toSkip.push(file);
|
|
278
|
+
} else {
|
|
279
|
+
toUpdate.push(file);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
let toDelete = [];
|
|
283
|
+
if (input.syncDelete) {
|
|
284
|
+
for (const remote of input.remoteObjects) {
|
|
285
|
+
if (localKeys.has(remote.key))
|
|
286
|
+
continue;
|
|
287
|
+
if (redirectKeys.has(remote.key))
|
|
288
|
+
continue;
|
|
289
|
+
toDelete.push(remote.key);
|
|
290
|
+
}
|
|
291
|
+
toDelete = toDelete.sort();
|
|
292
|
+
}
|
|
293
|
+
return {
|
|
294
|
+
bucket: input.bucket,
|
|
295
|
+
target: input.target,
|
|
296
|
+
toUpload: sortByKey(toUpload),
|
|
297
|
+
toUpdate: sortByKey(toUpdate),
|
|
298
|
+
toSkip: sortByKey(toSkip),
|
|
299
|
+
toDelete,
|
|
300
|
+
redirects: plannedRedirects,
|
|
301
|
+
cloudfront: input.cloudfront
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// src/util/parallel-limit.ts
|
|
306
|
+
async function parallelLimit(items, limit, worker) {
|
|
307
|
+
const results = new Array(items.length);
|
|
308
|
+
let cursor = 0;
|
|
309
|
+
async function next() {
|
|
310
|
+
while (true) {
|
|
311
|
+
const index = cursor++;
|
|
312
|
+
if (index >= items.length)
|
|
313
|
+
return;
|
|
314
|
+
const item = items[index];
|
|
315
|
+
try {
|
|
316
|
+
const value = await worker(item, index);
|
|
317
|
+
results[index] = { ok: true, value, item };
|
|
318
|
+
} catch (error) {
|
|
319
|
+
results[index] = { ok: false, error, item };
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
const workers = Array.from({ length: Math.min(limit, items.length) }, () => next());
|
|
324
|
+
await Promise.all(workers);
|
|
325
|
+
return results;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// src/stages/delete-stale.ts
|
|
329
|
+
import { DeleteObjectsCommand } from "@aws-sdk/client-s3";
|
|
330
|
+
|
|
331
|
+
// src/util/retry.ts
|
|
332
|
+
async function withRetry(fn, options) {
|
|
333
|
+
let lastErr;
|
|
334
|
+
for (let i = 0;i < options.attempts; i++) {
|
|
335
|
+
try {
|
|
336
|
+
return await fn();
|
|
337
|
+
} catch (err) {
|
|
338
|
+
lastErr = err;
|
|
339
|
+
if (i < options.attempts - 1) {
|
|
340
|
+
await new Promise((resolve2) => setTimeout(resolve2, options.baseMs * 4 ** i));
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
throw lastErr;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// src/stages/delete-stale.ts
|
|
348
|
+
var MAX_KEYS_PER_BATCH = 1000;
|
|
349
|
+
async function deleteStale(input) {
|
|
350
|
+
if (input.keys.length === 0)
|
|
351
|
+
return;
|
|
352
|
+
for (let i = 0;i < input.keys.length; i += MAX_KEYS_PER_BATCH) {
|
|
353
|
+
const batch = input.keys.slice(i, i + MAX_KEYS_PER_BATCH);
|
|
354
|
+
const command = new DeleteObjectsCommand({
|
|
355
|
+
Bucket: input.bucket,
|
|
356
|
+
Delete: {
|
|
357
|
+
Objects: batch.map((Key) => ({ Key })),
|
|
358
|
+
Quiet: true
|
|
359
|
+
}
|
|
360
|
+
});
|
|
361
|
+
await withRetry(() => input.client.send(command), input.retry);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// src/stages/invalidate.ts
|
|
366
|
+
import {
|
|
367
|
+
CreateInvalidationCommand
|
|
368
|
+
} from "@aws-sdk/client-cloudfront";
|
|
369
|
+
async function invalidateCloudFront(input) {
|
|
370
|
+
const callerReference = `s3-ship-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
|
371
|
+
const command = new CreateInvalidationCommand({
|
|
372
|
+
DistributionId: input.distributionId,
|
|
373
|
+
InvalidationBatch: {
|
|
374
|
+
CallerReference: callerReference,
|
|
375
|
+
Paths: {
|
|
376
|
+
Quantity: input.paths.length,
|
|
377
|
+
Items: input.paths
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
});
|
|
381
|
+
const response = await input.client.send(command);
|
|
382
|
+
return response.Invalidation?.Id;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// src/stages/redirects.ts
|
|
386
|
+
import { PutObjectCommand } from "@aws-sdk/client-s3";
|
|
387
|
+
async function putRedirect(input) {
|
|
388
|
+
const command = new PutObjectCommand({
|
|
389
|
+
Bucket: input.bucket,
|
|
390
|
+
Key: input.redirect.fullKey,
|
|
391
|
+
Body: "",
|
|
392
|
+
ContentType: "text/html; charset=utf-8",
|
|
393
|
+
WebsiteRedirectLocation: input.redirect.to,
|
|
394
|
+
ChecksumAlgorithm: "CRC32"
|
|
395
|
+
});
|
|
396
|
+
await withRetry(() => input.client.send(command), input.retry);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// src/stages/upload.ts
|
|
400
|
+
import { readFile as readFile2 } from "node:fs/promises";
|
|
401
|
+
import { PutObjectCommand as PutObjectCommand2 } from "@aws-sdk/client-s3";
|
|
402
|
+
|
|
403
|
+
// src/util/content-type.ts
|
|
404
|
+
import { extname as extname2 } from "node:path";
|
|
405
|
+
var MAP = {
|
|
406
|
+
".html": "text/html; charset=utf-8",
|
|
407
|
+
".htm": "text/html; charset=utf-8",
|
|
408
|
+
".css": "text/css; charset=utf-8",
|
|
409
|
+
".js": "application/javascript; charset=utf-8",
|
|
410
|
+
".mjs": "application/javascript; charset=utf-8",
|
|
411
|
+
".json": "application/json; charset=utf-8",
|
|
412
|
+
".map": "application/json; charset=utf-8",
|
|
413
|
+
".xml": "application/xml; charset=utf-8",
|
|
414
|
+
".txt": "text/plain; charset=utf-8",
|
|
415
|
+
".md": "text/markdown; charset=utf-8",
|
|
416
|
+
".svg": "image/svg+xml",
|
|
417
|
+
".png": "image/png",
|
|
418
|
+
".jpg": "image/jpeg",
|
|
419
|
+
".jpeg": "image/jpeg",
|
|
420
|
+
".gif": "image/gif",
|
|
421
|
+
".webp": "image/webp",
|
|
422
|
+
".avif": "image/avif",
|
|
423
|
+
".ico": "image/x-icon",
|
|
424
|
+
".bmp": "image/bmp",
|
|
425
|
+
".woff": "font/woff",
|
|
426
|
+
".woff2": "font/woff2",
|
|
427
|
+
".ttf": "font/ttf",
|
|
428
|
+
".otf": "font/otf",
|
|
429
|
+
".eot": "application/vnd.ms-fontobject",
|
|
430
|
+
".pdf": "application/pdf",
|
|
431
|
+
".mp4": "video/mp4",
|
|
432
|
+
".webm": "video/webm",
|
|
433
|
+
".mp3": "audio/mpeg",
|
|
434
|
+
".wav": "audio/wav",
|
|
435
|
+
".ogg": "audio/ogg",
|
|
436
|
+
".wasm": "application/wasm",
|
|
437
|
+
".zip": "application/zip",
|
|
438
|
+
".gz": "application/gzip"
|
|
439
|
+
};
|
|
440
|
+
function inferContentType(filename) {
|
|
441
|
+
const ext = extname2(filename).toLowerCase();
|
|
442
|
+
return MAP[ext] ?? "application/octet-stream";
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// src/stages/upload.ts
|
|
446
|
+
async function uploadFile(input) {
|
|
447
|
+
const body = await readFile2(input.file.localPath);
|
|
448
|
+
const contentType = inferContentType(input.file.localPath);
|
|
449
|
+
const cacheControl = input.cacheControlFor(input.file.key);
|
|
450
|
+
const command = new PutObjectCommand2({
|
|
451
|
+
Bucket: input.bucket,
|
|
452
|
+
Key: input.file.key,
|
|
453
|
+
Body: body,
|
|
454
|
+
ContentType: contentType,
|
|
455
|
+
CacheControl: cacheControl,
|
|
456
|
+
ChecksumAlgorithm: "CRC32"
|
|
457
|
+
});
|
|
458
|
+
await withRetry(() => input.client.send(command), input.retry);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// src/stages/execute.ts
|
|
462
|
+
async function runUploads(files, input) {
|
|
463
|
+
const results = await parallelLimit(files, input.concurrency, (file) => uploadFile({
|
|
464
|
+
client: input.s3Client,
|
|
465
|
+
bucket: input.plan.bucket,
|
|
466
|
+
file,
|
|
467
|
+
cacheControlFor: input.cacheControlFor,
|
|
468
|
+
retry: input.retry
|
|
469
|
+
}));
|
|
470
|
+
const ok = results.filter((r) => r.ok).length;
|
|
471
|
+
const failures = results.filter((r) => !r.ok).map((r) => ({ key: r.item.key, error: r.error }));
|
|
472
|
+
return { ok, failures };
|
|
473
|
+
}
|
|
474
|
+
async function executePlan(input) {
|
|
475
|
+
const failures = [];
|
|
476
|
+
const uploadResult = await runUploads(input.plan.toUpload, input);
|
|
477
|
+
failures.push(...uploadResult.failures);
|
|
478
|
+
const updateResult = await runUploads(input.plan.toUpdate, input);
|
|
479
|
+
failures.push(...updateResult.failures);
|
|
480
|
+
const redirectResults = await parallelLimit(input.plan.redirects, input.concurrency, (redirect) => putRedirect({
|
|
481
|
+
client: input.s3Client,
|
|
482
|
+
bucket: input.plan.bucket,
|
|
483
|
+
redirect,
|
|
484
|
+
retry: input.retry
|
|
485
|
+
}));
|
|
486
|
+
const redirected = redirectResults.filter((r) => r.ok).length;
|
|
487
|
+
for (const r of redirectResults) {
|
|
488
|
+
if (!r.ok) {
|
|
489
|
+
failures.push({ key: r.item.fullKey, error: r.error });
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
let deleted = 0;
|
|
493
|
+
if (input.plan.toDelete.length > 0) {
|
|
494
|
+
try {
|
|
495
|
+
await deleteStale({
|
|
496
|
+
client: input.s3Client,
|
|
497
|
+
bucket: input.plan.bucket,
|
|
498
|
+
keys: input.plan.toDelete,
|
|
499
|
+
retry: input.retry
|
|
500
|
+
});
|
|
501
|
+
deleted = input.plan.toDelete.length;
|
|
502
|
+
} catch (error) {
|
|
503
|
+
failures.push({ key: "<delete-batch>", error });
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
let invalidationId;
|
|
507
|
+
if (!input.skipInvalidate && input.plan.cloudfront && input.cfClient) {
|
|
508
|
+
try {
|
|
509
|
+
invalidationId = await invalidateCloudFront({
|
|
510
|
+
client: input.cfClient,
|
|
511
|
+
distributionId: input.plan.cloudfront.distributionId,
|
|
512
|
+
paths: input.plan.cloudfront.invalidationPaths
|
|
513
|
+
});
|
|
514
|
+
} catch (error) {
|
|
515
|
+
failures.push({ key: "<cloudfront-invalidation>", error });
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
return {
|
|
519
|
+
uploaded: uploadResult.ok,
|
|
520
|
+
updated: updateResult.ok,
|
|
521
|
+
skipped: input.plan.toSkip.length,
|
|
522
|
+
deleted,
|
|
523
|
+
redirected,
|
|
524
|
+
invalidationId,
|
|
525
|
+
failures
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// src/stages/list-remote.ts
|
|
530
|
+
import { ListObjectsV2Command } from "@aws-sdk/client-s3";
|
|
531
|
+
async function listRemoteFiles(input) {
|
|
532
|
+
const prefix = normalizeTargetPrefix(input.target);
|
|
533
|
+
const results = [];
|
|
534
|
+
let continuationToken;
|
|
535
|
+
do {
|
|
536
|
+
const command = new ListObjectsV2Command({
|
|
537
|
+
Bucket: input.bucket,
|
|
538
|
+
Prefix: prefix || undefined,
|
|
539
|
+
ContinuationToken: continuationToken
|
|
540
|
+
});
|
|
541
|
+
const response = await input.client.send(command);
|
|
542
|
+
for (const item of response.Contents ?? []) {
|
|
543
|
+
if (!item.Key)
|
|
544
|
+
continue;
|
|
545
|
+
results.push({
|
|
546
|
+
key: item.Key,
|
|
547
|
+
size: item.Size ?? 0,
|
|
548
|
+
etag: stripQuotes(item.ETag ?? "")
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
continuationToken = response.NextContinuationToken;
|
|
552
|
+
} while (continuationToken);
|
|
553
|
+
return results;
|
|
554
|
+
}
|
|
555
|
+
function stripQuotes(etag) {
|
|
556
|
+
return etag.replace(/^"+|"+$/g, "");
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// src/stages/scan.ts
|
|
560
|
+
import { createHash } from "node:crypto";
|
|
561
|
+
import { existsSync as existsSync2, statSync } from "node:fs";
|
|
562
|
+
import { readFile as readFile3 } from "node:fs/promises";
|
|
563
|
+
import { sep } from "node:path";
|
|
564
|
+
import { glob } from "tinyglobby";
|
|
565
|
+
var ALWAYS_IGNORE = ["**/.DS_Store", "**/Thumbs.db", "**/.git/**", "**/*.swp"];
|
|
566
|
+
async function scanLocalFiles(input) {
|
|
567
|
+
if (!existsSync2(input.source) || !statSync(input.source).isDirectory()) {
|
|
568
|
+
throw new Error(`[scan] source directory does not exist: ${input.source}`);
|
|
569
|
+
}
|
|
570
|
+
const ignore = [...ALWAYS_IGNORE, ...input.ignore];
|
|
571
|
+
const paths = await glob(["**/*"], {
|
|
572
|
+
cwd: input.source,
|
|
573
|
+
dot: true,
|
|
574
|
+
onlyFiles: true,
|
|
575
|
+
ignore,
|
|
576
|
+
absolute: true
|
|
577
|
+
});
|
|
578
|
+
const files = await Promise.all(paths.map(async (absPath) => {
|
|
579
|
+
const buf = await readFile3(absPath);
|
|
580
|
+
const hash = createHash("md5").update(buf).digest("hex");
|
|
581
|
+
const rel = absPath.slice(input.source.length).replace(/^[\\/]/, "");
|
|
582
|
+
const key = rel.split(sep).join("/");
|
|
583
|
+
return { key, localPath: absPath, size: buf.byteLength, hash };
|
|
584
|
+
}));
|
|
585
|
+
files.sort((a, b) => a.key < b.key ? -1 : a.key > b.key ? 1 : 0);
|
|
586
|
+
return files;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// src/util/glob-match.ts
|
|
590
|
+
function escapeRegex(s) {
|
|
591
|
+
return s.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
592
|
+
}
|
|
593
|
+
function globToRegex(pattern) {
|
|
594
|
+
let out = "";
|
|
595
|
+
let i = 0;
|
|
596
|
+
while (i < pattern.length) {
|
|
597
|
+
const c = pattern[i];
|
|
598
|
+
if (c === "*") {
|
|
599
|
+
if (pattern[i + 1] === "*") {
|
|
600
|
+
out += ".*";
|
|
601
|
+
i += 2;
|
|
602
|
+
if (pattern[i] === "/")
|
|
603
|
+
i++;
|
|
604
|
+
} else {
|
|
605
|
+
out += "[^/]*";
|
|
606
|
+
i++;
|
|
607
|
+
}
|
|
608
|
+
} else if (c === "?") {
|
|
609
|
+
out += "[^/]";
|
|
610
|
+
i++;
|
|
611
|
+
} else {
|
|
612
|
+
out += escapeRegex(c);
|
|
613
|
+
i++;
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
return new RegExp(`^${out}$`);
|
|
617
|
+
}
|
|
618
|
+
function matchGlob(key, pattern) {
|
|
619
|
+
return globToRegex(pattern).test(key);
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
// src/deploy.ts
|
|
623
|
+
var DEFAULT_RETRY = { attempts: 3, baseMs: 200 };
|
|
624
|
+
var DEFAULT_CONCURRENCY = 10;
|
|
625
|
+
async function deploy(options) {
|
|
626
|
+
const loaded = await loadConfigFile({ cwd: options.cwd, configPath: options.configPath });
|
|
627
|
+
const validation = validateConfig(loaded.config);
|
|
628
|
+
if (!validation.ok) {
|
|
629
|
+
const summary = validation.errors.map((e) => ` - [${e.path || "<root>"}] ${e.message}`).join(`
|
|
630
|
+
`);
|
|
631
|
+
throw new Error(`[config] Invalid config (${loaded.sourcePath}):
|
|
632
|
+
${summary}`);
|
|
633
|
+
}
|
|
634
|
+
const resolvedConfig = resolveConfig(validation.value, {
|
|
635
|
+
env: options.env,
|
|
636
|
+
profile: options.profile,
|
|
637
|
+
bucket: options.bucket,
|
|
638
|
+
target: options.target,
|
|
639
|
+
source: options.source,
|
|
640
|
+
syncDelete: options.syncDelete,
|
|
641
|
+
envVars: options.envVars
|
|
642
|
+
});
|
|
643
|
+
const s3Factory = options.s3ClientFactory ?? createS3Client;
|
|
644
|
+
const cfFactory = options.cfClientFactory ?? createCloudFrontClient;
|
|
645
|
+
const s3Client = s3Factory({
|
|
646
|
+
region: resolvedConfig.region,
|
|
647
|
+
profile: resolvedConfig.profile
|
|
648
|
+
});
|
|
649
|
+
const cfClient = resolvedConfig.cloudfront ? cfFactory({
|
|
650
|
+
region: resolvedConfig.region,
|
|
651
|
+
profile: resolvedConfig.profile
|
|
652
|
+
}) : undefined;
|
|
653
|
+
const sourcePath = resolveSourcePath(options.cwd, resolvedConfig.source);
|
|
654
|
+
const [localFiles, remoteObjects] = await Promise.all([
|
|
655
|
+
scanLocalFiles({ source: sourcePath, ignore: resolvedConfig.ignore }),
|
|
656
|
+
listRemoteFiles({
|
|
657
|
+
client: s3Client,
|
|
658
|
+
bucket: resolvedConfig.bucket,
|
|
659
|
+
target: resolvedConfig.target
|
|
660
|
+
})
|
|
661
|
+
]);
|
|
662
|
+
const plan = computeDiff({
|
|
663
|
+
bucket: resolvedConfig.bucket,
|
|
664
|
+
target: resolvedConfig.target,
|
|
665
|
+
localFiles,
|
|
666
|
+
remoteObjects,
|
|
667
|
+
redirects: resolvedConfig.redirects,
|
|
668
|
+
syncDelete: resolvedConfig.syncDelete,
|
|
669
|
+
cloudfront: resolvedConfig.cloudfront
|
|
670
|
+
});
|
|
671
|
+
if (options.dryRun) {
|
|
672
|
+
return { resolvedConfig, plan, dryRun: true, configPath: loaded.sourcePath };
|
|
673
|
+
}
|
|
674
|
+
const cacheControlFor = buildCacheControlMatcher(resolvedConfig.cacheControl);
|
|
675
|
+
const report = await executePlan({
|
|
676
|
+
plan,
|
|
677
|
+
s3Client,
|
|
678
|
+
cfClient,
|
|
679
|
+
cacheControlFor,
|
|
680
|
+
retry: DEFAULT_RETRY,
|
|
681
|
+
concurrency: options.concurrency ?? DEFAULT_CONCURRENCY,
|
|
682
|
+
skipInvalidate: options.skipInvalidate
|
|
683
|
+
});
|
|
684
|
+
return { resolvedConfig, plan, report, dryRun: false, configPath: loaded.sourcePath };
|
|
685
|
+
}
|
|
686
|
+
function resolveSourcePath(cwd, source) {
|
|
687
|
+
if (source.startsWith("/"))
|
|
688
|
+
return source;
|
|
689
|
+
return `${cwd}/${source}`;
|
|
690
|
+
}
|
|
691
|
+
function buildCacheControlMatcher(rules) {
|
|
692
|
+
if (rules.length === 0)
|
|
693
|
+
return () => {
|
|
694
|
+
return;
|
|
695
|
+
};
|
|
696
|
+
return (key) => {
|
|
697
|
+
for (const rule of rules) {
|
|
698
|
+
if (matchGlob(key, rule.match))
|
|
699
|
+
return rule.cacheControl;
|
|
700
|
+
}
|
|
701
|
+
return;
|
|
702
|
+
};
|
|
703
|
+
}
|
|
704
|
+
export {
|
|
705
|
+
deploy,
|
|
706
|
+
defineConfig
|
|
707
|
+
};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { UploadPlan } from './stages/diff.js';
|
|
2
|
+
import type { ExecutionReport } from './stages/execute.js';
|
|
3
|
+
export interface ReportOptions {
|
|
4
|
+
verbose?: boolean;
|
|
5
|
+
}
|
|
6
|
+
export declare function formatPlan(plan: UploadPlan, options?: ReportOptions): string;
|
|
7
|
+
export declare function formatReport(report: ExecutionReport, options?: ReportOptions): string;
|
|
8
|
+
//# sourceMappingURL=reporter.d.ts.map
|