@everystack/cli 0.2.17 → 0.2.18
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/cli/aws.ts +29 -0
- package/src/cli/commands/update.ts +54 -8
- package/src/handler/publish.ts +25 -5
package/package.json
CHANGED
package/src/cli/aws.ts
CHANGED
|
@@ -42,6 +42,35 @@ export async function uploadToS3(
|
|
|
42
42
|
}));
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
export async function headS3(
|
|
46
|
+
region: string,
|
|
47
|
+
bucket: string,
|
|
48
|
+
key: string,
|
|
49
|
+
): Promise<{ etag: string; size?: number } | null> {
|
|
50
|
+
const client = await getS3(region);
|
|
51
|
+
const { HeadObjectCommand } = await import('@aws-sdk/client-s3');
|
|
52
|
+
try {
|
|
53
|
+
const response = await client.send(new HeadObjectCommand({
|
|
54
|
+
Bucket: bucket,
|
|
55
|
+
Key: key,
|
|
56
|
+
}));
|
|
57
|
+
return {
|
|
58
|
+
etag: response.ETag || '',
|
|
59
|
+
size: response.ContentLength,
|
|
60
|
+
};
|
|
61
|
+
} catch (err: unknown) {
|
|
62
|
+
if (err && typeof err === 'object' && 'name' in err) {
|
|
63
|
+
const name = (err as { name: string }).name;
|
|
64
|
+
if (name === 'NotFound' || name === '404') return null;
|
|
65
|
+
}
|
|
66
|
+
if (err && typeof err === 'object' && '$metadata' in err) {
|
|
67
|
+
const meta = (err as { $metadata: { httpStatusCode?: number } }).$metadata;
|
|
68
|
+
if (meta?.httpStatusCode === 404) return null;
|
|
69
|
+
}
|
|
70
|
+
throw err;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
45
74
|
export async function getFromS3(
|
|
46
75
|
region: string,
|
|
47
76
|
bucket: string,
|
|
@@ -6,7 +6,7 @@ import { pipeline } from 'node:stream/promises';
|
|
|
6
6
|
import { createWriteStream } from 'node:fs';
|
|
7
7
|
import { spawn } from 'node:child_process';
|
|
8
8
|
import { resolveConfig, type CliConfig } from '../config.js';
|
|
9
|
-
import { uploadToS3, invokeAction } from '../aws.js';
|
|
9
|
+
import { uploadToS3, headS3, invokeAction } from '../aws.js';
|
|
10
10
|
import { step, success, warn, fail, info } from '../output.js';
|
|
11
11
|
import { exportApp } from '../utils/export.js';
|
|
12
12
|
import { walkDirectory } from '../utils/walk.js';
|
|
@@ -186,7 +186,10 @@ export async function updateCommand(flags: UpdateFlags & Record<string, string>)
|
|
|
186
186
|
const branchName = branch || channel;
|
|
187
187
|
const storagePrefix = `releases/${branchName}/${runtimeVersion}/${groupId}/${platform}`;
|
|
188
188
|
|
|
189
|
-
// Upload each asset directly to S3 with content hashing
|
|
189
|
+
// Upload each asset directly to S3 with content hashing.
|
|
190
|
+
// Non-launch assets use a shared content-addressed store (assets/{hash}{ext})
|
|
191
|
+
// so identical files across releases are stored once. Launch assets (JS bundles)
|
|
192
|
+
// remain per-release for rollback isolation.
|
|
190
193
|
const uploaded: Array<{
|
|
191
194
|
path: string;
|
|
192
195
|
s3Key: string;
|
|
@@ -199,12 +202,42 @@ export async function updateCommand(flags: UpdateFlags & Record<string, string>)
|
|
|
199
202
|
}> = [];
|
|
200
203
|
|
|
201
204
|
const UPLOAD_CONCURRENCY = 6;
|
|
205
|
+
let newAssets = 0;
|
|
206
|
+
let skippedAssets = 0;
|
|
202
207
|
for (let i = 0; i < assets.length; i += UPLOAD_CONCURRENCY) {
|
|
203
208
|
const batch = assets.slice(i, i + UPLOAD_CONCURRENCY);
|
|
204
209
|
try {
|
|
205
210
|
await Promise.all(batch.map(async (asset) => {
|
|
206
211
|
const data = Buffer.from(asset.data, 'base64');
|
|
207
|
-
const
|
|
212
|
+
const sha256 = crypto.createHash('sha256').update(data).digest('base64')
|
|
213
|
+
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
214
|
+
const md5 = crypto.createHash('md5').update(data).digest('hex');
|
|
215
|
+
|
|
216
|
+
// Launch assets → per-release path (changes every build, needs rollback isolation)
|
|
217
|
+
// Non-launch assets → shared content-addressed path (fonts, images — rarely change)
|
|
218
|
+
const s3Key = asset.isLaunchAsset
|
|
219
|
+
? `${storagePrefix}/${asset.path}`
|
|
220
|
+
: `assets/${sha256}${asset.fileExtension}`;
|
|
221
|
+
|
|
222
|
+
// Skip upload if shared asset already exists
|
|
223
|
+
if (!asset.isLaunchAsset) {
|
|
224
|
+
const existing = await headS3(config.region, config.updatesBucket, s3Key);
|
|
225
|
+
if (existing) {
|
|
226
|
+
skippedAssets++;
|
|
227
|
+
uploaded.push({
|
|
228
|
+
path: asset.path,
|
|
229
|
+
s3Key,
|
|
230
|
+
contentHash: sha256,
|
|
231
|
+
filename: md5,
|
|
232
|
+
mimeType: asset.contentType,
|
|
233
|
+
fileExtension: asset.fileExtension,
|
|
234
|
+
isLaunchAsset: asset.isLaunchAsset,
|
|
235
|
+
sizeBytes: data.length,
|
|
236
|
+
});
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
208
241
|
await uploadToS3(
|
|
209
242
|
config.region,
|
|
210
243
|
config.updatesBucket,
|
|
@@ -212,9 +245,7 @@ export async function updateCommand(flags: UpdateFlags & Record<string, string>)
|
|
|
212
245
|
data,
|
|
213
246
|
asset.contentType,
|
|
214
247
|
);
|
|
215
|
-
|
|
216
|
-
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
217
|
-
const md5 = crypto.createHash('md5').update(data).digest('hex');
|
|
248
|
+
newAssets++;
|
|
218
249
|
uploaded.push({
|
|
219
250
|
path: asset.path,
|
|
220
251
|
s3Key,
|
|
@@ -258,7 +289,11 @@ export async function updateCommand(flags: UpdateFlags & Record<string, string>)
|
|
|
258
289
|
process.exit(1);
|
|
259
290
|
}
|
|
260
291
|
if (result?.groupId && !groupId) groupId = result.groupId;
|
|
261
|
-
|
|
292
|
+
if (skippedAssets > 0) {
|
|
293
|
+
success(`${platform}: ${newAssets} new, ${skippedAssets} unchanged (skipped)`);
|
|
294
|
+
} else {
|
|
295
|
+
success(`${platform}: ${uploaded.length} assets`);
|
|
296
|
+
}
|
|
262
297
|
} catch (err: any) {
|
|
263
298
|
fail(`Registration failed for ${platform}: ${err.message}`);
|
|
264
299
|
info('Ensure your IAM user/role has lambda:InvokeFunction permission.');
|
|
@@ -345,11 +380,18 @@ export async function updateCommand(flags: UpdateFlags & Record<string, string>)
|
|
|
345
380
|
step(`Uploading ${clientAssets.length} client assets...`);
|
|
346
381
|
const UPLOAD_CONCURRENCY = 10;
|
|
347
382
|
let uploaded = 0;
|
|
383
|
+
let skipped = 0;
|
|
348
384
|
|
|
349
385
|
for (let i = 0; i < clientAssets.length; i += UPLOAD_CONCURRENCY) {
|
|
350
386
|
const batch = clientAssets.slice(i, i + UPLOAD_CONCURRENCY);
|
|
351
387
|
try {
|
|
352
388
|
await Promise.all(batch.map(async (asset) => {
|
|
389
|
+
// Skip upload if asset already exists (content-hash filenames = identical content)
|
|
390
|
+
const existing = await headS3(config.region, config.clientBundlesBucket, asset.relativePath);
|
|
391
|
+
if (existing) {
|
|
392
|
+
skipped++;
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
353
395
|
const data = Buffer.from(asset.data, 'base64');
|
|
354
396
|
await uploadToS3(
|
|
355
397
|
config.region,
|
|
@@ -367,7 +409,11 @@ export async function updateCommand(flags: UpdateFlags & Record<string, string>)
|
|
|
367
409
|
process.exit(1);
|
|
368
410
|
}
|
|
369
411
|
}
|
|
370
|
-
|
|
412
|
+
if (skipped > 0) {
|
|
413
|
+
success(`${uploaded} new, ${skipped} unchanged (skipped)`);
|
|
414
|
+
} else {
|
|
415
|
+
success(`${uploaded}/${clientAssets.length} assets uploaded to s3://${config.clientBundlesBucket}/`);
|
|
416
|
+
}
|
|
371
417
|
}
|
|
372
418
|
|
|
373
419
|
// 4. Write release manifests directly to S3 (source of truth for SSR).
|
package/src/handler/publish.ts
CHANGED
|
@@ -304,20 +304,40 @@ export async function handlePublish(request: Request, options: UpdatesHandlerOpt
|
|
|
304
304
|
const groupId = body.groupId || crypto.randomUUID();
|
|
305
305
|
const storagePrefix = `releases/${branchName}/${body.runtimeVersion}/${groupId}/${body.platform}`;
|
|
306
306
|
|
|
307
|
-
// Upload assets to storage, building post-upload records
|
|
307
|
+
// Upload assets to storage, building post-upload records.
|
|
308
|
+
// Non-launch assets use a shared content-addressed store (assets/{hash}{ext})
|
|
309
|
+
// so identical files across releases are stored once. Launch assets (JS bundles)
|
|
310
|
+
// remain per-release for rollback isolation.
|
|
308
311
|
const uploadedAssets: RegisterMobileAsset[] = [];
|
|
309
312
|
for (const asset of body.assets) {
|
|
310
313
|
const data = Buffer.from(asset.data, 'base64');
|
|
311
|
-
const
|
|
312
|
-
|
|
314
|
+
const isLaunch = asset.isLaunchAsset ?? false;
|
|
315
|
+
const contentHash = sha256Base64Url(data);
|
|
316
|
+
const ext = asset.fileExtension.startsWith('.') ? asset.fileExtension : `.${asset.fileExtension}`;
|
|
317
|
+
|
|
318
|
+
// Launch assets → per-release path; non-launch → shared content-addressed path
|
|
319
|
+
const s3Key = isLaunch
|
|
320
|
+
? `${storagePrefix}/${asset.path}`
|
|
321
|
+
: `assets/${contentHash}${ext}`;
|
|
322
|
+
|
|
323
|
+
// Skip upload if shared asset already exists
|
|
324
|
+
if (!isLaunch) {
|
|
325
|
+
const exists = await options.storage.exists(s3Key);
|
|
326
|
+
if (!exists) {
|
|
327
|
+
await options.storage.put(s3Key, data, asset.contentType);
|
|
328
|
+
}
|
|
329
|
+
} else {
|
|
330
|
+
await options.storage.put(s3Key, data, asset.contentType);
|
|
331
|
+
}
|
|
332
|
+
|
|
313
333
|
uploadedAssets.push({
|
|
314
334
|
path: asset.path,
|
|
315
335
|
s3Key,
|
|
316
|
-
contentHash
|
|
336
|
+
contentHash,
|
|
317
337
|
filename: md5Hex(data),
|
|
318
338
|
mimeType: asset.contentType,
|
|
319
339
|
fileExtension: asset.fileExtension,
|
|
320
|
-
isLaunchAsset:
|
|
340
|
+
isLaunchAsset: isLaunch,
|
|
321
341
|
sizeBytes: data.length,
|
|
322
342
|
});
|
|
323
343
|
}
|