@axonpack/preview 0.0.0-stage → 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.
Files changed (182) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/LICENSE +21 -0
  3. package/README.md +118 -2
  4. package/android/build.gradle +21 -0
  5. package/android/consumer-rules.pro +3 -0
  6. package/android/src/main/java/expo/modules/axonpackpreview/AxonpackPreviewModule.kt +72 -0
  7. package/android/src/main/java/expo/modules/axonpackpreview/BundleLoaderOverride.kt +41 -0
  8. package/android/src/main/java/expo/modules/axonpackpreview/HermesHeader.kt +24 -0
  9. package/android/src/main/java/expo/modules/axonpackpreview/PreviewPackage.kt +76 -0
  10. package/android/src/main/java/expo/modules/axonpackpreview/PreviewState.kt +295 -0
  11. package/app.plugin.js +112 -0
  12. package/bin/preview.js +2 -0
  13. package/build/cli.cjs +1465 -0
  14. package/build/cli.d.ts +3 -0
  15. package/build/cli.d.ts.map +1 -0
  16. package/build/cli.js +164 -0
  17. package/build/cli.js.map +1 -0
  18. package/build/components/preview-list/preview-list.component.d.ts +13 -0
  19. package/build/components/preview-list/preview-list.component.d.ts.map +1 -0
  20. package/build/components/preview-list/preview-list.component.js +316 -0
  21. package/build/components/preview-list/preview-list.component.js.map +1 -0
  22. package/build/components/preview-list/preview-list.types.d.ts +59 -0
  23. package/build/components/preview-list/preview-list.types.d.ts.map +1 -0
  24. package/build/components/preview-list/preview-list.types.js +2 -0
  25. package/build/components/preview-list/preview-list.types.js.map +1 -0
  26. package/build/components/preview-provider/preview-provider.component.d.ts +12 -0
  27. package/build/components/preview-provider/preview-provider.component.d.ts.map +1 -0
  28. package/build/components/preview-provider/preview-provider.component.js +19 -0
  29. package/build/components/preview-provider/preview-provider.component.js.map +1 -0
  30. package/build/constants/dev-preview-servers/dev-preview-servers.const.d.ts +26 -0
  31. package/build/constants/dev-preview-servers/dev-preview-servers.const.d.ts.map +1 -0
  32. package/build/constants/dev-preview-servers/dev-preview-servers.const.js +9 -0
  33. package/build/constants/dev-preview-servers/dev-preview-servers.const.js.map +1 -0
  34. package/build/constants/preview-list/theme.const.d.ts +5 -0
  35. package/build/constants/preview-list/theme.const.d.ts.map +1 -0
  36. package/build/constants/preview-list/theme.const.js +65 -0
  37. package/build/constants/preview-list/theme.const.js.map +1 -0
  38. package/build/index.d.ts +24 -0
  39. package/build/index.d.ts.map +1 -0
  40. package/build/index.js +16 -0
  41. package/build/index.js.map +1 -0
  42. package/build/services/cli/config.service.d.ts +13 -0
  43. package/build/services/cli/config.service.d.ts.map +1 -0
  44. package/build/services/cli/config.service.js +39 -0
  45. package/build/services/cli/config.service.js.map +1 -0
  46. package/build/services/cli/project.service.d.ts +25 -0
  47. package/build/services/cli/project.service.d.ts.map +1 -0
  48. package/build/services/cli/project.service.js +81 -0
  49. package/build/services/cli/project.service.js.map +1 -0
  50. package/build/services/cli/title.service.d.ts +15 -0
  51. package/build/services/cli/title.service.d.ts.map +1 -0
  52. package/build/services/cli/title.service.js +24 -0
  53. package/build/services/cli/title.service.js.map +1 -0
  54. package/build/services/dev-preview-servers/discover.service.d.ts +14 -0
  55. package/build/services/dev-preview-servers/discover.service.d.ts.map +1 -0
  56. package/build/services/dev-preview-servers/discover.service.js +106 -0
  57. package/build/services/dev-preview-servers/discover.service.js.map +1 -0
  58. package/build/services/native/build-info.service.d.ts +11 -0
  59. package/build/services/native/build-info.service.d.ts.map +1 -0
  60. package/build/services/native/build-info.service.js +18 -0
  61. package/build/services/native/build-info.service.js.map +1 -0
  62. package/build/services/native/native.service.d.ts +24 -0
  63. package/build/services/native/native.service.d.ts.map +1 -0
  64. package/build/services/native/native.service.js +13 -0
  65. package/build/services/native/native.service.js.map +1 -0
  66. package/build/services/previews/download.service.d.ts +14 -0
  67. package/build/services/previews/download.service.d.ts.map +1 -0
  68. package/build/services/previews/download.service.js +66 -0
  69. package/build/services/previews/download.service.js.map +1 -0
  70. package/build/services/previews/local-previews.service.d.ts +25 -0
  71. package/build/services/previews/local-previews.service.d.ts.map +1 -0
  72. package/build/services/previews/local-previews.service.js +50 -0
  73. package/build/services/previews/local-previews.service.js.map +1 -0
  74. package/build/services/previews/reload.service.d.ts +6 -0
  75. package/build/services/previews/reload.service.d.ts.map +1 -0
  76. package/build/services/previews/reload.service.js +13 -0
  77. package/build/services/previews/reload.service.js.map +1 -0
  78. package/build/services/previews/remote.service.d.ts +11 -0
  79. package/build/services/previews/remote.service.d.ts.map +1 -0
  80. package/build/services/previews/remote.service.js +34 -0
  81. package/build/services/previews/remote.service.js.map +1 -0
  82. package/build/services/previews/select.service.d.ts +11 -0
  83. package/build/services/previews/select.service.d.ts.map +1 -0
  84. package/build/services/previews/select.service.js +25 -0
  85. package/build/services/previews/select.service.js.map +1 -0
  86. package/build/services/previews/use-preview.service.d.ts +21 -0
  87. package/build/services/previews/use-preview.service.d.ts.map +1 -0
  88. package/build/services/previews/use-preview.service.js +24 -0
  89. package/build/services/previews/use-preview.service.js.map +1 -0
  90. package/build/services/previews/use-running.service.d.ts +4 -0
  91. package/build/services/previews/use-running.service.d.ts.map +1 -0
  92. package/build/services/previews/use-running.service.js +21 -0
  93. package/build/services/previews/use-running.service.js.map +1 -0
  94. package/build/services/publish/bundle.service.d.ts +11 -0
  95. package/build/services/publish/bundle.service.d.ts.map +1 -0
  96. package/build/services/publish/bundle.service.js +46 -0
  97. package/build/services/publish/bundle.service.js.map +1 -0
  98. package/build/services/publish/commands.service.d.ts +23 -0
  99. package/build/services/publish/commands.service.d.ts.map +1 -0
  100. package/build/services/publish/commands.service.js +130 -0
  101. package/build/services/publish/commands.service.js.map +1 -0
  102. package/build/services/publish/eas-env.service.d.ts +9 -0
  103. package/build/services/publish/eas-env.service.d.ts.map +1 -0
  104. package/build/services/publish/eas-env.service.js +49 -0
  105. package/build/services/publish/eas-env.service.js.map +1 -0
  106. package/build/services/registry/registry.service.d.ts +27 -0
  107. package/build/services/registry/registry.service.d.ts.map +1 -0
  108. package/build/services/registry/registry.service.js +89 -0
  109. package/build/services/registry/registry.service.js.map +1 -0
  110. package/build/services/s3/s3.service.d.ts +59 -0
  111. package/build/services/s3/s3.service.d.ts.map +1 -0
  112. package/build/services/s3/s3.service.js +154 -0
  113. package/build/services/s3/s3.service.js.map +1 -0
  114. package/build/services/start/start.service.d.ts +48 -0
  115. package/build/services/start/start.service.d.ts.map +1 -0
  116. package/build/services/start/start.service.js +172 -0
  117. package/build/services/start/start.service.js.map +1 -0
  118. package/build/stores/dev-preview-servers/dev-preview-servers.store.d.ts +21 -0
  119. package/build/stores/dev-preview-servers/dev-preview-servers.store.d.ts.map +1 -0
  120. package/build/stores/dev-preview-servers/dev-preview-servers.store.js +91 -0
  121. package/build/stores/dev-preview-servers/dev-preview-servers.store.js.map +1 -0
  122. package/build/stores/previews/previews.store.d.ts +36 -0
  123. package/build/stores/previews/previews.store.d.ts.map +1 -0
  124. package/build/stores/previews/previews.store.js +134 -0
  125. package/build/stores/previews/previews.store.js.map +1 -0
  126. package/build/utils/config/config.util.d.ts +39 -0
  127. package/build/utils/config/config.util.d.ts.map +1 -0
  128. package/build/utils/config/config.util.js +24 -0
  129. package/build/utils/config/config.util.js.map +1 -0
  130. package/build/utils/config/expo-config.util.d.ts +30 -0
  131. package/build/utils/config/expo-config.util.d.ts.map +1 -0
  132. package/build/utils/config/expo-config.util.js +43 -0
  133. package/build/utils/config/expo-config.util.js.map +1 -0
  134. package/build/utils/dev-preview-servers/merge-servers.util.d.ts +47 -0
  135. package/build/utils/dev-preview-servers/merge-servers.util.d.ts.map +1 -0
  136. package/build/utils/dev-preview-servers/merge-servers.util.js +37 -0
  137. package/build/utils/dev-preview-servers/merge-servers.util.js.map +1 -0
  138. package/build/utils/preview-list/theme.util.d.ts +5 -0
  139. package/build/utils/preview-list/theme.util.d.ts.map +1 -0
  140. package/build/utils/preview-list/theme.util.js +12 -0
  141. package/build/utils/preview-list/theme.util.js.map +1 -0
  142. package/build/utils/previews/layout.util.d.ts +89 -0
  143. package/build/utils/previews/layout.util.d.ts.map +1 -0
  144. package/build/utils/previews/layout.util.js +30 -0
  145. package/build/utils/previews/layout.util.js.map +1 -0
  146. package/build/utils/previews/preview-items.util.d.ts +23 -0
  147. package/build/utils/previews/preview-items.util.d.ts.map +1 -0
  148. package/build/utils/previews/preview-items.util.js +39 -0
  149. package/build/utils/previews/preview-items.util.js.map +1 -0
  150. package/build/utils/previews/preview-title.util.d.ts +2 -0
  151. package/build/utils/previews/preview-title.util.d.ts.map +1 -0
  152. package/build/utils/previews/preview-title.util.js +6 -0
  153. package/build/utils/previews/preview-title.util.js.map +1 -0
  154. package/build/utils/previews/running.util.d.ts +23 -0
  155. package/build/utils/previews/running.util.d.ts.map +1 -0
  156. package/build/utils/previews/running.util.js +23 -0
  157. package/build/utils/previews/running.util.js.map +1 -0
  158. package/build/utils/publish/changelog.util.d.ts +5 -0
  159. package/build/utils/publish/changelog.util.d.ts.map +1 -0
  160. package/build/utils/publish/changelog.util.js +19 -0
  161. package/build/utils/publish/changelog.util.js.map +1 -0
  162. package/build/utils/publish/indexes.util.d.ts +12 -0
  163. package/build/utils/publish/indexes.util.d.ts.map +1 -0
  164. package/build/utils/publish/indexes.util.js +52 -0
  165. package/build/utils/publish/indexes.util.js.map +1 -0
  166. package/build/utils/s3/sigv4.util.d.ts +24 -0
  167. package/build/utils/s3/sigv4.util.d.ts.map +1 -0
  168. package/build/utils/s3/sigv4.util.js +67 -0
  169. package/build/utils/s3/sigv4.util.js.map +1 -0
  170. package/expo-module.config.json +10 -0
  171. package/ios/AxonpackPreview.podspec +30 -0
  172. package/ios/AxonpackPreviewModule.swift +55 -0
  173. package/ios/PreviewReactDelegateHandler.swift +147 -0
  174. package/ios/PreviewState.swift +236 -0
  175. package/notes/README.md +36 -0
  176. package/notes/bundles.md +35 -0
  177. package/notes/dev-preview-servers.md +67 -0
  178. package/notes/distribution.md +138 -0
  179. package/notes/native-loader.md +56 -0
  180. package/notes/storage.md +145 -0
  181. package/notes/testing.md +97 -0
  182. package/package.json +107 -4
