react-native-update 10.48.3 → 10.50.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.
Files changed (41) hide show
  1. package/BUNDLEHASH_MIGRATION.md +355 -0
  2. package/NATIVE_CHECKUPDATE_DESIGN.md +192 -0
  3. package/android/jni/Android.mk +1 -0
  4. package/android/lib/arm64-v8a/librnupdate.so +0 -0
  5. package/android/lib/armeabi-v7a/librnupdate.so +0 -0
  6. package/android/lib/x86/librnupdate.so +0 -0
  7. package/android/lib/x86_64/librnupdate.so +0 -0
  8. package/android/src/main/java/cn/reactnative/modules/update/BundledResourceCopier.java +42 -9
  9. package/android/src/main/java/cn/reactnative/modules/update/DownloadTask.java +22 -1
  10. package/android/src/main/java/cn/reactnative/modules/update/PatchFailedException.java +16 -0
  11. package/android/src/main/java/cn/reactnative/modules/update/UpdateContext.java +125 -0
  12. package/android/src/main/java/cn/reactnative/modules/update/UpdateModuleImpl.java +23 -3
  13. package/android/src/newarch/cn/reactnative/modules/update/UpdateModule.java +5 -0
  14. package/android/src/oldarch/cn/reactnative/modules/update/UpdateModule.java +5 -0
  15. package/cpp/patch_core/digest.cpp +230 -0
  16. package/cpp/patch_core/digest.h +61 -0
  17. package/cpp/patch_core/patch_core.cpp +17 -1
  18. package/cpp/patch_core/patch_core.h +8 -0
  19. package/cpp/patch_core/tests/patch_core_test.cpp +169 -0
  20. package/harmony/pushy/src/main/cpp/CMakeLists.txt +1 -0
  21. package/harmony/pushy/src/main/cpp/pushy.cpp +94 -0
  22. package/harmony/pushy/src/main/ets/DownloadTask.ts +85 -0
  23. package/harmony/pushy/src/main/ets/NativePatchCore.ts +4 -0
  24. package/harmony/pushy/src/main/ets/PushyTurboModule.ts +11 -0
  25. package/harmony/pushy/src/main/ets/UpdateContext.ts +79 -4
  26. package/harmony/pushy.har +0 -0
  27. package/ios/RCTPushy/RCTPushy.mm +116 -7
  28. package/package.json +1 -1
  29. package/react-native-update.podspec +1 -0
  30. package/src/NativePushy.ts +6 -0
  31. package/src/client.ts +119 -7
  32. package/src/core.ts +27 -0
  33. package/src/endpoint.ts +98 -51
  34. package/src/error.ts +4 -0
  35. package/src/isInRollout.ts +2 -1
  36. package/src/locales/en.ts +6 -0
  37. package/src/locales/zh.ts +6 -0
  38. package/src/provider.tsx +36 -7
  39. package/src/telemetry.ts +8 -0
  40. package/src/type.ts +34 -3
  41. package/src/utils.ts +13 -0
@@ -2,6 +2,7 @@
2
2
  #import "RCTPushyDownloader.h"
3
3
  #import "ZipArchive.h"
4
4
  #include "../../cpp/patch_core/archive_patch_core.h"
5
+ #include "../../cpp/patch_core/digest.h"
5
6
  #include "../../cpp/patch_core/hbc_transform_wire.h"
6
7
  #include "../../cpp/patch_core/error_codes.h"
7
8
  #include "../../cpp/patch_core/patch_core.h"
@@ -32,6 +33,10 @@ static NSString *const keyHashInfo = @"REACTNATIVECN_PUSHY_HASH_";
32
33
  static NSString *const keyFirstLoadMarked = @"REACTNATIVECN_PUSHY_FIRSTLOADMARKED_KEY";
33
34
  static NSString *const keyRolledBackMarked = @"REACTNATIVECN_PUSHY_ROLLEDBACKMARKED_KEY";
34
35
  static NSString *const KeyPackageUpdatedMarked = @"REACTNATIVECN_PUSHY_ISPACKAGEUPDATEDMARKED_KEY";
36
+ // bundleHash cache: "<cacheKey>|<sha256hex>" where cacheKey identifies the
37
+ // installed binary (packageVersion + embedded bundle size + mtime). Recomputed
38
+ // only when the key changes, i.e. once per install.
39
+ static NSString *const keyBundleHashCache = @"REACTNATIVECN_PUSHY_BUNDLEHASH_KEY";
35
40
  static NSString *const PushyErrorDomain = @"cn.reactnative.pushy";
