bleam 0.0.8 → 0.0.10
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/dist/cli.cjs +269 -22
- package/dist/cli.d.cts +32 -2
- package/dist/cli.d.ts +32 -2
- package/dist/cli.js +270 -23
- package/dist/platform.d.cts +2 -0
- package/dist/platform.d.ts +2 -0
- package/dist/{ui-1WepaMS4.d.cts → ui-Bg11tvlc.d.ts} +1 -1
- package/dist/{ui-Ce-pnUUA.d.ts → ui-Dd7SXdbg.d.cts} +7 -7
- package/dist/ui.d.cts +1 -1
- package/dist/ui.d.ts +1 -1
- package/dist/window.d.cts +1 -1
- package/dist/window.d.ts +1 -1
- package/package.json +5 -1
- package/templates/image-generation/app/index.tsx +1 -1
- package/templates/native/.gitattributes +1 -0
- package/templates/native/App.tsx +26 -0
- package/templates/native/app.json +12 -0
- package/templates/native/index.ts +8 -0
- package/templates/native/ios/.xcode.env +11 -0
- package/templates/native/ios/Bleam/AI/Flux2/Configuration/Flux2Config.swift +288 -0
- package/templates/native/ios/Bleam/AI/Flux2/Configuration/MemoryConfig.swift +233 -0
- package/templates/native/ios/Bleam/AI/Flux2/Configuration/MemoryOptimizationConfig.swift +157 -0
- package/templates/native/ios/Bleam/AI/Flux2/Configuration/ModelRegistry.swift +425 -0
- package/templates/native/ios/Bleam/AI/Flux2/Configuration/QuantizationConfig.swift +138 -0
- package/templates/native/ios/Bleam/AI/Flux2/Configuration/TransformerRepoProfile.swift +59 -0
- package/templates/native/ios/Bleam/AI/Flux2/Configuration/VAEConfig.swift +134 -0
- package/templates/native/ios/Bleam/AI/Flux2/Flux2Core.swift +55 -0
- package/templates/native/ios/Bleam/AI/Flux2/Loading/WeightLoader.swift +1192 -0
- package/templates/native/ios/Bleam/AI/Flux2/Pipeline/FluxImageRunner.swift +1282 -0
- package/templates/native/ios/Bleam/AI/Flux2/Pipeline/LatentUtils.swift +191 -0
- package/templates/native/ios/Bleam/AI/Flux2/Scheduler/FlowMatchEulerScheduler.swift +279 -0
- package/templates/native/ios/Bleam/AI/Flux2/Text/KleinTextEncoder.swift +380 -0
- package/templates/native/ios/Bleam/AI/Flux2/Transformer/Flux2Attention.swift +379 -0
- package/templates/native/ios/Bleam/AI/Flux2/Transformer/Flux2Embeddings.swift +159 -0
- package/templates/native/ios/Bleam/AI/Flux2/Transformer/Flux2FeedForward.swift +154 -0
- package/templates/native/ios/Bleam/AI/Flux2/Transformer/Flux2FusedKernels.swift +198 -0
- package/templates/native/ios/Bleam/AI/Flux2/Transformer/Flux2KVCache.swift +36 -0
- package/templates/native/ios/Bleam/AI/Flux2/Transformer/Flux2Modulation.swift +193 -0
- package/templates/native/ios/Bleam/AI/Flux2/Transformer/Flux2ParallelAttention.swift +389 -0
- package/templates/native/ios/Bleam/AI/Flux2/Transformer/Flux2RoPE.swift +304 -0
- package/templates/native/ios/Bleam/AI/Flux2/Transformer/Flux2SingleBlock.swift +290 -0
- package/templates/native/ios/Bleam/AI/Flux2/Transformer/Flux2Transformer.swift +530 -0
- package/templates/native/ios/Bleam/AI/Flux2/Transformer/Flux2TransformerBlock.swift +362 -0
- package/templates/native/ios/Bleam/AI/Flux2/Utils/Flux2Debug.swift +101 -0
- package/templates/native/ios/Bleam/AI/Flux2/Utils/MLXCheckpoint.swift +118 -0
- package/templates/native/ios/Bleam/AI/Flux2/Utils/MemoryManager.swift +201 -0
- package/templates/native/ios/Bleam/AI/Flux2/VAE/AutoencoderKL.swift +476 -0
- package/templates/native/ios/Bleam/AI/Flux2/VAE/ResnetBlock.swift +316 -0
- package/templates/native/ios/Bleam/AI/Flux2/VAE/VAEDecoder.swift +120 -0
- package/templates/native/ios/Bleam/AI/Flux2/VAE/VAEEncoder.swift +136 -0
- package/templates/native/ios/Bleam/AppDelegate.swift +482 -0
- package/templates/native/ios/Bleam/Appearance.swift +62 -0
- package/templates/native/ios/Bleam/Bridging-Header.h +3 -0
- package/templates/native/ios/Bleam/Images.xcassets/AppIcon.appiconset/Contents.json +13 -0
- package/templates/native/ios/Bleam/Images.xcassets/Contents.json +6 -0
- package/templates/native/ios/Bleam/Info.plist +76 -0
- package/templates/native/ios/Bleam/PrivacyInfo.xcprivacy +48 -0
- package/templates/native/ios/Bleam/SceneDelegate.swift +58 -0
- package/templates/native/ios/Bleam/SplashScreen.storyboard +47 -0
- package/templates/native/ios/Bleam/Supporting/Expo.plist +6 -0
- package/templates/native/ios/Bleam/bleam.entitlements +10 -0
- package/templates/native/ios/Bleam.xcodeproj/project.pbxproj +1147 -0
- package/templates/native/ios/Bleam.xcodeproj/xcshareddata/xcschemes/Bleam.xcscheme +88 -0
- package/templates/native/ios/GenerationService/GenerationService.entitlements +10 -0
- package/templates/native/ios/GenerationService/ImageGenerationRunner.swift +591 -0
- package/templates/native/ios/GenerationService/Info.plist +31 -0
- package/templates/native/ios/GenerationService/main.swift +165 -0
- package/templates/native/ios/PlatformHelper/main.swift +726 -0
- package/templates/native/ios/Podfile +198 -0
- package/templates/native/ios/Podfile.lock +2284 -0
- package/templates/native/ios/Podfile.properties.json +5 -0
- package/templates/native/ios/Shared/Generation/GenerationServiceProtocol.swift +12 -0
- package/templates/native/ios/Shared/Generation/GenerationWorkerProtocol.swift +124 -0
- package/templates/native/metro.config.js +6 -0
- package/templates/native/modules/bleam-runtime/BleamRuntime.podspec +14 -0
- package/templates/native/modules/bleam-runtime/expo-module.config.json +8 -0
- package/templates/native/modules/bleam-runtime/ios/AIModule.swift +1258 -0
- package/templates/native/modules/bleam-runtime/ios/GenerationContracts.swift +135 -0
- package/templates/native/modules/bleam-runtime/ios/PlatformModule.swift +329 -0
- package/templates/native/modules/bleam-runtime/package.json +5 -0
- package/templates/native/package.json +39 -0
- package/templates/native/scripts/build/mlx-frameworks.ts +421 -0
- package/templates/native/scripts/start/index.ts +474 -0
- package/templates/native/scripts/start/known-failures.ts +20 -0
- package/templates/native/scripts/start/loader.ts +76 -0
- package/templates/native/scripts/start/native-state.ts +242 -0
- package/templates/native/scripts/start/package.json +4 -0
- package/templates/native/scripts/start/swift-packages.ts +414 -0
- package/templates/native/scripts/start/xcode-formatter.ts +106 -0
- package/templates/native/tsconfig.json +14 -0
- package/templates/native/yarn.lock +3749 -0
- package/templates/updates/README.md +272 -0
- package/templates/updates/src/index.ts +385 -0
- package/templates/updates/src/schema.ts +385 -0
- package/templates/updates/tsconfig.json +12 -0
- package/templates/updates/wrangler.jsonc +19 -0
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
# Bleam Update Service
|
|
2
|
+
|
|
3
|
+
This Worker is the local proof for issues #14 and #15. It resolves public OTA
|
|
4
|
+
and platform manifests from a local R2 simulation and serves release artifacts
|
|
5
|
+
through opaque Bleam download URLs.
|
|
6
|
+
|
|
7
|
+
It has no deployment command, no Cloudflare account ID, no remote binding, no
|
|
8
|
+
publisher endpoint, and no authentication. Running it cannot create or mutate
|
|
9
|
+
remote Cloudflare resources.
|
|
10
|
+
|
|
11
|
+
## Local Development
|
|
12
|
+
|
|
13
|
+
The normal app workflow does not require a separate update-service command.
|
|
14
|
+
`bleam dev` starts the packaged Worker automatically at
|
|
15
|
+
`http://127.0.0.1:8787`, waits for `/v1/health`, and stops the Worker when the
|
|
16
|
+
owning dev session exits. If a healthy Worker is already running, it is reused
|
|
17
|
+
and left running.
|
|
18
|
+
|
|
19
|
+
Local R2 state and Worker logs persist globally at:
|
|
20
|
+
|
|
21
|
+
```txt
|
|
22
|
+
~/Library/Caches/bleam/updates/state
|
|
23
|
+
~/Library/Caches/bleam/updates/worker.log
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Use Wrangler directly only while developing the update service itself.
|
|
27
|
+
Wrangler 4.110.0 or newer is required:
|
|
28
|
+
|
|
29
|
+
```sh
|
|
30
|
+
wrangler dev --config services/updates/wrangler.jsonc --local \
|
|
31
|
+
--persist-to services/updates/.wrangler/state
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
This repository-local R2 data lives under `services/updates/.wrangler/state`
|
|
35
|
+
and is ignored by Git. Seed repository-local artifacts with Wrangler, always
|
|
36
|
+
passing both `--local` and the same `--persist-to` directory:
|
|
37
|
+
|
|
38
|
+
```sh
|
|
39
|
+
wrangler r2 object put bleam-updates-local/releases/com.example.notes/ota/0.0.10.json \
|
|
40
|
+
--file release.json \
|
|
41
|
+
--local \
|
|
42
|
+
--persist-to services/updates/.wrangler/state \
|
|
43
|
+
--config services/updates/wrangler.jsonc
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Run the end-to-end local proof with:
|
|
47
|
+
|
|
48
|
+
```sh
|
|
49
|
+
sh services/updates/scripts/smoke.sh
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
The smoke script creates temporary OTA and platform ZIP artifacts, seeds a
|
|
53
|
+
temporary local R2 state directory, starts Wrangler on `127.0.0.1:8791`, and
|
|
54
|
+
verifies both resolver routes, opaque artifact downloads, response hashes and
|
|
55
|
+
sizes, and a missing-release `404`.
|
|
56
|
+
|
|
57
|
+
Run the real CLI publication proof with:
|
|
58
|
+
|
|
59
|
+
```sh
|
|
60
|
+
sh services/updates/scripts/ota-e2e.sh
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
This starts an isolated local Worker/R2 instance, creates a temporary Bleam
|
|
64
|
+
project, runs `bleam publish ota`, resolves and downloads the published release,
|
|
65
|
+
verifies its SHA-256, and extracts its `bundle/bundle.js` payload.
|
|
66
|
+
|
|
67
|
+
Publish a signed full-app platform archive from a configured project with:
|
|
68
|
+
|
|
69
|
+
```sh
|
|
70
|
+
bleam publish platform --service-url http://127.0.0.1:8787
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
This runs the standalone Release build, packages the signed app as
|
|
74
|
+
`bleam-macos-app-zip-v1`, validates the declared bytes and SHA-256 through the
|
|
75
|
+
local service, and activates the stable `macos-arm64` resolver record.
|
|
76
|
+
|
|
77
|
+
With a configured Apple development team, run the full standalone lifecycle:
|
|
78
|
+
|
|
79
|
+
```sh
|
|
80
|
+
sh services/updates/scripts/ota-installed-e2e.sh
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
This builds and launches an isolated signed app, confirms an OTA download and
|
|
84
|
+
next-launch activation, publishes a hash-valid broken bundle, and confirms the
|
|
85
|
+
following launch rejects it and returns to the embedded bundle. It also verifies
|
|
86
|
+
the app's per-user helper remains registered with launchd after the app quits.
|
|
87
|
+
Finally, it publishes the signed app as a platform release, verifies and caches
|
|
88
|
+
it through the dedicated helper, applies Update and Relaunch, checks the rollback
|
|
89
|
+
backup and replacement receipt, validates the installed app and helper
|
|
90
|
+
signatures, and confirms a moved copy cannot self-update.
|
|
91
|
+
|
|
92
|
+
Before a release, run the same lifecycle with clean-state preflight and cleanup
|
|
93
|
+
assertions:
|
|
94
|
+
|
|
95
|
+
```sh
|
|
96
|
+
sh services/updates/scripts/platform-release-readiness.sh
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
This refuses existing state for the isolated test identity and verifies the app,
|
|
100
|
+
preferences, caches, helper copy, LaunchAgent, launchd registration, and update
|
|
101
|
+
receipts are absent again after the lifecycle. A release still requires this
|
|
102
|
+
check on a separate clean Mac; the local harness cannot substitute for fresh
|
|
103
|
+
hardware and user-account validation.
|
|
104
|
+
|
|
105
|
+
## Public API
|
|
106
|
+
|
|
107
|
+
```text
|
|
108
|
+
GET /v1/apps/:bundleIdentifier/ota/:runtimeVersion
|
|
109
|
+
GET /v1/apps/:bundleIdentifier/platform/macos-arm64
|
|
110
|
+
GET /v1/downloads/:downloadId
|
|
111
|
+
POST /v1/local/releases/ota
|
|
112
|
+
POST /v1/local/releases/platform
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
The POST routes are accepted only on `localhost` and exist solely for local
|
|
116
|
+
publishing development. They validate the release record, artifact byte size,
|
|
117
|
+
and SHA-256 before writing the immutable artifact and activating its matching
|
|
118
|
+
resolver record.
|
|
119
|
+
|
|
120
|
+
`bundleIdentifier` is the Bleam app identity. A release manifest always binds
|
|
121
|
+
it to the Apple `teamIdentifier`; the Worker validates both from the stored
|
|
122
|
+
record before it responds.
|
|
123
|
+
|
|
124
|
+
Resolver responses are `Cache-Control: no-store` because release activation
|
|
125
|
+
and future rollout decisions must remain server-controlled. Download IDs are
|
|
126
|
+
immutable, so artifact responses are cacheable and use one-year immutable
|
|
127
|
+
caching. The R2 object key is present only in the internal download record.
|
|
128
|
+
|
|
129
|
+
## Manifest Contract
|
|
130
|
+
|
|
131
|
+
### Terminology
|
|
132
|
+
|
|
133
|
+
- An **OTA update** replaces the app's JavaScript bundle and assets without
|
|
134
|
+
replacing the installed `.app`.
|
|
135
|
+
- A **platform update** replaces the complete signed `.app`, including the
|
|
136
|
+
Bleam runtime, helper, native APIs, and native dependencies.
|
|
137
|
+
- The **runtime version** identifies the native environment required by an OTA
|
|
138
|
+
release. Compatibility is an exact string match.
|
|
139
|
+
- A **release manifest** describes one immutable artifact. A **resolver record**
|
|
140
|
+
selects the currently active release for an app, channel, and target.
|
|
141
|
+
- A release is **staged** after its artifact has been downloaded, verified, and
|
|
142
|
+
extracted. It becomes active on the next cold launch.
|
|
143
|
+
|
|
144
|
+
Both release forms use `schemaVersion: 1`, `channel: "stable"`, a unique
|
|
145
|
+
`releaseId`, `bundleIdentifier`, `teamIdentifier`, `publishedAt`, and an
|
|
146
|
+
artifact containing its format, opaque `downloadId`, exact byte size, lowercase
|
|
147
|
+
SHA-256 digest, and the resolver-provided download URL.
|
|
148
|
+
|
|
149
|
+
OTA releases add `bundleVersion` and an exact `runtimeVersion`:
|
|
150
|
+
|
|
151
|
+
```json
|
|
152
|
+
{
|
|
153
|
+
"schemaVersion": 1,
|
|
154
|
+
"kind": "ota",
|
|
155
|
+
"bundleIdentifier": "com.example.notes",
|
|
156
|
+
"teamIdentifier": "ABCDE12345",
|
|
157
|
+
"channel": "stable",
|
|
158
|
+
"releaseId": "ota_release_01",
|
|
159
|
+
"bundleVersion": "1.2.0+4",
|
|
160
|
+
"runtimeVersion": "0.0.10",
|
|
161
|
+
"artifact": {
|
|
162
|
+
"format": "bleam-ota-zip-v1",
|
|
163
|
+
"downloadId": "ota_release_01",
|
|
164
|
+
"url": "http://127.0.0.1:8787/v1/downloads/ota_release_01",
|
|
165
|
+
"sizeBytes": 1843921,
|
|
166
|
+
"sha256": "b6d25f9f844da5e5d810501774e5949760a5a45f32e4d8f449905c61d6a8d58d"
|
|
167
|
+
},
|
|
168
|
+
"publishedAt": "2026-07-12T16:00:00Z"
|
|
169
|
+
}
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
Platform releases add signed-app identity and target metadata:
|
|
173
|
+
|
|
174
|
+
```json
|
|
175
|
+
{
|
|
176
|
+
"schemaVersion": 1,
|
|
177
|
+
"kind": "platform",
|
|
178
|
+
"bundleIdentifier": "com.example.notes",
|
|
179
|
+
"teamIdentifier": "ABCDE12345",
|
|
180
|
+
"channel": "stable",
|
|
181
|
+
"releaseId": "platform_release_01",
|
|
182
|
+
"appVersion": "1.3.0",
|
|
183
|
+
"buildNumber": "5",
|
|
184
|
+
"bleamPlatformVersion": "0.0.10",
|
|
185
|
+
"platformFingerprint": "82a09a48ea84e66a65615e30f0f62b7c10f2d5137f4eafa67ca44d90bd9c8823",
|
|
186
|
+
"redeploy": false,
|
|
187
|
+
"target": {
|
|
188
|
+
"os": "macos",
|
|
189
|
+
"architecture": "arm64",
|
|
190
|
+
"installLocation": "user-applications",
|
|
191
|
+
"minimumMacOSVersion": "13.4"
|
|
192
|
+
},
|
|
193
|
+
"artifact": {
|
|
194
|
+
"format": "bleam-macos-app-zip-v1",
|
|
195
|
+
"downloadId": "platform_release_01",
|
|
196
|
+
"url": "http://127.0.0.1:8787/v1/downloads/platform_release_01",
|
|
197
|
+
"sizeBytes": 182743921,
|
|
198
|
+
"sha256": "ab851b776c2656b3725a31882ca8a9bf0e64ae508703a32cd4f8400eb1bdc6ec"
|
|
199
|
+
},
|
|
200
|
+
"publishedAt": "2026-07-12T16:00:00Z"
|
|
201
|
+
}
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
### Platform Feed And Archive
|
|
205
|
+
|
|
206
|
+
The initial platform feed is the stable, Apple-silicon resolver at
|
|
207
|
+
`GET /v1/apps/:bundleIdentifier/platform/macos-arm64`. It returns only releases
|
|
208
|
+
targeting `macos`, `arm64`, and the `user-applications` install location. The
|
|
209
|
+
helper must reject a release whose app version is not semantic, whose build
|
|
210
|
+
number is not decimal, or whose minimum macOS version exceeds the current OS.
|
|
211
|
+
|
|
212
|
+
`platformFingerprint` is a deterministic SHA-256 over Bleam runtime identity
|
|
213
|
+
and supported signed bundle inputs. It excludes developer TypeScript and
|
|
214
|
+
ordinary OTA assets. Local publication rejects an unchanged fingerprint unless
|
|
215
|
+
the manifest records an explicit `redeploy: true`; redeployment does not bypass
|
|
216
|
+
artifact or installed-app verification. Legacy manifests without a fingerprint
|
|
217
|
+
remain readable only so their next normal publication can establish one.
|
|
218
|
+
|
|
219
|
+
`bleam-macos-app-zip-v1` is a ZIP containing exactly one top-level `app/`
|
|
220
|
+
directory and exactly one complete `.app` beneath it:
|
|
221
|
+
|
|
222
|
+
```text
|
|
223
|
+
app/<display name>.app/
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
The archive must not contain another top-level entry, multiple app bundles,
|
|
227
|
+
absolute paths, or entries that escape `app/` through `..` or symlink traversal.
|
|
228
|
+
The helper extracts into the app-specific cache, never directly into
|
|
229
|
+
`~/Applications`, and treats the extracted bundle as untrusted until checkpoint
|
|
230
|
+
verification of SHA-256, bundle ID, team ID, app/build versions, architecture,
|
|
231
|
+
and code signature succeeds. The release ID and artifact download ID are
|
|
232
|
+
immutable; changing app bytes requires a new release and download ID.
|
|
233
|
+
|
|
234
|
+
The R2 layout is internal:
|
|
235
|
+
|
|
236
|
+
```text
|
|
237
|
+
releases/<bundleIdentifier>/ota/<runtimeVersion>.json
|
|
238
|
+
releases/<bundleIdentifier>/platform/macos-arm64.json
|
|
239
|
+
downloads/<downloadId>.json
|
|
240
|
+
artifacts/<private release path>
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
## Verification And Recovery
|
|
244
|
+
|
|
245
|
+
Publication validates the manifest schema, declared artifact byte size, and
|
|
246
|
+
SHA-256 before activating a resolver record. The app repeats size and SHA-256
|
|
247
|
+
verification after download and rejects mismatched artifacts before extraction.
|
|
248
|
+
Both release types bind the artifact to a bundle identifier and Apple team
|
|
249
|
+
identifier. OTA releases additionally require the app's exact runtime version.
|
|
250
|
+
|
|
251
|
+
OTA bundles are cached under
|
|
252
|
+
`~/Library/Caches/Bleam/OTA/<bundleIdentifier>/<releaseId>/`. The active release
|
|
253
|
+
ID is stored separately from the bundle so an incomplete download cannot become
|
|
254
|
+
active. A staged release is selected only on a later cold launch and is marked
|
|
255
|
+
as attempted before React Native starts.
|
|
256
|
+
|
|
257
|
+
Startup is confirmed when React Native reports either JavaScript loading on the
|
|
258
|
+
bridge runtime or root content appearing on Fabric. If the app exits or the
|
|
259
|
+
bundle fails before confirmation, the next launch rejects that release, clears
|
|
260
|
+
its active and attempted state, and loads the embedded `main.jsbundle`. A
|
|
261
|
+
rejected release is not selected again while it remains the resolver's active
|
|
262
|
+
release.
|
|
263
|
+
|
|
264
|
+
Platform artifacts are downloaded and applied by the per-app helper. It verifies
|
|
265
|
+
the artifact hash, archive layout, bundle and team identity, app and build
|
|
266
|
+
versions, Bleam runtime and platform fingerprint, arm64 architecture, code
|
|
267
|
+
signature, and minimum macOS version before replacement. Hosted authentication,
|
|
268
|
+
ownership, entitlement, and deployment remain separate follow-up work.
|
|
269
|
+
|
|
270
|
+
Standalone builds configured with `BLEAM_UPDATE_SERVICE_URL` check the matching
|
|
271
|
+
OTA resolver after React starts. A verified release is cached for the next cold
|
|
272
|
+
launch and follows the activation and recovery behavior above.
|
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
import {
|
|
2
|
+
compareRuntimeVersions,
|
|
3
|
+
type DownloadRecord,
|
|
4
|
+
isBundleIdentifier,
|
|
5
|
+
isDownloadId,
|
|
6
|
+
isRuntimeVersion,
|
|
7
|
+
parseDownloadRecord,
|
|
8
|
+
parsePublishedRelease,
|
|
9
|
+
parseStoredRelease,
|
|
10
|
+
resolveRelease,
|
|
11
|
+
type StoredRelease,
|
|
12
|
+
} from './schema'
|
|
13
|
+
|
|
14
|
+
interface StoredObject {
|
|
15
|
+
body: ReadableStream<Uint8Array>
|
|
16
|
+
httpEtag: string
|
|
17
|
+
size: number
|
|
18
|
+
text(): Promise<string>
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface UpdatesBucket {
|
|
22
|
+
get(key: string): Promise<StoredObject | null>
|
|
23
|
+
put(
|
|
24
|
+
key: string,
|
|
25
|
+
value: string | ArrayBuffer,
|
|
26
|
+
options?: { httpMetadata?: { contentType: string } },
|
|
27
|
+
): Promise<unknown>
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface Env {
|
|
31
|
+
UPDATES: UpdatesBucket
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const jsonHeaders = {
|
|
35
|
+
'content-type': 'application/json; charset=utf-8',
|
|
36
|
+
'cache-control': 'no-store',
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function json(value: unknown, status = 200, method = 'GET') {
|
|
40
|
+
return new Response(method === 'HEAD' ? null : JSON.stringify(value), {
|
|
41
|
+
status,
|
|
42
|
+
headers: jsonHeaders,
|
|
43
|
+
})
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function error(code: string, status: number, method: string) {
|
|
47
|
+
return json({ error: { code } }, status, method)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function notFound(method: string) {
|
|
51
|
+
return error('not_found', 404, method)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function unavailable(method: string) {
|
|
55
|
+
return error('invalid_release', 500, method)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function methodNotAllowed() {
|
|
59
|
+
return new Response(null, {
|
|
60
|
+
status: 405,
|
|
61
|
+
headers: { allow: 'GET, HEAD' },
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function isLocalRequest(request: Request) {
|
|
66
|
+
const hostname = new URL(request.url).hostname
|
|
67
|
+
return hostname === '127.0.0.1' || hostname === 'localhost'
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function base64Bytes(value: string) {
|
|
71
|
+
const binary = atob(value)
|
|
72
|
+
const bytes = new Uint8Array(binary.length)
|
|
73
|
+
for (let index = 0; index < binary.length; index += 1) {
|
|
74
|
+
bytes[index] = binary.charCodeAt(index)
|
|
75
|
+
}
|
|
76
|
+
return bytes
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function sha256(bytes: Uint8Array) {
|
|
80
|
+
const digest = await crypto.subtle.digest(
|
|
81
|
+
'SHA-256',
|
|
82
|
+
bytes.buffer.slice(
|
|
83
|
+
bytes.byteOffset,
|
|
84
|
+
bytes.byteOffset + bytes.byteLength,
|
|
85
|
+
) as ArrayBuffer,
|
|
86
|
+
)
|
|
87
|
+
return Array.from(new Uint8Array(digest), (byte) =>
|
|
88
|
+
byte.toString(16).padStart(2, '0'),
|
|
89
|
+
).join('')
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function publishLocalRelease(
|
|
93
|
+
request: Request,
|
|
94
|
+
bucket: UpdatesBucket,
|
|
95
|
+
expectedKind: StoredRelease['kind'],
|
|
96
|
+
) {
|
|
97
|
+
if (!isLocalRequest(request)) return notFound(request.method)
|
|
98
|
+
|
|
99
|
+
let input: Record<string, unknown>
|
|
100
|
+
try {
|
|
101
|
+
input = (await request.json()) as Record<string, unknown>
|
|
102
|
+
} catch {
|
|
103
|
+
return error('invalid_request', 400, request.method)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (typeof input.artifactBase64 !== 'string') {
|
|
107
|
+
return error('invalid_request', 400, request.method)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
try {
|
|
111
|
+
const release = parsePublishedRelease(input.release)
|
|
112
|
+
if (release.kind !== expectedKind) {
|
|
113
|
+
return error('invalid_request', 400, request.method)
|
|
114
|
+
}
|
|
115
|
+
const artifact = base64Bytes(input.artifactBase64)
|
|
116
|
+
if (artifact.byteLength !== release.artifact.sizeBytes) {
|
|
117
|
+
return error('invalid_request', 400, request.method)
|
|
118
|
+
}
|
|
119
|
+
if ((await sha256(artifact)) !== release.artifact.sha256) {
|
|
120
|
+
return error('invalid_request', 400, request.method)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const releaseKey =
|
|
124
|
+
release.kind === 'ota'
|
|
125
|
+
? `releases/${release.bundleIdentifier}/ota/${release.runtimeVersion}.json`
|
|
126
|
+
: `releases/${release.bundleIdentifier}/platform/macos-arm64.json`
|
|
127
|
+
if (release.kind === 'platform') {
|
|
128
|
+
const currentValue = await readStoredJson(bucket, releaseKey)
|
|
129
|
+
const current = currentValue
|
|
130
|
+
? parseStoredRelease(currentValue)
|
|
131
|
+
: undefined
|
|
132
|
+
const unchanged =
|
|
133
|
+
current?.kind === 'platform' &&
|
|
134
|
+
current.platformFingerprint !== undefined &&
|
|
135
|
+
current.platformFingerprint === release.platformFingerprint
|
|
136
|
+
if (
|
|
137
|
+
current?.kind === 'platform' &&
|
|
138
|
+
compareRuntimeVersions(
|
|
139
|
+
release.bleamPlatformVersion,
|
|
140
|
+
current.bleamPlatformVersion,
|
|
141
|
+
) < 0
|
|
142
|
+
) {
|
|
143
|
+
return error('platform_downgrade', 409, request.method)
|
|
144
|
+
}
|
|
145
|
+
if (unchanged && !release.redeploy) {
|
|
146
|
+
return error('platform_unchanged', 409, request.method)
|
|
147
|
+
}
|
|
148
|
+
if (!unchanged && release.redeploy) {
|
|
149
|
+
return error('redeploy_not_required', 400, request.method)
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const artifactKey = `artifacts/${release.kind}/${release.artifact.downloadId}.zip`
|
|
154
|
+
const download: DownloadRecord = {
|
|
155
|
+
schemaVersion: 1,
|
|
156
|
+
downloadId: release.artifact.downloadId,
|
|
157
|
+
artifactKey,
|
|
158
|
+
contentType: 'application/zip',
|
|
159
|
+
}
|
|
160
|
+
await bucket.put(artifactKey, artifact.buffer, {
|
|
161
|
+
httpMetadata: { contentType: 'application/zip' },
|
|
162
|
+
})
|
|
163
|
+
await bucket.put(
|
|
164
|
+
`downloads/${download.downloadId}.json`,
|
|
165
|
+
JSON.stringify(download),
|
|
166
|
+
{ httpMetadata: { contentType: 'application/json' } },
|
|
167
|
+
)
|
|
168
|
+
await bucket.put(releaseKey, JSON.stringify(release), {
|
|
169
|
+
httpMetadata: { contentType: 'application/json' },
|
|
170
|
+
})
|
|
171
|
+
return json(
|
|
172
|
+
resolveRelease(
|
|
173
|
+
release,
|
|
174
|
+
new URL(`/v1/downloads/${download.downloadId}`, request.url).toString(),
|
|
175
|
+
),
|
|
176
|
+
201,
|
|
177
|
+
)
|
|
178
|
+
} catch {
|
|
179
|
+
return error('invalid_request', 400, request.method)
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function decodedSegment(value: string): string | undefined {
|
|
184
|
+
try {
|
|
185
|
+
return decodeURIComponent(value)
|
|
186
|
+
} catch {
|
|
187
|
+
return undefined
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function readStoredJson(bucket: UpdatesBucket, key: string) {
|
|
192
|
+
const object = await bucket.get(key)
|
|
193
|
+
if (!object) return undefined
|
|
194
|
+
|
|
195
|
+
try {
|
|
196
|
+
return JSON.parse(await object.text()) as unknown
|
|
197
|
+
} catch {
|
|
198
|
+
return undefined
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function releaseResponse(
|
|
203
|
+
release: StoredRelease,
|
|
204
|
+
request: Request,
|
|
205
|
+
method: string,
|
|
206
|
+
) {
|
|
207
|
+
const artifactUrl = new URL(
|
|
208
|
+
`/v1/downloads/${release.artifact.downloadId}`,
|
|
209
|
+
request.url,
|
|
210
|
+
).toString()
|
|
211
|
+
return json(resolveRelease(release, artifactUrl), 200, method)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async function resolveOta(
|
|
215
|
+
bucket: UpdatesBucket,
|
|
216
|
+
request: Request,
|
|
217
|
+
bundleIdentifier: string,
|
|
218
|
+
runtimeVersion: string,
|
|
219
|
+
method: string,
|
|
220
|
+
) {
|
|
221
|
+
if (
|
|
222
|
+
!isBundleIdentifier(bundleIdentifier) ||
|
|
223
|
+
!isRuntimeVersion(runtimeVersion)
|
|
224
|
+
) {
|
|
225
|
+
return notFound(method)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const stored = await readStoredJson(
|
|
229
|
+
bucket,
|
|
230
|
+
`releases/${bundleIdentifier}/ota/${runtimeVersion}.json`,
|
|
231
|
+
)
|
|
232
|
+
if (!stored) return notFound(method)
|
|
233
|
+
|
|
234
|
+
try {
|
|
235
|
+
const release = parseStoredRelease(stored)
|
|
236
|
+
if (
|
|
237
|
+
release.kind !== 'ota' ||
|
|
238
|
+
release.bundleIdentifier !== bundleIdentifier ||
|
|
239
|
+
release.runtimeVersion !== runtimeVersion
|
|
240
|
+
) {
|
|
241
|
+
return unavailable(method)
|
|
242
|
+
}
|
|
243
|
+
return releaseResponse(release, request, method)
|
|
244
|
+
} catch {
|
|
245
|
+
return unavailable(method)
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async function resolvePlatform(
|
|
250
|
+
bucket: UpdatesBucket,
|
|
251
|
+
request: Request,
|
|
252
|
+
bundleIdentifier: string,
|
|
253
|
+
target: string,
|
|
254
|
+
method: string,
|
|
255
|
+
) {
|
|
256
|
+
if (!isBundleIdentifier(bundleIdentifier) || target !== 'macos-arm64') {
|
|
257
|
+
return notFound(method)
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const stored = await readStoredJson(
|
|
261
|
+
bucket,
|
|
262
|
+
`releases/${bundleIdentifier}/platform/${target}.json`,
|
|
263
|
+
)
|
|
264
|
+
if (!stored) return notFound(method)
|
|
265
|
+
|
|
266
|
+
try {
|
|
267
|
+
const release = parseStoredRelease(stored)
|
|
268
|
+
if (
|
|
269
|
+
release.kind !== 'platform' ||
|
|
270
|
+
release.bundleIdentifier !== bundleIdentifier ||
|
|
271
|
+
`${release.target.os}-${release.target.architecture}` !== target
|
|
272
|
+
) {
|
|
273
|
+
return unavailable(method)
|
|
274
|
+
}
|
|
275
|
+
return releaseResponse(release, request, method)
|
|
276
|
+
} catch {
|
|
277
|
+
return unavailable(method)
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async function downloadArtifact(
|
|
282
|
+
bucket: UpdatesBucket,
|
|
283
|
+
downloadId: string,
|
|
284
|
+
method: string,
|
|
285
|
+
) {
|
|
286
|
+
if (!isDownloadId(downloadId)) return notFound(method)
|
|
287
|
+
|
|
288
|
+
const stored = await readStoredJson(bucket, `downloads/${downloadId}.json`)
|
|
289
|
+
if (!stored) return notFound(method)
|
|
290
|
+
|
|
291
|
+
let download: DownloadRecord
|
|
292
|
+
try {
|
|
293
|
+
download = parseDownloadRecord(stored)
|
|
294
|
+
} catch {
|
|
295
|
+
return unavailable(method)
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if (download.downloadId !== downloadId) return unavailable(method)
|
|
299
|
+
|
|
300
|
+
const artifact = await bucket.get(download.artifactKey)
|
|
301
|
+
if (!artifact) return notFound(method)
|
|
302
|
+
|
|
303
|
+
const headers = new Headers({
|
|
304
|
+
'content-type': download.contentType,
|
|
305
|
+
'cache-control': 'public, max-age=31536000, immutable',
|
|
306
|
+
etag: artifact.httpEtag,
|
|
307
|
+
'content-length': String(artifact.size),
|
|
308
|
+
})
|
|
309
|
+
return new Response(method === 'HEAD' ? null : artifact.body, {
|
|
310
|
+
status: 200,
|
|
311
|
+
headers,
|
|
312
|
+
})
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
export default {
|
|
316
|
+
async fetch(request: Request, env: Env): Promise<Response> {
|
|
317
|
+
const url = new URL(request.url)
|
|
318
|
+
if (request.method === 'GET' && url.pathname === '/v1/health') {
|
|
319
|
+
return json({ healthy: true })
|
|
320
|
+
}
|
|
321
|
+
if (
|
|
322
|
+
request.method === 'POST' &&
|
|
323
|
+
url.pathname === '/v1/local/releases/ota'
|
|
324
|
+
) {
|
|
325
|
+
return publishLocalRelease(request, env.UPDATES, 'ota')
|
|
326
|
+
}
|
|
327
|
+
if (
|
|
328
|
+
request.method === 'POST' &&
|
|
329
|
+
url.pathname === '/v1/local/releases/platform'
|
|
330
|
+
) {
|
|
331
|
+
return publishLocalRelease(request, env.UPDATES, 'platform')
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
if (request.method !== 'GET' && request.method !== 'HEAD') {
|
|
335
|
+
return methodNotAllowed()
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const path = url.pathname.split('/').filter(Boolean)
|
|
339
|
+
const method = request.method
|
|
340
|
+
|
|
341
|
+
if (
|
|
342
|
+
path.length === 5 &&
|
|
343
|
+
path[0] === 'v1' &&
|
|
344
|
+
path[1] === 'apps' &&
|
|
345
|
+
path[3] === 'ota'
|
|
346
|
+
) {
|
|
347
|
+
const bundleIdentifier = decodedSegment(path[2])
|
|
348
|
+
const runtimeVersion = decodedSegment(path[4])
|
|
349
|
+
if (!bundleIdentifier || !runtimeVersion) return notFound(method)
|
|
350
|
+
return resolveOta(
|
|
351
|
+
env.UPDATES,
|
|
352
|
+
request,
|
|
353
|
+
bundleIdentifier,
|
|
354
|
+
runtimeVersion,
|
|
355
|
+
method,
|
|
356
|
+
)
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
if (
|
|
360
|
+
path.length === 5 &&
|
|
361
|
+
path[0] === 'v1' &&
|
|
362
|
+
path[1] === 'apps' &&
|
|
363
|
+
path[3] === 'platform'
|
|
364
|
+
) {
|
|
365
|
+
const bundleIdentifier = decodedSegment(path[2])
|
|
366
|
+
const target = decodedSegment(path[4])
|
|
367
|
+
if (!bundleIdentifier || !target) return notFound(method)
|
|
368
|
+
return resolvePlatform(
|
|
369
|
+
env.UPDATES,
|
|
370
|
+
request,
|
|
371
|
+
bundleIdentifier,
|
|
372
|
+
target,
|
|
373
|
+
method,
|
|
374
|
+
)
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
if (path.length === 3 && path[0] === 'v1' && path[1] === 'downloads') {
|
|
378
|
+
const downloadId = decodedSegment(path[2])
|
|
379
|
+
if (!downloadId) return notFound(method)
|
|
380
|
+
return downloadArtifact(env.UPDATES, downloadId, method)
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
return notFound(method)
|
|
384
|
+
},
|
|
385
|
+
}
|