@@ -0,0 +1,236 @@
1
+ import Foundation
2
+ import React
3
+ import os
4
+
5
+ let previewLog = Logger(subsystem: "dev.axonpack.preview", category: "AxonpackPreview")
6
+
7
+ /**
8
+ Which bundle the next start should load. JS writes `state.json` under the app's Documents folder
9
+ (what expo-file-system calls the documents directory), and native only ever reads it. The bundle
10
+ path in it is relative, because the app's container path changes when the app is updated.
11
+ */
12
+ enum PreviewState {
13
+ static var root: URL? {
14
+ FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first?
15
+ .appendingPathComponent("preview", isDirectory: true)
16
+ }
17
+
18
+ /// Previews need the New Architecture; under the old one only the base bundle ever loads.
19
+ static var newArchitecture: Bool { RCTIsNewArchEnabled() }
20
+
21
+ static func warnOldArchitecture() {
22
+ previewLog.warning("@axonpack/preview needs the New Architecture, so the selected preview is ignored and the base bundle loads")
23
+ }
24
+
25
+ /// The selected preview if this app can run it, else nil for the base bundle.
26
+ static func bundleToLoad() -> URL? {
27
+ guard let preview = selectedBundle() else {
28
+ return nil
29
+ }
30
+ guard madeForThisBuild() else {
31
+ return nil
32
+ }
33
+ guard newArchitecture else {
34
+ warnOldArchitecture()
35
+ return nil
36
+ }
37
+ if let embedded = embeddedHermesVersion, let version = hermesVersion(of: preview), embedded != version {
38
+ previewLog.warning("The preview is Hermes bytecode \(version) but this build runs \(embedded), so the base bundle loads")
39
+ return nil
40
+ }
41
+ guard claimStart() else {
42
+ return nil
43
+ }
44
+ return preview
45
+ }
46
+
47
+ /// The bundle inside the app was compiled by the Hermes this build runs. A debug build has none,
48
+ /// and then nothing is checked.
49
+ static let embeddedHermesVersion: UInt32? = Bundle.main.url(forResource: "main", withExtension: "jsbundle")
50
+ .flatMap { hermesVersion(of: $0) }
51
+
52
+ /// Hermes bytecode starts with an 8-byte magic number, then its format version as a 32-bit int.
53
+ /// Nil for anything else, such as a plain JS bundle.
54
+ static func hermesVersion(of url: URL) -> UInt32? {
55
+ guard let handle = try? FileHandle(forReadingFrom: url) else {
56
+ return nil
57
+ }
58
+ defer { try? handle.close() }
59
+ guard let header = try? handle.read(upToCount: 12), header.count == 12 else {
60
+ return nil
61
+ }
62
+ let magic: [UInt8] = [0xc6, 0x1f, 0xbc, 0x03, 0xc1, 0x03, 0x19, 0x1f]
63
+ guard Array(header.prefix(8)) == magic else {
64
+ return nil
65
+ }
66
+ return header.suffix(4).enumerated().reduce(UInt32(0)) { $0 | UInt32($1.element) << (8 * UInt32($1.offset)) }
67
+ }
68
+
69
+ // Crash fallback. Every JS start has its own token. A preview start writes it into the state as
70
+ // `pending`, and JS clears it once the app has mounted. Finding someone else's token means an
71
+ // earlier start of this preview never got that far, so the preview is marked broken and the base
72
+ // bundle loads instead. Several lookups in one start see their own token and change nothing.
73
+ private static let processToken = UUID().uuidString
74
+ private static var starts = 0
75
+ private static var token: String { "\(processToken):\(starts)" }
76
+
77
+ /// Called before a reload, so the next start has a token of its own.
78
+ static func nextStart() {
79
+ starts += 1
80
+ }
81
+
82
+ private static var stateURL: URL? { root?.appendingPathComponent("state.json") }
83
+
84
+ private static func readJson() -> [String: Any] {
85
+ guard
86
+ let stateURL,
87
+ let data = try? Data(contentsOf: stateURL),
88
+ let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
89
+ else {
90
+ return [:]
91
+ }
92
+ return json
93
+ }
94
+
95
+ private static func writeJson(_ json: [String: Any]) {
96
+ guard let root, let stateURL else {
97
+ return
98
+ }
99
+ try? FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
100
+ try? JSONSerialization.data(withJSONObject: json).write(to: stateURL, options: .atomic)
101
+ }
102
+
103
+ private static func claimStart() -> Bool {
104
+ var json = readJson()
105
+ let pending = json["pending"] as? String ?? ""
106
+ if pending == token {
107
+ return true
108
+ }
109
+ if !pending.isEmpty {
110
+ let id = (json["bundle"] as? String ?? "").components(separatedBy: "/").first ?? ""
111
+ previewLog.warning("The preview \(id, privacy: .public) stopped before it finished starting, so it is marked broken and the base bundle loads")
112
+ json["broken"] = (json["broken"] as? [String] ?? []) + [id]
113
+ json["bundle"] = nil
114
+ json["pending"] = nil
115
+ writeJson(json)
116
+ return false
117
+ }
118
+ json["pending"] = token
119
+ writeJson(json)
120
+ return true
121
+ }
122
+
123
+ /// The key the config plugin baked into this build; nil without the plugin.
124
+ private static var buildKey: String? {
125
+ Bundle.main.object(forInfoDictionaryKey: "AxonpackPreviewKey") as? String
126
+ }
127
+
128
+ /// A preview chosen on an earlier build (the app was updated since) is not loaded: with app
129
+ /// version matching, a new version is a new native build. The choice is cleared, so the app and
130
+ /// the list both show the base bundle. Without a key on either side there is nothing to compare.
131
+ private static func madeForThisBuild() -> Bool {
132
+ var json = readJson()
133
+ guard let chosenFor = json["key"] as? String, let build = buildKey, chosenFor != build else {
134
+ return true
135
+ }
136
+ previewLog.warning("The chosen preview was made for build \(chosenFor, privacy: .public) but this is \(build, privacy: .public), so the base bundle loads")
137
+ json["bundle"] = nil
138
+ json["key"] = nil
139
+ json["pending"] = nil
140
+ writeJson(json)
141
+ return false
142
+ }
143
+
144
+ /// JS calls this once the app has mounted: this start worked.
145
+ static func confirmStart() {
146
+ var json = readJson()
147
+ if json["pending"] as? String == token {
148
+ json["pending"] = nil
149
+ writeJson(json)
150
+ }
151
+ }
152
+
153
+ /// Records the choice for the next start or reload. Nil clears it, so the base bundle loads. The
154
+ /// path is relative to the preview folder and must stay inside it. Choosing a preview again takes
155
+ /// it off the broken list, since it may have been fixed and downloaded again.
156
+ static func select(_ bundle: String?, key: String? = nil) throws {
157
+ guard let root else {
158
+ return
159
+ }
160
+ var json = readJson()
161
+ json["pending"] = nil
162
+ if let bundle {
163
+ guard root.appendingPathComponent(bundle).standardizedFileURL.path.hasPrefix(root.standardizedFileURL.path + "/") else {
164
+ throw PreviewPathOutsideException()
165
+ }
166
+ let id = bundle.components(separatedBy: "/").first ?? ""
167
+ json["bundle"] = bundle
168
+ json["key"] = key
169
+ json["broken"] = (json["broken"] as? [String] ?? []).filter { $0 != id }
170
+ } else {
171
+ json["bundle"] = nil
172
+ json["key"] = nil
173
+ }
174
+ writeJson(json)
175
+ }
176
+
177
+ // Local Metro servers (debug builds only). `localServer` sits next to the preview fields and
178
+ // never changes them, so stopping a local server brings back whatever preview was chosen.
179
+
180
+ /// The chosen local server's bundle URL, or nil; always nil in a release build.
181
+ static var localServerURL: URL? {
182
+ #if DEBUG
183
+ guard
184
+ let server = readJson()["localServer"] as? [String: Any],
185
+ let url = server["url"] as? String
186
+ else {
187
+ return nil
188
+ }
189
+ return URL(string: url)
190
+ #else
191
+ return nil
192
+ #endif
193
+ }
194
+
195
+ static func selectLocalServer(_ url: String?) {
196
+ var json = readJson()
197
+ if let url {
198
+ json["localServer"] = ["url": url]
199
+ } else {
200
+ json["localServer"] = nil
201
+ }
202
+ writeJson(json)
203
+ }
204
+
205
+ /// The raw choice and the broken list, for JS to show.
206
+ static func read() -> [String: Any?] {
207
+ let json = readJson()
208
+ return [
209
+ "bundle": json["bundle"] as? String,
210
+ "broken": json["broken"] as? [String] ?? [],
211
+ "localServer": (json["localServer"] as? [String: Any])?["url"] as? String,
212
+ ]
213
+ }
214
+
215
+ /// Nil means "load the base bundle", which is also the answer to anything unexpected.
216
+ static func selectedBundle() -> URL? {
217
+ guard let root, let data = try? Data(contentsOf: root.appendingPathComponent("state.json")) else {
218
+ return nil
219
+ }
220
+ guard
221
+ let state = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
222
+ let relative = state["bundle"] as? String, !relative.isEmpty
223
+ else {
224
+ previewLog.warning("Could not read the preview state, loading the base bundle")
225
+ return nil
226
+ }
227
+ let bundle = root.appendingPathComponent(relative).standardizedFileURL
228
+ // A path like "../x" must not let the state point outside our own folder.
229
+ let inside = bundle.path.hasPrefix(root.standardizedFileURL.path + "/")
230
+ guard inside, FileManager.default.fileExists(atPath: bundle.path) else {
231
+ previewLog.warning("Selected bundle \(relative, privacy: .public) is missing, loading the base bundle")
232
+ return nil
233
+ }
234
+ return bundle
235
+ }
236
+ }
@@ -0,0 +1,36 @@
1
+ # @axonpack/preview notes
2
+
3
+ Scope: this package only. One note per area, and **each one owns its own feature status**: what is
4
+ built, what is not, and what never will be. There is no combined list anywhere; a second copy would
5
+ only disagree with these.
6
+
7
+ | Note | Covers |
8
+ | -------------------------------------------------- | ----------------------------------------------------------------- |
9
+ | [bundles.md](./bundles.md) | Building a preview bundle, and which native build it can run on |
10
+ | [native-loader.md](./native-loader.md) | Starting the app from a downloaded bundle, and getting back again |
11
+ | [distribution.md](./distribution.md) | Publishing a preview so installed apps can find it |
12
+ | [storage.md](./storage.md) | Where previews are kept, and running a local S3 to test with |
13
+ | [testing.md](./testing.md) | Running the example end to end on your own machine |
14
+ | [dev-preview-servers.md](./dev-preview-servers.md) | Several local Metro servers for one app, switched live (debug) |
15
+
16
+ To run the example end to end without a cloud account, follow [testing.md](./testing.md).
17
+
18
+ ## How to edit an area note
19
+
20
+ - **Features first**, as a checklist: `- [x]` for shipped, `- [ ]` for not built yet. One
21
+ plain-English line each, no sub-lists, no paragraphs.
22
+ - **Describe a feature from the outside**: what someone using the package gets. No file names, no
23
+ function or option names, and no explanation of how it works. That belongs in the prose below the
24
+ checklist and in the code comments.
25
+ - **Prose after the checklist** carries the design: how it works, and the decisions that constrain
26
+ it. Why, not what.
27
+ - **Won't do goes last**, for what the platform makes impossible. No checkboxes there, because nothing in
28
+ it is waiting to be built. Move a line there instead of deleting it, so the reason survives. If
29
+ something becomes possible, move it back up as `- [ ]`.
30
+ - **A feature belongs to exactly one note.** If two notes would list it, one of them is wrong.
31
+ - New work goes in the note for its area; only a genuinely new surface earns a new note, and a new
32
+ note earns a row in the table above.
33
+
34
+ The writing rules that apply to every note in the repo, and how the symlinks under
35
+ `notes/@axonpack/` work, are in
36
+ [the repo's notes guide](https://github.com/axonpack/axonpack/blob/main/notes/README.md).
@@ -0,0 +1,35 @@
1
+ # Bundles
2
+
3
+ Building a preview bundle, and which native build it can run on.
4
+
5
+ - [x] Builds the same release bundle and images the native build would embed, for Android and iOS
6
+ - [x] Names a preview after its branch, with a readable title QA sees
7
+ - [x] Marks every preview with the native build it was made for, so an app only ever offers ones it can run
8
+ - [x] Lets the developer match previews to builds by app version (the default) or by native fingerprint
9
+
10
+ A preview is the output of `expo export:embed --bytecode`: Hermes bytecode plus the images next to
11
+ it, in the folders the native build uses. React Native finds images beside a bundle loaded from a
12
+ file, so nothing in JS has to map them.
13
+
14
+ Bytecode runs only on a build with the same native code and the same Hermes version. Each build
15
+ gets a key, and the app lists only previews published under its own key. The developer picks how
16
+ the key is made, with one option in the app config that the build and `publish` both read:
17
+
18
+ ```json
19
+ "plugins": [["@axonpack/preview", { "compatibility": "appVersion" }]]
20
+ ```
21
+
22
+ | `compatibility` | Key | Pick it when |
23
+ | ---------------------- | ----------------------- | ---------------------------------------------------------------------------------------------------------- |
24
+ | `appVersion` (default) | `version-<app version>` | You bump the version for every native change. Simple, and readable in the bucket |
25
+ | `fingerprint` | `fingerprint-<hash>` | Native changes can land without a version bump. Exact, the same hash expo-updates' fingerprint policy uses |
26
+
27
+ The trade-off of `appVersion`: a native change without a version bump is not detected, so a preview
28
+ built on the old native code is still offered. Two things catch most of the damage: a preview whose
29
+ Hermes bytecode version differs from the build's is not loaded, and a preview that fails while
30
+ starting falls back to the base bundle and is marked broken. `publish` prints a one-line warning in
31
+ this mode. With `fingerprint`, any native change (a new native dependency, a config plugin change,
32
+ a prebuild with different settings) makes older previews incompatible.
33
+
34
+ A preview chosen on one build is not started by a later build with another key; the app starts the
35
+ base bundle and the download moves to Incompatible.
@@ -0,0 +1,67 @@
1
+ # Dev preview servers
2
+
3
+ Several Metro servers for the same app, one per task or worktree, and one debug build that switches
4
+ between them live.
5
+
6
+ - [x] Lists every Metro server for this app on the developer's computer, in a debug build
7
+ - [x] Finds plain `expo start` servers, and servers started with `preview start` with their branch
8
+ - [x] Shows a new server within about two seconds, and drops one that stopped
9
+ - [x] Leaves out other apps' servers; keeps the same app built for other native code, marked
10
+ - [x] Switches the running app to a server, and back, with no rebuild
11
+ - [x] Starts Metro on a free port and announces it with one command, `preview start`
12
+ - [x] Asks for the name the app shows, prefilled from the branch (`--title` or no terminal skips it)
13
+ - [x] Off unless the app turns it on, and never in a release build
14
+ - [x] Only this app's servers, by the slug the config plugin bakes in; a build without it says so
15
+
16
+ ## Turning it on
17
+
18
+ ```ts
19
+ <PreviewProvider config={{ ...config, devPreviewServers: true }}>
20
+ ```
21
+
22
+ It is one switch, off by default, and only shows in a debug build. Nothing else is set: the app probes
23
+ ports 8081 to 8099, asks the registry on port 19150 (the same constant `preview start` uses, in
24
+ `src/constants/dev-preview-servers/dev-preview-servers.const.ts`), looks on the host its own Metro is on (plus `10.0.2.2` on the
25
+ Android emulator), and lists only servers for the slug the config plugin baked into the build. A
26
+ build made without the plugin has no slug, and the section says so instead of listing anything.
27
+
28
+ ## How it works
29
+
30
+ **Running it.** In the example, `bun run start:preview` (or `bunx preview start`).
31
+
32
+ **The name.** In a terminal, `preview start` asks for the name the app's list shows, prefilled with
33
+ the branch's readable title (`task-1-login-fix` becomes "Task 1 login fix"). Enter keeps it, or edit
34
+ it first. `--title <name>` skips the question, and so does a run with no terminal (CI, an AI agent),
35
+ which takes the branch title. `publish` asks the same way for the title QA sees.
36
+
37
+ **Two sources.** `preview start` runs `expo start` on a free port and announces it to a small
38
+ registry on the computer (started by the first `start`, gone once no server is left), with the
39
+ branch, worktree and the native key `publish` would use. Heartbeats every 5 s keep it listed; it
40
+ drops out 15 s after the last, so a killed process goes too. Plain `expo start` servers are found
41
+ by probing the port range for Metro's `/status`; each one's Expo manifest then gives its slug,
42
+ name, version and bundle URL. A server found both ways uses the registry's details. While the list
43
+ is on screen the app looks every 2 s, and never starts a look while the last one runs. A probe gives
44
+ up after 2 s: Android sends 5 requests per host at a time and the emulator takes about 400 ms to
45
+ refuse a closed port, so a tighter limit dropped live servers there. A closed port on the computer
46
+ itself fails at once, so the scan stays cheap.
47
+
48
+ **Which servers.** Only the app's own slug, which the config plugin bakes into the build. A server
49
+ for the same app but another native key (another app version, or another fingerprint) is listed and
50
+ marked, since a developer may still want it. A plain server in fingerprint mode shows "native build
51
+ unknown", because its manifest does not carry a fingerprint.
52
+
53
+ **Reaching it.** The app probes the host its own Metro is on. On the Android emulator it also tries
54
+ `10.0.2.2`, which reaches the computer with no `adb reverse`. A phone on USB needs `adb reverse`,
55
+ which `preview start` runs for its port and the registry's when `adb` is on the PATH. A phone
56
+ on Wi-Fi was started from the computer's LAN address, so the same rule finds the others.
57
+
58
+ **Switching.** The choice is stored next to the preview choice and never changes it. On every start
59
+ of a debug build the dev preview server wins, then a downloaded preview, then the default Metro. Android
60
+ points React Native's dev support at the server (and puts the developer's own dev-menu host back
61
+ afterwards); iOS starts from the server's bundle URL. "Stop using dev preview server" brings back what ran
62
+ before, a chosen preview included; "Back to base" stops both.
63
+
64
+ ## Won't do
65
+
66
+ - Run in a release build. There is no Metro there, and a release build ignores the setting even if it
67
+ is in the saved state.
@@ -0,0 +1,138 @@
1
+ # Distribution
2
+
3
+ Publishing a preview so installed apps can find it.
4
+
5
+ - [x] Publishes a preview from the developer's machine with one command
6
+ - [x] Replaces a preview when the same branch is published again
7
+ - [x] Gives every preview a changelog, from the branch's commits unless one is passed in
8
+ - [x] Keeps one list per native build for the app, and one list of everything for people
9
+ - [x] Removes a preview, and cleans up files nothing uses any more
10
+ - [x] Runs against a local S3 server while developing
11
+
12
+ Previews go to an S3-compatible bucket: S3, R2, MinIO or any other host with the S3 API. The app
13
+ gets the endpoint, the bucket and, for a private bucket, a read-only key from the config it passes
14
+ in.
15
+
16
+ ## Commands
17
+
18
+ Run from the app's root (`--project <dir>` otherwise). `preview --help` lists every option.
19
+
20
+ ```sh
21
+ npx preview publish # id = git branch, both platforms; asks for the title
22
+ npx preview publish --id task-1 --changelog="- Fixes the login"
23
+ npx preview unpublish task-1
24
+ npx preview prune
25
+ ```
26
+
27
+ `publish` builds with `expo export:embed --dev false --bytecode`, the same step a release build
28
+ runs, so the bundle is Hermes bytecode with its images beside it. It reads the `compatibility`
29
+ option from the app config (see [bundles.md](./bundles.md)), builds the same key the app was built
30
+ with, records the app version and the native fingerprint either way, uploads each file once under
31
+ its sha256, writes the preview's manifest, and only then adds it to the lists. `--compatibility`
32
+ overrides the option, with a warning, since a build of the app would then not list the preview. An app never sees a preview whose files
33
+ are not all there. The changelog is the commit subjects since the branch left the base branch
34
+ (`--base`, default: the repo's default branch); `--changelog=<text>` or `--changelog-file` replace it.
35
+
36
+ ### EAS environment
37
+
38
+ `--environment <name>` builds with an EAS environment's variables, like `eas update --environment`:
39
+
40
+ ```sh
41
+ npx preview publish --environment preview
42
+ ```
43
+
44
+ It runs `eas env:pull --environment <name> --non-interactive` into a temp folder outside the app,
45
+ reads the file and deletes it. The values go into the environment of everything publish runs that
46
+ reads the app config: the config itself (app version, slug), the fingerprint and `expo
47
+ export:embed`, so the key matches a build made in that environment. A variable already set in the
48
+ shell wins over the EAS one. Without the flag nothing changes and eas-cli is never called.
49
+
50
+ It needs eas-cli installed, a login (`eas login`, or `EXPO_TOKEN` in CI) and the app linked to an
51
+ EAS project (`eas init`); publish says which one is missing. Variables with **secret** visibility
52
+ are never readable outside EAS servers: `eas env:pull` leaves them out (as a comment), so set them
53
+ in the shell if the bundle needs them. Plain text and sensitive ones come through. A file-type
54
+ variable is written by `eas env:pull` itself into the app's `.eas/.env/` folder, and its value is
55
+ that file's path.
56
+
57
+ ## CI
58
+
59
+ [`ci/github-actions/`](../ci/github-actions/) has a sample GitHub Actions workflow for each
60
+ package manager. Copy the one that matches your app's lockfile into `.github/workflows/`; GitHub
61
+ does not run them from this package.
62
+
63
+ | Lockfile | File to copy |
64
+ | ------------------- | ----------------------------------------------------------- |
65
+ | `bun.lock` | [`preview.bun.yml`](../ci/github-actions/preview.bun.yml) |
66
+ | `package-lock.json` | [`preview.npm.yml`](../ci/github-actions/preview.npm.yml) |
67
+ | `pnpm-lock.yaml` | [`preview.pnpm.yml`](../ci/github-actions/preview.pnpm.yml) |
68
+ | `yarn.lock` | [`preview.yarn.yml`](../ci/github-actions/preview.yarn.yml) |
69
+
70
+ All four have the same jobs; only the setup, install and run steps differ. The yarn file uses
71
+ `yarn install --immutable` (Yarn 2 and later); for Yarn 1 change it to `--frozen-lockfile`. It
72
+ publishes each pull request when it is opened, pushed to or reopened, with the branch name as the
73
+ id and the pull request's base branch for the changelog. Pull requests from forks get no secrets,
74
+ so it only works for branches in the repo.
75
+
76
+ Repository secrets it reads:
77
+
78
+ | Secret | Needed |
79
+ | ------------------------------ | ----------------------------------------------- |
80
+ | `PREVIEW_S3_URL` | yes |
81
+ | `PREVIEW_S3_ACCESS_KEY_ID` | yes |
82
+ | `PREVIEW_S3_SECRET_ACCESS_KEY` | yes |
83
+ | `PREVIEW_S3_REGION` | no, default `us-east-1` |
84
+ | `EXPO_TOKEN` | only for `--environment` (an Expo access token) |
85
+
86
+ Without EAS variables, drop the `expo/expo-github-action` step, `EXPO_TOKEN` and `EAS_ENVIRONMENT`.
87
+
88
+ ### Run by hand
89
+
90
+ Each workflow also has a `workflow_dispatch` trigger, for publishing any branch, or removing a
91
+ preview, without a pull request. Run it from the Actions tab
92
+ ([GitHub's guide](https://docs.github.com/en/actions/managing-workflow-runs-and-deployments/managing-workflow-runs/manually-running-a-workflow)). Its inputs:
93
+
94
+ | Input | Values | Meaning |
95
+ | ------------- | ------------------------------------------- | ------------------------------------------------------------- |
96
+ | `action` | `publish` (default), `unpublish` | Which job runs |
97
+ | `id` | any, optional | The preview id. Empty means the branch the run was started on |
98
+ | `platform` | `all` (default), `android`, `ios` | Publish only |
99
+ | `environment` | `preview` (default), or any EAS environment | Passed as `--environment`. Empty means no `--environment` |
100
+
101
+ A dispatched publish counts the changelog from the repository's default branch. A pull request
102
+ run always uses `--environment preview`.
103
+
104
+ ### Unpublish on close (optional)
105
+
106
+ When a pull request closes, merged or not, the `unpublish` job removes its preview from every
107
+ build's list. The job is optional. To keep previews after close, do one of these in your copy of
108
+ the workflow:
109
+
110
+ - remove `closed` from the trigger's `types` (a dispatch can still unpublish)
111
+ - delete the `unpublish` job
112
+ - change the job's condition to `if: false`
113
+
114
+ The last two also turn off unpublishing by dispatch.
115
+
116
+ Then remove previews by hand with `preview unpublish <id>`, and delete the files no preview uses
117
+ any more with `preview prune`.
118
+
119
+ ## Layout
120
+
121
+ ```
122
+ index.json every native build and its previews, for people
123
+ index/<platform>/<key>.json what one native build can run; the app reads this
124
+ previews/<platform>/<key>/<id>.json one preview's manifest: files, changelog, versions
125
+ files/<sha256> bundle and image blobs, shared between previews
126
+ ```
127
+
128
+ `<key>` is `version-1.0.0` or `fingerprint-<hash>`. Every manifest and index entry also records the
129
+ compatibility setting, the app version and the native fingerprint, so `index.json` can group builds
130
+ by version whichever setting made them.
131
+
132
+ ```
133
+
134
+ ```
135
+
136
+ Both lists are read, changed and written back only if nobody wrote in between (`If-Match` on the
137
+ ETag, or `If-None-Match: *` for a new file). A clash reads again and retries, so two publishes at
138
+ once both end up listed. `unpublish` leaves files in place; `prune` deletes the ones no manifest uses.
@@ -0,0 +1,56 @@
1
+ # Native loader
2
+
3
+ Starting the app from a downloaded bundle, and getting back again.
4
+
5
+ - [ ] Starts the app from the chosen preview, with no change to the app's native code
6
+ - [ ] Switches to another preview or back to the base build with one restart
7
+ - [ ] Works in debug builds too: a chosen preview loads instead of Metro, and going back to the base build returns to Metro
8
+ - [x] Falls back to the base build when a preview crashes before it has started
9
+ - [ ] Works next to expo-updates, which keeps managing the base build
10
+ - [ ] Needs the New Architecture; under the old one it logs a warning and always loads the base build
11
+
12
+ Previews need React Native's New Architecture, the default from Expo SDK 53. Under the old one the
13
+ package never loads a preview: it logs one warning and the base bundle loads, so the app still
14
+ starts.
15
+
16
+ In a debug build the base is whatever Metro serves. While a preview is chosen it loads from the
17
+ device and Metro is not asked; going back to the base reloads from Metro.
18
+
19
+ If a preview fails before the app has mounted, it is marked broken and the base bundle loads. On
20
+ Android that happens straight away: the app restarts into the base bundle. On iOS the failed preview
21
+ leaves a blank screen until the app is closed and opened again, and that next start is the base
22
+ bundle. Errors that do not come from a starting preview are thrown on as usual, so the app behaves as
23
+ it would without this package.
24
+
25
+ With no preview chosen, the package changes nothing at startup: expo-updates, or the bundle inside
26
+ the app, loads as usual.
27
+
28
+ expo-updates is placed ahead of every other module on both platforms, so this package is never
29
+ asked first. On Android a chosen preview is also put straight into React's host, in the private
30
+ field expo-dev-launcher uses, so it wins even after expo-updates has applied a downloaded update; if
31
+ that field is missing, the base loads and a warning says why. On iOS, while expo-updates is active,
32
+ the base bundle starts and the app reloads once straight into the preview. Going back to the base
33
+ hands the start back to expo-updates.
34
+
35
+ ## Testing next to expo-updates
36
+
37
+ The example leaves expo-updates out, so its default setup stays small. To try the two together:
38
+
39
+ 1. In `example/`, run `npx expo install expo-updates`.
40
+ 2. Add to `app.json` under `expo`, so expo-updates is on in release builds but never asks the
41
+ network and always starts its embedded update:
42
+ ```json
43
+ "runtimeVersion": "1.0.0",
44
+ "updates": {
45
+ "url": "https://localhost.invalid/preview-example",
46
+ "checkAutomatically": "NEVER",
47
+ "fallbackToCacheTimeout": 0
48
+ }
49
+ ```
50
+ 3. `npx expo prebuild`, then a release build: `npx expo run:android --variant release` or
51
+ `npx expo run:ios --configuration Release`.
52
+ 4. With no preview chosen, the app shows the embedded bundle. Choose a preview: it loads, and again
53
+ after closing and reopening the app. Go back to the base: the embedded bundle loads.
54
+ 5. To check the case where expo-updates has applied a downloaded update, point `updates.url` at a
55
+ real update server (EAS Update, or your own), publish an update, let the app apply it, then
56
+ repeat step 4. Going back to the base should load that update.