36
41
 
37
42
  // file def
@@ -134,6 +139,25 @@ static BOOL PushyStringIsBlank(NSString *value) {
134
139
  return [[value stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]] length] == 0;
135
140
  }
136
141
 
142
+ // Server-provided identifiers (hash/originHash) are used as child names under
143
+ // the download root; anything that could resolve outside of it (path
144
+ // separators, "..", ".") must be rejected before touching the filesystem.
145
+ static BOOL PushyIsSafePathComponent(NSString *value) {
146
+ if (PushyStringIsBlank(value)) {
147
+ return NO;
148
+ }
149
+ if ([value isEqualToString:@"."] || [value isEqualToString:@".."]) {
150
+ return NO;
151
+ }
152
+ if ([value containsString:@"/"] || [value containsString:@"\\"]) {
153
+ return NO;
154
+ }
155
+ if ([value rangeOfString:@"\0"].location != NSNotFound) {
156
+ return NO;
157
+ }
158
+ return YES;
159
+ }
160
+
137
161
  static void PushyRejectError(RCTPromiseRejectBlock reject, NSError *error) {
138
162
  // Prefer the stable cross-platform code (error_codes.h); fall back to the
139
163
  // numeric NSError code for system errors that were not classified.
@@ -157,15 +181,28 @@ static pushy::patch::PatchManifest PushyPatchManifestFromJson(NSDictionary *json
157
181
  pushy::patch::PatchManifest manifest;
158
182
 
159
183
  NSDictionary *copies = json[@"copies"];
184
+ // Content checksum per copy target ("copiesCrc", pdiff manifests from
185
+ // CLI >= 2.21.2). The patch core verifies the copy source against it and
186
+ // fails the patch on mismatch, so the JS strategy chain falls back to the
187
+ // full package instead of copying drifted bytes from a rebuilt binary.
188
+ NSDictionary *copiesCrc = json[@"copiesCrc"];
189
+ if (![copiesCrc isKindOfClass:[NSDictionary class]]) {
190
+ copiesCrc = nil;
191
+ }
160
192
  for (NSString *to in copies) {
161
193
  NSString *from = copies[to];
162
194
  if (from.length <= 0) {
163
195
  from = to;
164
196
  }
165
- manifest.copies.push_back(pushy::patch::CopyOperation{
166
- PushyToStdString(from),
167
- PushyToStdString(to),
168
- });
197
+ pushy::patch::CopyOperation operation;
198
+ operation.from = PushyToStdString(from);
199
+ operation.to = PushyToStdString(to);
200
+ NSNumber *expectedCrc = copiesCrc[to];
201
+ if ([expectedCrc isKindOfClass:[NSNumber class]]) {
202
+ operation.has_expected_crc = true;
203
+ operation.expected_crc = (uint32_t)[expectedCrc unsignedLongLongValue];
204
+ }
205
+ manifest.copies.push_back(operation);
169
206
  }
170
207
 
171
208
  NSDictionary *deletes = json[@"deletes"];
@@ -243,6 +280,7 @@ static void PushyApplyStateToDefaults(NSUserDefaults *defaults, const pushy::sta
243
280
  callback:(void (^)(NSError *error))callback;
244
281
  - (BOOL)switchVersion:(NSString *)hash error:(NSError **)error;
245
282
  - (BOOL)ensureDirectoryExistsAtPath:(NSString *)path;
283
+ + (void)excludeFromBackup:(NSString *)path;
246
284
  - (void)unzipFileAtPath:(NSString *)path
247
285
  toDestination:(NSString *)destination
248
286
  completionHandler:(void (^)(NSError *error))completionHandler;
@@ -512,6 +550,56 @@ RCT_EXPORT_METHOD(restartApp:(RCTPromiseResolveBlock)resolve
512
550
  resolve(@true);
513
551
  }
514
552
 
553
+ RCT_EXPORT_METHOD(getBundleHash:(RCTPromiseResolveBlock)resolve
554
+ rejecter:(RCTPromiseRejectBlock)reject)
555
+ {
556
+ // bundleHash = sha256 of the bundle embedded in the binary — the identity
557
+ // of the binary itself, not of whatever hot update is currently running.
558
+ // It must hash exactly the bytes pdiff patches from (binaryBundleURL, the
559
+ // pdiff fromBundle). Lazily computed once per install, cached in defaults.
560
+ // Never rejects: an empty string means "unknown" and the server falls back
561
+ // to the buildTime heuristic.
562
+ #if DEBUG
563
+ // Metro serves the bundle in debug; the embedded file (if any) is not what
564
+ // is running. Mirrors the dev behaviour of buildTime.
565
+ resolve(@"");
566
+ #else
567
+ dispatch_async(_fileQueue, ^{
568
+ NSString *path = [[RCTPushy binaryBundleURL] path];
569
+ if (path == nil) {
570
+ resolve(@"");
571
+ return;
572
+ }
573
+ NSDictionary *attributes =
574
+ [[NSFileManager defaultManager] attributesOfItemAtPath:path error:nil];
575
+ if (attributes == nil) {
576
+ resolve(@"");
577
+ return;
578
+ }
579
+ NSString *cacheKey = [NSString stringWithFormat:@"%@|%llu|%.0f",
580
+ [RCTPushy packageVersion],
581
+ attributes.fileSize,
582
+ [attributes.fileModificationDate timeIntervalSince1970]];
583
+
584
+ NSUserDefaults *defaults = PushyDefaults();
585
+ NSString *cached = [defaults stringForKey:keyBundleHashCache];
586
+ NSString *cachedPrefix = [cacheKey stringByAppendingString:@"|"];
587
+ if ([cached hasPrefix:cachedPrefix]) {
588
+ resolve([cached substringFromIndex:cachedPrefix.length]);
589
+ return;
590
+ }
591
+
592
+ NSString *hash = PushyFromStdString(
593
+ pushy::digest::Sha256File(PushyToStdString(path))) ?: @"";
594
+ if (hash.length > 0) {
595
+ [defaults setObject:[cachedPrefix stringByAppendingString:hash]
596
+ forKey:keyBundleHashCache];
597
+ }
598
+ resolve(hash);
599
+ });
600
+ #endif
601
+ }
602
+
515
603
  RCT_EXPORT_METHOD(markSuccess:(RCTPromiseResolveBlock)resolve
516
604
  rejecter:(RCTPromiseRejectBlock)reject)
517
605
  {
@@ -639,12 +727,12 @@ RCT_EXPORT_METHOD(resetToPackagedBundle:(RCTPromiseResolveBlock)resolve
639
727
  NSString *updateUrl = PushyOptionString(options, @"updateUrl");
640
728
  NSString *hash = PushyOptionString(options, @"hash");
641
729
 
642
- if (PushyStringIsBlank(updateUrl) || PushyStringIsBlank(hash)) {
730
+ if (PushyStringIsBlank(updateUrl) || !PushyIsSafePathComponent(hash)) {
643
731
  callback(PushyErrorWithCode(pushy::error_codes::kInvalidOptions, ERROR_OPTIONS));
644
732
  return;
645
733
  }
646
734
  NSString *originHash = PushyOptionString(options, @"originHash");
647
- if (type == PushyTypePatchFromPpk && PushyStringIsBlank(originHash)) {
735
+ if (type == PushyTypePatchFromPpk && !PushyIsSafePathComponent(originHash)) {
648
736
  callback(PushyErrorWithCode(pushy::error_codes::kInvalidOptions, ERROR_OPTIONS));
649
737
  return;
650
738
  }
@@ -838,7 +926,7 @@ RCT_EXPORT_METHOD(resetToPackagedBundle:(RCTPromiseResolveBlock)resolve
838
926
 
839
927
  - (BOOL)switchVersion:(NSString *)hash error:(NSError **)error
840
928
  {
841
- if (PushyStringIsBlank(hash)) {
929
+ if (!PushyIsSafePathComponent(hash)) {
842
930
  if (error != NULL) {
843
931
  *error = PushyErrorWithCode(pushy::error_codes::kInvalidOptions, ERROR_OPTIONS);
844
932
  }
@@ -867,6 +955,10 @@ RCT_EXPORT_METHOD(resetToPackagedBundle:(RCTPromiseResolveBlock)resolve
867
955
  NSFileManager *fileManager = [NSFileManager defaultManager];
868
956
  BOOL isDirectory = NO;
869
957
  if ([fileManager fileExistsAtPath:path isDirectory:&isDirectory]) {
958
+ if (isDirectory) {
959
+ // Directories created by older versions never got the flag.
960
+ [RCTPushy excludeFromBackup:path];
961
+ }
870
962
  return isDirectory;
871
963
  }
872
964
 
@@ -878,10 +970,27 @@ RCT_EXPORT_METHOD(resetToPackagedBundle:(RCTPromiseResolveBlock)resolve
878
970
  if (!success && error != nil) {
879
971
  RCTLogWarn(@"Pushy create directory error: %@", error.localizedDescription);
880
972
  }
973
+ if (success) {
974
+ [RCTPushy excludeFromBackup:path];
975
+ }
881
976
 
882
977
  return success;
883
978
  }
884
979
 
980
+ // Everything under rctpushy is re-downloadable, and Application Support is
981
+ // backed up to iCloud by default — Apple requires such content to be
982
+ // excluded from backups.
983
+ + (void)excludeFromBackup:(NSString *)path
984
+ {
985
+ NSURL *url = [NSURL fileURLWithPath:path isDirectory:YES];
986
+ NSError *error = nil;
987
+ if (![url setResourceValue:@YES
988
+ forKey:NSURLIsExcludedFromBackupKey
989
+ error:&error]) {
990
+ RCTLogWarn(@"Pushy exclude from backup error: %@", error.localizedDescription);
991
+ }
992
+ }
993
+
885
994
  - (void)unzipFileAtPath:(NSString *)path
886
995
  toDestination:(NSString *)destination
887
996
  completionHandler:(void (^)(NSError *error))completionHandler
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-update",
3
- "version": "10.48.3",
3
+ "version": "10.50.0",
4
4
  "description": "react-native hot update",
5
5
  "main": "src/index",
6
6
  "types": "src/index.ts",
@@ -111,6 +111,7 @@ Pod::Spec.new do |s|
111
111
  s.subspec 'RCTPushy' do |ss|
112
112
  ss.source_files = ['ios/RCTPushy/*.{h,m,mm}',
113
113
  'cpp/patch_core/archive_patch_core.{h,cpp}',
114
+ 'cpp/patch_core/digest.{h,cpp}',
114
115
  'cpp/patch_core/hbc_transform.{h,cpp}',
115
116
  'cpp/patch_core/hbc_transform_wire.{h,cpp}',
116
117
  'cpp/patch_core/patch_core.{h,cpp}',
@@ -20,6 +20,12 @@ export interface Spec extends TurboModule {
20
20
  restartApp(): Promise<void>;
21
21
  setNeedUpdate(options: { hash: string }): Promise<void>;
22
22
  markSuccess(): Promise<void>;
23
+ /**
24
+ * sha256 of the JS bundle embedded in the binary (the pdiff source), lazily
25
+ * computed and cached natively. Resolves to an empty string when unknown
26
+ * (debug build, no embedded bundle, hash failure) — never rejects.
27
+ */
28
+ getBundleHash(): Promise<string>;
23
29
  resetToPackagedBundle(): Promise<void>;
24
30
  downloadPatchFromPpk(options: {
25
31
  updateUrl: string;
package/src/client.ts CHANGED
@@ -8,6 +8,7 @@ import {
8
8
  cInfo,
9
9
  currentVersion,
10
10
  currentVersionInfo,
11
+ getBundleHash,
11
12
  isFirstTime,
12
13
  isRolledBack,
13
14
  PushyModule,
@@ -51,6 +52,7 @@ import {
51
52
  noop,
52
53
  promiseAny,
53
54
  testUrls,
55
+ warn,
54
56
  } from './utils';
55
57
 
56
58
  /**
@@ -99,7 +101,7 @@ const excludeConfiguredEndpoints = (
99
101
 
100
102
  assertWeb();
101
103
 
102
- const defaultClientOptions: ClientOptions = {
104
+ const createDefaultClientOptions = (): ClientOptions => ({
103
105
  appKey: '',
104
106
  autoMarkSuccess: true,
105
107
  updateStrategy: __DEV__ ? 'alwaysAlert' : 'alertUpdateAndIgnoreError',
@@ -107,7 +109,7 @@ const defaultClientOptions: ClientOptions = {
107
109
  logger: noop,
108
110
  debug: false,
109
111
  throwError: false,
110
- };
112
+ });
111
113
 
112
114
  export const sharedState: {
113
115
  progressHandlers: Record<string, EmitterSubscription>;
@@ -131,6 +133,13 @@ export const sharedState: {
131
133
  applyingUpdate: false,
132
134
  };
133
135
 
136
+ // The SDK is a process-level singleton: module-level sharedState, the global
137
+ // i18n locale and the native update state are all per-process, so a second
138
+ // client would silently share (and fight over) them. Constructing one is a
139
+ // hard integration error, except for the idempotent re-creation of the same
140
+ // client (same type + appKey), which dev fast-refresh triggers legitimately.
141
+ let activeClient: Pushy | undefined;
142
+
134
143
  const assertHash = (hash: string) => {
135
144
  if (!sharedState.downloadedHash) {
136
145
  log(`no downloaded hash yet, ignore switch to ${hash}`);
@@ -145,7 +154,7 @@ const assertHash = (hash: string) => {
145
154
 
146
155
  // for China users
147
156
  export class Pushy {
148
- options = { ...defaultClientOptions };
157
+ options: ClientOptions = createDefaultClientOptions();
149
158
  clientType: 'Pushy' | 'Cresc' = 'Pushy';
150
159
  lastChecking?: number;
151
160
  lastRespJson?: Promise<CheckResult>;
@@ -167,6 +176,23 @@ export class Pushy {
167
176
 
168
177
  constructor(options: ClientOptions, clientType?: 'Pushy' | 'Cresc') {
169
178
  this.clientType = clientType || 'Pushy';
179
+ if (activeClient) {
180
+ if (
181
+ activeClient.clientType === this.clientType &&
182
+ activeClient.options.appKey === options.appKey
183
+ ) {
184
+ // Same client re-created (e.g. fast refresh re-running the module
185
+ // that builds it): apply the latest options and hand back the
186
+ // existing instance instead of forking process-level state.
187
+ activeClient.setOptions(options);
188
+ // biome-ignore lint/correctness/noConstructorReturn: intentional singleton — identical re-creation must yield the existing instance
189
+ return activeClient;
190
+ }
191
+ throw new UpdateError(
192
+ i18n.t('error_client_singleton'),
193
+ 'SINGLETON_VIOLATION'
194
+ );
195
+ }
170
196
  this.options.server = cloneServerConfig(SERVER_PRESETS[this.clientType]);
171
197
 
172
198
  i18n.setLocale(
@@ -191,8 +217,28 @@ export class Pushy {
191
217
  },
192
218
  });
193
219
  }
220
+ activeClient = this;
194
221
  }
195
222
 
223
+ /**
224
+ * Bumped on every setOptions call. `options` is mutated in place (its
225
+ * identity never changes), so reactive consumers (the UpdateProvider)
226
+ * subscribe via onOptionsChange and re-read using this version as the
227
+ * change signal.
228
+ */
229
+ optionsVersion = 0;
230
+ private optionsListeners = new Set<() => void>();
231
+ /**
232
+ * Subscribe to option changes (any setOptions call). Returns an
233
+ * unsubscribe function.
234
+ */
235
+ onOptionsChange = (listener: () => void) => {
236
+ this.optionsListeners.add(listener);
237
+ return () => {
238
+ this.optionsListeners.delete(listener);
239
+ };
240
+ };
241
+
196
242
  setOptions = (options: Partial<ClientOptions>) => {
197
243
  for (const [key, value] of Object.entries(options)) {
198
244
  if (value !== undefined) {
@@ -205,6 +251,34 @@ export class Pushy {
205
251
  }
206
252
  }
207
253
  }
254
+ this.optionsVersion++;
255
+ for (const listener of this.optionsListeners) {
256
+ try {
257
+ listener();
258
+ } catch (e: any) {
259
+ log('onOptionsChange listener error:', e?.message || e);
260
+ }
261
+ }
262
+ };
263
+
264
+ private providerMounted = false;
265
+ /**
266
+ * Called by UpdateProvider on mount. A second concurrently mounted
267
+ * provider is the same integration error as a second client — fail hard
268
+ * instead of double-subscribing app-state listeners and update checks.
269
+ * Returns the release function used on unmount.
270
+ */
271
+ claimProviderMount = () => {
272
+ if (this.providerMounted) {
273
+ throw new UpdateError(
274
+ this.t('error_provider_singleton'),
275
+ 'SINGLETON_VIOLATION'
276
+ );
277
+ }
278
+ this.providerMounted = true;
279
+ return () => {
280
+ this.providerMounted = false;
281
+ };
208
282
  };
209
283
 
210
284
  /**
@@ -450,11 +524,12 @@ export class Pushy {
450
524
  };
451
525
  requestCheckResult = async (
452
526
  endpoint: string,
453
- fetchPayload: Parameters<typeof fetch>[1]
527
+ fetchPayload: Parameters<typeof fetch>[1],
528
+ signal?: AbortSignal
454
529
  ) => {
455
530
  const resp = await fetchWithTimeout(
456
531
  this.getCheckUrl(endpoint),
457
- fetchPayload,
532
+ signal ? { ...fetchPayload, signal } : fetchPayload,
458
533
  DEFAULT_FETCH_TIMEOUT_MS
459
534
  );
460
535
 
@@ -476,9 +551,13 @@ export class Pushy {
476
551
  const { endpoint, value } = await executeEndpointFallback<CheckResult>({
477
552
  configuredEndpoints: this.getConfiguredCheckEndpoints(),
478
553
  getRemoteEndpoints: this.getRemoteEndpoints,
479
- tryEndpoint: async (currentEndpoint) => {
554
+ tryEndpoint: async (currentEndpoint, signal) => {
480
555
  try {
481
- return await this.requestCheckResult(currentEndpoint, fetchPayload);
556
+ return await this.requestCheckResult(
557
+ currentEndpoint,
558
+ fetchPayload,
559
+ signal
560
+ );
482
561
  } catch (e) {
483
562
  log('check endpoint failed', currentEndpoint, e);
484
563
  throw e;
@@ -591,6 +670,11 @@ export class Pushy {
591
670
  this.notifyAfterCheckUpdate({ status: 'skipped' });
592
671
  return;
593
672
  }
673
+ // 内容寻址的二进制身份,服务端据此精确判定 pdiff 适用性(取代 buildTime
674
+ // 启发式)。同步读 core 里已预取的值:还没算完就省略字段(服务端回退
675
+ // buildTime 启发式),下一次检查自然带上——绝不为它 await、拖慢或复杂化
676
+ // 检查流程。
677
+ const bundleHash = __DEV__ ? '' : getBundleHash();
594
678
  const now = Date.now();
595
679
  if (
596
680
  this.lastRespJson &&
@@ -620,6 +704,7 @@ export class Pushy {
620
704
  cInfo,
621
705
  // 可消费的 diff 轨道版本(2 = hdiffv2 轨道),服务端据此门控下发
622
706
  ...(supportedDiffVersion ? { diffV: supportedDiffVersion } : {}),
707
+ ...(bundleHash ? { bundleHash } : {}),
623
708
  ...extra,
624
709
  };
625
710
  if (__DEV__) {
@@ -651,6 +736,16 @@ export class Pushy {
651
736
 
652
737
  log('checking result:', result);
653
738
 
739
+ if (result?.bundleStatus === 'unknownBundle') {
740
+ // 服务端判定当前二进制内嵌 bundle 未注册,增量已被降级为全量。只面向
741
+ // 开发者(日志 + 遥测,控制台聚合是主渠道);终端用户无感知。
742
+ warn(this.t('warn_unknown_bundle'));
743
+ this.report({
744
+ type: 'bundleMismatch',
745
+ data: bundleHash ? { bundleHash } : {},
746
+ });
747
+ }
748
+
654
749
  this.notifyAfterCheckUpdate({ status: 'completed', result });
655
750
  return result;
656
751
  } catch (e: any) {
@@ -886,6 +981,23 @@ export class Pushy {
886
981
  delete sharedState.progressHandlers[hash];
887
982
  }
888
983
  delete sharedState.progressCallbacks[hash];
984
+ if (succeeded && errorMessages.length > 0) {
985
+ // An earlier strategy failed and a later one rescued the download
986
+ // (e.g. pdiff copiesCrc mismatch on a rebuilt binary → full). Surface
987
+ // the degradation: it is invisible to the end user but tells the
988
+ // platform that incremental delivery is failing for this binary.
989
+ this.report({
990
+ type: 'downloadFallback',
991
+ // UpdateError.code already carries the classified native rejection
992
+ // code (PATCH_FAILED vs DOWNLOAD_FAILED).
993
+ code: lastError?.code,
994
+ data: {
995
+ newVersion: hash,
996
+ succeeded,
997
+ },
998
+ message: errorMessages.join(';'),
999
+ });
1000
+ }
889
1001
  if (!succeeded) {
890
1002
  const message = errorMessages.join(';');
891
1003
  if (lastError) {
package/src/core.ts CHANGED
@@ -71,6 +71,33 @@ export const supportedDiffVersion: number =
71
71
  PushyConstants.supportedDiffVersion || 0;
72
72
  let uuid = PushyConstants.uuid;
73
73
 
74
+ // bundleHash = sha256 of the JS bundle embedded in the binary (the pdiff
75
+ // source). Content identity for the server's pdiff-applicability check,
76
+ // replacing the buildTime heuristic. Natively lazy-computed and cached;
77
+ // prefetched fire-and-forget at module load so the value is usually settled
78
+ // before the first checkUpdate builds its body. Until then (first install,
79
+ // slow device) getBundleHash returns '' and the field is simply omitted — the
80
+ // server falls back to the buildTime heuristic and the next check carries it.
81
+ // Deliberately synchronous at read time: checkUpdate must not await anything
82
+ // between its dedup window and the fetch, and a hash is never worth delaying
83
+ // a check for.
84
+ let bundleHash = '';
85
+ // The JS layer can arrive via hot update onto an older binary whose native
86
+ // module predates the method, hence the feature detect. On web PushyModule is
87
+ // a noop Proxy and the call settles to ''.
88
+ if (typeof PushyModule.getBundleHash === 'function') {
89
+ Promise.resolve(PushyModule.getBundleHash())
90
+ .then((hash: unknown) => {
91
+ bundleHash = String(hash || '');
92
+ })
93
+ .catch((e: any) => {
94
+ log('getBundleHash failed:', e?.message || e);
95
+ });
96
+ }
97
+
98
+ /** '' while unknown (not yet computed, debug build, no embedded bundle, older native). */
99
+ export const getBundleHash = (): string => bundleHash;
100
+
74
101
  async function getLocalHashInfo(hash: string) {
75
102
  return JSON.parse(await PushyModule.getLocalHashInfo(hash));
76
103
  }
package/src/endpoint.ts CHANGED
@@ -14,9 +14,10 @@ export interface EndpointAttemptFailure {
14
14
  export interface ExecuteEndpointFallbackOptions<T> {
15
15
  configuredEndpoints: string[];
16
16
  getRemoteEndpoints?: () => Promise<string[]>;
17
- tryEndpoint: (endpoint: string) => Promise<T>;
17
+ tryEndpoint: (endpoint: string, signal?: AbortSignal) => Promise<T>;
18
18
  random?: () => number;
19
19
  now?: () => number;
20
+ hedgeDelayMs?: number;
20
21
  onFirstFailure?: (failure: EndpointAttemptFailure) => void | Promise<void>;
21
22
  }
22
23
 
@@ -54,60 +55,104 @@ export const pickRandomEndpoint = (
54
55
  return endpoints[Math.floor(random() * endpoints.length)];
55
56
  };
56
57
 
57
- export async function selectFastestSuccessfulEndpoint<T>(
58
+ export const DEFAULT_HEDGE_DELAY_MS = 250;
59
+
60
+ /**
61
+ * Hedged race over the candidate endpoints: the first candidate starts
62
+ * immediately, each following one is only started after `hedgeDelayMs` of
63
+ * silence (or immediately when a previous attempt failed). The first
64
+ * successful response wins and every other in-flight request is aborted, so
65
+ * one slow endpoint never gates a fast one, and healthy rounds send a single
66
+ * request instead of blasting every candidate at once.
67
+ */
68
+ export function selectFastestSuccessfulEndpoint<T>(
58
69
  endpoints: string[],
59
- tryEndpoint: (endpoint: string) => Promise<T>,
60
- now: () => number = Date.now
70
+ tryEndpoint: (endpoint: string, signal?: AbortSignal) => Promise<T>,
71
+ now: () => number = Date.now,
72
+ hedgeDelayMs: number = DEFAULT_HEDGE_DELAY_MS
61
73
  ): Promise<{
62
- successes: EndpointAttemptSuccess<T>[];
74
+ success?: EndpointAttemptSuccess<T>;
63
75
  failures: EndpointAttemptFailure[];
64
76
  }> {
65
- const attempts = await Promise.all(
66
- endpoints.map(async (endpoint) => {
67
- const start = now();
68
- try {
69
- const value = await tryEndpoint(endpoint);
70
- return {
71
- ok: true as const,
72
- endpoint,
73
- value,
74
- duration: now() - start,
75
- };
76
- } catch (error) {
77
- return {
78
- ok: false as const,
79
- endpoint,
80
- error: normalizeError(error),
81
- };
82
- }
83
- })
84
- );
85
-
86
- const successes: EndpointAttemptSuccess<T>[] = [];
87
- const failures: EndpointAttemptFailure[] = [];
88
-
89
- for (const attempt of attempts) {
90
- if (attempt.ok) {
91
- successes.push({
92
- endpoint: attempt.endpoint,
93
- value: attempt.value,
94
- duration: attempt.duration,
95
- });
96
- continue;
77
+ return new Promise((resolve) => {
78
+ if (!endpoints.length) {
79
+ resolve({ failures: [] });
80
+ return;
97
81
  }
98
82
 
99
- failures.push({
100
- endpoint: attempt.endpoint,
101
- error: attempt.error,
102
- });
103
- }
83
+ const failures: EndpointAttemptFailure[] = [];
84
+ const controllers: AbortController[] = [];
85
+ let nextIndex = 0;
86
+ let pending = 0;
87
+ let settled = false;
88
+ let hedgeTimer: ReturnType<typeof setTimeout> | undefined;
104
89
 
105
- successes.sort((left, right) => left.duration - right.duration);
90
+ const finish = (success?: EndpointAttemptSuccess<T>) => {
91
+ if (settled) {
92
+ return;
93
+ }
94
+ settled = true;
95
+ if (hedgeTimer) {
96
+ clearTimeout(hedgeTimer);
97
+ }
98
+ for (const controller of controllers) {
99
+ controller.abort();
100
+ }
101
+ resolve({ success, failures });
102
+ };
103
+
104
+ const launchNext = () => {
105
+ if (hedgeTimer) {
106
+ clearTimeout(hedgeTimer);
107
+ hedgeTimer = undefined;
108
+ }
109
+ if (settled || nextIndex >= endpoints.length) {
110
+ return;
111
+ }
112
+ const endpoint = endpoints[nextIndex++];
113
+ const controller =
114
+ typeof AbortController === 'undefined'
115
+ ? undefined
116
+ : new AbortController();
117
+ if (controller) {
118
+ controllers.push(controller);
119
+ }
120
+ pending++;
121
+ const start = now();
122
+ tryEndpoint(endpoint, controller?.signal)
123
+ .then((value) => {
124
+ if (controller) {
125
+ // Only the losing requests get aborted, not the winner's own
126
+ // (already settled) one.
127
+ const index = controllers.indexOf(controller);
128
+ if (index >= 0) {
129
+ controllers.splice(index, 1);
130
+ }
131
+ }
132
+ finish({ endpoint, value, duration: now() - start });
133
+ })
134
+ .catch((error) => {
135
+ pending--;
136
+ if (settled) {
137
+ // Losers cancelled after a win must not count as failures.
138
+ return;
139
+ }
140
+ failures.push({ endpoint, error: normalizeError(error) });
141
+ if (nextIndex < endpoints.length) {
142
+ // A failure frees its slot: hedge the next candidate right away
143
+ // instead of waiting out the stagger.
144
+ launchNext();
145
+ } else if (pending === 0) {
146
+ finish(undefined);
147
+ }
148
+ });
149
+ if (!settled && nextIndex < endpoints.length) {
150
+ hedgeTimer = setTimeout(launchNext, hedgeDelayMs);
151
+ }
152
+ };
106
153
 
107
- return {
108
- successes,
109
- failures,
110
- };
154
+ launchNext();
155
+ });
111
156
  }
112
157
 
113
158
  export async function executeEndpointFallback<T>({
@@ -116,6 +161,7 @@ export async function executeEndpointFallback<T>({
116
161
  tryEndpoint,
117
162
  random = Math.random,
118
163
  now = Date.now,
164
+ hedgeDelayMs = DEFAULT_HEDGE_DELAY_MS,
119
165
  onFirstFailure,
120
166
  }: ExecuteEndpointFallbackOptions<T>): Promise<EndpointAttemptSuccess<T>> {
121
167
  const excludedEndpoints = new Set<string>();
@@ -154,14 +200,15 @@ export async function executeEndpointFallback<T>({
154
200
  throw lastError;
155
201
  }
156
202
 
157
- const { successes, failures } = await selectFastestSuccessfulEndpoint(
203
+ const { success, failures } = await selectFastestSuccessfulEndpoint(
158
204
  candidates,
159
205
  tryEndpoint,
160
- now
206
+ now,
207
+ hedgeDelayMs
161
208
  );
162
209
 
163
- if (successes.length) {
164
- return successes[0];
210
+ if (success) {
211
+ return success;
165
212
  }
166
213
 
167
214
  for (const failure of failures) {