eoas 3.1.3 → 3.2.0-beta2

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.
@@ -72,8 +72,9 @@ class Publish extends core_1.Command {
72
72
  required: false,
73
73
  }),
74
74
  dumpSourcemap: core_1.Flags.boolean({
75
- description: 'Emit Hermes source maps alongside the bundle so the published artifact can be symbolicated by tools like Sentry or PostHog.',
76
- default: false,
75
+ description: 'Emit Hermes source maps alongside the bundle (default: true). Without a source map Hermes bakes a random temp path into the bytecode, so two exports of identical code never hash the same and server-side change detection cannot work. The maps also let tools like Sentry or PostHog symbolicate the published artifact; they stay in the output directory and are never uploaded. Disable with --no-dumpSourcemap.',
76
+ default: true,
77
+ allowNo: true,
77
78
  }),
78
79
  'rollout-percentage': core_1.Flags.integer({
79
80
  min: 1,
@@ -259,7 +260,7 @@ class Publish extends core_1.Command {
259
260
  });
260
261
  log_1.default.withInfo(`expoConfig.json file created in ${outputDir} directory`);
261
262
  const uploadFilesSpinner = (0, ora_1.ora)('📤 Uploading files...').start();
262
- const files = (0, assets_1.computeFilesRequests)(projectDir, outputDir, platform || expoConfig_1.RequestedPlatform.All);
263
+ const files = await (0, assets_1.computeFilesRequests)(projectDir, outputDir, platform || expoConfig_1.RequestedPlatform.All);
263
264
  if (!files.length) {
264
265
  uploadFilesSpinner.fail('No files to upload');
265
266
  process.exit(1);
@@ -268,30 +269,47 @@ class Publish extends core_1.Command {
268
269
  // One group id for the whole run: every platform update of this publish
269
270
  // shares it, so control plane servers list them as a single publish.
270
271
  const publishGroupId = (0, crypto_1.randomUUID)();
272
+ // Collected rather than logged inline: writing to the terminal under a
273
+ // running spinner interleaves with its frames.
274
+ const unchangedPlatforms = [];
271
275
  try {
272
- uploadUrls = await Promise.all(runtimeVersions.map(async ({ runtimeVersion, platform }) => {
276
+ const outcomes = await Promise.all(runtimeVersions.map(async ({ runtimeVersion, platform }) => {
273
277
  if (!runtimeVersion) {
274
278
  throw new Error('Runtime version is not resolved');
275
279
  }
276
- return {
277
- ...(await (0, assets_1.requestUploadUrls)({
278
- body: {
279
- fileNames: files.map(file => file.path),
280
- },
281
- requestUploadUrl: `${serverUrl}/${appId}/requestUploadUrl/${branch}`,
282
- auth: credentials,
280
+ try {
281
+ return {
282
+ ...(await (0, assets_1.requestUploadUrls)({
283
+ body: { files: (0, assets_1.buildUploadFiles)(files, platform) },
284
+ requestUploadUrl: `${serverUrl}/${appId}/requestUploadUrl/${branch}`,
285
+ auth: credentials,
286
+ runtimeVersion,
287
+ platform,
288
+ commitHash,
289
+ message: resolvedMessage,
290
+ rolloutPercentage,
291
+ publishGroup: publishGroupId,
292
+ branch,
293
+ })),
283
294
  runtimeVersion,
284
295
  platform,
285
- commitHash,
286
- message: resolvedMessage,
287
- rolloutPercentage,
288
- publishGroup: publishGroupId,
289
- branch,
290
- })),
291
- runtimeVersion,
292
- platform,
293
- };
296
+ };
297
+ }
298
+ catch (error) {
299
+ if (error instanceof assets_1.NoChangesDetectedError) {
300
+ unchangedPlatforms.push(platform);
301
+ return null;
302
+ }
303
+ throw error;
304
+ }
294
305
  }));
306
+ uploadUrls = outcomes.filter((entry) => {
307
+ return entry !== null;
308
+ });
309
+ if (!uploadUrls.length) {
310
+ uploadFilesSpinner.warn('⚠️ No changes found in the update, nothing to deploy');
311
+ return;
312
+ }
295
313
  // Every path and URL the server handed back is checked here, before a
296
314
  // single file is opened. A server that forges a filePath would otherwise
297
315
  // make the CLI read arbitrary files off this machine and PUT them wherever
@@ -358,6 +376,14 @@ class Publish extends core_1.Command {
358
376
  }
359
377
  }));
360
378
  uploadFilesSpinner.succeed('✅ Files uploaded successfully');
379
+ for (const { platform: uploadedPlatform, uploadRequests } of uploadUrls) {
380
+ const totalFiles = (0, assets_1.buildUploadFiles)(files, uploadedPlatform).length;
381
+ const deduplicated = totalFiles - uploadRequests.length;
382
+ log_1.default.withInfo(`📊 ${uploadedPlatform}: ${uploadRequests.length}/${totalFiles} files uploaded, ${deduplicated} deduplicated (already on the server)`);
383
+ }
384
+ for (const skipped of unchangedPlatforms) {
385
+ log_1.default.withInfo(`⚠️ There is no change in the update for ${skipped}, ignored...`);
386
+ }
361
387
  }
362
388
  catch (e) {
363
389
  uploadFilesSpinner.fail('❌ Failed to upload static files');
@@ -423,9 +449,14 @@ class Publish extends core_1.Command {
423
449
  if (hasSuccess) {
424
450
  log_1.default.withInfo(`🌿 Branch: \`${branch}\``);
425
451
  log_1.default.withInfo(`⏳ Deployed at: \`${new Date().toUTCString()}\`\n`);
452
+ for (const { platform: publishedPlatform, updateId } of uploadUrls) {
453
+ log_1.default.withInfo(`🆔 ${publishedPlatform} update id: \`${updateId}\``);
454
+ }
426
455
  const groupAcknowledged = uploadUrls.every(u => u.publishGroup === publishGroupId);
427
456
  if (groupAcknowledged) {
428
- log_1.default.withInfo(`📦 Publish group: \`${publishGroupId}\``);
457
+ log_1.default.withInfo(`📦 Publish group: \`${publishGroupId}\` (groups the ${uploadUrls
458
+ .map(u => u.platform)
459
+ .join(' + ')} updates of this run)`);
429
460
  }
430
461
  else if (uploadUrls.length > 1) {
431
462
  // Only worth a note when several platforms were published: a single
@@ -1,3 +1,4 @@
1
+ import { Platform } from '@expo/config';
1
2
  import Joi from 'joi';
2
3
  import { Credentials } from './auth';
3
4
  import { RequestedPlatform } from './expoConfig';
@@ -6,12 +7,26 @@ export interface AssetToUpload {
6
7
  path: string;
7
8
  name: string;
8
9
  ext: string;
10
+ hash: string;
11
+ key: string;
12
+ platform: Platform | null;
13
+ isLaunchAsset: boolean;
9
14
  }
10
- export declare function computeFilesRequests(projectDir: string, outputDir: string, requestedPlatform: RequestedPlatform): AssetToUpload[];
15
+ export type FileRole = 'launch' | 'asset' | 'config';
16
+ export interface FileUploadItem {
17
+ path: string;
18
+ hash: string;
19
+ key?: string;
20
+ ext?: string;
21
+ role: FileRole;
22
+ }
23
+ export declare function buildUploadFiles(files: AssetToUpload[], platform: string): FileUploadItem[];
24
+ export declare function computeFilesRequests(projectDir: string, outputDir: string, requestedPlatform: RequestedPlatform): Promise<AssetToUpload[]>;
11
25
  export interface RequestUploadUrlItem {
12
26
  requestUploadUrl: string;
13
27
  fileName: string;
14
28
  filePath: string;
29
+ originalFileName: string;
15
30
  headers?: Record<string, string>;
16
31
  }
17
32
  export interface RequestUploadUrlsResponse {
@@ -41,9 +56,13 @@ export declare function resolveUploadRequests({ uploadRequests, exportDir, manif
41
56
  manifest: AssetToUpload[];
42
57
  }): Promise<ResolvedUploadRequest[]>;
43
58
  export declare function activeRolloutConflictMessage(branch: string): string;
59
+ export declare class NoChangesDetectedError extends Error {
60
+ readonly platform: string;
61
+ constructor(platform: string);
62
+ }
44
63
  export declare function requestUploadUrls({ body, requestUploadUrl, auth, runtimeVersion, platform, commitHash, message, rolloutPercentage, publishGroup, branch, }: {
45
64
  body: {
46
- fileNames: string[];
65
+ files: FileUploadItem[];
47
66
  };
48
67
  requestUploadUrl: string;
49
68
  auth: Credentials;
@@ -1,11 +1,12 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.requestUploadUrls = exports.activeRolloutConflictMessage = exports.resolveUploadRequests = exports.RequestUploadUrlsResponseJoi = exports.computeFilesRequests = exports.MetadataJoi = void 0;
3
+ exports.requestUploadUrls = exports.NoChangesDetectedError = exports.activeRolloutConflictMessage = exports.resolveUploadRequests = exports.RequestUploadUrlsResponseJoi = exports.computeFilesRequests = exports.buildUploadFiles = exports.MetadataJoi = void 0;
4
4
  const tslib_1 = require("tslib");
5
5
  const fs_extra_1 = tslib_1.__importDefault(require("fs-extra"));
6
6
  const joi_1 = tslib_1.__importDefault(require("joi"));
7
7
  const path_1 = tslib_1.__importDefault(require("path"));
8
8
  const auth_1 = require("./auth");
9
+ const crypto_1 = require("./crypto");
9
10
  const expoConfig_1 = require("./expoConfig");
10
11
  const fetch_1 = require("./fetch");
11
12
  const log_1 = tslib_1.__importDefault(require("./log"));
@@ -24,6 +25,26 @@ exports.MetadataJoi = joi_1.default.object({
24
25
  web: fileMetadataJoi,
25
26
  }).required(),
26
27
  }).required();
28
+ // buildUploadFiles is one platform's publish: its launch asset, its assets and
29
+ // the config files, each stamped with its role. This is what tells the server
30
+ // which bundle is which — only the CLI reads metadata.json.
31
+ function buildUploadFiles(files, platform) {
32
+ return files
33
+ .filter(file => file.platform === null || file.platform === platform)
34
+ .map(file => {
35
+ if (file.platform === null) {
36
+ return { path: file.path, hash: file.hash, role: 'config' };
37
+ }
38
+ return {
39
+ path: file.path,
40
+ hash: file.hash,
41
+ key: file.key,
42
+ ext: file.ext,
43
+ role: file.isLaunchAsset ? 'launch' : 'asset',
44
+ };
45
+ });
46
+ }
47
+ exports.buildUploadFiles = buildUploadFiles;
27
48
  function loadMetadata(distRoot) {
28
49
  // eslint-disable-next-line
29
50
  const fileContent = fs_extra_1.default.readFileSync(path_1.default.join(distRoot, 'metadata.json'), 'utf8');
@@ -54,23 +75,65 @@ function loadMetadata(distRoot) {
54
75
  log_1.default.debug(`Loaded ${platforms.length} platform(s): ${platforms.join(', ')}`);
55
76
  return metadata;
56
77
  }
57
- function computeFilesRequests(projectDir, outputDir, requestedPlatform) {
58
- const metadata = loadMetadata(path_1.default.join(projectDir, outputDir));
59
- const assets = [
60
- { path: 'metadata.json', name: 'metadata.json', ext: 'json' },
61
- { path: 'expoConfig.json', name: 'expoConfig.json', ext: 'json' },
78
+ async function digestExportFile(exportRoot, relativePath) {
79
+ const absolutePath = path_1.default.resolve(exportRoot, relativePath);
80
+ if (absolutePath !== exportRoot && !absolutePath.startsWith(exportRoot + path_1.default.sep)) {
81
+ throw new Error(`Refusing to hash "${relativePath}": it resolves outside the export directory.`);
82
+ }
83
+ return await (0, crypto_1.digestFile)(absolutePath);
84
+ }
85
+ async function computeFilesRequests(projectDir, outputDir, requestedPlatform) {
86
+ const exportDir = path_1.default.join(projectDir, outputDir);
87
+ let exportRoot;
88
+ try {
89
+ exportRoot = await fs_extra_1.default.realpath(exportDir);
90
+ }
91
+ catch {
92
+ throw new Error(`Export directory ${exportDir} could not be resolved.`);
93
+ }
94
+ const metadata = loadMetadata(exportRoot);
95
+ const pending = [
96
+ {
97
+ path: 'metadata.json',
98
+ name: 'metadata.json',
99
+ ext: 'json',
100
+ platform: null,
101
+ isLaunchAsset: false,
102
+ },
103
+ {
104
+ path: 'expoConfig.json',
105
+ name: 'expoConfig.json',
106
+ ext: 'json',
107
+ platform: null,
108
+ isLaunchAsset: false,
109
+ },
62
110
  ];
63
111
  for (const platform of Object.keys(metadata.fileMetadata)) {
64
112
  if (requestedPlatform !== expoConfig_1.RequestedPlatform.All && requestedPlatform !== platform) {
65
113
  continue;
66
114
  }
67
115
  const bundle = metadata.fileMetadata[platform].bundle;
68
- assets.push({ path: bundle, name: path_1.default.basename(bundle), ext: 'hbc' });
116
+ pending.push({
117
+ path: bundle,
118
+ name: path_1.default.basename(bundle),
119
+ ext: 'hbc',
120
+ platform,
121
+ isLaunchAsset: true,
122
+ });
69
123
  for (const asset of metadata.fileMetadata[platform].assets) {
70
- assets.push({ path: asset.path, name: path_1.default.basename(asset.path), ext: asset.ext });
124
+ pending.push({
125
+ path: asset.path,
126
+ name: path_1.default.basename(asset.path),
127
+ ext: asset.ext,
128
+ platform,
129
+ isLaunchAsset: false,
130
+ });
71
131
  }
72
132
  }
73
- return assets;
133
+ return await Promise.all(pending.map(async (entry) => ({
134
+ ...entry,
135
+ ...(await digestExportFile(exportRoot, entry.path)),
136
+ })));
74
137
  }
75
138
  exports.computeFilesRequests = computeFilesRequests;
76
139
  // The server dictates which local files the CLI opens and where their bytes are
@@ -91,6 +154,7 @@ const uploadRequestJoi = joi_1.default.object({
91
154
  .required(),
92
155
  fileName: joi_1.default.string().required(),
93
156
  filePath: joi_1.default.string().required(),
157
+ originalFileName: joi_1.default.string().required(),
94
158
  headers: uploadRequestHeadersJoi,
95
159
  // Unknown keys are tolerated so a newer server can add fields without
96
160
  // breaking older CLIs; nothing reads them.
@@ -177,40 +241,41 @@ async function resolveUploadRequests({ uploadRequests, exportDir, manifest, }) {
177
241
  const resolved = [];
178
242
  for (const item of uploadRequests) {
179
243
  assertSafeUploadUrl(item.requestUploadUrl);
180
- assertRelativePathShape(item.filePath);
181
- const manifestEntry = manifestByPath.get(item.filePath);
244
+ const exportPath = item.originalFileName;
245
+ assertRelativePathShape(exportPath);
246
+ const manifestEntry = manifestByPath.get(exportPath);
182
247
  if (!manifestEntry) {
183
- throw new Error(`Refusing to upload "${item.filePath}": the server asked for a file that is not part of this export.`);
248
+ throw new Error(`Refusing to upload "${exportPath}": the server asked for a file that is not part of this export.`);
184
249
  }
185
- if (item.fileName !== path_1.default.basename(item.filePath)) {
186
- throw new Error(`Refusing to upload "${item.filePath}": the server returned the mismatched name "${item.fileName}".`);
250
+ if (item.fileName !== path_1.default.basename(exportPath)) {
251
+ throw new Error(`Refusing to upload "${exportPath}": the server returned the mismatched name "${item.fileName}".`);
187
252
  }
188
- if (seen.has(item.filePath)) {
189
- throw new Error(`The server requested "${item.filePath}" more than once.`);
253
+ if (seen.has(exportPath)) {
254
+ throw new Error(`The server requested "${exportPath}" more than once.`);
190
255
  }
191
- seen.add(item.filePath);
192
- const absolutePath = path_1.default.resolve(exportRoot, item.filePath);
256
+ seen.add(exportPath);
257
+ const absolutePath = path_1.default.resolve(exportRoot, exportPath);
193
258
  // Unreachable on POSIX: a path with no '..' segment and no leading separator
194
259
  // cannot resolve out of the root. Kept for the Windows drive-relative case
195
260
  // ("C:file" when the export root sits on another drive) and as a backstop if
196
261
  // the checks above are ever relaxed.
197
262
  if (absolutePath !== exportRoot && !absolutePath.startsWith(exportRoot + path_1.default.sep)) {
198
- throw new Error(`Refusing to upload "${item.filePath}": it resolves outside the export directory.`);
263
+ throw new Error(`Refusing to upload "${exportPath}": it resolves outside the export directory.`);
199
264
  }
200
265
  let realPath;
201
266
  try {
202
267
  realPath = await fs_extra_1.default.realpath(absolutePath);
203
268
  }
204
269
  catch {
205
- throw new Error(`File ${item.filePath} not found in the export directory.`);
270
+ throw new Error(`File ${exportPath} not found in the export directory.`);
206
271
  }
207
272
  // The root is already canonical, so any difference here means a symlink was
208
273
  // traversed, either as the file itself or as one of its parent directories.
209
274
  if (realPath !== absolutePath) {
210
- throw new Error(`Refusing to upload "${item.filePath}": it is or goes through a symlink.`);
275
+ throw new Error(`Refusing to upload "${exportPath}": it is or goes through a symlink.`);
211
276
  }
212
277
  if (!(await fs_extra_1.default.lstat(absolutePath)).isFile()) {
213
- throw new Error(`Refusing to upload "${item.filePath}": it is not a regular file.`);
278
+ throw new Error(`Refusing to upload "${exportPath}": it is not a regular file.`);
214
279
  }
215
280
  resolved.push({ item, absolutePath, manifestEntry });
216
281
  }
@@ -221,6 +286,15 @@ function activeRolloutConflictMessage(branch) {
221
286
  return `A progressive rollout is already active for branch "${branch}" on this runtime version. End or revert it from the dashboard before publishing a new update.`;
222
287
  }
223
288
  exports.activeRolloutConflictMessage = activeRolloutConflictMessage;
289
+ class NoChangesDetectedError extends Error {
290
+ platform;
291
+ constructor(platform) {
292
+ super(`There is no change in the update for ${platform}`);
293
+ this.platform = platform;
294
+ this.name = 'NoChangesDetectedError';
295
+ }
296
+ }
297
+ exports.NoChangesDetectedError = NoChangesDetectedError;
224
298
  async function requestUploadUrls({ body, requestUploadUrl, auth, runtimeVersion, platform, commitHash, message, rolloutPercentage, publishGroup, branch, }) {
225
299
  const uploadUrl = new URL(requestUploadUrl);
226
300
  uploadUrl.searchParams.set('runtimeVersion', runtimeVersion);
@@ -247,6 +321,9 @@ async function requestUploadUrls({ body, requestUploadUrl, auth, runtimeVersion,
247
321
  if (response.status === 409) {
248
322
  throw new Error(activeRolloutConflictMessage(branch));
249
323
  }
324
+ if (response.status === 406) {
325
+ throw new NoChangesDetectedError(platform);
326
+ }
250
327
  if (!response.ok) {
251
328
  const text = await response.text();
252
329
  throw new Error(`Failed to request upload URL: ${text}${(0, auth_1.missingEooTokenHint)(response.status)}`);
@@ -0,0 +1,8 @@
1
+ /// <reference types="node" />
2
+ /// <reference types="node" />
3
+ export declare function toBase64Url(buffer: Buffer): string;
4
+ export interface FileDigest {
5
+ hash: string;
6
+ key: string;
7
+ }
8
+ export declare function digestFile(filePath: string): Promise<FileDigest>;
@@ -0,0 +1,27 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.digestFile = exports.toBase64Url = void 0;
4
+ const tslib_1 = require("tslib");
5
+ const crypto_1 = require("crypto");
6
+ const fs_1 = tslib_1.__importDefault(require("fs"));
7
+ // Same encoding as internal/crypto.GetBase64URLEncoding: the string
8
+ // shapeManifestAsset puts on ManifestAsset.hash.
9
+ function toBase64Url(buffer) {
10
+ return buffer.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
11
+ }
12
+ exports.toBase64Url = toBase64Url;
13
+ async function digestFile(filePath) {
14
+ const sha256 = (0, crypto_1.createHash)('sha256');
15
+ const md5 = (0, crypto_1.createHash)('md5');
16
+ await new Promise((resolve, reject) => {
17
+ fs_1.default.createReadStream(filePath)
18
+ .on('error', reject)
19
+ .on('data', chunk => {
20
+ sha256.update(chunk);
21
+ md5.update(chunk);
22
+ })
23
+ .on('end', resolve);
24
+ });
25
+ return { hash: toBase64Url(sha256.digest()), key: md5.digest('hex') };
26
+ }
27
+ exports.digestFile = digestFile;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eoas",
3
- "version": "3.1.3",
3
+ "version": "3.2.0-beta2",
4
4
  "main": "index.js",
5
5
  "scripts": {
6
6
  "build": "tsc --project tsconfig.json",